← All stories

COMPONENT · ui-modal

ui-modal

Component Tested once · inherited everywhere it appears Used by: create-event-modal, all confirmation dialogs, picker modals

A modal is the most failure-mode-rich primitive in the Aperture admin: focus management, scroll lock, escape key, backdrop click, screen-reader role, return-focus on close, mobile-sheet behavior, layered z-index. Every branch that opens a modal inherits this contract — branches don't re-test these mechanics, they reference ui-modal by id and the harness chains the component's evaluators automatically.

Component contract

Every modal in the Aperture admin MUST be implemented through the canonical <UiModal> component. The linter scans the React source and flags any data-test attribute that a story claims is a ui-modal instance but isn't actually rendered through <UiModal>. There is no per-page rolled-my-own-modal path. This is the same contract idea as the per-story Stable test attributes table, lifted to component identity.

The component takes:

  • open: boolean — controlled. Mounting with open=false is allowed; the DOM may still contain the modal for transition purposes, but the harness's selector-visible predicate will treat it as absent because display:none applies.
  • onClose: () => void — fires for esc, backdrop click, and the close button. Never fires for clicks inside the modal body.
  • label: string — the modal's accessible name. Renders as the aria-labelledby target. Required.
  • description?: string — optional aria-describedby target.
  • size?: "sm" | "md" | "lg" | "sheet" — affects max-width on desktop. sheet forces full-screen sheet behavior on all viewports.
  • initialFocus?: Selector — element that receives focus on open. Defaults to the first focusable element inside the modal body.
  • closeOnBackdrop?: boolean — defaults true; set false for destructive-confirmation modals to force an explicit choice.

Interaction surface

Every interaction below is part of the contract. A branch that uses ui-modal inherits all of them automatically.

  1. Mounting with open=true.

    The modal renders into a portal at the end of <body> (not in the DOM tree of its caller, to escape stacking contexts). The backdrop element receives a data-test=ui-modal-backdrop attribute. The dialog itself receives data-test=<instance-id> (the caller's id, not ui-modal) so failure messages reference the specific instance. aria-modal="true" and role="dialog" are set on the dialog.

  2. On open, focus moves to the initialFocus target.

    If initialFocus is provided, focus moves there. Otherwise, focus moves to the first focusable element inside the dialog body (computed via the standard tabbable algorithm). If nothing focusable exists, focus moves to the dialog itself, which has tabindex="-1".

  3. Tab cycles inside the modal.

    Tab from the last focusable element returns to the first. Shift-Tab from the first focusable element goes to the last. Tab order matches DOM order, which matches visual reading order top-to-bottom, left-to-right.

  4. Esc closes.

    Pressing Esc anywhere inside the modal fires onClose. The dialog dismisses, focus returns to the element that had focus before the modal opened (computed by the component, not the caller).

  5. Backdrop click closes (if closeOnBackdrop is true).

    Clicking the backdrop fires onClose. Clicks inside the dialog body do NOT bubble to the backdrop. Clicks on the backdrop while a child element has focus do not lose the user's place — the click target is the backdrop itself, not the body.

  6. Body scroll is locked while the modal is open.

    The component sets overflow: hidden on <body> while open and restores the prior value on close. Scroll locking does not introduce horizontal layout shift on macOS (which uses overlay scrollbars by default); on Windows or Linux where scrollbars take width, the component compensates by adding right-padding equal to the scrollbar width to <body>.

  7. Layering — multiple modals stack predictably.

    A modal opened from inside another modal (e.g., a confirmation dialog) layers above the parent. Z-index is managed by the component (computed dynamically based on a global counter), not by callers. The backdrop of the topmost modal covers everything below; Esc closes the topmost only.

Failure modes

Focus escapes the modal via Tab

Trigger: user tabs through a modal with N focusable elements, on the (N+1)th tab focus lands on a button OUTSIDE the modal.

This is a classic regression after a refactor. The harness asserts focus-trap by tabbing N+5 times and verifying each focused element's ancestor chain includes the modal root. If any focused element is outside the modal, the probe fails with the exact element identified.

Recovery: Component fix — usually a missing tabindex-management edge case (e.g., elements added dynamically inside the modal after mount).

Focus does not return on close

Trigger: user opens the modal from a button, closes it (Esc, backdrop, or close button). Focus lands somewhere arbitrary instead of back on the trigger button.

The component must capture the previously-focused element on open and restore it on close. The harness asserts document.activeElement equals the pre-open active element after close.

Recovery: Component fix — restore-focus implementation.

Esc inside a nested form submits the form instead of closing the modal

Trigger: user is in a form input inside a modal, hits Esc.

Esc must be intercepted by the modal's keyboard handler (added at the document level via capture phase), preventing it from reaching the form. The harness simulates Esc inside a focused input and asserts the modal closes AND no submit event fires on any descendant form.

Recovery: Component fix — keyboard handler must use capture phase.

Backdrop click closes when it should not (destructive confirm)

Trigger: a delete-event confirmation modal has closeOnBackdrop=false. User clicks outside.

The dialog must remain open. The harness clicks the backdrop, asserts the modal is still rendered after a 200ms wait. If onClose fires inappropriately, the probe fails — this catches accidental defaults.

Recovery: Component fix — honor closeOnBackdrop prop.

Body scroll leaks while modal is open

Trigger: modal is open, user scrolls with their mouse wheel over the backdrop, and the body scrolls behind the modal.

The harness scripts a wheel event on the backdrop and asserts document.body.scrollTop is unchanged. overflow:hidden on body is the standard fix; the component must apply it on open and restore on close. (Without restoration, opening and closing the modal accumulates a state bug.)

Recovery: Component fix — proper scroll-lock lifecycle.

Aria-modal not set, screen reader announces background content

Trigger: an AT user tabs while the modal is open and hears content from outside the modal.

The dialog must have aria-modal="true". The harness asserts the attribute via DOM inspection; in real screen-reader mode, it also walks the accessibility tree and asserts background landmarks are pruned.

Recovery: Component fix — set aria-modal.

Layered modal does not block interaction with the parent

Trigger: a confirm-dialog opens above an edit-form modal. User clicks on a button visible behind the confirm dialog.

The topmost modal's backdrop must cover everything below it, and clicks on the backdrop must not propagate to lower modals. The harness opens a stacked modal, attempts to click a button in the lower modal at viewport coordinates that overlap the upper modal's backdrop, and asserts the lower modal's button does NOT receive a click event.

Recovery: Component fix — z-index management.

Initial focus moves to a hidden element

Trigger: the form inside the modal has a hidden honeypot field as its first input. The component focuses it on open.

The first-focusable computation must skip elements that fail selector-visible. The harness opens a modal whose first DOM-order focusable is hidden via display:none and asserts the actual focused element is the next visible focusable.

Recovery: Component fix — first-focusable algorithm respects effective visibility.

Mobile sheet behavior

At viewports below 640px wide, modals become full-screen sheets. The behavior changes:

  • The dialog fills the viewport edge-to-edge, no centered card.
  • Backdrop click is suppressed — the only ways to close are the close button (always rendered, top-right with data-test=ui-modal-close) and Esc on hardware keyboards.
  • The submit button (or last focusable button in the modal body) becomes sticky to the bottom edge with a 16px safe-area-inset padding to clear iOS home indicator.
  • Scroll lock applies to the document; the modal body itself scrolls if its content exceeds viewport height.
  • If the user pulls down on the dialog header (gesture support, opt-in via prop), the modal dismisses.

Accessibility

Per probe, the component must:

  • Have role="dialog" and aria-modal="true" on the dialog element.
  • Reference aria-labelledby pointing at the modal title element (which has data-test=ui-modal-title).
  • Reference aria-describedby if a description is provided.
  • Pass axe-core with no violations of severity serious or critical.
  • Have color contrast on the title and primary actions ≥ 4.5:1 against their backgrounds.
  • Trap focus within the dialog (already covered above).
  • Restore focus on close (already covered above).
  • Announce state changes via aria-live="polite" regions inside the modal body for content that changes after open (e.g., loading → loaded transitions).

Stable test attributes

Component-level contract: every <UiModal> instance MUST expose these attributes regardless of the caller. Branches that use ui-modal rely on these existing without re-declaring them.

Visibility teeth. Each attribute must be present AND effectively visible when the modal is open. data-test=ui-modal-backdrop and the dialog itself fail selector-visible when open=false — that's correct, the modal is closed. The teeth catch the case where open=true but the implementation hides one of the required interaction targets to dodge a probe.

data-testWherePurpose
ui-modal-backdropDocument body portalThe clickable backdrop element; covers the viewport when open
ui-modal-dialogInside ui-modal-backdropThe dialog element with role="dialog" and aria-modal="true"
ui-modal-titleInside ui-modal-dialogThe accessible name target referenced by aria-labelledby
ui-modal-descriptionInside ui-modal-dialogOptional; the aria-describedby target if a description was provided
ui-modal-closeInside ui-modal-dialogExplicit close button; always rendered, even on desktop with backdrop-click enabled
ui-modal-bodyInside ui-modal-dialogThe scrollable content region

In addition, the dialog element receives the caller's instance id via a separate data-test attribute (e.g., data-test="create-event-modal"). That attribute is set by the caller, NOT by ui-modal. A single dialog node thus carries two data-test values: the caller's instance id (per-branch) and ui-modal-dialog (the component-tier contract). Both must be present.

Agent test plan

The harness exercises this component story standalone (against a fixture page that mounts a <UiModal> in isolation) AND inherits these probes when running any branch story whose usesComponents field references ui-modal.

Standalone probes (against fixture page)
The fixture page lives at /admin-test/ui-modal-fixture and exposes:
- a "Open standard modal" button
- a "Open modal with closeOnBackdrop=false" button
- a "Open nested confirm" button (opens modal, then opens a confirm-dialog from inside)
- a "Open with hidden first focusable" button

Probes run against each variant:
- focus-trap-cycle: tab N+5 times, assert all focused elements are descendants of ui-modal-dialog
- focus-return-after-close: open + close, assert document.activeElement equals the trigger
- esc-closes: press Esc, assert dialog dismisses and onClose fires once
- esc-inside-form-no-submit: focus an input inside the modal, press Esc, assert dialog closes AND no form submit fired
- backdrop-click-closes: click ui-modal-backdrop, assert dismissal (only when closeOnBackdrop=true)
- backdrop-click-no-close-when-disabled: click ui-modal-backdrop with closeOnBackdrop=false, assert dialog still rendered after 200ms
- scroll-lock: open, dispatch wheel event on body, assert body.scrollTop unchanged
- scroll-lock-restore: open + close, assert body's overflow is restored to its pre-open value
- aria-modal-true: assert ui-modal-dialog has aria-modal="true"
- layered-z-index: open nested confirm, attempt to click a button at coords overlapping the upper backdrop, assert lower-modal button receives no click
- initial-focus-skips-hidden: open variant with hidden first focusable, assert focus is on the next visible focusable
- mobile-sheet-fills-viewport: at 375x667, assert dialog bbox covers full viewport
- mobile-sheet-no-backdrop-close: at 375x667, click backdrop, assert dialog still rendered
Inherited probes (chained when a branch references ui-modal)
When a branch story declares:
  "usesComponents": [{ "name": "ui-modal", "instance": { "dataTest": "create-event-modal" } }]
the harness:
1. Identifies the modal instance by the named selector.
2. Replays the standalone probes above against THIS specific instance, substituting:
   - ui-modal-dialog → the element matching both [data-test=create-event-modal] AND [data-test=ui-modal-dialog]
   - ui-modal-backdrop → the backdrop element rendered alongside this instance
3. Reports findings as "ui-modal contract failed at instance create-event-modal" rather than as the branch's own failure, so triage routes to the component fix not the branch fix.
4. The branch's own probes still run for branch-specific behavior; component-tier and branch-tier probes coexist without re-implementation.
What this saves
If 50 branches use ui-modal, each previously had to assert focus-trap, esc-closes, backdrop-click, focus-return, scroll-lock, aria-modal, layered z-index, initial-focus, mobile-sheet behavior — 9 assertions × 50 branches = 450 inline probes.

Now: ui-modal owns 9 probes once. Branches add usesComponents reference. 50 branches × 1 reference = 50 references. Component story is the single source of truth.

Tightening: if we discover a 10th interaction failure mode tomorrow (say, two-finger-pinch closes the sheet on iOS Safari unexpectedly), it gets added to ui-modal once and inherited by all 50 branches automatically. The Ratchet ratchets at the component tier.