Select menus
Answer a select menu pick, from a string menu you write the options for to the menus Discord resolves for you. Covers routing the pick, the resolving kinds, and one handler serving several menus.
A row holds five buttons. Once a choice needs more than five, or someone should pick several things at once, a select menu fits better. It's a dropdown of up to 25 options.
One kind carries options you write yourself. For the rest, Discord resolves the picks into users, roles, channels, or a mix of users and roles, and sends the objects behind those ids. You skip asking for an id as text and fetching it yourself. Every kind gets its own handler base, and each one routes the way a button does.
import { StringSelectMenuBuilder } from '@discordjs/builders';
import { CustomId, RowComponent } from '@seedcord/gateway';
export const TopicsId = new CustomId('topics').snowflake('userId');
export class TopicPicker extends RowComponent<'menu_string'> {
constructor(userId: string) {
super('menu_string');
this.instance.addComponents(
new StringSelectMenuBuilder()
.setCustomId(TopicsId.encode({ userId }))
.setPlaceholder('Pick your topics')
.setMinValues(1)
.setMaxValues(3)
.addOptions(
{ label: 'Releases', value: 'releases' },
{ label: 'Outages', value: 'outages' },
{ label: 'Events', value: 'events' }
)
);
}
}setMinValues(1) and setMaxValues(3) above let someone pick one topic, two, or all three. Both default to 1, and Discord caps each at 25. A select menu takes a whole row. Discord rejects a message that puts a button or a second menu beside it.
Routing the pick
The menu above carries options you wrote, so it routes through @StringMenuRoute to a StringMenuHandler. Pass the same CustomId definitions to the decorator and the generic. Passing different ones is a compile error.
import {
CustomId,
StringMenuHandler,
StringMenuRoute
} from '@seedcord/gateway';
// in your project this is defined in the component file that mints it
const TopicsId = new CustomId('topics').snowflake('userId');
@StringMenuRoute(TopicsId)
export class Topics extends StringMenuHandler<[typeof TopicsId]> {
public async execute(): Promise<void> {
const { userId } = this.params;
const picked = this.values;
await this.update(
`<@${userId}> now follows ${picked.join(', ')}.`
);
}
}this.values holds what someone picked. On a string menu those are the value strings you set on the options, like 'releases'. this.params, this.match, this.update, and this.deferUpdate all work as they do on Buttons.
Gateway only
Every gateway menu base takes discord.js's CacheType as a second type argument, defaulting to 'cached'. Buttons explains what that default claims about a DM. An http menu base takes only the route tuple.
The resolving kinds
The resolving kinds fill this.values with ids and carry the objects those ids point at. A user menu routes through @UserMenuRoute to a UserMenuHandler, which declares this.users and this.members beside the ids.
@UserMenuRoute(AssignId)
export class Assign extends UserMenuHandler<[typeof AssignId]> {
public async execute(): Promise<void> {
const { roleId } = this.params;
const chosen = this.users;
await this.update(
`Assigning <@&${roleId}> to ${chosen.size} member(s).`
);
}
}Each base declares only the members its menu resolves.
| base | decorator | what the handler carries |
|---|---|---|
StringMenuHandler | @StringMenuRoute | this.values |
UserMenuHandler | @UserMenuRoute | this.values, this.users, this.members |
RoleMenuHandler | @RoleMenuRoute | this.values, this.roles |
ChannelMenuHandler | @ChannelMenuRoute | this.values, this.channels |
MentionableMenuHandler | @MentionableMenuRoute | this.values, this.users, this.members, this.roles |
If you read a member the base leaves out, like this.channels on a UserMenuHandler, TypeScript reports an error on that property.
Discord resolves this.members only inside a guild, so it comes back empty in a DM. A mentionable menu accepts users and roles together. this.users and this.roles each hold whatever matched, so either one can be empty.
Gateway and http differ
Both transports return a Collection keyed by id. The values differ. Gateway returns live discord.js instances, and http returns the payloads Discord sent.
| getter | gateway, 'cached' | http |
|---|---|---|
this.users | User | APIUser |
this.members | GuildMember | APIInteractionDataResolvedGuildMember |
this.roles | Role | APIRole |
this.channels | a discord.js channel | APIInteractionDataResolvedChannel |
One handler, several menus
Two menus of the same kind can share one class. Name every id on the decorator, then give this.match an arm per prefix.
@StringMenuRoute(RegionId, LanguageId)
export class Filters extends StringMenuHandler<
[typeof RegionId, typeof LanguageId]
> {
public async execute(): Promise<void> {
const picked = this.values.join(', ');
await this.match({
region: ({ userId }) =>
this.update(`<@${userId}> is in ${picked}.`),
language: ({ userId }) =>
this.update(`<@${userId}> reads ${picked}.`)
});
}
}picked is read once, before match, and both arms use it. Share a class like this when both menus run the same code first. Two menus that share nothing read better as two handlers, one file each.
this.match takes one arm for each id in the generic, keyed by that id's prefix, so your editor offers region and language.
await this.match({ '- language
- region
Warning
A string menu and a user menu need a handler each. If you point one kind's decorator at another kind's base, TypeScript reports "the decorator does not match the handler kind" on the decorator line.
Putting several menus on one message takes a row each. Give each menu its own id. Route them to the same handler when they share a kind.