/**
* @file Accessible contact dialog (slide-in drawer or centered lightbox).
*
* ## Why not `showModal()`?
*
* The `<dialog>` is opened with `dialog.show()`, not `showModal()`.
* `showModal()` puts the dialog in the browser's "top layer", and its
* `::backdrop` then painted above the high-z-index challenge overlay that
* Google's reCAPTCHA appended to `<body>`, hiding it (see the comment on
* `#contact-modal` in `src/input.css`). The form now uses Cloudflare
* Turnstile, whose challenge stays inside the widget, so that constraint is
* gone — the hand-built dialog stays because it works and is tested. This
* file builds what `showModal()` would give for free:
*
* - a real backdrop element (`#contact-backdrop`) for the dimmed background
* and click-outside-to-close;
* - `inert` on the rest of `<body>` while open (unfocusable and hidden from
* assistive technology);
* - manual Escape handling;
*
* plus what every dialog needs anyway: a focus trap, a scroll lock, and
* returning focus to the button that opened it.
*
* ## Where the form comes from
*
* The dialog has almost no markup of its own. It **clones** `#intake-form`
* — the one authored copy, which is also the no-JS fallback and the form
* Netlify detects at build time — and the inline success panel. The clone's
* ids are re-keyed from the `in-` prefix to `cf-` so both copies can live in
* one document without duplicate ids. The PHI (protected health information)
* note rides along inside the form clone.
*
* Validation and the Netlify submit come from `contact-form.js`, shared with
* the inline intake form.
*
* ## Hooks
*
* - Any element with `data-open-contact` opens the dialog when clicked.
* - `data-contact-title="…"` sets the dialog title (default: the button's text).
* - `data-variant="lightbox"` shows it centered instead of as a side drawer.
* - `data-context="…"` is passed to the form (see `setContext`).
* - Any element inside the dialog with `data-close-contact` closes it.
* - `contact:open` / `contact:close` events are dispatched on `document`.
*
* Loaded as an ES module. Its import of `contact-form.js` gets a `?v=` cache
* hash added at build time by `scripts/version-assets.js`.
*
* @module modal
*/
import { initContactForm } from './contact-form.js'; // load the shared form behavior
/** The `<dialog id="contact-modal">`. @type {HTMLDialogElement|null} */
let dialog = document.getElementById('contact-modal'); // find the dialog
/** The separate dimmed backdrop behind it. @type {HTMLElement|null} */
let backdrop = document.getElementById('contact-backdrop'); // find the backdrop
/** The authored inline form that gets cloned. @type {HTMLFormElement|null} */
let source = document.getElementById('intake-form'); // find the inline form
/** The inline success panel that gets cloned. @type {HTMLElement|null} */
let successSrc = document.querySelector('[data-contact-success]'); // find the inline success panel
// Secondary pages (e.g. for-professionals.html) have contact triggers but no
// form or dialog of their own: borrow both from the home page, so each is still
// authored once. The triggers there are links to /index.html#intake, which is
// where a click goes until this finishes (or if it fails).
if (!source && document.querySelector('[data-open-contact]')) { // triggers, but nothing to clone?
try { // a failed fetch leaves the links working as plain links
const res = await fetch('/index.html', { credentials: 'same-origin' }); // load the home page
const home = new DOMParser().parseFromString(await res.text(), 'text/html'); // parse it without running it
source = home.getElementById('intake-form'); // its form
successSrc = home.querySelector('[data-contact-success]'); // its success panel
if (!dialog && source) { // add its backdrop and dialog to this page
backdrop = document.importNode(home.getElementById('contact-backdrop'), true); // copy the backdrop
dialog = document.importNode(home.getElementById('contact-modal'), true); // copy the dialog
dialog.querySelectorAll('a[href="#intake"]').forEach((a) => { a.href = '/index.html#intake'; }); // fallback hrefs point home
document.body.append(backdrop, dialog); // top-level, so setBackgroundInert skips them
window.chaEmailLinks?.(dialog); // build the dialog's "Call us" link
}
} catch { // network or parse failure:
source = null; // leave the page as it is
}
}
// Everything below needs both the dialog and the form to clone.
if (dialog && source) { // only continue if both exist
/**
* Swaps the inline form's `in-` id prefix for the dialog's `cf-` prefix,
* wherever it starts a word (so space-separated id lists work too).
*
* @param {string|null} v An id, or a space-separated list of ids.
* @returns {string|null} The re-keyed value (falsy input returned as-is).
*/
const rekey = (v) => (v ? v.replace(/\bin-/g, 'cf-') : v); // replace word-initial "in-" with "cf-"
// -------------------------------------------------------------------------
// Build the dialog's form from the inline one
// -------------------------------------------------------------------------
/** The dialog's copy of the intake form. @type {HTMLFormElement} */
const form = /** @type {HTMLFormElement} */ (document.importNode(source, true)); // deep-copy the inline form (it may come from the fetched home page)
form.id = 'contact-form'; // give the copy its own id
// The modal supplies its own padding/scroll chrome; drop the inline card layout.
form.classList.remove('card', 'mx-auto', 'mt-8', 'max-w-measure', 'p-6', 'sm:p-8'); // remove the inline card styling
// Re-key every id, and every attribute that refers to an id, so labels and
// error descriptions still point at the right fields inside the clone.
form.querySelectorAll('[id]').forEach((el) => { el.id = rekey(el.id); }); // re-key every id
form.querySelectorAll('[for]').forEach((el) => { el.htmlFor = rekey(el.htmlFor); }); // re-key every label's for=
form.querySelectorAll('[aria-describedby]').forEach((el) => { // for each element with aria-describedby:
el.setAttribute('aria-describedby', rekey(el.getAttribute('aria-describedby'))); // re-key the ids it lists
});
form.querySelectorAll('[aria-labelledby]').forEach((el) => { // for each element labelled by an id (the Turnstile field):
el.setAttribute('aria-labelledby', rekey(el.getAttribute('aria-labelledby'))); // re-key the ids it lists
});
// The PHI note rides along in the clone (id in- → cf-); give it the modal's top gap.
form.querySelector('[data-phi-note]')?.classList.add('mt-3'); // add top spacing to the PHI note
// Turnstile: if the inline widget had already rendered, its iframe came
// along in the copy, dead, and so did its token (single-use, and the inline
// form's) and the revealed field around them. Empty all three; contact-form.js
// brings the dialog's own check out and renders it on the visitor's first
// edit, then fills the field from the widget's callback.
form.querySelectorAll('script').forEach((s) => s.remove()); // remove any copied <script> tags
form.querySelector('[data-turnstile]')?.replaceChildren(); // drop the dead copied widget, if any
form.querySelector('[data-turnstile-field]')?.setAttribute('hidden', ''); // put the check away again
const tokenField = form.querySelector('input[name="cf-turnstile-response"]'); // the copied token field
if (tokenField) tokenField.value = ''; // start without a token
/** The clone's submit button. @type {HTMLButtonElement} */
const submitBtn = form.querySelector('button[type="submit"]'); // find the copy's submit button
submitBtn.classList.remove('sm:w-auto'); // full-width in the modal
// Label ("Start Your Consultation") rides along in the clone — authored once on #intake-form.
// Put the finished clone into the dialog in place of its placeholder.
dialog.querySelector('[data-contact-form-mount]').replaceWith(form); // swap the dialog's form placeholder for the copy
// Point the dialog's description at the cloned PHI note (id exists only post-clone).
dialog.setAttribute('aria-describedby', 'cf-phi'); // describe the dialog with the PHI note
// Success panel: clone the inline one so the headline / copy live in one place.
const success = document.importNode(successSrc, true); // deep-copy the inline success panel
success.classList.remove('card', 'mx-auto', 'mt-8', 'max-w-measure', 'p-6'); // remove the inline card styling
success.classList.add('mt-3'); // clear the modal title, which stays visible
success.insertAdjacentHTML( // append…
'beforeend', // …at the end of the panel…
'<p class="mt-5"><button type="button" class="btn btn-ghost" data-close-contact>Close</button></p>' // …a Close button
);
dialog.querySelector('[data-contact-success-mount]').replaceWith(success); // swap the dialog's success placeholder for the copy
// Borrowed from the homepage (see the fetch above), the panel's "call us"
// link is still an unbuilt placeholder aimed at an #intake this page lacks:
// point its fallback home, then have email.js build the number. On the
// homepage the link was built before cloning, so both steps change nothing.
if (!document.getElementById('intake')) { // no intake section on this page:
success.querySelectorAll('a[href="#intake"]').forEach((a) => { a.href = '/index.html#intake'; }); // fallback hrefs point home
}
window.chaEmailLinks?.(success); // build the panel's phone link
// The panel's "Book time with us" link goes to the inline scheduler in the
// homepage's #schedule band, which is behind the dialog: close first, then
// go there as main.js does for any same-page link (this copy was made after
// main.js wired those). On a page without the band the link leads home.
success.querySelectorAll('a[href="#schedule"]').forEach((a) => { // for each link to the scheduler:
const target = document.getElementById('schedule'); // the band, if this page has it
if (!target) { // no band here:
a.href = '/index.html#schedule'; // go to the homepage's
return; // nothing to wire
}
a.addEventListener('click', (e) => { // when it's clicked:
e.preventDefault(); // the page is scroll-locked, so the browser's jump would do nothing
closeContact(); // hand the page back (the message is sent: no discard check)
target.scrollIntoView({ block: 'start' }); // scroll to the band (smooth or not is the stylesheet's call)
history.pushState(null, '', '#schedule'); // add the hash to the URL and history
const hadTabindex = target.hasAttribute('tabindex'); // did it already have a tabindex?
if (!hadTabindex) target.setAttribute('tabindex', '-1'); // if not, make it focusable by script
target.focus({ preventScroll: true }); // move focus there without fighting the scroll
if (!hadTabindex) target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true }); // and tidy up when focus leaves
});
});
// Booking-first block + title: after a send, hide the booking prompt and say so
// in the title; a fresh open (contact.reset) restores both.
/** The "book a call instead" block at the top. @type {HTMLElement|null} */
const quickbook = dialog.querySelector('[data-quickbook]'); // find the booking block
/** The dialog's heading. @type {HTMLElement|null} */
const titleEl = dialog.querySelector('#cm-title'); // find the dialog title
// The title echoes the label of whichever button opened the dialog (see
// openContact), so the visitor lands on the words they just clicked. The
// authored title is the fallback for programmatic opens.
/** The authored heading text. @type {string|undefined} */
const defaultTitle = titleEl?.textContent.trim(); // read the authored title text
/** The heading for the current open (restored when the form resets). */
let titleText = defaultTitle; // current title starts as the authored one
/** Whether a consultation was booked during this open (see the `calendly:booked` listener). */
let booked = false; // nothing booked yet
// Attach validation + submit behavior. The callbacks keep the dialog's
// own chrome (booking block, title) in step with the form's state.
const contact = initContactForm(form, { // set up the copied form with…
errorSummary: form.querySelector('[data-contact-errors]'), // …the copy's own error summary
successPanel: success, // …the copied success panel
submitBtn, // …the copy's submit button
onSuccess() { // after a successful send:
if (quickbook) quickbook.hidden = true; // hide the booking block
if (titleEl) titleEl.textContent = 'Message sent'; // change the title to "Message sent"
},
onReset() { // after a reset:
booked = false; // a fresh open has booked nothing
if (quickbook) quickbook.hidden = false; // show the booking block again
quickbook?.classList.remove('is-booked'); // and everything in it
if (titleEl) titleEl.textContent = titleText; // restore this open's title
},
});
// A consultation booked in the dialog's Calendly panel: calendly.js has swapped
// the frame for the site's thanks (inside the booking block) and says so with
// this event. Clear the dialog around it, as a sent message does: no prompt to
// book or call, no message form, and a title that says what happened. The next
// open's reset (above) puts it all back.
dialog.addEventListener('calendly:booked', () => { // when a booking completes in the dialog:
booked = true; // remember it for the close check
quickbook?.classList.add('is-booked'); // hide the lede, buttons and divider around the thanks (src/input.css)
form.hidden = true; // hide the message form
if (titleEl) titleEl.textContent = 'Call booked'; // retitle the dialog
});
/**
* Selector for elements that can receive keyboard focus; used for the
* focus trap and for choosing what to focus when the dialog opens.
* @type {string}
*/
const FOCUSABLE = // focusable-element selector:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; // links, enabled form controls, and positive-tabindex elements
/**
* Makes everything on the page except the dialog and its backdrop inert
* (or restores it).
*
* `showModal()` makes everything outside the dialog `inert` (unfocusable,
* hidden from assistive tech) automatically; `show()` doesn't, so it's done
* here. Only direct children of `<body>` need the attribute — `inert`
* applies to their whole subtree.
*
* @param {boolean} on `true` to make the background inert, `false` to undo.
* @returns {void}
*/
function setBackgroundInert(on) { // define setBackgroundInert(on)
for (const el of document.body.children) { // for each top-level element in <body>:
if (el === dialog || el === backdrop) continue; // leave the dialog and backdrop alone
if (on) el.setAttribute('inert', ''); // make it inert
else el.removeAttribute('inert'); // or make it interactive again
}
}
/** Element to return focus to on close. @type {HTMLElement|null} */
let lastFocused = null; // nothing to return focus to yet
/** Page scroll position saved when the scroll lock was applied. */
let scrollY = 0; // no saved scroll position yet
// A close whose exit animation is still playing (the dialog stays [open]
// until it ends). closeGen lets a stale animation settle without ending a
// later close; closeTimer is the fallback if an animation never reports back.
let closing = false; // not closing
let closeGen = 0; // close generation counter
let closeTimer = 0; // no fallback timer yet
// -------------------------------------------------------------------------
// Open / close
// -------------------------------------------------------------------------
/**
* Opens the contact dialog.
*
* Steps: finish any close still animating; remember where focus was; set
* the title; reset the form; choose drawer vs lightbox; pass the trigger's
* context to the form and start the spam time-trap; lock page scrolling;
* show the backdrop and dialog; make the page inert; announce
* `contact:open`; scroll the dialog to the top; focus the first useful
* control.
*
* @param {HTMLElement} [trigger] The element that was clicked. Its
* `data-contact-title`, `data-variant` and `data-context` attributes
* customize the dialog.
* @returns {void}
*/
function openContact(trigger) { // define openContact(trigger)
// Re-opened mid-exit: end that close now so the entrance replays cleanly.
if (closing) finishClose(); // finish a close that's still animating
lastFocused = trigger || document.activeElement; // remember what to refocus on close
// Match the title to the trigger's visible label. `data-contact-title`
// overrides it; textContent skips icons, and whitespace is collapsed.
titleText = // title is the first of:
trigger?.dataset.contactTitle || // the trigger's data-contact-title,
trigger?.textContent.replace(/\s+/g, ' ').trim() || // its visible text with whitespace tidied,
defaultTitle; // or the authored title
if (titleEl) titleEl.textContent = titleText; // show that title
// Always start from a blank form with no errors, whatever the last visit
// left (onReset restores the title set just above).
contact?.reset(); // clear the form and any errors
// Most triggers open the right-anchored drawer; a `data-variant="lightbox"`
// trigger presents the same dialog as a centered lightbox instead.
dialog.classList.toggle('is-lightbox', trigger?.dataset.variant === 'lightbox'); // centered lightbox if requested, else drawer
contact?.setContext(trigger?.dataset.context); // record which audience path opened the form
contact?.stampOpened(); // restart the spam time-trap clock
// Scroll lock: pin <body> in place, offset by the current scroll so the
// page doesn't visibly jump. (overflow:hidden alone doesn't stop
// scrolling on iOS Safari.)
scrollY = window.scrollY; // remember the page's scroll position
document.body.style.position = 'fixed'; // pin <body> so the page can't scroll
document.body.style.top = `-${scrollY}px`; // shift it up so the view doesn't jump
document.body.style.width = '100%'; // keep its full width while fixed
backdrop?.removeAttribute('hidden'); // show the backdrop
setBackgroundInert(true); // make the page behind inert
if (typeof dialog.show === 'function') { // browser supports dialog.show()?
dialog.show(); // open the dialog (non-modal)
} else { // otherwise:
dialog.setAttribute('open', ''); // very old browsers: degrade to inline
}
document.dispatchEvent(new CustomEvent('contact:open')); // announce that the dialog opened
// The Turnstile widget is deliberately not rendered here: opening the
// dialog isn't filling the form in. contact-form.js brings it out on the
// visitor's first edit.
// Every open starts at the top, like a fresh form. Done after show(): a
// closed <dialog> has no box, so an earlier reset may not stick.
scrollToTop(); // scroll the dialog content to the top
// Focus the booking block's first control if it's showing, else the name
// field, else whatever is first focusable. "Showing" is checked per control:
// for a visitor who has already booked, the stylesheet hides "Book time with
// us" (see calendly.js), and focus() on a control with no box does nothing.
const first = [...dialog.querySelectorAll('[data-quickbook]:not([hidden]) :is(button, a)')].find((el) => el.getClientRects().length) || dialog.querySelector('#cf-name') || dialog.querySelector(FOCUSABLE); // pick the first control to focus
first?.focus({ preventScroll: true }); // focus it without scrolling
}
/**
* Starts closing the dialog.
*
* The page is handed back immediately (interactivity, scroll position,
* focus) while the dialog and backdrop play their exit animation on top.
* {@link finishClose} runs when the animations end — or after 500 ms at
* the latest, in case an animation never reports that it finished.
*
* Does nothing if the dialog is already closing or isn't open.
* Callers that should respect unsaved input use {@link requestClose}.
*
* @returns {void}
*/
function closeContact() { // define closeContact()
if (closing || !dialog.hasAttribute('open')) return; // already closing or not open: stop
closing = true; // mark a close as in progress
// Hand the page back at once -- interactivity, scroll position, focus --
// while the dialog plays its exit on top (.is-closing, src/input.css).
// The page is parked exactly where it was, so releasing the lock under the
// backdrop doesn't visibly move it. The dialog goes inert so nothing inside
// it can take focus or clicks on the way out.
setBackgroundInert(false); // make the page interactive again
dialog.setAttribute('inert', ''); // make the dialog non-interactive
// Release the scroll lock and jump straight back to where the visitor was.
// 'instant' overrides the page's global `scroll-behavior: smooth`, which
// would otherwise animate the page up from the parked top position.
document.body.style.position = ''; // unpin <body>…
document.body.style.top = ''; // …remove its offset…
document.body.style.width = ''; // …and its forced width
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' }); // jump back to the saved scroll position
lastFocused?.focus?.({ preventScroll: true }); // refocus the element that opened the dialog
// Start the exit animation (defined in CSS).
dialog.classList.add('is-closing'); // start the dialog's exit animation
backdrop?.classList.add('is-closing'); // start the backdrop's exit animation
// Tag this close with a generation number; `done` only acts if no newer
// open/close has happened since.
const gen = ++closeGen; // new generation number for this close
const done = () => { if (gen === closeGen) finishClose(); }; // finish only if nothing newer has happened
// No getAnimations() (old browsers) resolves immediately: an instant close.
const exits = typeof dialog.getAnimations === 'function' // if animations can be listed:
? [...dialog.getAnimations(), ...(backdrop?.getAnimations() ?? [])] // collect the dialog's and backdrop's running animations
: []; // otherwise none
Promise.all(exits.map((a) => a.finished)).then(done, done); // finish when they've all ended (or failed)
closeTimer = setTimeout(done, 500); // or after 500 ms, whichever is first
}
/**
* Completes a close once the exit animation is over: actually closes the
* `<dialog>`, hides the backdrop, removes the temporary classes and
* `inert`, clears the form, and announces `contact:close`.
*
* Safe to call more than once; only the first call after a
* {@link closeContact} does anything.
*
* @returns {void}
*/
function finishClose() { // define finishClose()
if (!closing) return; // no close in progress: stop
closing = false; // mark the close as done
closeGen++; // invalidate any other pending finish
clearTimeout(closeTimer); // cancel the fallback timer
// Fallback for browsers without <dialog> support: remove the attribute.
if (typeof dialog.close === 'function' && dialog.open) dialog.close(); // close the dialog natively
else dialog.removeAttribute('open'); // or just remove its open attribute
backdrop?.setAttribute('hidden', ''); // hide the backdrop
dialog.removeAttribute('inert'); // make the dialog interactive again
dialog.classList.remove('is-closing', 'is-lightbox'); // remove the exit and lightbox classes
backdrop?.classList.remove('is-closing'); // remove the backdrop's exit class
// Leave nothing behind once it's out of sight: blank fields, no errors.
hideDiscard(); // close any discard prompt
contact?.reset(); // clear the form
document.dispatchEvent(new CustomEvent('contact:close')); // announce that the dialog closed
}
// -------------------------------------------------------------------------
// "Discard your message?" check
// -------------------------------------------------------------------------
// Closing clears the form, so closing a half-filled one (close button,
// Escape, backdrop click) asks first. An in-dialog panel rather than
// window.confirm(): it matches the site, is announced as an alertdialog, and
// keeps focus inside the modal. The success panel's Close skips the check.
/** The confirmation panel. @type {HTMLElement|null} */
const confirmEl = dialog.querySelector('[data-discard-confirm]'); // find the discard prompt
/** The dialog's scrolling content area. @type {HTMLElement|null} */
const scrollEl = dialog.querySelector('.modal-scroll'); // find the scrolling content area
/** Where to put focus back if the visitor chooses "keep editing". @type {HTMLElement|null} */
let confirmReturn = null; // nothing to refocus yet
/**
* Scrolls the dialog's content back to the top, instantly.
*
* `.modal-scroll` is the dialog's only scroller. `'instant'` overrides the
* page's global `scroll-behavior: smooth`.
*
* @returns {void}
*/
function scrollToTop() { // define scrollToTop()
scrollEl?.scrollTo({ top: 0, left: 0, behavior: 'instant' }); // jump the content area to the top
}
/**
* Closes the dialog, unless there's unsent input — in which case it asks
* for confirmation first. Used by every "close" control.
*
* @returns {void}
*/
function requestClose() { // define requestClose()
if (closing || !dialog.hasAttribute('open')) return; // already closing or not open: stop
if (confirmEl && !confirmEl.hidden) return; // already asking
// Ask only if something was typed and it hasn't been sent. A booking hides
// the form, so there is nothing left on screen to ask about.
if (confirmEl && contact && !contact.submitted && !booked && contact.dirty) showDiscard(); // unsent input: ask first
else closeContact(); // otherwise close straight away
}
/**
* Shows the discard-confirmation panel, remembers where focus was, makes
* the form area inert behind it, and focuses the "keep editing" button (the
* safe default).
*
* @returns {void}
*/
function showDiscard() { // define showDiscard()
confirmReturn = dialog.contains(document.activeElement) // remember where to refocus:
? document.activeElement // the focused element if it's in the dialog,
: dialog.querySelector('.modal-close'); // else the dialog's close button
scrollEl?.setAttribute('inert', ''); // make the form area inert behind the prompt
confirmEl.hidden = false; // show the prompt
confirmEl.querySelector('[data-discard-cancel]')?.focus({ preventScroll: true }); // focus "keep editing"
}
/**
* Hides the discard-confirmation panel and re-enables the form area.
* No-op if the panel isn't showing.
*
* @returns {void}
*/
function hideDiscard() { // define hideDiscard()
if (!confirmEl || confirmEl.hidden) return; // no prompt or already hidden: stop
confirmEl.hidden = true; // hide the prompt
scrollEl?.removeAttribute('inert'); // make the form area interactive again
}
// "Keep editing": dismiss the panel and go back to where the visitor was.
confirmEl?.querySelector('[data-discard-cancel]')?.addEventListener('click', () => { // when "keep editing" is clicked:
hideDiscard(); // hide the prompt
confirmReturn?.focus?.({ preventScroll: true }); // put focus back where it was
});
// "Discard": dismiss the panel and close (which also clears the form).
confirmEl?.querySelector('[data-discard-ok]')?.addEventListener('click', () => { // when "discard" is clicked:
hideDiscard(); // hide the prompt
closeContact(); // close the dialog
});
// -------------------------------------------------------------------------
// Event wiring
// -------------------------------------------------------------------------
// Every "contact us" trigger on the page opens the dialog.
document.querySelectorAll('[data-open-contact]').forEach((btn) => { // for each open trigger on the page:
btn.addEventListener('click', (e) => { // when it's clicked:
e.preventDefault(); // a link trigger (secondary pages) must not also navigate
openContact(btn); // open the dialog
});
});
// Close buttons inside the dialog (including the success panel's "Close",
// which is added above — it skips the discard check because the form has
// been submitted by then).
dialog.querySelectorAll('[data-close-contact]').forEach((btn) => { // for each close button in the dialog:
btn.addEventListener('click', requestClose); // ask to close when it's clicked
});
// ESC: showModal() fired a native 'cancel' event for this; show() doesn't,
// so listen for the key directly. With the discard check up, Escape backs
// out of the check (keep editing) rather than closing.
dialog.addEventListener('keydown', (e) => { // on any key press inside the dialog:
if (e.key === 'Escape' && dialog.open) { // Escape while open:
e.preventDefault(); // stop the browser's default handling
if (confirmEl && !confirmEl.hidden) confirmEl.querySelector('[data-discard-cancel]')?.click(); // prompt showing: act as "keep editing"
else requestClose(); // otherwise ask to close
}
});
// Outside click: with showModal() this was e.target === dialog (the
// ::backdrop click target); with a real backdrop element, just listen on it.
backdrop?.addEventListener('click', requestClose); // clicking the backdrop asks to close
// Focus trap: Tab from the last control wraps to the first, and Shift+Tab
// from the first wraps to the last, so keyboard focus can't leave the dialog.
dialog.addEventListener('keydown', (e) => { // on any key press inside the dialog:
if (e.key !== 'Tab') return; // not Tab: ignore
// Only controls that are actually visible (offsetParent is null for
// display:none) and not inside an inert area — plus the currently focused
// element, which may be position:fixed (offsetParent also null).
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter( // list focusable controls, keeping only…
(el) => (el.offsetParent !== null || el === document.activeElement) && !el.closest('[inert]') // …visible (or focused) ones not inside an inert area
);
if (!items.length) return; // nothing focusable: let Tab behave normally
const firstEl = items[0]; // the first focusable control
const lastEl = items[items.length - 1]; // the last focusable control
if (e.shiftKey && document.activeElement === firstEl) { // Shift+Tab on the first control:
e.preventDefault(); // stop focus leaving the dialog
lastEl.focus(); // wrap to the last control
} else if (!e.shiftKey && document.activeElement === lastEl) { // Tab on the last control:
e.preventDefault(); // stop focus leaving the dialog
firstEl.focus(); // wrap to the first control
}
});
}