Skip to content
Coastal Healthcare Advocates

Module: modal

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.

Source:

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
Source:

(inner) backdrop

The separate dimmed backdrop behind it. @type {HTMLElement|null}

Source:

(inner) booked

Whether a consultation was booked during this open (see the calendly:booked listener).

Source:

(inner, constant) confirmEl

The confirmation panel. @type {HTMLElement|null}

Source:

(inner) confirmReturn

Where to put focus back if the visitor chooses "keep editing". @type {HTMLElement|null}

Source:

(inner, constant) defaultTitle

The authored heading text. @type {string|undefined}

Source:

(inner) dialog

The <dialog id="contact-modal">. @type {HTMLDialogElement|null}

Source:

(inner, constant) form

The dialog's copy of the intake form. @type {HTMLFormElement}

Source:

(inner) lastFocused

Element to return focus to on close. @type {HTMLElement|null}

Source:

(inner, constant) quickbook

The "book a call instead" block at the top. @type {HTMLElement|null}

Source:

(inner, constant) scrollEl

The dialog's scrolling content area. @type {HTMLElement|null}

Source:

(inner) scrollY

Page scroll position saved when the scroll lock was applied.

Source:

(inner) source

The authored inline form that gets cloned. @type {HTMLFormElement|null}

Source:

(inner, constant) submitBtn

The clone's submit button. @type {HTMLButtonElement}

Source:

(inner) successSrc

The inline success panel that gets cloned. @type {HTMLElement|null}

Source:

(inner, constant) titleEl

The dialog's heading. @type {HTMLElement|null}

Source:

(inner) titleText

The heading for the current open (restored when the form resets).

Source:

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.

Source:
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.

Source:
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.

Source:
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 data-contact-title, data-variant and data-context attributes customize the dialog.

Source:
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.

Source:
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.

Source:
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.

Source:
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

true to make the background inert, false to undo.

Source:
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).

Source:
Returns:
Type
void