// hive-dialog.js — , the backdrop + box shadow-DOM custom // element behind `openDialog` (modal.js). Not exported; constructed and // configured by `openDialog` only. Callers set `._opts` before // `append()`ing it (custom elements can't take constructor args when // created via `document.createElement`), then the element renders itself // in `connectedCallback` and reports the outcome via a `hive-dialog-close` // CustomEvent (`detail` = the resolved value) rather than exposing a // resolve/reject pair directly — that keeps the element a normal DOM node // with a normal event contract instead of a bespoke Promise-ish object. // The element itself *is* the backdrop (`:host` carries the fixed- // position/centering rules); the box, title, message, content, and // buttons all render inside its shadow root. // // Imports `../hive-btn/hive-btn.js` for its side effect (registers // ``) since the dialog's own buttons are `` elements. import { el } from '../dom.js'; import { attachShadowCss } from '../shadow-css.js'; import '../hive-btn/hive-btn.js'; // registers — side-effect import, no named export needed import dialogCss from './hive-dialog.css'; class HiveDialog extends HTMLElement { connectedCallback() { const { title = '', message = '', content = null, buttons = [{ label: 'ok', value: true }], danger = false, dismissable = true, } = this._opts || {}; const root = attachShadowCss(this, dialogCss); let settled = false; const done = (value) => { if (settled) return; settled = true; document.removeEventListener('keydown', onKey, true); this.dispatchEvent(new CustomEvent('hive-dialog-close', { detail: value })); this.remove(); }; const onKey = (e) => { if (dismissable && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); done(null); } }; this._done = done; // exposed for close-on-escape-elsewhere callers, if ever needed const btnEls = buttons.map((b) => { // `b.class` names the variant ('cancel' | 'confirm'); `b.danger` // overrides it to the 'danger' look regardless (a destructive // confirm button reads as danger, not as a plain confirm). const variant = b.danger ? 'danger' : b.class; const btn = el('hive-btn', { type: 'button', ...(variant ? { variant } : {}), }, 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. IDs are scoped to // this shadow root, so no cross-instance collision risk even without // the random suffix — kept anyway since it costs nothing and guards // against a future shared-DOM edge case (e.g. `::part()` piercing). const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9); const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null; const messageEl = message ? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message) : null; const boxAttrs = { class: '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: 'actions' }, ...btnEls.map((b) => b.btn))); root.append(box); this.addEventListener('click', (e) => { // `e.target` is retargeted to `this` (the host) for ANY click that // originated inside the shadow tree, once it bubbles out to a // listener attached on the host itself — per spec, retargeting // applies to every ancestor outside the shadow tree, and the host // element's own light-DOM-side listeners count as outside. So // `e.target === this` was true for every click inside `.box` too // (title, message, a bare checkbox row with no button to consume // it first), not just genuine backdrop clicks — dismissing the // whole dialog on, say, a checkbox click. // `composedPath()[0]` is the true original target, unaffected by // retargeting: it's `this` only when nothing inside the shadow // tree was actually under the cursor, i.e. a real backdrop click. if (dismissable && e.composedPath()[0] === this) done(null); }); document.addEventListener('keydown', onKey, true); 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(); } } customElements.define('hive-dialog', HiveDialog);