Skip to content
Coastal Healthcare Advocates

Source: modal.js

/**
 * @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
    }
  });
}