Merge pull request #13 from Oliver-Akins/feature/improved-ask-modal

Improved Ask Modal
This commit is contained in:
Oliver 2025-07-24 20:43:53 -06:00 committed by GitHub
commit f67efa7ffa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 344 additions and 10 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,6 +1,5 @@
import { __ID__, filePath } from "../consts.mjs"; import { __ID__, filePath } from "../consts.mjs";
import { attributeSorter } from "../utils/attributeSort.mjs"; import { attributeSorter } from "../utils/attributeSort.mjs";
import { Logger } from "../utils/Logger.mjs";
import { toID } from "../utils/toID.mjs"; import { toID } from "../utils/toID.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -133,7 +132,6 @@ export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2)
}; };
}; };
Logger.debug(`Updating ${binding} value to ${value}`);
setProperty(this.#attributes, binding, value); setProperty(this.#attributes, binding, value);
await this.render({ parts: [ `attributes` ]}); await this.render({ parts: [ `attributes` ]});
}; };

View file

@ -1,10 +1,7 @@
import { Logger } from "../utils/Logger.mjs";
const { Actor } = foundry.documents; const { Actor } = foundry.documents;
export class TAFActor extends Actor { export class TAFActor extends Actor {
async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) { async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) {
Logger.table({ attribute, value, isDelta, isBar });
const attr = foundry.utils.getProperty(this.system, attribute); const attr = foundry.utils.getProperty(this.system, attribute);
const current = isBar ? attr.value : attr; const current = isBar ? attr.value : attr;
const update = isDelta ? current + value : value; const update = isDelta ? current + value : value;

View file

@ -1,5 +1,3 @@
import { Logger } from "../utils/Logger.mjs";
const { TokenDocument } = foundry.documents; const { TokenDocument } = foundry.documents;
const { getProperty, getType, hasProperty, isSubclass } = foundry.utils; const { getProperty, getType, hasProperty, isSubclass } = foundry.utils;
@ -61,7 +59,7 @@ export class TAFTokenDocument extends TokenDocument {
*/ */
getBarAttribute(barName, {alternative} = {}) { getBarAttribute(barName, {alternative} = {}) {
const attribute = alternative || this[barName]?.attribute; const attribute = alternative || this[barName]?.attribute;
Logger.log(barName, attribute);
if (!attribute || !this.actor) { if (!attribute || !this.actor) {
return null; return null;
}; };

View file

@ -0,0 +1,5 @@
import { filePath } from "../consts.mjs";
export default {
systemFilePath: filePath,
};

View file

@ -13,6 +13,7 @@ import { registerWorldSettings } from "../settings/world.mjs";
// Utils // Utils
import { __ID__ } from "../consts.mjs"; import { __ID__ } from "../consts.mjs";
import helpers from "../handlebarsHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs"; import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../apps/elements/_index.mjs"; import { registerCustomComponents } from "../apps/elements/_index.mjs";
@ -33,6 +34,8 @@ Hooks.on(`init`, () => {
}, },
); );
registerCustomComponents();
registerWorldSettings(); registerWorldSettings();
registerCustomComponents();
Handlebars.registerHelper(helpers);
}); });

View file

@ -1 +1,2 @@
import "./api.mjs";
import "./hooks/init.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;
};
};

40
styles/Apps/Ask.css Normal file
View file

@ -0,0 +1,40 @@
.taf.Ask {
min-width: 330px;
.prompt {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
align-items: center;
}
.window-content {
gap: 1rem;
overflow: auto;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
button {
flex-grow: 1;
}
}
label {
color: var(--color-form-label);
font-weight: bold;
}
p {
margin: 0;
}
}

View file

@ -12,5 +12,6 @@
/* Apps */ /* Apps */
@import url("./Apps/common.css") layer(apps); @import url("./Apps/common.css") layer(apps);
@import url("./Apps/PlayerSheet.css") layer(apps); @import url("./Apps/Ask.css") layer(apps);
@import url("./Apps/AttributeManager.css") layer(apps); @import url("./Apps/AttributeManager.css") layer(apps);
@import url("./Apps/PlayerSheet.css") layer(apps);

View file

@ -0,0 +1,13 @@
<div class="control-row">
<button
type="button"
data-action="cancel"
>
Cancel
</button>
<button
type="submit"
>
Confirm and Close
</button>
</div>

12
templates/Ask/inputs.hbs Normal file
View file

@ -0,0 +1,12 @@
<div class="dialog-content">
{{#if description}}
<p>
{{ description }}
</p>
{{/if}}
{{#each inputs as | i |}}
<div class="prompt">
{{> (concat (systemFilePath "templates/Ask/inputs/" ) i.type ".hbs") i}}
</div>
{{/each}}
</div>

View file

@ -0,0 +1,3 @@
<p class="prompt__details">
{{{ details }}}
</p>

View file

@ -0,0 +1,12 @@
<label
for="{{id}}"
>
{{ label }}
</label>
<input
type="{{ inputType }}"
id="{{ id }}"
name="{{ key }}"
{{ valueAttribute }}="{{ defaultValue }}"
{{#if autofocus}}autofocus{{/if}}
>