dashboard: extract themed dialog into a reusable modal.js component
Move the themed dialog out of common.js into its own modal.js module: a general openDialog(title/message/content/buttons) primitive with themedConfirm as a thin cancel/confirm wrapper on top. tabs.js imports it from there. No behaviour change to the stop-confirm flow; the dialog is now a standalone reusable component other surfaces can open.
This commit is contained in:
parent
03b10d22cd
commit
0407e7da62
3 changed files with 117 additions and 67 deletions
|
|
@ -46,72 +46,6 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
||||||
return f;
|
return f;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── themed confirm modal ───────────────────────────────────────────────
|
|
||||||
// In-theme replacement for the browser's native `confirm()` so destructive
|
|
||||||
// actions match the dashboard chrome instead of a jarring OS dialog. Returns
|
|
||||||
// a Promise resolving to `null` when cancelled, or an object of the checkbox
|
|
||||||
// states (keyed by each checkbox's `name`) when confirmed — `{}` when there
|
|
||||||
// are no checkboxes. Escape, a backdrop click, or Cancel all reject; for a
|
|
||||||
// `danger` action the Cancel button takes initial focus (so a stray Enter
|
|
||||||
// doesn't fire the destructive path). 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 = {}) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
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 { name: cb.name, input, row };
|
|
||||||
});
|
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
function done(result) {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
document.removeEventListener('keydown', onKey, true);
|
|
||||||
backdrop.remove();
|
|
||||||
resolve(result);
|
|
||||||
}
|
|
||||||
const cancel = () => done(null);
|
|
||||||
const confirmAction = () => {
|
|
||||||
const out = {};
|
|
||||||
for (const b of boxes) out[b.name] = b.input.checked;
|
|
||||||
done(out);
|
|
||||||
};
|
|
||||||
function onKey(e) {
|
|
||||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelBtn = el('button', { type: 'button', class: 'btn tc-btn tc-cancel' }, cancelLabel);
|
|
||||||
const okBtn = el('button', {
|
|
||||||
type: 'button', class: 'btn tc-btn tc-confirm' + (danger ? ' tc-danger' : ''),
|
|
||||||
}, confirmLabel);
|
|
||||||
cancelBtn.addEventListener('click', cancel);
|
|
||||||
okBtn.addEventListener('click', confirmAction);
|
|
||||||
|
|
||||||
const box = el('div', { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' },
|
|
||||||
title ? el('div', { class: 'tc-title' }, title) : null,
|
|
||||||
el('div', { class: 'tc-message' }, message),
|
|
||||||
boxes.length ? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row)) : null,
|
|
||||||
el('div', { class: 'tc-actions' }, cancelBtn, okBtn));
|
|
||||||
|
|
||||||
const backdrop = el('div', { class: 'tc-backdrop' }, box);
|
|
||||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) cancel(); });
|
|
||||||
document.addEventListener('keydown', onKey, true);
|
|
||||||
document.body.append(backdrop);
|
|
||||||
(danger ? cancelBtn : okBtn).focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page-level submit interceptor for `data-async` forms — the pattern every
|
// Page-level submit interceptor for `data-async` forms — the pattern every
|
||||||
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
|
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
|
||||||
// Without this a `data-async` form POSTs natively and the browser navigates
|
// Without this a `data-async` form POSTs natively and the browser navigates
|
||||||
|
|
|
||||||
115
frontend/packages/dashboard/src/modal.js
Normal file
115
frontend/packages/dashboard/src/modal.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
// 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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const box = el('div', { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' },
|
||||||
|
title ? el('div', { class: 'tc-title' }, title) : null,
|
||||||
|
message ? el('div', { class: 'tc-message' }, message) : null,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,11 @@ import { marked } from 'marked';
|
||||||
import {
|
import {
|
||||||
$, el, esc, form,
|
$, el, esc, form,
|
||||||
fmtAgeSecs,
|
fmtAgeSecs,
|
||||||
Panel, NOTIF, themedConfirm,
|
Panel, NOTIF,
|
||||||
makePathLink, appendText, appendLinkified,
|
makePathLink, appendText, appendLinkified,
|
||||||
openStream, renderServerWarnings, bindAsyncForms,
|
openStream, renderServerWarnings, bindAsyncForms,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
import { themedConfirm } from './modal.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
import {
|
import {
|
||||||
containersState, syncContainersFromSnapshot,
|
containersState, syncContainersFromSnapshot,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue