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; inerton 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-contactopens 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 (seesetContext).
- Any element inside the dialog with
data-close-contactcloses it. contact:open/contact:closeevents are dispatched ondocument.
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.
Members
(inner, constant) FOCUSABLE :string
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
(inner) backdrop
The separate dimmed backdrop behind it. @type {HTMLElement|null}
(inner) booked
Whether a consultation was booked during this open (see the calendly:booked listener).
(inner, constant) confirmEl
The confirmation panel. @type {HTMLElement|null}
(inner) confirmReturn
Where to put focus back if the visitor chooses "keep editing". @type {HTMLElement|null}
(inner, constant) defaultTitle
The authored heading text. @type {string|undefined}
(inner) dialog
The <dialog id="contact-modal">. @type {HTMLDialogElement|null}
(inner, constant) form
The dialog's copy of the intake form. @type {HTMLFormElement}
(inner) lastFocused
Element to return focus to on close. @type {HTMLElement|null}
(inner, constant) quickbook
The "book a call instead" block at the top. @type {HTMLElement|null}
(inner, constant) scrollEl
The dialog's scrolling content area. @type {HTMLElement|null}
(inner) scrollY
Page scroll position saved when the scroll lock was applied.
(inner) source
The authored inline form that gets cloned. @type {HTMLFormElement|null}
(inner, constant) submitBtn
The clone's submit button. @type {HTMLButtonElement}
(inner) successSrc
The inline success panel that gets cloned. @type {HTMLElement|null}
(inner, constant) titleEl
The dialog's heading. @type {HTMLElement|null}
(inner) titleText
The heading for the current open (restored when the form resets).
Methods
(inner) closeContact() → {void}
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.
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 requestClose.
Returns:
- Type
- void
(inner) finishClose() → {void}
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
closeContact does anything.
Returns:
- Type
- void
(inner) hideDiscard() → {void}
Hides the discard-confirmation panel and re-enables the form area. No-op if the panel isn't showing.
Returns:
- Type
- void
(inner) openContact(triggeropt) → {void}
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.
Parameters:
| Name | Type | Attributes | Description |
|---|---|---|---|
trigger |
HTMLElement |
<optional> |
The element that was clicked. Its
|
Returns:
- Type
- void
(inner) rekey(v) → {string|null}
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).
Parameters:
| Name | Type | Description |
|---|---|---|
v |
string | null | An id, or a space-separated list of ids. |
Returns:
The re-keyed value (falsy input returned as-is).
- Type
- string | null
(inner) requestClose() → {void}
Closes the dialog, unless there's unsent input — in which case it asks for confirmation first. Used by every "close" control.
Returns:
- Type
- void
(inner) scrollToTop() → {void}
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:
- Type
- void
(inner) setBackgroundInert(on) → {void}
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.
Parameters:
| Name | Type | Description |
|---|---|---|
on |
boolean |
|
Returns:
- Type
- void
(inner) showDiscard() → {void}
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:
- Type
- void