Scripting Guide

Lesson 8: Select Menus and Modals

Dropdown menus for choices and modal forms for free-text input, plus how to read what the user picked or typed.

String select menus

A select menu offers up to 25 options in a dropdown. Like buttons, menus are posted by one agent and handled by an interactionCreate agent through their customId.

color-menu-post.js (event: messageCreate)
import {
  ActionRowBuilder,
  StringSelectMenuBuilder,
  StringSelectMenuOptionBuilder,
} from "discord";

export async function onMessage(message) {
  if (message.author.bot) return;
  if (message.content !== "!colors") return;

  const menu = new StringSelectMenuBuilder()
    .setCustomId("colorpick:menu")
    .setPlaceholder("Choose your favorite color")
    .setMinValues(1)
    .setMaxValues(2) // allow picking up to two
    .addOptions(
      new StringSelectMenuOptionBuilder()
        .setLabel("Red").setValue("red").setDescription("Warm and loud"),
      new StringSelectMenuOptionBuilder()
        .setLabel("Blue").setValue("blue").setDescription("Calm and cool"),
      new StringSelectMenuOptionBuilder()
        .setLabel("Green").setValue("green").setDescription("Fresh"),
    );

  await message.channel.send({
    content: "Pick one or two:",
    components: [new ActionRowBuilder().addComponents(menu)],
  });
}
color-menu-handle.js (event: interactionCreate)
export async function onInteraction(interaction) {
  if (!interaction.isStringSelectMenu()) return;
  if (interaction.customId !== "colorpick:menu") return;

  // values is an array of the selected option values, e.g. ["red", "green"]
  await interaction.reply(
    interaction.user.username + " picked: " + interaction.values.join(", ")
  );
}

Besides string menus there are entity pickers that need no options of their own: UserSelectMenuBuilder, RoleSelectMenuBuilder, ChannelSelectMenuBuilder (optionally restricted with setChannelTypes), and MentionableSelectMenuBuilder. Their handlers receive the picked IDs in interaction.values and are detected with isUserSelectMenu(), isRoleSelectMenu(), isChannelSelectMenu(), and isMentionableSelectMenu().

Modals: pop-up forms

A modal is a form with text inputs that opens in response to an interaction. You cannot open a modal from a plain message; the flow is button (or menu) first, modal second, submission third. That usually means two interactionCreate branches in one handler agent:

feedback.js (event: interactionCreate)
import {
  ModalBuilder,
  TextInputBuilder,
  TextInputStyle,
  LabelBuilder,
} from "discord";

export async function onInteraction(interaction) {
  // Step 2: the button opens the modal.
  if (interaction.isButton() && interaction.customId === "fb:open") {
    const modal = new ModalBuilder()
      .setCustomId("fb:form")
      .setTitle("Send feedback");

    const input = new TextInputBuilder()
      .setCustomId("fb_text")
      .setStyle(TextInputStyle.Paragraph) // Short = one line
      .setPlaceholder("Tell us everything...")
      .setMinLength(10)
      .setMaxLength(1000)
      .setRequired(true);

    modal.addLabelComponents(
      new LabelBuilder()
        .setLabel("Your feedback")
        .setDescription("What should we improve?")
        .setTextInputComponent(input),
    );

    await interaction.showModal(modal);
    return;
  }

  // Step 3: the submission arrives as a modal-submit interaction.
  if (interaction.isModalSubmit() && interaction.customId === "fb:form") {
    const text = interaction.fields.getTextInputValue("fb_text");
    await interaction.reply("Thanks! We received: " + text.slice(0, 100));
  }
}

A separate messageCreate agent posts the entry button (fb:open), exactly as in Lesson 7. Expected behavior: clicking Send feedback opens the form; submitting it posts a confirmation with the first 100 characters typed.

Reading modal fields

  • interaction.fields.getTextInputValue("id") returns the typed string and throws if the field does not exist (a programming error you will see in the error channel).
  • interaction.fields also works as a plain object keyed by field custom ID, so interaction.fields["fb_text"] is equivalent.
  • Each text input needs its own LabelBuilder wrapper; a modal holds up to 5 inputs.

Common pitfalls

  • Opening a modal after acknowledging. showModal() must be the first response to the interaction; you cannot defer first.
  • Mismatch between min/max values and the handler. With setMaxValues(2), interaction.values can hold two entries; handle the array, not values[0] alone.
  • Reusing the menu's customId for an option value. Options have values, the menu has the customId; the handler routes on the customId and reads values.
  • Forgetting `setRequired(true)`. Optional empty inputs come back as empty strings; validate length yourself if it matters.

Exercise

Build a report flow: !report posts a button; the button opens a modal with two inputs (offender id, what happened); on submit, send an embed to a private moderators channel containing the reporter's name and both fields, then reply ephemerally Report filed.