Files
Foundry-VTT-Docker/resources/app/client/ui/dialog.js

345 lines
12 KiB
JavaScript
Raw Normal View History

2025-01-04 00:34:03 +01:00
/**
* @typedef {ApplicationOptions} DialogOptions
* @property {boolean} [jQuery=true] Whether to provide jQuery objects to callback functions (if true) or plain
* HTMLElement instances (if false). This is currently true by default but in the
* future will become false by default.
*/
/**
* @typedef {Object} DialogButton
* @property {string} icon A Font Awesome icon for the button
* @property {string} label The label for the button
* @property {boolean} disabled Whether the button is disabled
* @property {function(jQuery)} [callback] A callback function that fires when the button is clicked
*/
/**
* @typedef {object} DialogData
* @property {string} title The window title displayed in the dialog header
* @property {string} content HTML content for the dialog form
* @property {Record<string, DialogButton>} buttons The buttons which are displayed as action choices for the dialog
* @property {string} [default] The name of the default button which should be triggered on Enter keypress
* @property {function(jQuery)} [render] A callback function invoked when the dialog is rendered
* @property {function(jQuery)} [close] Common callback operations to perform when the dialog is closed
*/
/**
* Create a dialog window displaying a title, a message, and a set of buttons which trigger callback functions.
* @param {DialogData} data An object of dialog data which configures how the modal window is rendered
* @param {DialogOptions} [options] Dialog rendering options, see {@link Application}.
*
* @example Constructing a custom dialog instance
* ```js
* let d = new Dialog({
* title: "Test Dialog",
* content: "<p>You must choose either Option 1, or Option 2</p>",
* buttons: {
* one: {
* icon: '<i class="fas fa-check"></i>',
* label: "Option One",
* callback: () => console.log("Chose One")
* },
* two: {
* icon: '<i class="fas fa-times"></i>',
* label: "Option Two",
* callback: () => console.log("Chose Two")
* }
* },
* default: "two",
* render: html => console.log("Register interactivity in the rendered dialog"),
* close: html => console.log("This always is logged no matter which option is chosen")
* });
* d.render(true);
* ```
*/
class Dialog extends Application {
constructor(data, options) {
super(options);
this.data = data;
}
/**
* A bound instance of the _onKeyDown method which is used to listen to keypress events while the Dialog is active.
* @type {function(KeyboardEvent)}
*/
#onKeyDown;
/* -------------------------------------------- */
/**
* @override
* @returns {DialogOptions}
*/
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
template: "templates/hud/dialog.html",
focus: true,
classes: ["dialog"],
width: 400,
jQuery: true
});
}
/* -------------------------------------------- */
/** @inheritdoc */
get title() {
return this.data.title || "Dialog";
}
/* -------------------------------------------- */
/** @inheritdoc */
getData(options={}) {
let buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
let b = this.data.buttons[key];
b.cssClass = (this.data.default === key ? [key, "default", "bright"] : [key]).join(" ");
if ( b.condition !== false ) obj[key] = b;
return obj;
}, {});
return {
content: this.data.content,
buttons: buttons
};
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
html.find(".dialog-button").click(this._onClickButton.bind(this));
// Prevent the default form submission action if any forms are present in this dialog.
html.find("form").each((i, el) => el.onsubmit = evt => evt.preventDefault());
if ( !this.#onKeyDown ) {
this.#onKeyDown = this._onKeyDown.bind(this);
document.addEventListener("keydown", this.#onKeyDown);
}
if ( this.data.render instanceof Function ) this.data.render(this.options.jQuery ? html : html[0]);
if ( this.options.focus ) {
// Focus the default option
html.find(".default").focus();
}
html.find("[autofocus]")[0]?.focus();
}
/* -------------------------------------------- */
/**
* Handle a left-mouse click on one of the dialog choice buttons
* @param {MouseEvent} event The left-mouse click event
* @private
*/
_onClickButton(event) {
const id = event.currentTarget.dataset.button;
const button = this.data.buttons[id];
this.submit(button, event);
}
/* -------------------------------------------- */
/**
* Handle a keydown event while the dialog is active
* @param {KeyboardEvent} event The keydown event
* @private
*/
_onKeyDown(event) {
// Cycle Options
if ( event.key === "Tab" ) {
const dialog = this.element[0];
// If we are already focused on the Dialog, let the default browser behavior take over
if ( dialog.contains(document.activeElement) ) return;
// If we aren't focused on the dialog, bring focus to one of its buttons
event.preventDefault();
event.stopPropagation();
const dialogButtons = Array.from(document.querySelectorAll(".dialog-button"));
const targetButton = event.shiftKey ? dialogButtons.pop() : dialogButtons.shift();
targetButton.focus();
}
// Close dialog
if ( event.key === "Escape" ) {
event.preventDefault();
event.stopPropagation();
return this.close();
}
// Confirm choice
if ( event.key === "Enter" ) {
// Only handle Enter presses if an input element within the Dialog has focus
const dialog = this.element[0];
if ( !dialog.contains(document.activeElement) || (document.activeElement instanceof HTMLTextAreaElement) ) return;
event.preventDefault();
event.stopPropagation();
// Prefer a focused button, or enact the default option for the dialog
const button = document.activeElement.dataset.button || this.data.default;
const choice = this.data.buttons[button];
return this.submit(choice);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
async _renderOuter() {
let html = await super._renderOuter();
const app = html[0];
app.setAttribute("role", "dialog");
app.setAttribute("aria-modal", "true");
return html;
}
/* -------------------------------------------- */
/**
* Submit the Dialog by selecting one of its buttons
* @param {Object} button The configuration of the chosen button
* @param {PointerEvent} event The originating click event
* @private
*/
submit(button, event) {
const target = this.options.jQuery ? this.element : this.element[0];
try {
if ( button.callback ) button.callback.call(this, target, event);
this.close();
} catch(err) {
ui.notifications.error(err.message);
throw new Error(err);
}
}
/* -------------------------------------------- */
/** @inheritdoc */
async close(options={}) {
if ( this.data.close ) this.data.close(this.options.jQuery ? this.element : this.element[0]);
if ( this.#onKeyDown ) {
document.removeEventListener("keydown", this.#onKeyDown);
this.#onKeyDown = undefined;
}
return super.close(options);
}
/* -------------------------------------------- */
/* Factory Methods */
/* -------------------------------------------- */
/**
* A helper factory method to create simple confirmation dialog windows which consist of simple yes/no prompts.
* If you require more flexibility, a custom Dialog instance is preferred.
*
* @param {DialogData} config Confirmation dialog configuration
* @param {Function} [config.yes] Callback function upon yes
* @param {Function} [config.no] Callback function upon no
* @param {boolean} [config.defaultYes=true] Make "yes" the default choice?
* @param {boolean} [config.rejectClose=false] Reject the Promise if the Dialog is closed without making a choice.
* @param {DialogOptions} [config.options={}] Additional rendering options passed to the Dialog
*
* @returns {Promise<any>} A promise which resolves once the user makes a choice or closes the
* window.
*
* @example Prompt the user with a yes or no question
* ```js
* let d = Dialog.confirm({
* title: "A Yes or No Question",
* content: "<p>Choose wisely.</p>",
* yes: () => console.log("You chose ... wisely"),
* no: () => console.log("You chose ... poorly"),
* defaultYes: false
* });
* ```
*/
static async confirm({title, content, yes, no, render, defaultYes=true, rejectClose=false, options={}}={}) {
return this.wait({
title, content, render,
focus: true,
default: defaultYes ? "yes" : "no",
close: () => {
if ( rejectClose ) return;
return null;
},
buttons: {
yes: {
icon: '<i class="fas fa-check"></i>',
label: game.i18n.localize("Yes"),
callback: html => yes ? yes(html) : true
},
no: {
icon: '<i class="fas fa-times"></i>',
label: game.i18n.localize("No"),
callback: html => no ? no(html) : false
}
}
}, options);
}
/* -------------------------------------------- */
/**
* A helper factory method to display a basic "prompt" style Dialog with a single button
* @param {DialogData} config Dialog configuration options
* @param {Function} [config.callback] A callback function to fire when the button is clicked
* @param {boolean} [config.rejectClose=true] Reject the promise if the dialog is closed without confirming the
* choice, otherwise resolve as null
* @param {DialogOptions} [config.options] Additional dialog options
* @returns {Promise<any>} The returned value from the provided callback function, if any
*/
static async prompt({title, content, label, callback, render, rejectClose=true, options={}}={}) {
return this.wait({
title, content, render,
default: "ok",
close: () => {
if ( rejectClose ) return;
return null;
},
buttons: {
ok: { icon: '<i class="fas fa-check"></i>', label, callback }
}
}, options);
}
/* -------------------------------------------- */
/**
* Wrap the Dialog with an enclosing Promise which resolves or rejects when the client makes a choice.
* @param {DialogData} [data] Data passed to the Dialog constructor.
* @param {DialogOptions} [options] Options passed to the Dialog constructor.
* @param {object} [renderOptions] Options passed to the Dialog render call.
* @returns {Promise<any>} A Promise that resolves to the chosen result.
*/
static async wait(data={}, options={}, renderOptions={}) {
return new Promise((resolve, reject) => {
// Wrap buttons with Promise resolution.
const buttons = foundry.utils.deepClone(data.buttons);
for ( const [id, button] of Object.entries(buttons) ) {
const cb = button.callback;
function callback(html, event) {
const result = cb instanceof Function ? cb.call(this, html, event) : undefined;
resolve(result === undefined ? id : result);
}
button.callback = callback;
}
// Wrap close with Promise resolution or rejection.
const originalClose = data.close;
const close = () => {
const result = originalClose instanceof Function ? originalClose() : undefined;
if ( result !== undefined ) resolve(result);
else reject(new Error("The Dialog was closed without a choice being made."));
};
// Construct the dialog.
const dialog = new this({ ...data, buttons, close }, options);
dialog.render(true, renderOptions);
});
}
}