diff --git a/.changeset/four-buses-knock.md b/.changeset/four-buses-knock.md
new file mode 100644
index 0000000..5ed4c14
--- /dev/null
+++ b/.changeset/four-buses-knock.md
@@ -0,0 +1,5 @@
+---
+"jellycommands": minor
+---
+
+feat: add modal component
diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs
index 6ad5f95..e07f1a5 100644
--- a/packages/docs/astro.config.mjs
+++ b/packages/docs/astro.config.mjs
@@ -88,10 +88,23 @@ export default defineConfig({
},
],
},
+ {
+ label: 'Modals',
+ items: [
+ {
+ label: 'Creating Modals',
+ link: '/components/modals',
+ },
+ ],
+ },
{
label: 'Props',
link: '/components/props',
},
+ {
+ label: 'Custom Ids',
+ link: '/components/custom-ids',
+ },
{
label: 'Deferring Interactions',
link: '/components/deferring',
diff --git a/packages/docs/src/assets/docs/modal-failed.png b/packages/docs/src/assets/docs/modal-failed.png
new file mode 100644
index 0000000..d4f9808
Binary files /dev/null and b/packages/docs/src/assets/docs/modal-failed.png differ
diff --git a/packages/docs/src/assets/docs/working-modal.png b/packages/docs/src/assets/docs/working-modal.png
new file mode 100644
index 0000000..3c3d87f
Binary files /dev/null and b/packages/docs/src/assets/docs/working-modal.png differ
diff --git a/packages/docs/src/content/docs/components/buttons/index.mdx b/packages/docs/src/content/docs/components/buttons/index.mdx
index b4fff9d..795d319 100644
--- a/packages/docs/src/content/docs/components/buttons/index.mdx
+++ b/packages/docs/src/content/docs/components/buttons/index.mdx
@@ -99,73 +99,4 @@ Now when we click the button we see that it sends our "Hello World" response!

-## Custom Id
-
-Each button needs to be given a "custom id" when you create it, so when you handle a button press you can use the correct handler. The simplest example, as we saw above, is just to use a static custom id.
-
-```js {4}
-import { button } from 'jellycommands';
-
-export default button({
- id: 'test',
-
- async run({ interaction }) {},
-});
-```
-
-Unlike commands, we don't need to tell Discord about the buttons before we use them. They are effectively created every time you reply to an interaction with them. This means that our custom ids can be dynamic! This can simplify some interactions since we can store some information on the id. In order to make this possible the `id` option on a button component can also be regex or a function.
-
-### Regex
-
-This regex will be used to see if we have found a match for the button interaction. It should always start with `^` and `$` to ensure it's matching the whole id rather than just a section of it, and not be global.
-
-```js
-import { button } from 'jellycommands';
-
-export default button({
- id: /^page_\d+$/,
-
- async run({ interaction }) {
- console.log(interaction.customId);
- },
-});
-```
-
-### Matcher Function
-
-This function is passed the custom id, and then should return a boolean that indicates whether a match has been found.
-
-```js
-import { button } from 'jellycommands';
-
-export default button({
- id: (customId) => {
- return customId.startsWith('page_');
- },
-
- async run({ interaction }) {
- console.log(interaction.customId);
- },
-});
-```
-
-### Deferring
-
-By default Discord requires you to respond to a button within 3 seconds, otherwise it marks the interaction as failed. Often it'll take longer than 3 seconds to respond, so you need to "defer" your reply. If you defer your command you need to use `followUp` instead of reply:
-
-```js {6,10} ins="followUp" del="reply"
-import { button } from 'jellycommands';
-
-export default button({
- id: 'test',
-
- defer: true,
-
- async run({ interaction }) {
- await interaction.reply('Hello World');
- await interaction.followUp('Hello World');
- },
-});
-```
-
-[Read more on deferring](/components/deferring).
+The "custom id" system is very powerful, allowing for dynamic matching to store arbitrary data. [Learn more about custom ids](/components/custom-ids).
diff --git a/packages/docs/src/content/docs/components/custom-ids.mdx b/packages/docs/src/content/docs/components/custom-ids.mdx
new file mode 100644
index 0000000..013f020
--- /dev/null
+++ b/packages/docs/src/content/docs/components/custom-ids.mdx
@@ -0,0 +1,73 @@
+---
+title: Custom Ids
+description: Learn how to leverage custom ids with modals and buttons.
+---
+
+Buttons and modals need to be given a "custom id" when you create them, so that the correct handler is used. The simplest way to do this is just to use a static custom id.
+
+```js {4}
+import { button } from 'jellycommands';
+
+export default button({
+ id: 'test',
+
+ async run({ interaction }) {},
+});
+```
+
+Unlike commands, we don't need to tell Discord about buttons and modals before we use them. They are effectively created every time you reply to an interaction with them. This means that our custom ids can be dynamic! This can simplify some interactions since we can store some information on the id. In order to make this possible the `id` option on a button component can also be regex or a function.
+
+### Regex
+
+This regex will be used to see if we have found a match for the button interaction. It should always start with `^` and `$` to ensure it's matching the whole id rather than just a section of it, and not be global.
+
+```js
+import { button } from 'jellycommands';
+
+export default button({
+ id: /^page_\d+$/,
+
+ async run({ interaction }) {
+ console.log(interaction.customId);
+ },
+});
+```
+
+### Matcher Function
+
+This function is passed the custom id, and then should return a boolean that indicates whether a match has been found.
+
+```js
+import { modal } from 'jellycommands';
+
+export default modal({
+ id: (customId) => {
+ return customId.startsWith('page_');
+ },
+
+ async run({ interaction }) {
+ console.log(interaction.customId);
+ },
+});
+```
+
+### Deferring
+
+By default Discord requires you to respond to an interaction within 3 seconds, otherwise it marks the interaction as failed. Often it'll take longer than 3 seconds to respond, so you need to "defer" your reply. If you defer your command you need to use `followUp` instead of reply:
+
+```js {6,10} ins="followUp" del="reply"
+import { button } from 'jellycommands';
+
+export default button({
+ id: 'test',
+
+ defer: true,
+
+ async run({ interaction }) {
+ await interaction.reply('Hello World');
+ await interaction.followUp('Hello World');
+ },
+});
+```
+
+[Read more on deferring](/components/deferring).
diff --git a/packages/docs/src/content/docs/components/modals/index.mdx b/packages/docs/src/content/docs/components/modals/index.mdx
new file mode 100644
index 0000000..10ea2e0
--- /dev/null
+++ b/packages/docs/src/content/docs/components/modals/index.mdx
@@ -0,0 +1,114 @@
+---
+title: Modals
+description: Learn about how to create modals with JellyCommands.
+---
+
+import { Tabs, TabItem } from '@astrojs/starlight/components';
+
+A modal component can be used to respond to modal submissions, from modal you create. You'll need to create and send these modal yourself, so let's get started by creating a simple slash command to do so:
+
+
+
+ ```ts {2-7,14-18,20-21,23-27,29-30}
+ import { command } from 'jellycommands';
+ import {
+ TextInputBuilder,
+ TextInputStyle,
+ ModalBuilder,
+ ActionRowBuilder,
+ } from 'discord.js';
+
+ export default command({
+ name: 'test-modal',
+ description: 'Shows a modal with a text input',
+
+ async run({ interaction }) {
+ // Create a text input component with the builder
+ const nameInput = new TextInputBuilder()
+ .setCustomId('nameInput')
+ .setLabel('Whats your name?')
+ .setStyle(TextInputStyle.Short);
+
+ // All components need to be in a "row"
+ const row = new ActionRowBuilder()).addComponents(nameInput);
+
+ // Create the actual modal
+ const modal = new ModalBuilder()
+ .setCustomId('test')
+ .setTitle('Whats Your Name?')
+ .addComponents(row);
+
+ // Send the modal
+ interaction.showModal(modal);
+ },
+ });
+ ```
+
+
+
+
+ ```js {2-7,14-18,20-23,25-29,31-32}
+ import { command } from 'jellycommands';
+ import {
+ TextInputBuilder,
+ TextInputStyle,
+ ModalBuilder,
+ ActionRowBuilder,
+ } from 'discord.js';
+
+ export default command({
+ name: 'test-modal',
+ description: 'Shows a modal with a text input',
+
+ async run({ interaction }) {
+ // Create a text input component with the builder
+ const nameInput = new TextInputBuilder()
+ .setCustomId('nameInput')
+ .setLabel('Whats your name?')
+ .setStyle(TextInputStyle.Short);
+
+ // All components need to be in a "row"
+ const row = /** @type {ActionRowBuilder} */ (
+ new ActionRowBuilder()
+ ).addComponents(nameInput);
+
+ // Create the actual modal
+ const modal = new ModalBuilder()
+ .setCustomId('test')
+ .setTitle('Whats Your Name?')
+ .addComponents(row);
+
+ // Send the modal
+ interaction.showModal(modal);
+ },
+ });
+ ```
+
+
+
+
+On running this command, your modal is opened! However, you'll notice that when you submit the modal it fails:
+
+
+
+This is where the JellyCommands comes in, it allows us to create a modal component that can respond to modal submissions. All we need is the modal's "custom id", which you might have noticed us setting in the above example. From here we can read the value of the text input using [`interaction.fields.getTextInputValue`](https://discordjs.guide/interactions/modals.html#extracting-data-from-modal-submissions).
+
+```ts
+import { modal } from 'jellycommands';
+
+export default modal({
+ id: 'test',
+
+ async run({ interaction }) {
+ interaction.reply({
+ content: `Hello, ${interaction.fields.getTextInputValue('nameInput')}`,
+ });
+ },
+});
+```
+
+Now when we submit the modal we see that it sends our response!
+
+
+
+The "custom id" system is very powerful, allowing for dynamic matching to store arbitrary data. [Learn more about custom ids](/components/custom-ids).
diff --git a/packages/jellycommands/src/components/modals/modals.ts b/packages/jellycommands/src/components/modals/modals.ts
new file mode 100644
index 0000000..a57815b
--- /dev/null
+++ b/packages/jellycommands/src/components/modals/modals.ts
@@ -0,0 +1,55 @@
+import { type ModalOptions, modalSchema, type ModalField } from './options';
+import type { JellyCommands } from '../../JellyCommands';
+import type { ModalSubmitInteraction } from 'discord.js';
+import { Component, isComponent } from '../components';
+import type { MaybePromise } from '../../utils/types';
+import { MODALS_COMPONENT_ID } from './plugin';
+import { parseSchema } from '../../utils/zod';
+
+type InputComponentMapper = {
+ [K in T as K['customId']]: K['required'] extends false
+ ? string | null
+ : string;
+};
+
+export type ModalCallback = (context: {
+ client: JellyCommands;
+ props: Props;
+ interaction: ModalSubmitInteraction;
+ fields: InputComponentMapper;
+}) => MaybePromise;
+
+/**
+ * Represents a modal.
+ * @see https://jellycommands.dev/components/modals
+ */
+export class Modal extends Component> {
+ public readonly options: ModalOptions;
+
+ constructor(
+ _options: ModalOptions,
+ public readonly run: ModalCallback,
+ ) {
+ super(MODALS_COMPONENT_ID, 'Modal');
+ this.options = parseSchema(
+ 'modal',
+ modalSchema,
+ _options,
+ ) as ModalOptions;
+ }
+
+ static is(item: any): item is Modal {
+ return isComponent(item) && item.id === MODALS_COMPONENT_ID;
+ }
+}
+
+/**
+ * Creates a modal.
+ * @see https://jellycommands.dev/components/modals
+ */
+export const modal = (
+ options: ModalOptions & { run: ModalCallback },
+) => {
+ const { run, ...rest } = options;
+ return new Modal(rest, run);
+};
diff --git a/packages/jellycommands/src/components/modals/options.ts b/packages/jellycommands/src/components/modals/options.ts
new file mode 100644
index 0000000..004d673
--- /dev/null
+++ b/packages/jellycommands/src/components/modals/options.ts
@@ -0,0 +1,52 @@
+import {
+ type InteractionDeferReplyOptions,
+ type TextInputComponentData,
+} from 'discord.js';
+import type { BaseComponentOptions } from '../components';
+import type { MaybePromise } from '../../utils/types';
+import { z } from 'zod';
+
+export type ModalField = Omit;
+
+export interface ModalOptions
+ extends BaseComponentOptions {
+ /**
+ * The customId of the modal, or a regex/function to match against
+ */
+ id: string | RegExp | ((id: string) => MaybePromise);
+
+ /**
+ * Should the interaction be defered?
+ */
+ defer?: boolean | InteractionDeferReplyOptions;
+
+ /**
+ * The text input fields of the modal
+ */
+ fields: (T & ModalField)[];
+}
+
+export const modalSchema = z.object({
+ id: z.union([
+ z.string(),
+ z.instanceof(RegExp),
+ // todo test this
+ z
+ .function()
+ .args(z.string().optional())
+ .returns(z.union([z.boolean(), z.promise(z.boolean())])),
+ ]),
+
+ defer: z
+ .union([
+ z.boolean().default(false),
+ z.object({
+ ephemeral: z.boolean().optional(),
+ fetchReply: z.boolean().optional(),
+ }),
+ ])
+ .optional(),
+
+ disabled: z.boolean().default(false).optional(),
+ fields: z.any().array().min(1).max(4),
+});
diff --git a/packages/jellycommands/src/components/modals/plugin.ts b/packages/jellycommands/src/components/modals/plugin.ts
new file mode 100644
index 0000000..f36451e
--- /dev/null
+++ b/packages/jellycommands/src/components/modals/plugin.ts
@@ -0,0 +1,57 @@
+import { defineComponentPlugin } from '../../plugins/plugins';
+import type { Modal } from './modals';
+
+export const MODALS_COMPONENT_ID = 'jellycommands.modal';
+
+// TODO test this function
+async function findModal(
+ incomingId: string,
+ modals: Set,
+): Promise {
+ for (const modal of modals) {
+ const { id } = modal.options;
+
+ switch (typeof id) {
+ case 'string':
+ if (id === incomingId) return modal;
+ break;
+
+ case 'function':
+ // todo should this be sync only? might cause issues when not deffered
+ if (await id(incomingId)) return modal;
+ break;
+
+ case 'object':
+ if (id.test(incomingId)) return modal;
+ break;
+ }
+ }
+
+ return null;
+}
+
+export const modalsPlugin = defineComponentPlugin(MODALS_COMPONENT_ID, {
+ register(client, modals) {
+ client.on('interactionCreate', async (interaction) => {
+ if (interaction.isModalSubmit()) {
+ const modal = await findModal(interaction.customId, modals);
+
+ if (modal) {
+ if (modal.options.defer) {
+ await interaction.deferReply(
+ typeof modal.options.defer === 'object'
+ ? modal.options.defer
+ : {},
+ );
+ }
+
+ await modal.run({
+ client,
+ props: client.props,
+ interaction,
+ });
+ }
+ }
+ });
+ },
+});
diff --git a/packages/jellycommands/src/index.ts b/packages/jellycommands/src/index.ts
index be93eab..b249084 100644
--- a/packages/jellycommands/src/index.ts
+++ b/packages/jellycommands/src/index.ts
@@ -25,3 +25,7 @@ export type { EventOptions } from './components/events/options';
// Export button related
export { button, Button } from './components/buttons/buttons';
export type { ButtonOptions } from './components/buttons/options';
+
+// Export modal related
+export { modal, Modal } from './components/modals/modals';
+export type { ModalOptions } from './components/modals/options';
diff --git a/packages/jellycommands/src/plugins/core.ts b/packages/jellycommands/src/plugins/core.ts
index 29334f3..791b72b 100644
--- a/packages/jellycommands/src/plugins/core.ts
+++ b/packages/jellycommands/src/plugins/core.ts
@@ -1,10 +1,12 @@
import { commandsPlugin } from '../components/commands/plugin';
import { buttonsPlugin } from '../components/buttons/plugin';
import { eventsPlugin } from '../components/events/plugin';
+import { modalsPlugin } from '../components/modals/plugin';
import type { AnyPlugin } from './plugins';
export const CORE_PLUGINS: AnyPlugin[] = [
buttonsPlugin,
commandsPlugin,
eventsPlugin,
+ modalsPlugin,
];
diff --git a/packages/playground/src/commands/file-loaded/testModal.js b/packages/playground/src/commands/file-loaded/testModal.js
new file mode 100644
index 0000000..48a222d
--- /dev/null
+++ b/packages/playground/src/commands/file-loaded/testModal.js
@@ -0,0 +1,36 @@
+import {
+ TextInputBuilder,
+ TextInputStyle,
+ ModalBuilder,
+ ActionRowBuilder,
+} from 'discord.js';
+import { command } from 'jellycommands';
+
+export default command({
+ name: 'test-modal',
+ description: 'Shows a modal with a text input',
+
+ global: true,
+
+ async run({ interaction }) {
+ // Create a text input component with the builder
+ const nameInput = new TextInputBuilder()
+ .setCustomId('nameInput')
+ .setLabel('Whats your name?')
+ .setStyle(TextInputStyle.Short);
+
+ // All components need to be in a "row"
+ const row = /** @type {ActionRowBuilder} */ (
+ new ActionRowBuilder()
+ ).addComponents(nameInput);
+
+ // Create the actual modal
+ const modal = new ModalBuilder()
+ .setCustomId('test')
+ .setTitle('Whats Your Name?')
+ .addComponents(row);
+
+ // Send the modal
+ interaction.showModal(modal);
+ },
+});
diff --git a/packages/playground/src/index.js b/packages/playground/src/index.js
index c0a09eb..d331866 100644
--- a/packages/playground/src/index.js
+++ b/packages/playground/src/index.js
@@ -19,6 +19,7 @@ const client = new JellyCommands({
components: [
pog,
ready,
+ 'src/modals',
'src/commands/file-loaded',
'src/events/file-loaded',
'src/buttons',
diff --git a/packages/playground/src/modals/test.js b/packages/playground/src/modals/test.js
new file mode 100644
index 0000000..3a26d9b
--- /dev/null
+++ b/packages/playground/src/modals/test.js
@@ -0,0 +1,11 @@
+import { modal } from 'jellycommands';
+
+export default modal({
+ id: 'test',
+
+ async run({ interaction }) {
+ interaction.reply({
+ content: `Hello, ${interaction.fields.getTextInputValue('nameInput')}`,
+ });
+ },
+});