# 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()`](https://docs.seedcord.org/packages/gateway/latest/interaction-handler#show-modal), and the submit arrives as a separate interaction carrying the modal's custom id.

```ts title="src/components/AppealModal.ts"
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`](https://docs.seedcord.org/packages/custom-id/latest/custom-id), 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](#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.

```ts title="src/handlers/Appeal.ts"
@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](/replying/ack-states). 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`](https://docs.seedcord.org/packages/gateway/latest/modal-handler).

## Reading the submission

[`@ModalRoute`](https://docs.seedcord.org/packages/core/latest/modal-route) routes the submit by the modal's id, the same way [`@ButtonRoute`](https://docs.seedcord.org/packages/core/latest/button-route) routes a click. It also takes several ids, and `this.match` branches between them. [Buttons](/components/buttons) shows that form.

```ts title="src/handlers/AppealSubmit.ts"
@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`](https://docs.seedcord.org/packages/gateway/latest/component-handler#params), and the submitted inputs from [`this.fields`](https://docs.seedcord.org/packages/gateway/latest/modal-handler#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](/components/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.

{/* prettier-ignore-start */}

| 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`                          |

{/* prettier-ignore-end */}

`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.
