From 4ec03ae5178c75d5ddc7fe48b5eae7db1fdf4750 Mon Sep 17 00:00:00 2001 From: Naiyar <137700126+imnaiyar@users.noreply.github.com> Date: Thu, 11 Sep 2025 02:04:57 +0530 Subject: [PATCH] feat!: label component and selects in modal (#11081) BREAKING CHANGE: TextInputComponentData no longer accepts label BREAKING CHANGE: ActionRow and ActionRowData no longer accept TextInput BREAKING CHANGE: `ModalSubmitInteraction#transformComponent` is now private and no longer exposed publicly --------- Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- packages/discord.js/src/index.js | 1 + .../src/structures/LabelComponent.js | 54 ++++++++++++++ .../src/structures/ModalSubmitFields.js | 23 +++++- .../src/structures/ModalSubmitInteraction.js | 67 +++++++++++++---- .../interfaces/InteractionResponses.js | 7 -- packages/discord.js/src/util/Components.js | 32 +++++++- packages/discord.js/typings/index.d.ts | 74 +++++++++++++------ packages/discord.js/typings/index.test-d.ts | 33 ++++++--- 8 files changed, 234 insertions(+), 57 deletions(-) create mode 100644 packages/discord.js/src/structures/LabelComponent.js diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index d05fa7b45..1850749fe 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -180,6 +180,7 @@ exports.InteractionCallbackResponse = exports.InteractionCollector = require('./structures/InteractionCollector.js').InteractionCollector; exports.InteractionWebhook = require('./structures/InteractionWebhook.js').InteractionWebhook; exports.InviteGuild = require('./structures/InviteGuild.js').InviteGuild; +exports.LabelComponent = require('./structures/LabelComponent.js').LabelComponent; exports.MediaChannel = require('./structures/MediaChannel.js').MediaChannel; exports.MediaGalleryComponent = require('./structures/MediaGalleryComponent.js').MediaGalleryComponent; exports.MediaGalleryItem = require('./structures/MediaGalleryItem.js').MediaGalleryItem; diff --git a/packages/discord.js/src/structures/LabelComponent.js b/packages/discord.js/src/structures/LabelComponent.js new file mode 100644 index 000000000..c886f9b16 --- /dev/null +++ b/packages/discord.js/src/structures/LabelComponent.js @@ -0,0 +1,54 @@ +'use strict'; + +const { createComponent } = require('../util/Components.js'); +const { Component } = require('./Component.js'); + +/** + * Represents a label component + * + * @extends {Component} + */ +class LabelComponent extends Component { + constructor({ component, ...data }) { + super(data); + + /** + * The component in this label + * + * @type {Component} + * @readonly + */ + this.component = createComponent(component); + } + + /** + * The label of the component + * + * @type {string} + * @readonly + */ + get label() { + return this.data.label; + } + + /** + * The description of this component + * + * @type {?string} + * @readonly + */ + get description() { + return this.data.description ?? null; + } + + /** + * Returns the API-compatible JSON for this component + * + * @returns {APILabelComponent} + */ + toJSON() { + return { ...this.data, component: this.component.toJSON() }; + } +} + +exports.LabelComponent = LabelComponent; diff --git a/packages/discord.js/src/structures/ModalSubmitFields.js b/packages/discord.js/src/structures/ModalSubmitFields.js index 15484d7be..a1b580dd0 100644 --- a/packages/discord.js/src/structures/ModalSubmitFields.js +++ b/packages/discord.js/src/structures/ModalSubmitFields.js @@ -12,7 +12,7 @@ class ModalSubmitFields { /** * The components within the modal * - * @type {ActionRowModalData[]} + * @type {Array} */ this.components = components; @@ -22,7 +22,16 @@ class ModalSubmitFields { * @type {Collection} */ this.fields = components.reduce((accumulator, next) => { - for (const component of next.components) accumulator.set(component.customId, component); + // NOTE: for legacy support of action rows in modals, which has `components` + if ('components' in next) { + for (const component of next.components) accumulator.set(component.customId, component); + } + + // For label component + if ('component' in next) { + accumulator.set(next.component.customId, next.component); + } + return accumulator; }, new Collection()); } @@ -54,6 +63,16 @@ class ModalSubmitFields { getTextInputValue(customId) { return this.getField(customId, ComponentType.TextInput).value; } + + /** + * Gets the values of a string select component given a custom id + * + * @param {string} customId The custom id of the string select component + * @returns {string[]} + */ + getStringSelectValues(customId) { + return this.getField(customId, ComponentType.StringSelect).values; + } } exports.ModalSubmitFields = ModalSubmitFields; diff --git a/packages/discord.js/src/structures/ModalSubmitInteraction.js b/packages/discord.js/src/structures/ModalSubmitInteraction.js index 8c6f27ed1..6b5fa0e37 100644 --- a/packages/discord.js/src/structures/ModalSubmitInteraction.js +++ b/packages/discord.js/src/structures/ModalSubmitInteraction.js @@ -9,16 +9,38 @@ const { InteractionResponses } = require('./interfaces/InteractionResponses.js') const getMessage = lazy(() => require('./Message.js').Message); /** - * @typedef {Object} ModalData - * @property {string} value The value of the field + * @typedef {Object} BaseModalData * @property {ComponentType} type The component type of the field * @property {string} customId The custom id of the field + * @property {number} id The id of the field + */ + +/** + * @typedef {BaseModalData} TextInputModalData + * @property {string} value The value of the field + */ + +/** + * @typedef {BaseModalData} StringSelectModalData + * @property {string[]} values The values of the field + */ + +/** + * @typedef {TextInputModalData | StringSelectModalData} ModalData + */ + +/** + * @typedef {Object} LabelModalData + * @property {ModalData} component The component within the label + * @property {ComponentType} type The component type of the label + * @property {number} id The id of the label */ /** * @typedef {Object} ActionRowModalData - * @property {ModalData[]} components The components of this action row + * @property {TextInputModalData[]} components The components of this action row * @property {ComponentType} type The component type of the action row + * @property {number} id The id of the action row */ /** @@ -51,7 +73,7 @@ class ModalSubmitInteraction extends BaseInteraction { /** * The components within the modal * - * @type {ActionRowModalData[]} + * @type {Array} */ this.components = data.data.components?.map(component => ModalSubmitInteraction.transformComponent(component)); @@ -96,18 +118,35 @@ class ModalSubmitInteraction extends BaseInteraction { * * @param {*} rawComponent The data to transform * @returns {ModalData[]} + * @private */ static transformComponent(rawComponent) { - return rawComponent.components - ? { - type: rawComponent.type, - components: rawComponent.components.map(component => this.transformComponent(component)), - } - : { - value: rawComponent.value, - type: rawComponent.type, - customId: rawComponent.custom_id, - }; + if ('components' in rawComponent) { + return { + type: rawComponent.type, + id: rawComponent.id, + components: rawComponent.components.map(component => this.transformComponent(component)), + }; + } + + if ('component' in rawComponent) { + return { + type: rawComponent.type, + id: rawComponent.id, + component: this.transformComponent(rawComponent.component), + }; + } + + const data = { + type: rawComponent.type, + customId: rawComponent.custom_id, + id: rawComponent.id, + }; + + if (rawComponent.value) data.value = rawComponent.value; + if (rawComponent.values) data.values = rawComponent.values; + + return data; } /** diff --git a/packages/discord.js/src/structures/interfaces/InteractionResponses.js b/packages/discord.js/src/structures/interfaces/InteractionResponses.js index d0e0afca0..04b987156 100644 --- a/packages/discord.js/src/structures/interfaces/InteractionResponses.js +++ b/packages/discord.js/src/structures/interfaces/InteractionResponses.js @@ -9,13 +9,6 @@ const { InteractionCallbackResponse } = require('../InteractionCallbackResponse. const { InteractionCollector } = require('../InteractionCollector.js'); const { MessagePayload } = require('../MessagePayload.js'); -/** - * @typedef {Object} ModalComponentData - * @property {string} title The title of the modal - * @property {string} customId The custom id of the modal - * @property {ActionRow[]} components The components within this modal - */ - /** * Interface for classes that support shared interaction response types. * diff --git a/packages/discord.js/src/util/Components.js b/packages/discord.js/src/util/Components.js index 00a9564c3..9bda6020e 100644 --- a/packages/discord.js/src/util/Components.js +++ b/packages/discord.js/src/util/Components.js @@ -15,6 +15,24 @@ const { ComponentType } = require('discord-api-types/v10'); * @property {ComponentData[]} components The components in this action row */ +/** + * @typedef {Object} ModalComponentData + * @property {string} title The title of the modal + * @property {string} customId The custom id of the modal + * @property {LabelData[]} components The components within this modal + */ + +/** + * @typedef {StringSelectMenuComponentData|TextInputComponentData} ComponentInLabelData + */ + +/** + * @typedef {BaseComponentData} LabelData + * @property {string} label The label to use + * @property {string} [description] The optional description for the label + * @property {ComponentInLabelData} component The component within the label + */ + /** * @typedef {BaseComponentData} ButtonComponentData * @property {ButtonStyle} style The style of the button @@ -25,6 +43,17 @@ const { ComponentType } = require('discord-api-types/v10'); * @property {string} [url] The URL of the button */ +/** + * @typedef {BaseComponentData} StringSelectMenuComponentData + * @property {string} customId The custom id of the select menu + * @property {boolean} [disabled] Whether the select menu is disabled or not + * @property {number} [maxValues] The maximum amount of options that can be selected + * @property {number} [minValues] The minimum amount of options that can be selected + * @property {SelectMenuComponentOptionData[]} [options] The options in this select menu + * @property {string} [placeholder] The placeholder of the select menu + * @property {boolean} [required] Whether this component is required in modals + */ + /** * @typedef {Object} SelectMenuComponentOptionData * @property {string} label The label of the option @@ -52,7 +81,6 @@ const { ComponentType } = require('discord-api-types/v10'); * @typedef {BaseComponentData} TextInputComponentData * @property {string} customId The custom id of the text input * @property {TextInputStyle} style The style of the text input - * @property {string} label The text that appears on top of the text input field * @property {number} [minLength] The minimum number of characters that can be entered in the text input * @property {number} [maxLength] The maximum number of characters that can be entered in the text input * @property {boolean} [required] Whether or not the text input is required or not @@ -186,6 +214,7 @@ const { ChannelSelectMenuComponent } = require('../structures/ChannelSelectMenuC const { Component } = require('../structures/Component.js'); const { ContainerComponent } = require('../structures/ContainerComponent.js'); const { FileComponent } = require('../structures/FileComponent.js'); +const { LabelComponent } = require('../structures/LabelComponent.js'); const { MediaGalleryComponent } = require('../structures/MediaGalleryComponent.js'); const { MentionableSelectMenuComponent } = require('../structures/MentionableSelectMenuComponent.js'); const { RoleSelectMenuComponent } = require('../structures/RoleSelectMenuComponent.js'); @@ -213,4 +242,5 @@ const ComponentTypeToClass = { [ComponentType.Section]: SectionComponent, [ComponentType.Separator]: SeparatorComponent, [ComponentType.Thumbnail]: ThumbnailComponent, + [ComponentType.Label]: LabelComponent, }; diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 2c6fc92ce..d2e78ff43 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -50,6 +50,7 @@ import { APIInteractionDataResolvedChannel, APIInteractionDataResolvedGuildMember, APIInteractionGuildMember, + APILabelComponent, APIMediaGalleryComponent, APIMediaGalleryItem, APIMentionableSelectComponent, @@ -65,6 +66,7 @@ import { APIMessageTopLevelComponent, APIMessageUserSelectInteractionData, APIModalComponent, + APIModalInteractionResponseCallbackComponent, APIModalInteractionResponseCallbackData, APIModalSubmitInteraction, APIOverwrite, @@ -248,6 +250,7 @@ export class Activity { export type ActivityFlagsString = keyof typeof ActivityFlags; export interface BaseComponentData { + id?: number; type: ComponentType; } @@ -260,17 +263,22 @@ export type MessageActionRowComponentData = | StringSelectMenuComponentData | UserSelectMenuComponentData; -export type ModalActionRowComponentData = JSONEncodable | TextInputComponentData; +export type ActionRowComponentData = MessageActionRowComponentData; -export type ActionRowComponentData = MessageActionRowComponentData | ModalActionRowComponentData; - -export type ActionRowComponent = MessageActionRowComponent | ModalActionRowComponent; +export type ActionRowComponent = MessageActionRowComponent; export interface ActionRowData> extends BaseComponentData { components: readonly ComponentType[]; } +export type ComponentInLabelData = StringSelectMenuComponentData | TextInputComponentData; +export interface LabelData extends BaseComponentData { + component: ComponentInLabelData; + description?: string; + label: string; +} + export type MessageActionRowComponent = | ButtonComponent | ChannelSelectMenuComponent @@ -278,12 +286,11 @@ export type MessageActionRowComponent = | RoleSelectMenuComponent | StringSelectMenuComponent | UserSelectMenuComponent; -export type ModalActionRowComponent = TextInputComponent; -export class ActionRow extends Component< - APIActionRowComponent +export class ActionRow extends Component< + APIActionRowComponent > { - private constructor(data: APIActionRowComponent); + private constructor(data: APIActionRowComponent); public readonly components: ComponentType[]; public toJSON(): APIActionRowComponent>; } @@ -740,6 +747,12 @@ export class TextInputComponent extends Component { public get value(): string; } +export class LabelComponent extends Component { + public component: StringSelectMenuComponent | TextInputComponent; + public get label(): string; + public get description(): string | null; +} + export class BaseSelectMenuComponent extends Component { protected constructor(data: Data); public get placeholder(): string | null; @@ -2527,36 +2540,48 @@ export interface MessageReactionEventDetails { } export interface ModalComponentData { - components: readonly ( - | ActionRowData - | JSONEncodable> - )[]; + components: readonly LabelData[]; customId: string; title: string; } -export interface BaseModalData { +export interface BaseModalData { customId: string; - type: ComponentType; + id: number; + type: Type; } -export interface TextInputModalData extends BaseModalData { - type: ComponentType.TextInput; +export interface TextInputModalData extends BaseModalData { value: string; } +export interface StringSelectModalData extends BaseModalData { + values: readonly string[]; +} + +export type ModalData = StringSelectModalData | TextInputModalData; + +export interface LabelModalData { + component: readonly ModalData[]; + id: number; + type: ComponentType.Label; +} export interface ActionRowModalData { components: readonly TextInputModalData[]; type: ComponentType.ActionRow; } export class ModalSubmitFields { - private constructor(components: readonly (readonly ModalActionRowComponent[])[]); - public components: ActionRowModalData[]; - public fields: Collection; - public getField(customId: string, type: Type): TextInputModalData & { type: Type }; - public getField(customId: string, type?: ComponentType): TextInputModalData; + private constructor(components: readonly (ActionRowModalData | LabelModalData)[]); + public components: (ActionRowModalData | LabelModalData)[]; + public fields: Collection; + public getField( + customId: string, + type: Type, + ): { type: Type } & (StringSelectModalData | TextInputModalData); + public getField(customId: string, type?: ComponentType): StringSelectModalData | TextInputModalData; public getTextInputValue(customId: string): string; + public getStringSelectValues(customId: string): readonly string[]; } export interface ModalMessageModalSubmitInteraction @@ -2579,7 +2604,7 @@ export class ModalSubmitInteraction extend private constructor(client: Client, data: APIModalSubmitInteraction); public type: InteractionType.ModalSubmit; public readonly customId: string; - public readonly components: ActionRowModalData[]; + public readonly components: (ActionRowModalData | LabelModalData)[]; public readonly fields: ModalSubmitFields; public deferred: boolean; public ephemeral: boolean | null; @@ -3645,9 +3670,10 @@ export function verifyString(data: string, error?: typeof Error, errorMessage?: export type ComponentData = | ComponentInContainerData + | ComponentInLabelData | ContainerComponentData + | LabelData | MessageActionRowComponentData - | ModalActionRowComponentData | ThumbnailComponentData; export interface SendSoundboardSoundOptions { @@ -6669,6 +6695,7 @@ export interface BaseSelectMenuComponentData extends BaseComponentData { export interface StringSelectMenuComponentData extends BaseSelectMenuComponentData { options: readonly SelectMenuComponentOptionData[]; + required?: boolean; type: ComponentType.StringSelect; } @@ -6718,7 +6745,6 @@ export interface SelectMenuComponentOptionData { export interface TextInputComponentData extends BaseComponentData { customId: string; - label: string; maxLength?: number; minLength?: number; placeholder?: string; diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 7c64653a7..3f6b5dd90 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -2584,15 +2584,30 @@ await chatInputInteraction.showModal({ custom_id: 'abc', components: [ { - components: [ - { - custom_id: 'aa', - label: 'label', - style: TextInputStyle.Short, - type: ComponentType.TextInput, - }, - ], - type: ComponentType.ActionRow, + component: { + type: ComponentType.StringSelect, + id: 2, + custom_id: 'aa', + options: [{ label: 'a', value: 'b' }], + }, + type: ComponentType.Label, + label: 'yo', + }, + ], +}); + +await chatInputInteraction.showModal({ + title: 'abc', + customId: 'abc', + components: [ + { + type: ComponentType.Label, + component: { + type: ComponentType.TextInput, + style: TextInputStyle.Short, + customId: 'aa', + }, + label: 'yo', }, ], });