Add DialogManager to a global API and create the Ask dialog for the user

This commit is contained in:
Oliver-Akins 2025-07-24 20:34:17 -06:00
parent 02b49687cf
commit 44a88cc7b5
10 changed files with 334 additions and 1 deletions

30
module/api.mjs Normal file
View file

@ -0,0 +1,30 @@
// Apps
import { Ask } from "./apps/Ask.mjs";
import { AttributeManager } from "./apps/AttributeManager.mjs";
import { PlayerSheet } from "./apps/PlayerSheet.mjs";
// Utils
import { attributeSorter } from "./utils/attributeSort.mjs";
import { DialogManager } from "./utils/DialogManager.mjs";
import { toID } from "./utils/toID.mjs";
const { deepFreeze } = foundry.utils;
Object.defineProperty(
globalThis,
`taf`,
{
value: deepFreeze({
DialogManager,
Apps: {
Ask,
AttributeManager,
PlayerSheet,
},
utils: {
attributeSorter,
toID,
},
}),
},
);

115
module/apps/Ask.mjs Normal file
View file

@ -0,0 +1,115 @@
import { __ID__, filePath } from "../consts.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class Ask extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
tag: `dialog`,
classes: [
__ID__,
`dialog`, // accesses some Foundry-provided styling
`Ask`,
],
position: {
width: 330,
},
window: {
title: `Questions`,
resizable: true,
minimizable: true,
contentTag: `form`,
},
form: {
closeOnSubmit: true,
submitOnChange: false,
handler: this.#submit,
},
actions: {
cancel: this.#cancel,
},
};
static PARTS = {
inputs: {
template: filePath(`templates/Ask/inputs.hbs`),
templates: [
filePath(`templates/Ask/inputs/input.hbs`),
filePath(`templates/Ask/inputs/details.hbs`),
],
},
controls: {
template: filePath(`templates/Ask/controls.hbs`),
},
};
_inputs = [];
alwaysUseAnswerObject = false;
/** @type {string | undefined} */
_description = undefined;
/** @type {Function | undefined} */
_userOnConfirm;
/** @type {Function | undefined} */
_userOnCancel;
/** @type {Function | undefined} */
_userOnClose;
constructor({
inputs = [],
description = undefined,
onConfirm,
onCancel,
onClose,
alwaysUseAnswerObject,
...options
} = {}) {
super(options);
this.alwaysUseAnswerObject = alwaysUseAnswerObject;
this._inputs = inputs;
this._description = description;
this._userOnCancel = onCancel;
this._userOnConfirm = onConfirm;
this._userOnClose = onClose;
};
// #region Lifecycle
async _onFirstRender() {
super._onFirstRender();
this.element.show();
};
async _prepareContext() {
return {
inputs: this._inputs,
description: this._description,
};
};
async _onClose() {
super._onClose();
this._userOnClose?.();
};
// #endregion Lifecycle
// #region Actions
/** @this {AskDialog} */
static async #submit(_event, _element, formData) {
const answers = formData.object;
const keys = Object.keys(answers);
if (keys.length === 1 && !this.alwaysUseAnswerObject) {
this._userOnConfirm?.(answers[keys[0]]);
return;
};
this._userOnConfirm?.(answers);
};
/** @this {AskDialog} */
static async #cancel() {
this._userOnCancel?.();
this.close();
};
// #endregion Actions
};

View file

@ -1 +1,2 @@
import "./api.mjs";
import "./hooks/init.mjs";

View file

@ -0,0 +1,106 @@
import { Ask } from "../apps/Ask.mjs";
export class DialogManager {
/** @type {Map<string, Promise>} */
static #promises = new Map();
static #dialogs = new Map();
static async close(id) {
this.#dialogs.get(id)?.close();
this.#dialogs.delete(id);
this.#promises.delete(id);
};
/**
* Asks the user to provide a simple piece of information, this is primarily
* intended to be used within macros so that it can have better info gathering
* as needed. This returns an object of input keys/labels to the value the user
* input for that label, if there is only one input, this will return the value
* without an object wrapper, allowing for easier access.
*
* @param {AskConfig} data
* @param {AskOptions} opts
* @returns {AskResult}
*/
static async ask(
data,
{
onlyOneWaiting = true,
alwaysUseAnswerObject = true,
} = {},
) {
if (!data.id) {
return {
state: `errored`,
error: `An ID must be provided`,
};
};
if (!data.inputs.length) {
return {
state: `errored`,
error: `At least one input must be provided`,
};
};
const id = data.id;
// Don't do multi-thread waiting
if (this.#dialogs.has(id)) {
const app = this.#dialogs.get(id);
app.bringToFront();
if (onlyOneWaiting) {
return { state: `fronted` };
} else {
return this.#promises.get(id);
};
};
let autofocusClaimed = false;
for (const i of data.inputs) {
i.id ??= foundry.utils.randomID(16);
i.key ??= i.label;
switch (i.type) {
case `input`: {
i.inputType ??= `text`;
}
}
// Only ever allow one input to claim autofocus
i.autofocus &&= !autofocusClaimed;
autofocusClaimed ||= i.autofocus;
// Set the value's attribute name if it isn't specified explicitly
if (!i.valueAttribute) {
switch (i.inputType) {
case `checkbox`:
i.valueAttribute = `checked`;
break;
default:
i.valueAttribute = `value`;
};
};
};
const promise = new Promise((resolve) => {
const app = new Ask({
...data,
alwaysUseAnswerObject,
onClose: () => {
this.#dialogs.delete(id);
this.#promises.delete(id);
resolve({ state: `prompted` });
},
onConfirm: (answers) => resolve({ state: `prompted`, answers }),
});
app.render({ force: true });
this.#dialogs.set(id, app);
});
this.#promises.set(id, promise);
return promise;
};
static get size() {
return this.#dialogs.size;
};
};