import ApplicationV2 from "./application.mjs"; import {mergeObject} from "../../../common/utils/helpers.mjs"; /** * @typedef {import("../_types.mjs").ApplicationConfiguration} ApplicationConfiguration */ /** * @typedef {Object} DialogV2Button * @property {string} action The button action identifier. * @property {string} label The button label. Will be localized. * @property {string} [icon] FontAwesome icon classes. * @property {string} [class] CSS classes to apply to the button. * @property {boolean} [default] Whether this button represents the default action to take if the user * submits the form without pressing a button, i.e. with an Enter * keypress. * @property {DialogV2ButtonCallback} [callback] A function to invoke when the button is clicked. The value returned * from this function will be used as the dialog's submitted value. * Otherwise, the button's identifier is used. */ /** * @callback DialogV2ButtonCallback * @param {PointerEvent|SubmitEvent} event The button click event, or a form submission event if the dialog was * submitted via keyboard. * @param {HTMLButtonElement} button If the form was submitted via keyboard, this will be the default * button, otherwise the button that was clicked. * @param {HTMLDialogElement} dialog The dialog element. * @returns {Promise} */ /** * @typedef {Object} DialogV2Configuration * @property {boolean} [modal] Modal dialogs prevent interaction with the rest of the UI until they * are dismissed or submitted. * @property {DialogV2Button[]} buttons Button configuration. * @property {string} [content] The dialog content. * @property {DialogV2SubmitCallback} [submit] A function to invoke when the dialog is submitted. This will not be * called if the dialog is dismissed. */ /** * @callback DialogV2RenderCallback * @param {Event} event The render event. * @param {HTMLDialogElement} dialog The dialog element. */ /** * @callback DialogV2CloseCallback * @param {Event} event The close event. * @param {DialogV2} dialog The dialog instance. */ /** * @callback DialogV2SubmitCallback * @param {any} result Either the identifier of the button that was clicked to submit the * dialog, or the result returned by that button's callback. * @returns {Promise} */ /** * @typedef {object} DialogV2WaitOptions * @property {DialogV2RenderCallback} [render] A synchronous function to invoke whenever the dialog is rendered. * @property {DialogV2CloseCallback} [close] A synchronous function to invoke when the dialog is closed under any * circumstances. * @property {boolean} [rejectClose=true] Throw a Promise rejection if the dialog is dismissed. */ /** * A lightweight Application that renders a dialog containing a form with arbitrary content, and some buttons. * @extends {ApplicationV2} * * @example Prompt the user to confirm an action. * ```js * const proceed = await foundry.applications.api.DialogV2.confirm({ * content: "Are you sure?", * rejectClose: false, * modal: true * }); * if ( proceed ) console.log("Proceed."); * else console.log("Do not proceed."); * ``` * * @example Prompt the user for some input. * ```js * let guess; * try { * guess = await foundry.applications.api.DialogV2.prompt({ * window: { title: "Guess a number between 1 and 10" }, * content: '', * ok: { * label: "Submit Guess", * callback: (event, button, dialog) => button.form.elements.guess.valueAsNumber * } * }); * } catch { * console.log("User did not make a guess."); * return; * } * const n = Math.ceil(CONFIG.Dice.randomUniform() * 10); * if ( n === guess ) console.log("User guessed correctly."); * else console.log("User guessed incorrectly."); * ``` * * @example A custom dialog. * ```js * new foundry.applications.api.DialogV2({ * window: { title: "Choose an option" }, * content: ` * * * * `, * buttons: [{ * action: "choice", * label: "Make Choice", * default: true, * callback: (event, button, dialog) => button.form.elements.choice.value * }, { * action: "all", * label: "Take All" * }], * submit: result => { * if ( result === "all" ) console.log("User picked all options."); * else console.log(`User picked option: ${result}`); * } * }).render({ force: true }); * ``` */ export default class DialogV2 extends ApplicationV2 { /** @inheritDoc */ static DEFAULT_OPTIONS = { id: "dialog-{id}", classes: ["dialog"], tag: "dialog", form: { closeOnSubmit: true }, window: { frame: true, positioned: true, minimizable: false } }; /* -------------------------------------------- */ /** @inheritDoc */ _initializeApplicationOptions(options) { options = super._initializeApplicationOptions(options); if ( !options.buttons?.length ) throw new Error("You must define at least one entry in options.buttons"); options.buttons = options.buttons.reduce((obj, button) => { options.actions[button.action] = this.constructor._onClickButton; obj[button.action] = button; return obj; }, {}); return options; } /* -------------------------------------------- */ /** @override */ async _renderHTML(_context, _options) { const form = document.createElement("form"); form.className = "dialog-form standard-form"; form.autocomplete = "off"; form.innerHTML = ` ${this.options.content ? `
${this.options.content}
` : ""}
${this._renderButtons()}
`; form.addEventListener("submit", event => this._onSubmit(event.submitter, event)); return form; } /* -------------------------------------------- */ /** * Render configured buttons. * @returns {string} * @protected */ _renderButtons() { return Object.values(this.options.buttons).map(button => { const { action, label, icon, default: isDefault, class: cls="" } = button; return ` `; }).join(""); } /* -------------------------------------------- */ /** * Handle submitting the dialog. * @param {HTMLButtonElement} target The button that was clicked or the default button. * @param {PointerEvent|SubmitEvent} event The triggering event. * @returns {Promise} * @protected */ async _onSubmit(target, event) { event.preventDefault(); const button = this.options.buttons[target?.dataset.action]; const result = (await button?.callback?.(event, target, this.element)) ?? button?.action; await this.options.submit?.(result); return this.options.form.closeOnSubmit ? this.close() : this; } /* -------------------------------------------- */ /** @override */ _onFirstRender(_context, _options) { if ( this.options.modal ) this.element.showModal(); else this.element.show(); } /* -------------------------------------------- */ /** @inheritDoc */ _attachFrameListeners() { super._attachFrameListeners(); this.element.addEventListener("keydown", this._onKeyDown.bind(this)); } /* -------------------------------------------- */ /** @override */ _replaceHTML(result, content, _options) { content.replaceChildren(result); } /* -------------------------------------------- */ /** * Handle keypresses within the dialog. * @param {KeyboardEvent} event The triggering event. * @protected */ _onKeyDown(event) { // Capture Escape keypresses for dialogs to ensure that close is called properly. if ( event.key === "Escape" ) { event.preventDefault(); // Prevent default browser dialog dismiss behavior. event.stopPropagation(); this.close(); } } /* -------------------------------------------- */ /** * @this {DialogV2} * @param {PointerEvent} event The originating click event. * @param {HTMLButtonElement} target The button element that was clicked. * @protected */ static _onClickButton(event, target) { this._onSubmit(target, event); } /* -------------------------------------------- */ /* Factory Methods */ /* -------------------------------------------- */ /** * A utility helper to generate a dialog with yes and no buttons. * @param {Partial} [options] * @param {DialogV2Button} [options.yes] Options to overwrite the default yes button configuration. * @param {DialogV2Button} [options.no] Options to overwrite the default no button configuration. * @returns {Promise} Resolves to true if the yes button was pressed, or false if the no button * was pressed. If additional buttons were provided, the Promise resolves to * the identifier of the one that was pressed, or the value returned by its * callback. If the dialog was dismissed, and rejectClose is false, the * Promise resolves to null. */ static async confirm({ yes={}, no={}, ...options }={}) { options.buttons ??= []; options.buttons.unshift(mergeObject({ action: "yes", label: "Yes", icon: "fas fa-check", callback: () => true }, yes), mergeObject({ action: "no", label: "No", icon: "fas fa-xmark", default: true, callback: () => false }, no)); return this.wait(options); } /* -------------------------------------------- */ /** * A utility helper to generate a dialog with a single confirmation button. * @param {Partial} [options] * @param {Partial} [options.ok] Options to overwrite the default confirmation button configuration. * @returns {Promise} Resolves to the identifier of the button used to submit the dialog, * or the value returned by that button's callback. If the dialog was * dismissed, and rejectClose is false, the Promise resolves to null. */ static async prompt({ ok={}, ...options }={}) { options.buttons ??= []; options.buttons.unshift(mergeObject({ action: "ok", label: "Confirm", icon: "fas fa-check", default: true }, ok)); return this.wait(options); } /* -------------------------------------------- */ /** * Spawn a dialog and wait for it to be dismissed or submitted. * @param {Partial} [options] * @param {DialogV2RenderCallback} [options.render] A function to invoke whenever the dialog is rendered. * @param {DialogV2CloseCallback} [options.close] A function to invoke when the dialog is closed under any * circumstances. * @param {boolean} [options.rejectClose=true] Throw a Promise rejection if the dialog is dismissed. * @returns {Promise} Resolves to the identifier of the button used to submit the * dialog, or the value returned by that button's callback. If the * dialog was dismissed, and rejectClose is false, the Promise * resolves to null. */ static async wait({ rejectClose=true, close, render, ...options }={}) { return new Promise((resolve, reject) => { // Wrap submission handler with Promise resolution. const originalSubmit = options.submit; options.submit = async result => { await originalSubmit?.(result); resolve(result); }; const dialog = new this(options); dialog.addEventListener("close", event => { if ( close instanceof Function ) close(event, dialog); if ( rejectClose ) reject(new Error("Dialog was dismissed without pressing a button.")); else resolve(null); }, { once: true }); if ( render instanceof Function ) { dialog.addEventListener("render", event => render(event, dialog.element)); } dialog.render({ force: true }); }); } }