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.