The approval deny-reason prompt (and every themedPrompt dialog) used a single-line input. Make themedPrompt always a resizable <textarea> with chat-box keys: Enter submits (clicks the confirm button), Shift+Enter inserts a newline. Short answers stay one keystroke; multi-line reasons (e.g. a deny note) are now possible. No single-line variant — themedPrompt is only used by the data-async data-prompt path, so all those dialogs get the textarea. Closes #1840.
194 lines
7.8 KiB
JavaScript
194 lines
7.8 KiB
JavaScript
// modal.js — reusable themed modal/dialog component for the operator
|
|
// dashboard. An in-theme replacement for the browser's native
|
|
// `confirm()` / `alert()` overlays so destructive actions and prompts match
|
|
// the dashboard chrome instead of a jarring OS dialog.
|
|
//
|
|
// `openDialog` is the general primitive (any title/message/content + a row of
|
|
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
|
|
// checkboxes built on top of it. Styling lives in `dashboard.css` under the
|
|
// `.tc-*` classes.
|
|
|
|
import { el } from './common.js';
|
|
|
|
// openDialog({ title, message, content, buttons, danger, dismissable })
|
|
// → Promise resolving to the clicked button's `value`, or `null` when the
|
|
// dialog is dismissed (Escape, backdrop click, or a button whose value is
|
|
// null). `content` is an optional DOM node rendered between the message
|
|
// and the buttons (checkboxes, custom fields, …). `buttons` is
|
|
// `[{ label, value, danger?, class?, autofocus? }]`, rendered
|
|
// right-aligned. Initial focus: the `autofocus` button if any, else — for
|
|
// a `danger` dialog — the first non-destructive button (so a stray Enter
|
|
// can't fire the destructive path), else the last button.
|
|
export function openDialog(opts = {}) {
|
|
const {
|
|
title = '', message = '', content = null,
|
|
buttons = [{ label: 'ok', value: true }],
|
|
danger = false, dismissable = true,
|
|
} = opts;
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
function done(value) {
|
|
if (settled) return;
|
|
settled = true;
|
|
document.removeEventListener('keydown', onKey, true);
|
|
backdrop.remove();
|
|
resolve(value);
|
|
}
|
|
function onKey(e) {
|
|
if (dismissable && e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
done(null);
|
|
}
|
|
}
|
|
|
|
const btnEls = buttons.map((b) => {
|
|
const btn = el('button', {
|
|
type: 'button',
|
|
class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''),
|
|
}, b.label);
|
|
btn.addEventListener('click', () => done(b.value));
|
|
return { spec: b, btn };
|
|
});
|
|
|
|
// Give the dialog an accessible name: label it by its title if present,
|
|
// else by its message, via `aria-labelledby` (a11y — role=dialog needs a
|
|
// name). Only the labelling element carries the id.
|
|
const labelId = 'tc-dlg-' + Math.random().toString(36).slice(2, 9);
|
|
const titleEl = title ? el('div', { class: 'tc-title', id: labelId }, title) : null;
|
|
const messageEl = message
|
|
? el('div', title ? { class: 'tc-message' } : { class: 'tc-message', id: labelId }, message)
|
|
: null;
|
|
const boxAttrs = { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' };
|
|
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
|
|
const box = el('div', boxAttrs,
|
|
titleEl,
|
|
messageEl,
|
|
content || null,
|
|
el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn)));
|
|
|
|
const backdrop = el('div', { class: 'tc-backdrop' }, box);
|
|
backdrop.addEventListener('click', (e) => {
|
|
if (dismissable && e.target === backdrop) done(null);
|
|
});
|
|
document.addEventListener('keydown', onKey, true);
|
|
document.body.append(backdrop);
|
|
|
|
const focusTarget = btnEls.find((b) => b.spec.autofocus)
|
|
|| (danger ? btnEls.find((b) => !b.spec.danger) : null)
|
|
|| btnEls[btnEls.length - 1];
|
|
if (focusTarget) focusTarget.btn.focus();
|
|
});
|
|
}
|
|
|
|
// themedConfirm({ title, message, danger, confirmLabel, cancelLabel, checkboxes })
|
|
// → Promise<null | { [name]: bool }>. `null` = cancelled; otherwise an
|
|
// object of the checkbox states keyed by `name` (`{}` when there are none).
|
|
// Example:
|
|
// const r = await themedConfirm({ message: `stop ${n}?`, danger: true,
|
|
// confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] });
|
|
// if (!r) return; // cancelled
|
|
// doStop(r.graceful);
|
|
export function themedConfirm(opts = {}) {
|
|
const {
|
|
title = '', message = '', danger = false,
|
|
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
|
|
} = opts;
|
|
|
|
const boxes = checkboxes.map((cb) => {
|
|
const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name });
|
|
if (cb.checked) input.checked = true;
|
|
const row = el('label', { class: 'tc-checkrow' },
|
|
input, el('span', { class: 'tc-check-label' }, cb.label || cb.name));
|
|
return { input, row };
|
|
});
|
|
const content = boxes.length
|
|
? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row))
|
|
: null;
|
|
|
|
return openDialog({
|
|
title,
|
|
message,
|
|
content,
|
|
danger,
|
|
buttons: [
|
|
{ label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger },
|
|
{ label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger },
|
|
],
|
|
}).then((v) => {
|
|
if (v !== 'confirm') return null;
|
|
const out = {};
|
|
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
|
|
return out;
|
|
});
|
|
}
|
|
|
|
// themedPrompt({ title, message, label, placeholder, value, confirmLabel, cancelLabel })
|
|
// → Promise<string | null>. Themed replacement for window.prompt(): a
|
|
// resizable <textarea> dialog that resolves to the entered string on confirm,
|
|
// or null on cancel. Chat-box key behaviour: **Enter submits**, **Shift+Enter
|
|
// inserts a newline** — so short answers are one keystroke while multi-line
|
|
// reasons (e.g. an approval deny note) are still possible. Escape cancels
|
|
// (via openDialog).
|
|
export function themedPrompt(opts = {}) {
|
|
const {
|
|
title = '', message = '', label = '', placeholder = '', value = '',
|
|
confirmLabel = 'ok', cancelLabel = 'cancel',
|
|
} = opts;
|
|
const input = el('textarea', { class: 'tc-input tc-textarea', rows: '3', placeholder });
|
|
if (value) input.value = value;
|
|
// Enter submits (clicks the confirm button mounted by openDialog);
|
|
// Shift+Enter falls through to the textarea's default newline insert.
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
input.closest('.tc-box')?.querySelector('.tc-confirm')?.click();
|
|
}
|
|
});
|
|
const content = el('div', { class: 'tc-promptfield' },
|
|
label ? el('label', { class: 'tc-promptlabel' }, label) : null,
|
|
input);
|
|
const result = openDialog({
|
|
title,
|
|
message,
|
|
content,
|
|
buttons: [
|
|
{ label: cancelLabel, value: '__cancel__', class: 'tc-cancel' },
|
|
{ label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true },
|
|
],
|
|
}).then((v) => (v === '__ok__' ? input.value : null));
|
|
// Prefer focusing the field over the OK button once the dialog has mounted.
|
|
setTimeout(() => input.focus(), 0);
|
|
return result;
|
|
}
|
|
|
|
// themedToast(message, { type, duration }) — non-blocking transient
|
|
// notification; a lighter alternative to a modal for feedback that needs no
|
|
// decision (errors, validation, status). Stacks in a fixed top-right
|
|
// container, auto-dismisses after `duration` ms (errors linger longer), and
|
|
// can be clicked to dismiss early. `type` ∈ {'info','error','ok'}.
|
|
export function themedToast(message, opts = {}) {
|
|
const { type = 'info', duration } = opts;
|
|
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
|
let container = document.getElementById('tc-toasts');
|
|
if (!container) {
|
|
container = el('div', { id: 'tc-toasts', class: 'tc-toasts' });
|
|
document.body.append(container);
|
|
}
|
|
const toast = el('div', {
|
|
class: 'tc-toast tc-toast-' + type,
|
|
role: type === 'error' ? 'alert' : 'status',
|
|
}, message);
|
|
let removed = false;
|
|
const remove = () => {
|
|
if (removed) return;
|
|
removed = true;
|
|
toast.classList.add('tc-toast-out');
|
|
setTimeout(() => toast.remove(), 200);
|
|
};
|
|
toast.addEventListener('click', remove);
|
|
container.append(toast);
|
|
if (ms > 0) setTimeout(remove, ms);
|
|
return toast;
|
|
}
|