UNPKG

discordjs-choice-selectmenu-builder

Version:

An utility builder to represent arrays as paginated Discord select menus.

712 lines (709 loc) 24.2 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/ChoiceSelectMenuBuilder.ts var ChoiceSelectMenuBuilder_exports = {}; __export(ChoiceSelectMenuBuilder_exports, { ChoiceSelectMenuBuilder: () => ChoiceSelectMenuBuilder }); module.exports = __toCommonJS(ChoiceSelectMenuBuilder_exports); var import_discord = require("discord.js"); // src/PageManager.ts var PageManager = class { static { __name(this, "PageManager"); } constructor(array, selected, minChoices, maxChoices, currentPage = 0) { this.current = currentPage; this.array = array; this.selected = selected; this.minChoices = minChoices; if (maxChoices) { this.maxChoices = maxChoices; } } /** * The length that a single select menu page can have. */ length = 25; /** * A reference to the array to paginate. */ array; /** * A reference to the selected elements. */ selected; /** * The 0-indexed page the builder is currently on. * This is always in the range `0 <= current <= max` */ current; /** * The minimum amount of choices that a user must make. * Note that it only prevents selecting less than this value, it * can still be visually shown without any selections. */ minChoices; /** * The maximum amount of choices that a user may make. * This value defaults to `options.length`. */ maxChoices; get carrySelected() { return this.minChoices > 0; } /** * The maximum 0-indexed page the builder can reach. * This property is derived from the page's length */ get max() { if (!this.carrySelected) return Math.ceil(this.array.length / this.length) - 1; const notSelected = this.array.length - this.selected.size; const newPageSize = this.length - this.selected.size; return Math.ceil(notSelected / newPageSize) - 1; } getPage(pageNumber) { const page = Math.min(this.max, pageNumber ?? this.current); if (!this.carrySelected) { const start2 = page * this.length; const end2 = start2 + this.length; return this.getSlice(start2, end2, false); } const keys = [...this.selected.keys()]; let currentPage = 0; let start = 0; while (currentPage < page) { start = this.getEndIndex(start, keys); currentPage++; } const end = this.getEndIndex(start, keys); const output = [ ...this.selected.entries(), ...this.getSlice(start, end, true) ]; if (output.length > this.length) throw new Error( `Generated page exceeds set page length. Expected: <Array>.length <= ${this.length} Actual: ${output.length}` ); return output; } /** * Returns a slice of the array, along with its actual index location. * @param start - The start of the slice. * @param end - The end of the slice. * @param removeSelected - Removes selected indeces from the sliced array. */ getSlice(start, end, removeSelected) { const newSlice = this.array.slice(start, end).map((e, i) => [i + start, e]); if (!removeSelected) return newSlice; return newSlice.filter(([i, _]) => !this.selected.has(i)).slice(0, this.length - this.selected.size); } /** * Get the end index of the page starting from the start parameter. * Will include more elements if the keys exist on the page. * @param start - The offset to base the end index on. * @param selectedIndeces - The selected indeces */ getEndIndex(start, selectedIndeces) { let end = start + this.length; const withinBounds = selectedIndeces.filter( (i) => i >= start && i < end ).length; return end - selectedIndeces.length + withinBounds; } first() { if (this.hasFreePageMovement()) { this.current = 0; return; } const minSelected = Math.min(...this.selected.keys()); if (!isFinite(minSelected)) return; this.current = Math.ceil(minSelected / this.length); if (minSelected % this.length !== 0) { this.current -= 1; } } previous() { if (this.hasFreePageMovement()) { this.current = Math.max(0, this.current - 1); return; } const currentPageStart = this.current * this.length; const maxPreviousIndex = Math.max( ...this.selected.filter((_, n) => n < currentPageStart).keys() ); if (!isFinite(maxPreviousIndex)) return; this.current = Math.ceil(maxPreviousIndex / this.length); if (maxPreviousIndex % this.length !== 0) { this.current -= 1; } } next() { const max = this.max; if (this.hasFreePageMovement()) { this.current = Math.min(max, this.current + 1); return; } const currentPageEnd = this.current * this.length + this.length; const minNextIndex = Math.min( ...this.selected.filter((_, n) => n >= currentPageEnd).keys() ); if (!isFinite(minNextIndex)) return; this.current = Math.ceil(minNextIndex / this.length); if (minNextIndex % this.length !== 0) { this.current -= 1; } } last() { const max = this.max; if (this.hasFreePageMovement()) { this.current = max; return; } const maxSelected = Math.max(...this.selected.keys()); if (!isFinite(maxSelected)) return; this.current = Math.ceil(maxSelected / this.length); if (maxSelected % this.length !== 0) { this.current -= 1; } } hasFreePageMovement() { return ( // carrySelected follows the user no matter where this.carrySelected || // no pagination this.array.length <= this.length || // maxChoices has not yet been filled this.selected.size < (this.maxChoices ?? this.array.length) ); } }; // src/ChoiceSelectMenuBuilder.ts function trimString(input, len) { if (input === void 0) return void 0; if (input.length < len - 4) { return input; } else { return input.slice(0, len - 4) + " ..."; } } __name(trimString, "trimString"); var ChoiceSelectMenuBuilder = class _ChoiceSelectMenuBuilder { static { __name(this, "ChoiceSelectMenuBuilder"); } /** * Manages a select menu interface to select elements in an array. * @param choices - The array of choices to represent. * @param selected - A callback function that defines what values are chosen. */ constructor(choices, selected) { const collection = new import_discord.Collection(); this.options = choices; this.data = { selected: collection, labelFn: (value) => `${value}`, pages: new PageManager(choices, collection, 0), navigatorStyle: import_discord.ButtonStyle.Primary, pageLabelStyle: import_discord.ButtonStyle.Danger }; if (typeof selected !== "undefined") { this.addValues(selected); } } /** * Configure the discord limits that this builder uses. * As they may change over the years, this provides a way to * update them for compatability. * @param config The config options to update to new values. If a property is omitted, it is * reset to the initial value. */ static configureLimits(config) { _ChoiceSelectMenuBuilder.DiscordLimits = { MenuLength: config.MenuLength ?? 25, OptionDescription: config.OptionDescription ?? 100, OptionLabel: config.OptionLabel ?? 100 }; } /** * The limits that Discord enforces on certain select menu related properties. * Updated as of Jan 26, 2025. */ static DiscordLimits = { MenuLength: 25, OptionLabel: 100, OptionDescription: 100 }; static EnforceDiscordLimits = true; /** * Contains all data related to this builder instance. */ data; /** * The reference to the array this builder represents. */ options; /** * Sets the custom ID of this builder. * @param {string} customId - The custom ID to set */ setCustomId(customId) { this.data.customId = customId; return this; } /** * Set the minimum amount of choices of this builder. Defaults to * 0 for every new instance. * @param amount - The minimum amount of choices to select in this menu. */ setMinChoices(amount) { const { DiscordLimits } = _ChoiceSelectMenuBuilder; if (amount > DiscordLimits.MenuLength) throw new Error("MinChoices may not be above Discord's limit"); if (amount < 0) throw new Error("MinChoices must not be negative."); if (this.options.length && amount > this.options.length) throw new Error( "MinChoices must not exceed the amount of available options." ); this.data.pages.minChoices = amount; return this; } /** * Set the maximum amount of choices of this select menu. * @param amount - The maximum amount of choices to select in this menu. */ setMaxChoices(amount) { if (amount <= 0) throw new Error("MaxChoices may not be 0 or lower."); if (this.options.length && amount > this.options.length) throw new Error( "MaxChoices must not exceed the amount of available options." ); this.data.pages.maxChoices = amount; return this; } /** * Sets the callback function to transform an array element into a readable * label string. Discord's label character limit applies. * @param labelFn - The callback function to transform the element. * The returned string must be below Discord's label character limit. * @see {@link https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure} */ setLabel(labelFn) { this.data.labelFn = labelFn; return this; } /** * Sets the callback function to transform an array element into a readable * description string. Discord's description character limit applies. * Will not create a description by default. * @param descriptionFn - The callback function to transform the element. * The returned string must be below Discord's description character limit. * @see {@link https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure} */ setDescription(descriptionFn) { if (descriptionFn === null) { delete this.data.descriptionFn; return this; } this.data.descriptionFn = descriptionFn; return this; } /** * Set the button styles for the navigator buttons. * @param style - The style to apply on the navigator buttons. */ setNavigatorStyle(style) { this.data.navigatorStyle = style; return this; } /** * Set the button styles for the center button displaying the current page. * @param style - The style to apply on the center button displaying the current page. */ setPageLabelStyle(style) { this.data.pageLabelStyle = style; return this; } /** * Set the placeholder of this builder's select menu. * @param placeholder - A static string to set as placeholder, or * a callback function to dynamically set the placeholder. Passes * the minimum and maximum choices of the current select menu. * * The placeholder must be below Discord's placeholder character limit. * @see {@link https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-menu-structure */ setPlaceholder(placeholder) { if (placeholder === null) { delete this.data.placeholder; return this; } this.data.placeholder = placeholder; return this; } /** * Set the selected values of this builder. * @param selected - A callback function or an object / array of objects to use. * - If the passed type is a function, that function will be called to set the values. * - If the passed type is an array, it will default to `Array#includes()`. * - Otherwise, `Object.is()` equality is used. */ setValues(selected) { this.data.selected.clear(); this.addValues(selected); return this; } /** * Add the selected values of this builder. * @param selected - A callback function or an object / array of objects to use. * - If the passed type is a function, that function will be called to set the values. * - If the passed type is an array, it will default to `Array#includes()`. * - Otherwise, `Object.is()` equality is used. */ addValues(selected) { const selectFn = this.narrowSelectCallback(selected); const collection = this.data.selected; this.options.forEach((option, index, arr) => { if (selectFn(option, index, arr)) { collection.set(index, option); } }); const maxChoices = this.data.pages.maxChoices ?? this.options.length; if (maxChoices < this.data.selected.size) throw new Error( "Selected values exceed the configured maximum amount." ); return this; } /** * Filters the selected values based on the provided function. * @param valueFn - The callback function to use as filter. If this function * returns false, the selected value is removed from this menu. */ filterValues(valueFn) { const filtered = this.data.selected.filter(valueFn); this.data.selected = filtered; this.data.pages.selected = filtered; return this; } /** * Clears all selected values from this menu. */ clearValues() { this.data.selected.clear(); return this; } /** * Removes the last selected value and returns it. * If there are no selected values, it will return undefined. * * This value may be undefined even if `minChoices > 0`. */ popValue() { const lastKey = this.data.selected.lastKey(); if (typeof lastKey === "undefined") return void 0; const value = this.data.selected.get(lastKey); this.data.selected.delete(lastKey); return value; } /** * Returns a shallow copy of options that are visible on the current page. * If no page is specified, it will return the current page. * @param page - The page to fetch options from. */ optionsOnPage(page = this.data.pages.current) { return this.data.pages.getPage(page).map((v) => v[1]); } /** * Determines the selected values on the current page. If no * parameter is provided, it will take the current page. * * Note that if `minChoices > 0`, selected values will always exist on the page. * @param page - The page to fetch the selected options from. */ selectedOnPage(onPage = this.data.pages.current) { const page = this.data.pages.getPage(onPage); return page.filter((v) => this.data.selected.has(v[0])).map((v) => v[1]); } /** * The selected values of this select menu. * Returns a shallow copy of the provided choices. * If you only need the first property, consider using * {@link ChoiceSelectMenuBuilder#firstValue} * * This array may be empty even if `minChoices > 0`. */ get values() { return [...this.data.selected.values()]; } /** * The first selected value of this select menu. * * This value may be undefined even if `minChoices > 0`. */ get firstValue() { return this.data.selected.first(); } /** * Provides default function behaviour for non-functions passed to * methods. * ``` * const selected = [1, 2, 3]; * selectMenu.narrowSelectCallback(selected) // this is now (v) => selected.includes(v) * ``` * @param selected - The provided value or function to narrow down into a select function. * @returns - A function callback that can be used in `Array.prototype.filter()` and the like. */ narrowSelectCallback(selected) { if (typeof selected === "undefined") { return () => false; } if (Array.isArray(selected)) { return (v) => selected.includes(v); } if (typeof selected !== "function") { return (v) => Object.is(v, selected); } return selected; } /** * Changes the paginated menu to the first page. If the maximum * amount of choices has been reached, it only skips to pages with * selections on it. */ toFirstPage() { this.data.pages.first(); return this; } /** * Changes the paginated menu to the previous page. If the maximum * amount of choices has been reached, only skips to pages with * selections on it. */ toPreviousPage() { this.data.pages.previous(); return this; } /** * Changes the paginated menu to the next page. If the maximum * amount of choices has been reached, only skips to pages with * selections on it. */ toNextPage() { this.data.pages.next(); return this; } /** * Changes the paginated menu to the last page. If the maximum * amount of choices has been reached, it only skips to pages with * selections on it. */ toLastPage() { this.data.pages.last(); return this; } /** * Creates the action row based on this builder. * If the `choices` array is empty, no select menu will be generated. * If the array exceeds discord's limit for select menus, * a second row of page buttons will be passed. */ toActionRow() { if (this.options.length === 0) return []; if (typeof this.data.customId === "undefined") { throw new Error( "ChoiceSelectMenuBuilder.customId: expected a string primitive" ); } const { customId, placeholder, selected, pages } = this.data; const currentMax = Math.min( // - maxChoices could be = this.options.length, so // cap at this.optionsAtPage().length // - if there's only one page, then // this.selected.length === this.selectedOnPage().length, // so they cancel each other out. This is only for pagination // purposes. (pages.maxChoices ?? this.options.length) - selected.size + this.selectedOnPage().length, this.optionsOnPage().length ); const selectMenuData = { custom_id: customId, min_values: pages.minChoices, max_values: currentMax }; switch (typeof placeholder) { case "function": selectMenuData.placeholder = placeholder( pages.minChoices, currentMax ); break; case "string": selectMenuData.placeholder = placeholder; break; default: break; } const selectMenu = new import_discord.StringSelectMenuBuilder(selectMenuData); selectMenu.addOptions( pages.getPage().map(this.toAPISelectMenuOption, this) ); if (pages.max === 0) { return [ new import_discord.ActionRowBuilder({ components: [selectMenu] }) ]; } return [ this.navigatorButtons, new import_discord.ActionRowBuilder({ components: [selectMenu] }) ]; } /** * Determines whether or not the interaction belongs to this builder. * If the interaction belongs to this builder, it handles the received * interaction response. * @param interaction - The component interaction response to check */ isInteraction(interaction) { if (!this.hasComponent(interaction)) return false; if (interaction.isButton()) { const getPageButtonId = interaction.customId.split("--")?.pop(); switch (getPageButtonId) { case "firstPage": this.toFirstPage(); break; case "prevPage": this.toPreviousPage(); break; case "nextPage": this.toNextPage(); break; case "lastPage": this.toLastPage(); break; default: break; } return true; } this.updateSelectedFromValues(interaction.values); return true; } /** * Determines whether or not the interaction belongs to this builder. * @param interaction - The interaction to narrow */ hasComponent(interaction) { if (interaction.isCommand() || interaction.isAutocomplete()) return false; if (typeof this.data.customId === "undefined") return false; return interaction.customId.startsWith(this.data.customId); } /** * Parses an array of values (from a select menu) * into the selected values. This assumes that the StringSelectMenuInteraction * belongs to this ChoiceSelectMenuBuilder. If that assumption is not met or there * is some issue with the custom IDs, they will be filtered out. * @param values - The values to transform into selected values. */ updateSelectedFromValues(values) { const { pages } = this.data; if (!pages.carrySelected) { const currentPage = pages.getPage().map((v) => v[0]); const start = Math.min(...currentPage); const end = Math.max(...currentPage); this.filterValues((_, i) => i >= end || i < start); } else { this.data.selected.clear(); } const idsOnPage = values.map((v) => Number(v.split("--")?.pop())).filter((n) => !isNaN(n) && isFinite(n)); for (const i of idsOnPage) { const selectedOption = this.options.at(i); if (typeof selectedOption === "undefined") continue; this.data.selected.set(i, selectedOption); } } /** * Transforms the provided option into a usable API Select Menu Option. * @param row - The row to transform, including its index and element. */ toAPISelectMenuOption(row) { const { OptionLabel, OptionDescription } = _ChoiceSelectMenuBuilder.DiscordLimits; const [i, o] = row; return { label: trimString(this.data.labelFn(o, i), OptionLabel), description: trimString( this.data.descriptionFn?.(o, i), OptionDescription ), default: this.data.selected.has(i), value: `${this.data.customId}--${i}` }; } /** * Generates the page buttons for the currently selected page. * Disables buttons dependent on what page the user is on and * how many choices are remaining. */ get navigatorButtons() { const { customId, selected, pages, navigatorStyle, pageLabelStyle } = this.data; const currentPage = pages.getPage().map((v) => v[0]); const start = Math.min(...currentPage); const end = Math.max(...currentPage); const max = pages.max; let isAtStart = pages.current === 0; let isAtEnd = pages.current === max; if (!pages.carrySelected && selected.size >= (pages.maxChoices ?? this.options.length)) { isAtStart ||= selected.every((_, n) => n >= start); isAtEnd ||= selected.every((_, n) => n <= end); } return new import_discord.ActionRowBuilder().addComponents( new import_discord.ButtonBuilder().setLabel("\u23EE\uFE0F").setStyle(navigatorStyle).setDisabled(isAtStart).setCustomId(`${customId}--firstPage`), new import_discord.ButtonBuilder().setLabel("\u25C0\uFE0F").setStyle(navigatorStyle).setDisabled(isAtStart).setCustomId(`${customId}--prevPage`), // The center button displays what page you're currently on. new import_discord.ButtonBuilder().setLabel(`Page ${pages.current + 1}/${max + 1}`).setStyle(pageLabelStyle).setDisabled(true).setCustomId("btn-never"), new import_discord.ButtonBuilder().setLabel("\u25B6\uFE0F").setStyle(navigatorStyle).setDisabled(isAtEnd).setCustomId(`${customId}--nextPage`), new import_discord.ButtonBuilder().setLabel("\u23ED\uFE0F").setStyle(navigatorStyle).setDisabled(isAtEnd).setCustomId(`${customId}--lastPage`) ); } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ChoiceSelectMenuBuilder }); //# sourceMappingURL=index.js.map