PageSourceSearch

https://smrtbld.ai/js/utils/modal.js

js smrtbld.ai collected 2026-10-05 15:06:53 UTC 45,027 bytes, 899 lines download raw bytes

1/**
2 * Modal Utilities
3 * Helper to show modals using JsRender templates.
4 * Assumes modals are in 'modals.html' or bundled templates.
5 *
6 * Handles the full modal lifecycle out of the box:
7 *   - Render template into the #app-modals container
8 *   - Apply a canonical size class (sm/md/lg) when `size` is passed
9 *   - Wire `[data-dismiss="modal"]` buttons + backdrop to close
10 *   - Escape key closes a modal with no unsaved input (opt out with closeOnEscape: false)
11 *   - Backdrop-click close is OFF by default — accidental clicks while filling
12 *     out a form would lose data. Opt in with closeOnBackdrop: true.
13 *   - UNSAVED-INPUT GUARD, on by default (opt out with guardUnsavedInput: false).
14 *     Once the user has actually changed something inside the modal, the three
15 *     user dismiss paths stop behaving alike:
16 *       • `backdrop` — refuses. Nothing closes. (An accidental off-click.)
17 *       • `escape`   — refuses. Nothing closes. (An accidental keystroke.)
18 *       • `dismiss`  — the X / Cancel control asks "Discard unsaved changes?"
19 *                      and closes only on Discard. It is never a silent no-op:
20 *                      the visible close control is the one way out.
21 *     A refused `backdrop`/`escape` is not silent either: the visible close
22 *     control shakes once and a polite live region says why. A refusal that
23 *     rendered nothing would read as a dead app, which is what makes people
24 *     hit F5 — and that loses the work anyway.
25 *     A clean modal closes on all three exactly as it always did, so a
26 *     pre-populated edit modal opened and Escaped untouched still just closes.
27 *     `abort`, `programmatic` and caller-supplied reasons call close() directly
28 *     and are NEVER guarded — teardown must not be blockable.
29 *     What counts as "changed": the value/checked/selection of a real
30 *     `input`/`textarea`/`select` inside the modal, plus DropZone attachment
31 *     events. Two exceptions, both deliberate:
32 *       • `[data-guard-ignore]` (and anything inside `.searchable-select-dropdown`)
33 *         is a COMPONENT's own injected chrome that the user cannot SEE once it
34 *         closes. SearchableDropdown's option filter is the entire class, and it
35 *         exists only because `_close()` leaves stale text in a box that is then
36 *         hidden inside the collapsed popover — invisible residue reading as
37 *         unsaved work. It is NOT a general "filter boxes don't count" opt-out.
38 *         DAN RULED, 2026-08-21: "Keep as-is — typed is typed." A search or filter
39 *         input the user can SEE they typed into IS unsaved input, template-authored
40 *         ones included. So on a picker whose only control is its search box, typing
41 *         a filter and abandoning it means `backdrop` and `escape` refuse and the X
42 *         asks before discarding. That is the ruled behavior, not a defect — do not
43 *         add this attribute to escape it. The line is visibility: visible typed
44 *         text is protected; invisible component residue is not.
45 *       • A DropZone attachment marks the modal dirty UNCONDITIONALLY, including
46 *         when a caller loads files programmatically, so a modal opened with
47 *         files preloaded is dirty from the start (live instance:
48 *         pages/designer-task-detail.js:1628-1634). That is intended — the
49 *         preloaded file IS user-supplied, and a drag from the desktop produces
50 *         no trusted gesture inside the modal to arm a baseline from.
51 *     What the guard does NOT cover: a caller-wired `data-action` close that
52 *     calls close() itself. Route those through the handle's `dismiss(reason)`.
53 *   - `signal` (AbortSignal) auto-closes on page navigation
54 *   - `onClose` fires on every close path (dismiss / Escape / backdrop / signal / programmatic)
55 *   - Idempotent close — safe to call multiple times
56 *   - Body scroll-lock while modal is open
57 *   - Auto-enhances DropZone components inside the modal
58 */
59
60import { renderToString } from './templates.js';
61import { afterRender } from './dom.js';
62import { DropZone } from '../components/DropZone.js';
63import { SearchableDropdown } from '../components/SearchableDropdown.js';
64import { DatePicker } from '../components/DatePicker.js';
65
66const MODAL_CONTAINER_ID = 'app-modals'; // Ensure this exists in your layout
67const FOCUSABLE_SELECTOR = [
68    'a[href]',
69    'button:not([disabled])',
70    'input:not([disabled])',
71    'select:not([disabled])',
72    'textarea:not([disabled])',
73    '[tabindex]:not([tabindex="-1"])',
74].join(',');
75
76const isolationState = new WeakMap();
77let activeIsolationTargets = new Set();
78let bodyOverflowBeforeModal = null;
79let bodyScrollLockCount = 0;
80let modalSurfaceSequence = 0;
81const modalSurfaces = new Map();
82let confirmInstanceSequence = 0;
83
84function getConfirmCommitIcon(confirmText, fallbackIcon) {
85    const action = String(confirmText || '').trim().toLowerCase();
86    if (/^(delete|remove|revoke)\b/.test(action)) return 'trash-2';
87    if (/^archive\b/.test(action)) return 'archive';
88    if (/^(approve|confirm|accept|complete)\b/.test(action)) return 'check-circle';
89    if (/^(discard|reset)\b/.test(action)) return 'rotate-ccw';
90    if (/^(submit|send)\b/.test(action)) return 'send';
91    if (/^(continue|resume)\b/.test(action)) return 'play';
92    if (/^(create|add)\b/.test(action)) return 'plus-circle';
93    if (/^save\b/.test(action)) return 'save';
94    if (/^apply\b/.test(action)) return 'check';
95    if (/^(download|export)\b/.test(action)) return 'download';
96    if (/^upload\b/.test(action)) return 'upload';
97    return fallbackIcon || 'check-circle';
98}
99
100function setInteractionIsolation(element, isolated) {
101    if (!element) return;
102
103    if (isolated) {
104        if (!isolationState.has(element)) {
105            isolationState.set(element, {
106                inert: element.hasAttribute('inert'),
107                ariaHidden: element.getAttribute('aria-hidden'),
108            });
109        }
110        element.setAttribute('inert', '');
111        element.setAttribute('aria-hidden', 'true');
112        return;
113    }
114
115    const previous = isolationState.get(element);
116    if (!previous) return;
117    if (!previous.inert) element.removeAttribute('inert');
118    if (previous.ariaHidden == null) element.removeAttribute('aria-hidden');
119    else element.setAttribute('aria-hidden', previous.ariaHidden);
120    isolationState.delete(element);
121}
122
123function getTopModalSurface() {
124    const live = Array.from(modalSurfaces.entries())
125        .filter(([element]) => element.isConnected)
126        .sort((a, b) => a[1] - b[1]);
127    return live.at(-1)?.[0] || null;
128}
129
130export function getActiveModalAnnouncementTarget() {
131    const surface = getTopModalSurface();
132    if (!surface) return { active: false, target: null };
133    const target = surface.matches('[role="dialog"], .modal-st')
134        ? surface : surface.querySelector('[role="dialog"], .modal-st');
135    if (!target?.isConnected || target.closest('[inert], [aria-hidden="true"]')) {
136        return { active: true, target: null };
137    }
138    return { active: true, target };
139}
140
141function syncModalIsolation() {
142    const topModal = getTopModalSurface();
143    const nextTargets = new Set();
144
145    // CA-276: isolate sibling branches along an inline dialog's ancestor path,
146    // never an ancestor containing the active surface; portal behavior is unchanged.
147    if (topModal) {
148        let branch = topModal;
149        while (branch?.parentElement) {
150            const parent = branch.parentElement;
151            Array.from(parent.children).forEach(sibling => {
152                if (sibling !== branch) nextTargets.add(sibling);
153            });
154            if (parent === document.body) break;
155            branch = parent;
156        }
157    }
158
159    activeIsolationTargets.forEach(element => {
160        if (!nextTargets.has(element)) setInteractionIsolation(element, false);
161    });
162    nextTargets.forEach(element => {
163        if (!activeIsolationTargets.has(element)) setInteractionIsolation(element, true);
164    });
165    activeIsolationTargets = nextTargets;
166    return topModal;
167}
168
169function registerModalSurface(element) {
170    modalSurfaces.set(element, ++modalSurfaceSequence);
171    syncModalIsolation();
172    return () => {
173        activeIsolationTargets.delete(element);
174        setInteractionIsolation(element, false);
175        modalSurfaces.delete(element);
176        return syncModalIsolation();
177    };
178}
179
180function acquireBodyScrollLock() {
181    if (bodyScrollLockCount === 0) bodyOverflowBeforeModal = document.body.style.overflow;
182    bodyScrollLockCount += 1;
183    document.body.style.overflow = 'hidden';
184    let released = false;
185    return () => {
186        if (released) return;
187        released = true;
188        bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
189        if (bodyScrollLockCount === 0) {
190            document.body.style.overflow = bodyOverflowBeforeModal ?? '';
191            bodyOverflowBeforeModal = null;
192        }
193    };
194}
195
196function getFocusableElements(root) {
197    return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el =>
198        !el.hasAttribute('disabled') &&
199        el.getAttribute('aria-hidden') !== 'true' &&
200        !el.closest('[inert]')
201    );
202}
203
204/**
205 * Give an already-rendered dialog the same focus, isolation,
206 * stacking, scroll-lock, and return-focus ownership as showModal().
207 */
208export function createModalInteractionOwner({ element, initialFocus = null, trigger = null, onEscape = null } = {}) {
209    if (!(element instanceof HTMLElement)) throw new Error('A modal surface element is required.');
210    const modalBox = element.classList.contains('modal-st') ? element : element.querySelector('[role="dialog"], .modal-st');
211    const returnTarget = trigger instanceof HTMLElement ? trigger : null;
212    const unregisterSurface = registerModalSurface(element);
213    const releaseScrollLock = acquireBodyScrollLock();
214    let destroyed = false;
215
216    const onKeydown = (event) => {
217        if (getTopModalSurface() !== element) return;
218        if (event.key === 'Escape') {
219            event.preventDefault();
220            event.stopPropagation();
221            onEscape?.();
222            return;
223        }
224        if (event.key !== 'Tab') return;
225        const focusable = getFocusableElements(element);
226        if (focusable.length === 0) {
227            event.preventDefault();
228            modalBox?.focus();
229        } else {
230            const first = focusable[0];
231            const last = focusable.at(-1);
232            if (event.shiftKey && (document.activeElement === first || !element.contains(document.activeElement))) {
233                event.preventDefault();
234                last.focus();
235            } else if (!event.shiftKey && (document.activeElement === last || !element.contains(document.activeElement))) {
236                event.preventDefault();
237                first.focus();
238            }
239        }
240        event.stopPropagation();
241    };
242    element.addEventListener('keydown', onKeydown);
243
244    const firstFocus = initialFocus instanceof HTMLElement
245        ? initialFocus
246        : element.querySelector('[data-initial-focus]') || getFocusableElements(element)[0] || modalBox;
247    if (firstFocus === modalBox && !modalBox?.hasAttribute('tabindex')) modalBox?.setAttribute('tabindex', '-1');
248    firstFocus?.focus();
249
250    return {
251        destroy({ restoreFocus = true } = {}) {
252            if (destroyed) return;
253            destroyed = true;
254            const wasTopmost = getTopModalSurface() === element;
255            element.removeEventListener('keydown', onKeydown);
256            const nextTop = unregisterSurface();
257            releaseScrollLock();
258            if (!restoreFocus || !wasTopmost) return;
259            if (returnTarget?.isConnected && !returnTarget.closest('[inert]')) returnTarget.focus();
260            else {
261                const fallback = nextTop?.querySelector('[data-initial-focus]')
262                    || (nextTop ? getFocusableElements(nextTop)[0] : null)
263                    || nextTop?.querySelector('[role="dialog"], .modal-st');
264                fallback?.focus();
265            }
266        },
267    };
268}
269
270/**
271 * Modal size → canonical CSS class. Three sizes (sm/md/lg) match the
272 * .btn-st-sm / .btn-st-lg naming convention. See PATTERNS_BIBLE §10b.
273 *   sm — 28rem  confirm dialogs, single-input prompts
274 *   md — 42rem  standard form modals
275 *   lg — 56rem  dense forms with tables, multi-section editors
276 */
277const MODAL_SIZE_CLASS = {
278    sm: 'modal-st-sm',
279    md: 'modal-st-md',
280    lg: 'modal-st-lg',
281};
282
283/**
284 * Show a modal by template ID
285 * @param {string} templateId - e.g., '#tpl-confirm-modal'
286 * @param {object} data - Data to pass to template
287 * @param {object} [options] - Optional config
288 * @param {AbortSignal} [options.signal] - AbortSignal for auto-close on navigation
289 * @param {boolean} [options.closeOnEscape=true] - Close when Escape key is pressed
290 * @param {boolean} [options.closeOnBackdrop=false] - Close when clicking the backdrop. OFF by default to prevent accidental form-data loss.
291 * @param {Function} [options.onClose] - Callback fired on every close path
292 * @param {Function} [options.canDismiss] - Return false to veto user dismissal. Programmatic close and AbortSignal teardown still close immediately.
293 * @param {boolean} [options.guardUnsavedInput=true] - Protect input the user has
294 *        actually entered. While the modal is dirty, `backdrop` and `escape`
295 *        refuse to close and the `dismiss` control asks before discarding. A
296 *        clean modal is unaffected. Set false for a dialog that holds no
297 *        user-authored data (confirm() sets it on itself so it cannot recurse).
298 * @param {'sm'|'md'|'lg'} [options.size] - Modal width class. When omitted, the
299 *        template keeps whatever width it declares itself (no class added).
300 * @returns {Promise<{element: HTMLElement, close: Function, dismiss: Function}>}
301 *          `close(reason)` is the unguarded programmatic path; `dismiss(reason)`
302 *          enters the same guarded funnel the X / backdrop / Escape use.
303 */
304export async function showModal(templateId, data = {}, options = {}) {
305    const {
306        signal,
307        closeOnEscape = true,
308        closeOnBackdrop = false,
309        onClose,
310        canDismiss,
311        guardUnsavedInput = true,
312        size,
313    } = options;
314
315    let container = document.getElementById(MODAL_CONTAINER_ID);
316    if (!container) {
317        container = document.createElement('div');
318        container.id = MODAL_CONTAINER_ID;
319        document.body.appendChild(container);
320    }
321
322    // Render the modal and APPEND it (do NOT replace the container) so a modal can
323    // open ON TOP of another — e.g. the progress-photo picker over the RFI create
324    // form. The old renderTo() set the container's innerHTML, which destroyed any
325    // modal already open. Each modal is a full-screen fixed .modal-backdrop-st, so
326    // the later sibling in the DOM naturally paints on top → modal stacking.
327    const html = await renderToString(templateId, data);
328    const tmp = document.createElement('div');
329    tmp.innerHTML = html;
330    const modalEl = tmp.firstElementChild;
331    if (!modalEl) throw new Error('Modal template did not produce a root element');
332    const triggerEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
333    container.appendChild(modalEl);
334    afterRender(modalEl);
335    const unregisterSurface = registerModalSurface(modalEl);
336    const releaseScrollLock = acquireBodyScrollLock();
337
338    // Apply the canonical size class to the inner .modal-st box. Skipped
339    // entirely when `size` is omitted — the template's own width wins.
340    if (size) {
341        const sizeClass = MODAL_SIZE_CLASS[size];
342        if (sizeClass) {
343            const modalBox = modalEl.classList?.contains('modal-st')
344                ? modalEl
345                : modalEl.querySelector('.modal-st');
346            modalBox?.classList.add(sizeClass);
347        } else {
348            console.warn(`[modal] Unknown size "${size}". Use 'sm' | 'md' | 'lg'.`);
349        }
350    }
351
352    // Track whether this modal has been closed (idempotent guard)
353    let closed = false;
354    let abortListener = null;
355    const modalBox = modalEl.classList?.contains('modal-st')
356        ? modalEl
357        : modalEl.querySelector('.modal-st');
358
359    // Setup close handler (idempotent — safe to call multiple times)
360    const close = (reason = 'programmatic') => {
361        if (closed) return;
362        const wasTopmost = getTopModalSurface() === modalEl;
363        closed = true;
364        // Take the discard prompt with us. Without this, a programmatic teardown closes the
365        // form and leaves its "Discard unsaved changes?" mounted over the next screen.
366        dismissConfirmAbort?.abort();
367        if (signal && abortListener) signal.removeEventListener('abort', abortListener);
368        // Destroy the dialog's pickers while it is still the live surface: their popover / mobile
369        // sheet / backdrop are portaled to document.body and DatePicker holds a document mousedown
370        // listener, so none of that leaves with modalEl.remove() on its own (MCF-0327).
371        SearchableDropdown.destroyAll(modalEl);
372        DatePicker.destroyAll(modalEl);
373        modalEl.remove();
374        const nextTopModal = unregisterSurface();
375        releaseScrollLock();
376        if (onClose) {
377            try { onClose(reason); } catch (e) { console.error('[modal] onClose error:', e); }
378        }
379        if (wasTopmost) {
380            if (triggerEl?.isConnected && !triggerEl.closest('[inert]')) {
381                triggerEl.focus();
382            } else if (nextTopModal) {
383                const fallback = nextTopModal.querySelector('[data-initial-focus]')
384                    || getFocusableElements(nextTopModal)[0]
385                    || nextTopModal.querySelector('.modal-st');
386                fallback?.focus();
387            }
388        }
389    };
390
391    // ── Unsaved-input guard ────────────────────────────────────────────────
392    // The dirty test is a VALUE COMPARISON, and the baseline is armed LAZILY on
393    // the first TRUSTED pointerdown/keydown captured on the modal element. Both
394    // halves are forced by measured behavior of our own shared components:
395    //   • A plain input/change listener FALSE-POSITIVES. Programmatic population
396    //     dispatches bubbling SYNTHETIC change events — SearchableDropdown
397    //     ._syncToNative(), DatePicker.clear() / ._commitSelection(), and
398    //     utils/form-population.js all do — so every pre-populated edit modal in
399    //     the app would read dirty before the user touched it.
400    //   • Filtering those out with event.isTrusted then FALSE-NEGATIVES.
401    //     SearchableDropdown and DatePicker portal their popovers to
402    //     document.body, so the user's real option click never lands inside the
403    //     modal; the only thing that reaches the in-modal control is the
404    //     synthetic change isTrusted just rejected.
405    // Both components DO write into a real in-modal control, so a value snapshot
406    // sees their result. Capture phase at the modal root runs before the event
407    // reaches its target, which is what makes the snapshot pre-change; and the
408    // user cannot change a value without first making a trusted gesture on a
409    // trigger that IS inside the modal. Listeners live on modalEl and die with
410    // it, so there is no teardown debt.
411    const GUARD_CONTROL_SELECTOR = 'input, textarea, select';
412    // A COMPONENT's own transient chrome is not the user's data. SearchableDropdown
413    // builds a real <input> for its option filter (components/SearchableDropdown.js:311-317)
414    // inside `.searchable-select-dropdown`, which lives inside the modal; `_open()`
415    // clears it (`:700`) but no close path does (`_closePopover` :780 / `_closeSheet` :798
416    // both return it to the wrapper with the filter text intact). Without this exclusion,
417    // typing a filter and closing the list WITHOUT picking would leave the modal
418    // permanently dirty over nothing the user cares about — and 242 of the 420 live
419    // modal blocks declare a select[data-searchable]. The attribute is the contract;
420    // the class is the belt, so a class rename cannot silently re-arm this.
421    const GUARD_IGNORE_SELECTOR = '[data-guard-ignore], .searchable-select-dropdown';
422    // ONE predicate, used by BOTH the snapshot and the comparison. Two call sites
423    // filtering separately could drift and would then compare different SETS, which is
424    // the failure mode that makes a dirty test quietly wrong instead of loudly broken.
425    const guardControls = () => Array.from(modalEl.querySelectorAll(GUARD_CONTROL_SELECTOR))
426        .filter(el => !el.closest(GUARD_IGNORE_SELECTOR));
427    let inputBaseline = null;
428    let attachmentsDirty = false;
429
430    const selectedOptionValues = (el, useDefault) => {
431        const opts = Array.from(el.options || []);
432        if (!useDefault) return opts.filter(o => o.selected).map(o => o.value);
433        const explicit = opts.filter(o => o.defaultSelected);
434        if (explicit.length) return explicit.map(o => o.value);
435        // A single <select> with no `selected` attribute implicitly selects its
436        // first option, so that — not "nothing" — is its authored default.
437        return (!el.multiple && opts.length) ? [opts[0].value] : [];
438    };
439
440    const controlState = (el, useDefault = false) => {
441        if (el.tagName === 'SELECT') return `s:${selectedOptionValues(el, useDefault).join('\u0001')}`;
442        const type = String(el.type || '').toLowerCase();
443        if (type === 'checkbox' || type === 'radio') return `c:${(useDefault ? el.defaultChecked : el.checked) ? 1 : 0}`;
444        return `v:${useDefault ? (el.defaultValue ?? '') : el.value}`;
445    };
446
447    const armInputBaseline = (e) => {
448        if (inputBaseline || !e.isTrusted) return;
449        inputBaseline = new WeakMap();
450        guardControls().forEach(el => inputBaseline.set(el, controlState(el)));
451    };
452    // Capture phase, and registered AHEAD of the focus-trap listener below so the
453    // ordering also holds when modalEl is itself the event target (at-target
454    // listeners fire in registration order regardless of the capture flag).
455    for (const type of ['pointerdown', 'keydown']) {
456        modalEl.addEventListener(type, armInputBaseline, true);
457    }
458
459    // Attachments live in a plain JS array (components/DropZone.js:127), so no
460    // .value can see them. DropZone publishes both mutations as BUBBLING
461    // CustomEvents from inside the modal; they are synthetic by construction, so
462    // isTrusted must NOT gate them.
463    const markAttachmentsDirty = () => { attachmentsDirty = true; };
464    modalEl.addEventListener('files-selected', markAttachmentsDirty);
465    modalEl.addEventListener('file-removed', markAttachmentsDirty);
466
467    const hasUnsavedInput = () => {
468        // Deliberately ahead of the baseline check: an attachment is dirty even with no
469        // baseline, because a drag from the desktop produces no trusted gesture inside
470        // the modal at all. The cost is that a caller which PRELOADS a DropZone opens
471        // the modal already dirty (pages/designer-task-detail.js:1628-1634 does exactly
472        // that) — intended, since the preloaded file is user-supplied.
473        if (attachmentsDirty) return true;
474        if (!inputBaseline) return false;   // never interacted with ⇒ clean
475        return guardControls().some((el) => {
476            // A control injected AFTER the baseline (renderLinkStep2's step-2 body,
477            // for one) is compared against its own authored default, so an
478            // injected-but-untouched field stays clean.
479            const before = inputBaseline.has(el) ? inputBaseline.get(el) : controlState(el, true);
480            return controlState(el) !== before;
481        });
482    };
483
484    let dismissConfirmOpen = false;
485    // Lets close() tear down the discard prompt this modal raised. See requestDismiss.
486    let dismissConfirmAbort = null;
487
488    // A refusal that renders NOTHING reads as a dead app, and Dan's stated reason for the
489    // universal way-out rule is that a user who cannot get out "will hit F5". CA-046
490    // [functionality:8] already held that a veto returning false "with no feedback, so the
491    // X, Cancel and Escape all do visibly nothing" is a defect worth repairing; this card
492    // cannot repair that instance and then ship two new ones.
493    // Deliberately NOT a toast: `.sb-toast-container` is --z-toast (1100) over --z-modal
494    // (1050), pinned bottom-right with no mobile override, so on a bottom-sheet modal it
495    // paints over the sheet's own footer — covering the Close control it is pointing at.
496    // Deliberately NOT a focus move: on `escape` that would interrupt typing mid-word.
497    // HONEST CAVEATS: under prefers-reduced-motion the global block in tailwind.css forces
498    // animation-duration to 0.01ms, so the shake is suppressed and the live region is the
499    // whole residual; and a repeat refusal rewrites the same string into the same live
500    // region, which some screen readers will not re-announce (the shake does retrigger).
501    let refusalStatusEl = null;
502    const signalRefusal = () => {
503        // :not(.modal-backdrop-st) — DESIGN-DEBT #28 records template files still putting
504        // data-dismiss on the backdrop, and shaking the backdrop points at nothing.
505        const exit = modalEl.querySelector('button[data-dismiss="modal"]:not(.modal-backdrop-st)');
506        if (exit) {
507            // The repo's own retrigger idiom — utils/form-validation.js:137-138, reusing
508            // the one-shot .field-attention class already defined in tailwind.css.
509            exit.classList.add('field-attention');
510            exit.addEventListener('animationend', () => exit.classList.remove('field-attention'), { once: true });
511        }
512        if (!refusalStatusEl || !refusalStatusEl.isConnected) {
513            // Built through DOM APIs, never markup in JS (utils/help-popover.js:38-53).
514            refusalStatusEl = document.createElement('p');
515            refusalStatusEl.className = 'sr-only';
516            refusalStatusEl.setAttribute('role', 'status');
517            refusalStatusEl.setAttribute('aria-live', 'polite');
518            refusalStatusEl.setAttribute('aria-atomic', 'true');
519            // Appended to the DIALOG BOX, not the template root. Bucketed the way showModal
520            // actually resolves a root (`tmp.firstElementChild`), `aria-modal="true"` sits on
521            // an inner .modal-st in 37 of the 420 live blocks; 15 have it on the root and 368
522            // have none. Assistive tech honoring aria-modal ignores everything outside that
523            // subtree, so a region appended to modalEl would be a SIBLING of the dialog and go
524            // unheard — and it is the whole signal under prefers-reduced-motion, where the
525            // shake is forced to 0.01ms. Figures from
526            // `node Developer/PM/tools/ca-045-modal-guard-census.mjs` §6, not from a scan
527            // bucketed by the modal-backdrop-st element: the PIN gate carries aria-modal on
528            // its runtime root while its backdrop token sits on an inner div, so that
529            // spelling mis-sorts it.
530            (modalBox || modalEl).appendChild(refusalStatusEl);
531        }
532        refusalStatusEl.textContent = 'Unsaved changes. Use Close to discard them.';
533    };
534
535    const requestDismiss = async (reason) => {
536        if (canDismiss && canDismiss(reason) === false) return false;
537        if (!guardUnsavedInput || !hasUnsavedInput()) {
538            close(reason);
539            return true;
540        }
541        // Dirty. Dan's 2026-08-21 reason split: the two accidental exits refuse
542        // outright — identical to the app-wide closeOnBackdrop:false behavior every
543        // unopted modal already has — and the deliberate one asks.
544        if (reason === 'backdrop' || reason === 'escape') {
545            signalRefusal();
546            return false;
547        }
548        // No cue here: a confirm is already on screen, so the user is not looking at a
549        // dead app. This branch is literally in the "returns without closing" class and
550        // is excluded on the merits, not by oversight.
551        if (dismissConfirmOpen) return false;   // single-flight: no stacked confirms
552        dismissConfirmOpen = true;
553        // The discard prompt is a SECOND surface, and close() only ever removes its own
554        // element — so a programmatic teardown while the prompt is open used to close the
555        // form and leave the prompt mounted over the next screen, still holding the body
556        // scroll-lock, asking about a form that no longer exists. Handing confirm() the
557        // parent's own `signal` is not enough: the teardown paths that matter here do not
558        // abort at all (dl-items is torn down by switchSubModule with no abort, and by
559        // destroyDeficiencyListPage BEFORE its abort), which is exactly why destroy* now
560        // calls close() directly. So the prompt gets its OWN controller, close() trips it,
561        // and the outer signal is forwarded into it.
562        dismissConfirmAbort = new AbortController();
563        const forwardAbort = () => dismissConfirmAbort?.abort();
564        if (signal?.aborted) dismissConfirmAbort.abort();
565        else signal?.addEventListener('abort', forwardAbort, { once: true });
566        let discard = false;
567        try {
568            discard = await confirm({
569                title: 'Discard unsaved changes?',
570                message: 'Your edits will be lost.',
571                icon: 'trash-2',
572                confirmText: 'Discard',
573                confirmClass: 'btn-st-error',
574                signal: dismissConfirmAbort.signal,
575            });
576        } catch (error) {
577            // The discard prompt ITSELF failed (a missing #tpl-confirm-modal, a render
578            // error). Do not trap the user behind an X that does nothing: Dan's rule is
579            // "there's gotta be a way out of any modal", and someone who cannot get out
580            // hits F5 — which loses the work anyway and can strand queued offline ops.
581            // So the exit still works, and it says so out loud rather than silently.
582            console.error('[modal] discard confirm failed; closing without it:', error);
583            window.showToast?.('Could not confirm discard', 'error', { description: 'The dialog was closed.' });
584            discard = true;
585        } finally {
586            signal?.removeEventListener('abort', forwardAbort);
587            dismissConfirmAbort = null;
588            dismissConfirmOpen = false;
589        }
590        // A teardown that fired while the prompt was open resolves it false through its own
591        // onClose, so this returns without attempting a second close — close() is one-shot
592        // anyway, but not reaching it keeps the intent legible.
593        if (!discard) return false;
594        close(reason);
595        return true;
596    };
597
598    // Close on elements with data-dismiss="modal" (close buttons AND backdrop).
599    // Backdrop: only close on direct click (e.target === backdrop), not clicks
600    // that bubbled up from modal content — bubbled clicks while filling out a
601    // form must not lose data.
602    const dismissEls = modalEl.querySelectorAll('[data-dismiss="modal"]');
603    dismissEls.forEach(el => el.addEventListener('click', (e) => {
604        if (el.classList.contains('modal-backdrop-st') && e.target !== el) return;
605        requestDismiss('dismiss');
606    }));
607
608    // Optional backdrop-click close for templates that do NOT mark the
609    // backdrop with data-dismiss. Direct click only — never bubbled.
610    if (closeOnBackdrop && modalEl.classList?.contains('modal-backdrop-st')) {
611        modalEl.addEventListener('click', (e) => {
612            if (e.target === modalEl) requestDismiss('backdrop');
613        });
614    }
615
616    // Keep keyboard interaction inside the top modal. Handling this on the modal
617    // root lets controls receive their key event first, then prevents page-level
618    // shortcuts underneath the dialog from observing it.
619    modalEl.addEventListener('keydown', (e) => {
620        if (getTopModalSurface() !== modalEl) return;
621
622        if (e.key === 'Escape') {
623            e.preventDefault();
624            if (closeOnEscape) requestDismiss('escape');
625            e.stopPropagation();
626            return;
627        }
628
629        if (e.key === 'Tab') {
630            const focusable = getFocusableElements(modalEl);
631            if (focusable.length === 0) {
632                e.preventDefault();
633                modalBox?.focus();
634            } else {
635                const first = focusable[0];
636                const last = focusable[focusable.length - 1];
637                if (e.shiftKey && (document.activeElement === first || !modalEl.contains(document.activeElement))) {
638                    e.preventDefault();
639                    last.focus();
640                } else if (!e.shiftKey && (document.activeElement === last || !modalEl.contains(document.activeElement))) {
641                    e.preventDefault();
642                    first.focus();
643                }
644            }
645        }
646
647        e.stopPropagation();
648    });
649
650    const initialFocus = modalEl.querySelector('[data-initial-focus]')
651        || getFocusableElements(modalEl)[0]
652        || modalBox;
653    if (initialFocus === modalBox && !modalBox?.hasAttribute('tabindex')) modalBox?.setAttribute('tabindex', '-1');
654    initialFocus?.focus();
655
656    // Auto-enhance DropZone components in the modal
657    DropZone.initAll(modalEl);
658
659    // Auto-close on abort signal (page navigation tears down the parent module)
660    if (signal) {
661        if (signal.aborted) {
662            close('abort');
663        } else {
664            abortListener = () => close('abort');
665            signal.addEventListener('abort', abortListener, { once: true });
666        }
667    }
668
669    return {
670        element: modalEl,
671        close,
672        // The supported route for a caller-wired close control to enter the SAME
673        // guarded funnel the X / backdrop / Escape use, instead of going around it
674        // with a bare close(). `close` stays the unguarded programmatic path.
675        dismiss: (reason = 'dismiss') => requestDismiss(reason),
676    };
677}
678
679/**
680 * Prompt Input Modal — replaces native prompt()
681 * @param {object} options
682 * @param {string} options.title - Modal title
683 * @param {string} [options.message] - Body message
684 * @param {string} [options.icon] - Lucide icon name (default: 'edit-3')
685 * @param {string} [options.inputType] - Input type (default: 'text'). `'number'` is MAPPED to a
686 *   text control carrying `inputmode="decimal"` — see the note at the render call below; every
687 *   other value passes through unchanged.
688 * @param {string} [options.inputLabel] - The field's user-facing name. Renders a real
689 *   `<label for="prompt-input-value">` AND becomes the control's accessible name. Supply it
690 *   whenever a `validate` gate composes a sentence about this field: a rule that says
691 *   "Year is not a valid number" about a box labelled nothing is a sentence the user cannot
692 *   attach to anything, and `_findLabel` has no authored association to redden. Omitted, the
693 *   dialog renders exactly as before and the title carries the accessible name.
694 * @param {(value: string, inputEl: HTMLElement, container: HTMLElement) => (string|null|undefined)} [options.validate]
695 *   Optional commit gate. Return a failure message and the dialog STAYS OPEN with the typed value
696 *   intact and the promise unsettled; return null/undefined and the commit proceeds unchanged. The
697 *   caller owns the marking and the sentence, because `form-validation.js` imports THIS module and
698 *   a reverse import would be a cycle. Omit it and every byte of the previous behaviour is kept.
699 * @param {string} [options.placeholder] - Input placeholder
700 * @param {string} [options.defaultValue] - Default input value
701 * @param {string} [options.confirmText] - Confirm button text (default: 'OK')
702 * @param {string} [options.cancelText] - Cancel button text (default: 'Cancel')
703 * @param {AbortSignal} [options.signal] - AbortSignal for auto-close on navigation
704 * @returns {Promise<string|null>} The entered value, or null if cancelled
705 */
706export async function promptInput({
707    title, message, icon, inputType, inputLabel, placeholder, defaultValue, confirmText, cancelText,
708    signal, validate
709} = {}) {
710    // Hoisted deferred (the utils/email-compose.js:106-110 shape), settled exactly
711    // once by every exit. Cancel resolution rides showModal's onClose — the route
712    // confirm() already uses — so a dismissal REFUSED or DEFERRED by the
713    // unsaved-input guard leaves this promise unsettled with the typed value still
714    // on screen. The duplicate [data-dismiss] listener this replaced resolved the
715    // caller on click, before the funnel had decided anything.
716    let resolved = false;
717    let resolveFn;
718    const result = new Promise(r => { resolveFn = r; });
719    const safeResolve = (value) => {
720        if (resolved) return;
721        resolved = true;
722        resolveFn(value);
723    };
724
725    // THE NATIVE NUMERIC DIES HERE, IN THE HELPER, NOT AT THE CALLER — CA-138.
726    //
727    // `#tpl-prompt-input-modal` renders `type="{{>inputType || 'text'}}"`, the app's only dynamic
728    // input-type expression, and it was the numeric arc's one structural blind spot: a
729    // `type` computed at render time is not statically matchable, so `numeric-input-census.mjs`
730    // reported 0 for the family that owned the caller while a live native numeric business control
731    // existed (MCF-0423). Mapping at the CALLER would have fixed today's one caller and left the
732    // hole open for tomorrow's; mapping here means no present or future caller can produce a native
733    // numeric through this dialog at all.
734    //
735    // The mechanism is Dan's 2026-08-28 uniform ruling: `type="text"` + `inputmode="decimal"`, so
736    // JS sees the raw text (`12abc` / `$500` / `1,000` reach `validate` instead of being blanked by
737    // UA value sanitization before any JS runs — MCF-0401) and the mobile numeric keypad survives.
738    // Every other `inputType` passes through untouched.
739    const isNumericPrompt = inputType === 'number';
740    const modal = await showModal('#tpl-prompt-input-modal', {
741        title,
742        message,
743        icon,
744        inputType: isNumericPrompt ? 'text' : inputType,
745        inputMode: isNumericPrompt ? 'decimal' : undefined,
746        inputLabel,
747        placeholder,
748        defaultValue,
749        confirmText,
750        cancelText,
751    }, { signal, onClose: () => safeResolve(null) });
752
753    const inputEl = modal.element.querySelector('#prompt-input-value');
754    if (inputEl) {
755        inputEl.focus();
756        inputEl.select();
757    }
758
759    if (signal?.aborted) return null;
760
761    if (signal) {
762        signal.addEventListener('abort', () => {
763            modal.close();
764            safeResolve(null);
765        }, { once: true });
766    }
767
768    // Commit settles BEFORE closing — first settle wins over onClose's null, the
769    // same ordering confirm() uses at its own confirm-button branch.
770    const commit = () => {
771        const value = inputEl ? inputEl.value.trim() : '';
772        // The optional commit gate (CA-138). A refusal leaves the dialog open, the typed value on
773        // screen and the promise unsettled — the same shape the unsaved-input guard already gives a
774        // dismissal, so a refused year is corrected in place instead of re-opened from scratch.
775        if (validate && inputEl && validate(value, inputEl, modal.element)) return;
776        safeResolve(value || null);
777        modal.close();
778    };
779
780    const confirmBtn = modal.element.querySelector('[data-action="confirm"]');
781    confirmBtn?.addEventListener('click', commit);
782
783    // Enter key submits. Escape is deliberately NOT handled here any more: the
784    // guarded funnel owns that key, so a prompt the user has typed into refuses
785    // Escape instead of discarding it.
786    inputEl?.addEventListener('keydown', (e) => {
787        if (e.key === 'Enter') commit();
788    });
789
790    return result;
791}
792
793/**
794 * Standard Confirm Modal
795 * @param {object} options
796 * @param {string} options.title - Modal title
797 * @param {string} options.message - Body message
798 * @param {string} [options.icon] - Lucide icon name (default: 'alert-circle')
799 * @param {string} [options.confirmText] - Confirm button text (default: 'Confirm')
800 * @param {string} [options.cancelText] - Cancel button text (default: 'Cancel')
801 * @param {string} [options.confirmClass] - Confirm button class (default: 'btn-st-primary')
802 * @param {Function} [options.onConfirm] - Async callback when confirmed
803 * @param {AbortSignal} [options.signal] - AbortSignal for auto-close on navigation
804 * @returns {Promise<boolean>} true if confirmed, false if cancelled/dismissed/Escape
805 */
806export async function confirm({
807    title, message, icon, confirmText, cancelText, confirmClass, onConfirm, signal
808} = {}) {
809    if (signal?.aborted) return false;
810
811    let resolved = false;
812    let resolveFn;
813    const result = new Promise(r => { resolveFn = r; });
814    const safeResolve = (value) => {
815        if (resolved) return;
816        resolved = true;
817        resolveFn(value);
818    };
819    let pending = false;
820    const instanceId = `confirm-${++confirmInstanceSequence}`;
821
822    // onClose covers every non-confirm exit (dismiss / backdrop / Escape /
823    // signal abort / programmatic) — all resolve false.
824    const modal = await showModal('#tpl-confirm-modal', {
825        title,
826        message,
827        icon,
828        confirmText,
829        cancelText,
830        confirmClass,
831        confirmIcon: getConfirmCommitIcon(confirmText, icon),
832        confirmTitleId: `${instanceId}-title`,
833        confirmMessageId: `${instanceId}-message`,
834        confirmActionId: `${instanceId}-action`,
835        confirmStatusId: `${instanceId}-status`,
836    }, {
837        signal,
838        // Set HERE, not by the caller: this function's parameter set is closed and
839        // its options literal is built from scratch, so a caller-supplied
840        // `guardUnsavedInput` would be dropped on the floor with no error. A confirm
841        // dialog is a decision point, never a data-entry form — it has nothing to
842        // protect and must never answer a dismissal by stacking another confirm.
843        // That matters beyond the guard's own dialog: promptInput exists to hold
844        // typed input, so a dirty prompt stacks a real confirm over itself and that
845        // confirm has to terminate.
846        guardUnsavedInput: false,
847        canDismiss: () => !pending,
848        onClose: () => safeResolve(false),
849    });
850
851    // Confirm → run onConfirm callback if provided, then resolve true
852    const confirmBtn = modal.element.querySelector('[data-action="confirm"]');
853    const modalBox = modal.element.querySelector('.modal-st');
854    const dismissBtns = Array.from(modal.element.querySelectorAll('button[data-dismiss="modal"]'));
855    const idleIcon = confirmBtn?.querySelector('[data-confirm-idle-icon]');
856    const busyIcon = confirmBtn?.querySelector('[data-confirm-busy-icon]');
857    const label = confirmBtn?.querySelector('[data-confirm-label]');
858    const liveStatus = modal.element.querySelector('[data-confirm-status]');
859    const originalLabel = label?.textContent || confirmText || 'Confirm';
860    const setPending = (isPending) => {
861        pending = isPending;
862        if (confirmBtn) {
863            confirmBtn.disabled = isPending;
864            confirmBtn.setAttribute('aria-busy', String(isPending));
865        }
866        modalBox?.setAttribute('aria-busy', String(isPending));
867        dismissBtns.forEach(btn => {
868            btn.disabled = isPending;
869            btn.setAttribute('aria-disabled', String(isPending));
870        });
871        idleIcon?.classList.toggle('hidden', isPending);
872        busyIcon?.classList.toggle('hidden', !isPending);
873        if (label) label.textContent = isPending ? `${originalLabel}…` : originalLabel;
874        if (liveStatus) liveStatus.textContent = isPending ? `${originalLabel} in progress.` : '';
875    };
876
877    if (confirmBtn) {
878        confirmBtn.addEventListener('click', async () => {
879            if (pending || confirmBtn.disabled) return;
880            setPending(true);
881            try {
882                if (onConfirm) await onConfirm();
883                safeResolve(true);
884                modal.close();
885            } catch (error) {
886                if (error?.name !== 'AbortError' && !signal?.aborted) {
887                    window.showToast?.('Action failed', 'error', { description: error.message });
888                }
889                if (!signal?.aborted) {
890                    setPending(false);
891                    if (liveStatus) liveStatus.textContent = `${originalLabel} failed. You can retry.`;
892                    confirmBtn.focus();
893                }
894            }
895        });
896    }
897
898    return result;
899}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.