Modals
Collect text, menu picks, files, and checkboxes from a user with a modal. Covers opening it with showModal, reading the submission, what a label can hold, and rewriting the message it came from.
A button can't ask for text. A slash command option can, but only when someone runs the command. Its text box also takes a single line, so a paragraph of feedback won't fit. A modal is a form that Discord opens on top of the client. One submit can carry a paragraph of feedback, a role picked from a menu, an uploaded screenshot, and a ticked checkbox.
You open it with showModal(), and the submit arrives as a separate interaction carrying the modal's custom id.
import type { LabelBuilder } from '@discordjs/builders';
import { BuilderComponent, CustomId } from '@seedcord/gateway';
import { TextInputStyle } from 'discord.js';
export const AppealFormId = new CustomId('appealform').snowflake(
'userId'
);
export class AppealModal extends BuilderComponent<'modal'> {
constructor(userId: string) {
super('modal');
this.instance
.setCustomId(AppealFormId.encode({ userId }))
.setTitle('Appeal a ban')
.addLabelComponents((label) => this.reason(label));
}
private reason(label: LabelBuilder): LabelBuilder {
return label
.setLabel('Why should we unban you?')
.setDescription('A moderator reads this.')
.setTextInputComponent((input) =>
input
.setCustomId('reason')
.setStyle(TextInputStyle.Paragraph)
);
}
}A modal carries two kinds of custom id. You mint the modal's own id with CustomId, since seedcord routes the submit by it. Each input takes a plain string id you pick, like 'reason' above, and you read that field back by the same string.
A modal holds up to five top-level components, and each label wraps one input. addTextDisplayComponents adds a line of plain text, which counts toward the same five as a label.
The label above holds a text input. A label can hold a select menu, a file upload, a checkbox, a checkbox group, or a radio group in its place. What a label can hold pairs each kind with the getter that reads it back.
Opening it
A modal opens from a handler through showModal, which takes the built component.
@SlashRoute('ban')
export class Appeal extends SlashHandler<'ban'> {
public async execute(): Promise<void> {
const target = this.options.getUser('target');
await this.showModal(new AppealModal(target.id).component);
}
}Danger
showModal() has to be the initial response. Calling it after reply(), defer(), or deferUpdate() throws ReplyIllegalAckState, covered in When a reply throws. You can't defer first, so any lookup the form needs has to finish inside Discord's three seconds. Discord also forbids a modal opening another modal, so the call fails to compile inside a ModalHandler.
Reading the submission
@ModalRoute routes the submit by the modal's id, the same way @ButtonRoute routes a click. It also takes several ids, and this.match branches between them. Buttons shows that form.
@ModalRoute(AppealFormId)
export class AppealSubmit extends ModalHandler<
[typeof AppealFormId]
> {
public async execute(): Promise<void> {
const { userId } = this.params;
const reason = this.fields.getTextInputValue('reason');
await this.reply(
`Appeal from <@${userId}> recorded. ${reason}`
);
}
}Read the modal id's values from this.params, and the submitted inputs from this.fields. Each getter takes the input's own custom id, 'reason' here, and throws when no field has it.
That string isn't typed, so a typo compiles and throws only when someone submits. Put each input id in a constant beside the modal and import it into the handler, so both sides read the same value.
Gateway only
A gateway ModalHandler takes discord.js's CacheType as a second type argument, defaulting to 'cached'. Buttons explains what that default claims about a DM. An http ModalHandler takes only the route tuple.
Gateway and http differ
this.fields has the same getter names on both transports, and each getter throws on an unknown id either way. Gateway returns discord.js's ModalSubmitFields, whose select getters return live User, GuildMember, Role, and channel instances. Http returns seedcord's ModalFields, whose select getters return the payloads Discord sent, like APIUser and APIRole.
What a label can hold
A label wraps a text input or one of the other input kinds, and each kind has its own getter.
| what you put on the label | how you read it |
|---|---|
setTextInputComponent | getTextInputValue |
setStringSelectMenuComponent | getStringSelectValues |
setUserSelectMenuComponent | getSelectedUsers, getSelectedMembers |
setRoleSelectMenuComponent | getSelectedRoles |
setChannelSelectMenuComponent | getSelectedChannels |
setMentionableSelectMenuComponent | getSelectedMentionables |
setFileUploadComponent | getUploadedFiles |
setCheckboxComponent | getCheckbox |
setCheckboxGroupComponent | getCheckboxGroup |
setRadioGroupComponent | getRadioGroup |
getCheckbox returns a boolean. getCheckboxGroup returns the checked values. getSelectedChannels takes an optional list of allowed channel types and throws when someone picks outside it. Leave it out to accept any channel type.
Most getters that can return null take a required flag. Pass true to make the getter throw on an empty field. Its return type then drops the null. getSelectedMembers takes the custom id alone, so its result stays nullable.
Rewriting the message it came from
A modal opened from a button keeps that button's message as its source, so this.update() rewrites it.
A slash command can open the same modal. Discord omits the source message in that case, so update() and deferUpdate() both throw ReplyUpdateWithoutSource. The message says "Use reply() or defer() instead". followUp() throws there as well, since nothing has acknowledged the submit yet.