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 withopen=falseis allowed; the DOM may still contain the modal for transition purposes, but the harness'sselector-visiblepredicate will treat it as absent becausedisplay:noneapplies.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 thearia-labelledbytarget. Required.description?: string— optionalaria-describedbytarget.size?: "sm" | "md" | "lg" | "sheet"— affects max-width on desktop.sheetforces 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.
-
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 adata-test=ui-modal-backdropattribute. The dialog itself receivesdata-test=<instance-id>(the caller's id, notui-modal) so failure messages reference the specific instance.aria-modal="true"androle="dialog"are set on the dialog. -
On open, focus moves to the
initialFocustarget.If
initialFocusis 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 hastabindex="-1". -
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.
-
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). -
Backdrop click closes (if
closeOnBackdropis 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. -
Body scroll is locked while the modal is open.
The component sets
overflow: hiddenon<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>. -
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"andaria-modal="true"on the dialog element. - Reference
aria-labelledbypointing at the modal title element (which hasdata-test=ui-modal-title). - Reference
aria-describedbyif a description is provided. - Pass axe-core with no violations of severity
seriousorcritical. - 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-test | Where | Purpose |
|---|---|---|
ui-modal-backdrop | Document body portal | The clickable backdrop element; covers the viewport when open |
ui-modal-dialog | Inside ui-modal-backdrop | The dialog element with role="dialog" and aria-modal="true" |
ui-modal-title | Inside ui-modal-dialog | The accessible name target referenced by aria-labelledby |
ui-modal-description | Inside ui-modal-dialog | Optional; the aria-describedby target if a description was provided |
ui-modal-close | Inside ui-modal-dialog | Explicit close button; always rendered, even on desktop with backdrop-click enabled |
ui-modal-body | Inside ui-modal-dialog | The 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.