api dialog - Drowbe/coffee-pub-blacksmith GitHub Wiki
Audience: module authors who need a confirmation, a choice, or a short prompt, and Blacksmith contributors.
Four helpers over Foundry's DialogV2 with one dismissal contract, shared styling, and consistent promise results. Internal design notes live in the header comment of scripts/api-dialog.js.
Access it at game.modules.get('coffee-pub-blacksmith').api.dialog.
Every helper resolves on dismissal. None rejects.
| Dismissal | Resolves |
|---|---|
| Escape | closeValue |
| Title-bar close button | closeValue |
| Explicit Cancel button | cancelValue |
confirm does not distinguish close from cancel: both resolve closeValue, which defaults to false.
A helper rejects only when a consumer callback throws in a way it cannot present, or on a framework error. Passing no buttons to wait, or no choices to choose, throws.
This contract is the reason to use these helpers instead of DialogV2 directly: the raw statics reject when the dialog is dismissed unless rejectClose: false is passed.
Every helper takes content as string | HTMLElement | Promise<string | HTMLElement>.
A string is passed to DialogV2, which sanitizes it with foundry.utils.cleanHTML. An HTMLElement is not sanitized: DialogV2 reads its innerHTML and keeps that markup as it stands (foundry.mjs:57177). Pass a node when the content must survive literally rather than be cleaned, as utility-common.js:808 does for a copyable snippet.
Any node you pass is moved into a fresh wrapper div. DialogV2 rejects a content element that carries any attributes, so a <div class="my-thing"> handed over directly would throw. Wrapping means you can put whatever attributes you like on your own element, because they end up as markup inside the wrapper rather than attributes on it.
Passing a node does not preserve the node. DialogV2 serializes it and builds the dialog from that markup, so the element you handed over is never inserted, and anything bound to it beforehand is bound to something the user never sees. The failure is silent - the markup renders and inputs still report values, so a control bound early looks alive while reporting only its initial state. Bind after the dialog renders instead: controls for anything with an attach method, onRender for everything else.
Render your own Handlebars first and pass the result. These helpers do not load templates.
Do not wrap content in a <form>. DialogV2 already renders one around it, and getValue receives that form.
choose, prompt, and wait take controls: one controller, or an array of them, bound to the dialog root after every render. Anything exposing attach(root) qualifies, which includes api.entityList and api.quantitySplit.
const quantity = blacksmith.quantitySplit.create({ max: stack });
const { action, value } = await blacksmith.dialog.prompt({
title: 'How many?',
content: quantity.html,
controls: quantity,
getValue: () => quantity.getValue()
});This is the only way such a control works inside a dialog, for the reason under Content.
prompt re-attaches on every attempt, since a rejected value reopens the dialog as fresh markup. Attaching twice is safe: attach releases its previous binding first.
Controls are not destroyed when the dialog closes, so a button callback can still read a value out of one after the fact.
Read them with readFrom(element) rather than getValue() / getSelection(). Those depend on binding having succeeded and report the control's initial value when it has not, which is a plausible answer rather than an obviously wrong one. A control that binds nothing now logs a warning naming its inputName, so the case is at least visible; reading from the DOM makes it harmless.
onRender(element, dialog) runs after every render and covers anything controls does not - it receives the dialog's root element.
Resolves boolean.
const confirmed = await blacksmith.dialog.confirm({
title: 'Delete Pin',
content: '<p>Delete <strong>Old Well</strong>? This cannot be undone.</p>',
confirmLabel: 'Delete Pin',
confirmIcon: 'fa-solid fa-trash',
destructive: true
});| Option | Default | Behavior |
|---|---|---|
title |
'' |
Frame title. |
content |
'' |
See Content. |
confirmLabel / confirmIcon
|
Foundry's Yes | Confirm button. |
cancelLabel / cancelIcon
|
Foundry's No | Cancel button. |
destructive |
false |
Critical styling on the confirm button. |
defaultAction |
'cancel' |
Which button is focused: 'cancel' or 'confirm'. |
closeValue |
false |
Resolved on any dismissal. |
modal |
destructive |
Modal only when destructive is true. See Modality. |
classes |
[] |
Extra classes on the dialog root. |
position |
Foundry default | Pass width to size the dialog yourself. Doing so opts out of the width cap -- see Width. |
A dialog you do not size is capped; a dialog you do size is left alone.
ApplicationV2 defaults to width: "auto", and an auto-width frame sizes to its content. For a
paragraph of prose that means the viewport: a two-sentence question rendered as a single 1240px line,
which reads as a broken dialog rather than a wide one. Every helper here therefore adds
blacksmith-dialog-autowidth when the caller named no width, and styles/dialog.css caps that class at
--blacksmith-dialog-max-width (560px), or the viewport when the viewport is narrower.
The cap is class-gated rather than applied to every Blacksmith dialog because a numeric width is written
as an inline style.width, which max-width would override -- a blanket cap would silently shrink the
dialogs that had deliberately asked to be wide. If you want a wider dialog, pass
position: { width: 800 } and the cap does not apply to you.
Content wider than the measure -- a table, a <pre> -- scrolls inside the dialog rather than pushing the
frame back out.
Dialogs are not modal by default. confirm is the single exception: it defaults to
modal: destructive, so a destructive confirmation is modal and an ordinary one is not.
A modal dialog calls <dialog>.showModal(), which places it in the browser's top layer behind an inert
backdrop, and every element behind it stops receiving events. That is correct for a question that must be
answered before anything else happens. It is wrong for a value prompt raised from a window that is already
open, because the window that asked is part of what gets frozen - a quantity slider should not freeze the
loot window it belongs to.
Pass modal: true when the question genuinely must block everything. Every function accepts it.
Two things follow for consumers. A prompt raised from inside your own window should keep the default, so the
window stays usable behind it. And a confirmation that deletes something should pass destructive: true,
which both styles the button and makes the dialog modal - two things you want together, from one flag.
One choice from several. Resolves { action, value, result } where value is the chosen id.
const outcome = await blacksmith.dialog.choose({
title: 'Delete Pins',
content: '<p>Choose which pins to delete.</p>',
choices: [
{ id: 'scene', label: 'Current Scene', icon: 'fa-solid fa-map' },
{ id: 'all', label: 'All Scenes', icon: 'fa-solid fa-globe', destructive: true }
],
closeValue: null
});
if (outcome.action === 'submit') applyScope(outcome.value);Each choice accepts { id, label, icon, description, disabled, destructive, default, callback }. description renders as the button's tooltip. callback receives the choice id; its return value becomes result.
showCancel (default true) adds a Cancel button resolving { action: 'cancel', value: cancelValue }.
Collect and validate one value. Resolves { action, value, result }.
A rejected value reopens the dialog. DialogV2 has no supported way to stay open once a button is clicked, so validation is a reopen loop rather than an in-place error. The message is prepended to the content on the next attempt.
To preserve what the user typed across a reopen, pass content as a function. It receives { value, error, attempt } — value is the previous input, null on the first attempt. The message banner is rendered by the helper either way; the function only needs to pre-fill.
const outcome = await blacksmith.dialog.prompt({
title: 'New Profile',
content: ({ value }) => `
<div class="blacksmith-field">
<span class="blacksmith-field-label">Profile name</span>
<input type="text" name="profile" class="blacksmith-input" value="${value ?? ''}">
</div>`,
submitLabel: 'Save',
focusSelector: '[name="profile"]',
getValue: root => root.elements.profile.value.trim(),
validate: value => value ? null : 'Enter a profile name.',
cancelValue: '',
closeValue: ''
});| Option | Behavior |
|---|---|
getValue(root) |
Collects the value. root is the submit button's owning form, so root.elements.foo.value works. Called once per attempt. |
validate(value) |
Return a string to reject: the dialog reopens carrying that message. Return null to accept. |
onSubmit(value) |
Optional async work. Throwing reopens the dialog with the error message. Its return value becomes result. |
submitLabel / submitIcon
|
Defaults OK and a check icon. |
focusSelector |
Focused on each render. |
maxAttempts |
Reopen ceiling, default 10, so a validator that can never pass cannot loop forever. On exhaustion the result is action: 'close' and the last message goes to ui.notifications. |
const uuid = await blacksmith.dialog.pickActor({
title: 'Who is buying?',
actors: game.actors.filter(a => a.hasPlayerOwner)
});
if (uuid) { /* ... */ }| Option | Meaning |
|---|---|
title |
Dialog title. Defaults to Choose an Actor. |
actors |
Actors, or { uuid, name, img } descriptors. Entries without a uuid are dropped. |
confirmLabel / confirmIcon
|
The submit button. Default Select and a check. |
emptyMessage |
Shown instead of a list when actors is empty; resolves null. |
autoPickSingle |
Resolve immediately when there is exactly one actor, with no dialog. Default false. |
modal, classes
|
As elsewhere. |
Returns a UUID string, or null on cancel, close, or an empty list. A UUID rather than an Actor so the
result is serialisable and cannot go stale across the await -- resolve it with fromUuid when you need the
document.
Built on api.entityList in single mode, so rows carry portraits. choose cannot: it renders each option as
a DialogV2 button and passes icon through as a CSS class, so a portrait is not expressible there.
autoPickSingle is off by default deliberately. Only the caller knows whether the pick is a formality or a
decision, and skipping a confirmation the caller asked for is a surprise.
Custom buttons with the same dismissal contract. Use it when the other three do not fit.
const outcome = await blacksmith.dialog.wait({
title: 'Token move request',
content: '<p>Allow this move?</p>',
buttons: [
{ action: 'no', label: 'No', icon: 'fa-solid fa-xmark' },
{ action: 'yes', label: 'Allow', icon: 'fa-solid fa-check', default: true, callback: async () => approve() }
],
closeValue: null
});Each button accepts { action, label, icon, default, destructive, disabled, callback }. callback receives the dialog's form element and its return value becomes result. An action named cancel resolves action: 'cancel'.
Read the clicked button off
value, notaction. The result'sactionis one of the three vocabulary strings below — for any button that is not thecancelone it is'submit', whatever you named the button. The button's ownactionlands invalue.const { action, value } = await blacksmith.dialog.wait({ buttons: [{action: 'yes', ...}, {action: 'no', ...}] }); if (action !== blacksmith.dialog.ACTIONS.SUBMIT) return; // dismissed if (value === 'yes') { /* ... */ } // NOT `action === 'yes'`Testing
action === 'yes'is false on a Yes and silently does nothing — the dialog opens, the user answers, and no branch runs. It cost a full round of live testing on the darkness prompt in 13.23.0.
Consumer callbacks in choose and wait run after the dialog has closed — the form element is captured beforehand and handed to you, so read what you need from it rather than expecting a live dialog. This is deliberate: it keeps the helpers independent of whether DialogV2 awaits a button callback.
Unlike prompt, wait does not loop: a throwing callback rejects, because only prompt owns a validation cycle.
- Every helper routes through
DialogV2.wait()withrejectClose: false, so dismissal resolves rather than throwing. - Enter submits the default button, including from inside a text input — DialogV2 renders a real form.
- Validation messages render in a
blacksmith-dialog-errorbanner at the top of the content. - Styling comes from
styles/dialog.cssand the sharedblacksmith-window-btn-*classes; dialogs carry theblacksmith-dialogclass, plusblacksmith-dialog-destructivewhen a destructive action is present. Button classes, tooltips, and disabled state are applied from therendercallback, becauseclassanddisabledare not DialogV2 button fields. - A dialog closes as soon as a button is activated. Anything that needs to survive a failed operation and stay on screen wants a window, not a dialog.
choose, prompt, and wait resolve the same shape. api.dialog.ACTIONS exposes the three action strings.
{ action: 'submit', value, result }
{ action: 'cancel', value: cancelValue, result: undefined }
{ action: 'close', value: closeValue, result: undefined }