From 375903b35e297e3817f04452c9dcd3b02e1fe23c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 1 Nov 2026 03:40:16 -0800 Subject: [PATCH] docs(spec): AlertDialog records its shipped contract as current --- .../AlertDialog/AlertDialog.spec.md | 92 +++++++++++-------- 1 file changed, 54 insertions(+), 38 deletions(-) diff --git a/docs/specs/components/AlertDialog/AlertDialog.spec.md b/docs/specs/components/AlertDialog/AlertDialog.spec.md index 28cb26d010d9f..95e1f45b9b625 100644 --- a/docs/specs/components/AlertDialog/AlertDialog.spec.md +++ b/docs/specs/components/AlertDialog/AlertDialog.spec.md @@ -3,17 +3,19 @@ schema_version: 3 template_version: 7 kind: component id: component:AlertDialog -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-11-01 owners: [cixzhang] review_triggers: [public-api, behavior, accessibility, theming] verified_by: [ packages/core/src/AlertDialog/AlertDialog.test.tsx, packages/core/src/AlertDialog/useImperativeAlertDialog.test.tsx, + packages/core/src/AlertDialog/__tests__/AlertDialog.a11y.test.tsx, + packages/core/src/AlertDialog/__tests__/AlertDialog.a11y.chromium.spec.ts, packages/core/src/Dialog/Dialog.test.tsx, scripts/check-knowledge.mjs, ] @@ -43,11 +45,11 @@ system_specs: | Area | Contract | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Public contract | None changes. `AlertDialog`, `AlertDialogProps`, and `useImperativeAlertDialog` are exported from `@astryxdesign/core/AlertDialog`; `title`, `description`, `actionLabel`, `onAction`, and `onOpenChange` are required with `isOpen`, plus cancel label, action variant, loading, width, and an inline preview mode. | +| Public contract | `AlertDialog`, `AlertDialogProps`, and `useImperativeAlertDialog` are exported from `@astryxdesign/core/AlertDialog`; `title`, `description`, `actionLabel`, `onAction`, and `onOpenChange` are required with `isOpen`, plus cancel label, action variant, loading, width, and an inline preview mode. | | Behavior | A modal confirmation that interrupts: it opens through the native dialog, cannot be dismissed by a click outside, closes on Escape or Cancel through `onOpenChange(false)`, and runs `onAction` on the action button. | | End-user impact | A keyboard or screen-reader user is told a decision is needed (the title and description are announced), lands on Cancel, and cannot lose the dialog by clicking past it. On a touch screen the alert scales in and fades out as iOS's does and ignores taps while it enters (FR6, FR7), and on a phone it is 320 px wide (FR9); under every pointer, mouse included, a press that slides from Cancel to the action runs the action it lifts on (FR8). | -| Builder impact | A phone-width alert is 320 px unless the caller sets `width` (FR9); actions track a slide under every pointer (FR8). | -| Compatibility/readiness | Released behavior as observed; this draft records it and adopts the APG Alert Dialog pattern (AR1) under `spec:AST-020/FR15`. FR6–FR9 change shipped behavior and are specified, not implemented yet. | +| Builder impact | None. | +| Compatibility/readiness | FR1–FR5 and AR1 record shipped behavior, apart from AR1's Tab wrap. FR6–FR9 are requirements not shipped yet. Each gap is listed under Current-state impact. | | Review checks | Reject a modal AlertDialog without `role="alertdialog"`, a title or description not linked by id, initial focus on the destructive action, or a backdrop click that dismisses it. | | Governing rules | `architecture:public-component-api/INV1,INV5–INV8`; `architecture:component-theming-surface/INV3–INV7`; `architecture:layer-runtime`; `component:Dialog/AR1–AR3`; `spec:AST-020/FR15`; `spec:AST-094` FR4, FR13, FR35, DEC-15, DEC-17; `family:action-groups`; `design:reduced-motion`; `spec:AST-017`. | @@ -62,12 +64,10 @@ confirmation without assembling it. ## Compatibility and migration -- Released default preserved: `no` under a touch presentation: the alert - presents and dismisses with FR6's motion, holds pointer input while it enters - (FR7), and is 320 px wide on a phone (FR9); its actions track a slide under - every pointer (FR8) -- Compatibility class: nonbreaking behavior change; no public API, DOM, or - target change +- Released default preserved: `yes` today; shipping FR6–FR9 changes the touch + presentation, the phone width, and action activation under a slide +- Compatibility class: nonbreaking behavior change when FR6–FR9 ship; no public + API, DOM, or target change - Controlled/uncontrolled behavior: unchanged — `isOpen` with `onOpenChange` is controlled; `useImperativeAlertDialog` owns that pair for a caller that wants `show`/`hide` @@ -75,6 +75,22 @@ confirmation without assembling it. Consumer migration instructions belong in consumer docs and release notes. +## Current-state impact + +- **The touch presentation is not shipped (FR6).** Under a touch presentation + the alert opens and closes with Dialog's default motion, not the scale-in and + fade-out FR6 requires. +- **Pointer input is not held while the alert enters (FR7).** A tap that lands + during the entrance reaches the alert; `spec:AST-094` FR35 holds it. +- **The actions are not an action group yet (FR8).** A press that slides from + Cancel to the action runs nothing on lift; each button activates on its own + click (`family:action-groups`). +- **The phone width is not shipped (FR9).** On a phone the alert keeps its + default width rather than 320 px. +- **Tab leaves the alert (AR1).** Dialog does not yet contain Tab, so Tab from + the last button moves focus out of the alert to the browser's interface, + where AR1 has Tab wrap inside it through `component:Dialog` FR7. + ## Ownership boundary **Owns** @@ -103,20 +119,17 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -Draft requirements identify their basis so observed code is not mistaken for an -intentional decision. A `current` contract contains no unresolved rows. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------ | -| FR1 | A modal AlertDialog opens through Dialog's native modal path and a click on the backdrop does not dismiss it. | shipped source (`purpose="form"`); Alert Dialog accessibility contract (pending) | verify | -| FR2 | Escape and the Cancel button both call `onOpenChange(false)`; the action button calls `onAction` and leaves closing to the caller. | shipped source and focused tests | verify | -| FR3 | The anatomy is title, description, then the buttons: Cancel then action on wider screens, the action stacked above Cancel on small screens, Cancel keeping initial focus either way; the action may show a loading state while `isActionLoading`. | shipped source and focused tests | verify | -| FR4 | `isInline` renders the same anatomy without modality and without the alert-dialog role. | shipped source and public docs | verify | -| FR5 | `useImperativeAlertDialog` returns `show`, `hide`, and the element to render, and owns `isOpen`/`onOpenChange` for its caller. | shipped source and focused tests | verify | -| FR6 | Under a touch presentation (`spec:AST-094` FR13), a modal AlertDialog presents by scaling from 1.2 to 1 with its opacity and its backdrop's dimming rising from 0 to 1 on the motion role `springs.alertPresent`, started at the browser's commit, with the larger start kept inside the viewport; it dismisses by a fade alone on `springs.alertDismiss` and closes at the fade's end; a close during the presentation, or a reopen during the dismissal, springs from where it is. The roles are internal, with UIKit's measured values at `tempo` 1 and `'standard'`, and follow the theme's motion knobs (`spec:AST-094` FR4, DEC-15). Under Reduce Motion both are the shared cross-fade (`design:reduced-motion` DR1–DR3). A fine primary pointer keeps today's motion (`spec:AST-094` FR13 and its Non-goals). | proposed from measured iOS 26.2 behavior | decided | -| FR7 | Under a touch presentation, pointer input to the alert is held while it enters (`spec:AST-094` FR35); keyboard and assistive-technology input are never held, and the entrance never makes the content inert. While the alert leaves, its content is inert for the dismissal fade. The hold's timing stays internal (`spec:AST-002` FR3). | `spec:AST-094` FR35; owner decision (DEC-1) | decided | -| FR8 | AlertDialog's actions are an action group (`family:action-groups`): a press that slides across them runs the action it lifts on, and a lift outside them runs nothing, under every pointer; keyboard and assistive-technology behavior is unchanged. | `family:action-groups`; `spec:AST-094` DEC-17; DEC-3 | decided | -| FR9 | On a phone, a compact window (`spec:AST-012` FR9: narrower than `md` or shorter than 500 px) under a coarse primary pointer, the alert is 320 px wide in every theme, as iOS's is; no theme role sets it. A caller's explicit `width` still wins there (`architecture:public-component-api` INV5), and Dialog's viewport clamp still applies on a screen narrower than the alert. Elsewhere it keeps today's width. | owner decision (DEC-2) | decided | +| ID | Invariant | Basis | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| FR1 | A modal AlertDialog opens through Dialog's native modal path and a click on the backdrop does not dismiss it. | shipped source (`purpose="form"`); Alert Dialog accessibility contract (pending) | +| FR2 | Escape and the Cancel button both call `onOpenChange(false)`; the action button calls `onAction` and leaves closing to the caller. | shipped source and focused tests | +| FR3 | The anatomy is title, description, then the buttons: Cancel then action on wider screens, the action stacked above Cancel on small screens, Cancel keeping initial focus either way; the action may show a loading state while `isActionLoading`. | shipped source and focused tests | +| FR4 | `isInline` renders the same anatomy without modality and without the alert-dialog role. | shipped source and public docs | +| FR5 | `useImperativeAlertDialog` returns `show`, `hide`, and the element to render, and owns `isOpen`/`onOpenChange` for its caller. | shipped source and focused tests | +| FR6 | Under a touch presentation (`spec:AST-094` FR13), a modal AlertDialog presents by scaling from 1.2 to 1 with its opacity and its backdrop's dimming rising from 0 to 1 on the motion role `springs.alertPresent`, started at the browser's commit, with the larger start kept inside the viewport; it dismisses by a fade alone on `springs.alertDismiss` and closes at the fade's end; a close during the presentation, or a reopen during the dismissal, springs from where it is. The roles are internal, with UIKit's measured values at `tempo` 1 and `'standard'`, and follow the theme's motion knobs (`spec:AST-094` FR4, DEC-15). Under Reduce Motion both are the shared cross-fade (`design:reduced-motion` DR1–DR3). A fine primary pointer keeps today's motion (`spec:AST-094` FR13 and its Non-goals). Implementation gap: not shipped yet (Current-state impact). | proposed from measured iOS 26.2 behavior | +| FR7 | Under a touch presentation, pointer input to the alert is held while it enters (`spec:AST-094` FR35); keyboard and assistive-technology input are never held, and the entrance never makes the content inert. While the alert leaves, its content is inert for the dismissal fade. The hold's timing stays internal (`spec:AST-002` FR3). Implementation gap: not shipped yet (Current-state impact). | `spec:AST-094` FR35; owner decision (DEC-1) | +| FR8 | AlertDialog's actions are an action group (`family:action-groups`): a press that slides across them runs the action it lifts on, and a lift outside them runs nothing, under every pointer; keyboard and assistive-technology behavior is unchanged. Implementation gap: not shipped yet (Current-state impact). | `family:action-groups`; `spec:AST-094` DEC-17; DEC-3 | +| FR9 | On a phone, a compact window (`spec:AST-012` FR9: narrower than `md` or shorter than 500 px) under a coarse primary pointer, the alert is 320 px wide in every theme, as iOS's is; no theme role sets it. A caller's explicit `width` still wins there (`architecture:public-component-api` INV5), and Dialog's viewport clamp still applies on a screen narrower than the alert. Elsewhere it keeps today's width. Implementation gap: not shipped yet (Current-state impact). | owner decision (DEC-2) | ### Allowed variation @@ -139,10 +152,10 @@ intentional decision. A `current` contract contains no unresolved rows. ### Performance and resources -- **PR1 — Dialog-owned resources.** AlertDialog's listeners are the - small-screen media query behind the button order and the press tracking - `family:action-groups` FR5 gives its actions; Dialog owns the modal - lifecycle. +- **PR1 — Dialog-owned resources.** AlertDialog's own listener is the + small-screen media query behind the button order, joined by the press + tracking `family:action-groups` FR5 gives its actions once FR8 ships; Dialog + owns the modal lifecycle. - **PR2 — The touch presentation adds no element.** The motion plays on the dialog surface and its `::backdrop`; AlertDialog renders no extra element @@ -160,6 +173,8 @@ intentional decision. A `current` contract contains no unresolved rows. focus lands on Cancel, the least destructive action; Escape cancels; no click outside dismisses it. The inline preview is not an alert dialog and carries `role="group"`. + Implementation gap: Dialog does not yet contain Tab, so Tab from the last + button leaves the alert for the browser's interface (Current-state impact). ## Design relationships @@ -207,14 +222,15 @@ The dialog surface (the Dialog element AlertDialog renders) carries `alert-dialo ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()") | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` | -| FR6, PR2 | Browser tests under a coarse pointer in Chromium and WebKit, a spring-curve oracle, a busy-main-thread case, and real-device clips on iOS Safari and Android Chrome | present, dismiss, close during present, reopen during dismiss, Reduce Motion, a fine pointer, a narrow viewport | a curve off its role, a start outside the viewport, a jump on interruption, a scale under Reduce Motion, changed motion under a fine pointer, or an added element fails | `audit:AlertDialog/behavior` | -| FR7 | Browser tests under a coarse pointer, with keyboard and assistive-technology input | a tap during the presentation, Enter and Escape during it, a screen reader's activation, a tap during the dismissal; a mouse click during the presentation under a fine pointer | a held key or assistive-technology action, content made inert during the entrance, a tap that reaches a leaving alert, or a mouse click held under a fine pointer fails | `audit:AlertDialog/behavior` | -| FR8 | Pointer-stream tests with a mouse, a pen, and a finger, and keyboard tests | a press on Cancel slid onto the action and lifted, and a press lifted outside the actions, each with a mouse, a pen, and a finger; Enter and Space on each action | the press-start action running after a slide, any action running on a lift outside, or a changed keyboard or assistive-technology activation fails | `audit:AlertDialog/behavior` | -| FR9 | Browser tests at a phone width and at a desktop width, in the default and an alternate theme | a phone width and a desktop width, in the default and an alternate theme; a caller `width` at a phone width; a screen narrower than 320 px; a narrow window under a fine pointer; a coarse screen that is not compact | a phone-width alert other than 320 px in any theme without a caller `width`, a caller `width` overridden, a desktop width changed, an alert wider than the viewport, or a 320 px alert in a narrow fine-pointer window or on a non-compact coarse screen fails | `audit:AlertDialog/behavior` | -| AR1 | `AlertDialog.test.tsx` ("renders with alertdialog role", "links title via aria-labelledby", "links description via aria-describedby", "marks cancel as the initial focus target", "keeps the initial focus target on the least destructive action", "calls onOpenChange(false) on Escape", "stacks long destructive action labels above cancel on a narrow fine-pointer screen", "reaches both buttons by Tab, cancel first", "is a modal alertdialog", "does not claim the alertdialog role") and `useImperativeAlertDialog.test.tsx`; the no-backdrop-dismiss outcome and focus delegation under the Alert Dialog accessibility contract (`internal/a11y-spec`, pending) | open modal; after Escape; after Cancel | The role, name link, description link, or Cancel initial focus is lost, or a click outside dismisses the dialog. | `audit:AlertDialog/accessibility` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1–FR5 | `AlertDialog.test.tsx` ("calls onOpenChange(false) on Escape", "does not call onOpenChange when action is clicked", "does not call onAction on Escape", "does not disable the cancel button while the action is loading", "exposes a named group instead, with no aria-modal") and `useImperativeAlertDialog.test.tsx` ("opens on show()", "closes on hide()") | open, loading, inline, imperative | Escape not cancelling, the action closing by itself, or the inline path claiming the role fails. | `audit:AlertDialog/behavior` | +| FR6, PR2 | Browser tests under a coarse pointer in Chromium and WebKit, a spring-curve oracle, a busy-main-thread case, and real-device clips on iOS Safari and Android Chrome | present, dismiss, close during present, reopen during dismiss, Reduce Motion, a fine pointer, a narrow viewport | a curve off its role, a start outside the viewport, a jump on interruption, a scale under Reduce Motion, changed motion under a fine pointer, or an added element fails. No test exists yet; FR6 is not shipped. | `audit:AlertDialog/behavior` | +| FR7 | Browser tests under a coarse pointer, with keyboard and assistive-technology input | a tap during the presentation, Enter and Escape during it, a screen reader's activation, a tap during the dismissal; a mouse click during the presentation under a fine pointer | a held key or assistive-technology action, content made inert during the entrance, a tap that reaches a leaving alert, or a mouse click held under a fine pointer fails. No test exists yet; FR7 is not shipped. | `audit:AlertDialog/behavior` | +| FR8 | Pointer-stream tests with a mouse, a pen, and a finger, and keyboard tests | a press on Cancel slid onto the action and lifted, and a press lifted outside the actions, each with a mouse, a pen, and a finger; Enter and Space on each action | the press-start action running after a slide, any action running on a lift outside, or a changed keyboard or assistive-technology activation fails. No test exists yet; FR8 is not shipped. | `audit:AlertDialog/behavior` | +| FR9 | Browser tests at a phone width and at a desktop width, in the default and an alternate theme | a phone width and a desktop width, in the default and an alternate theme; a caller `width` at a phone width; a screen narrower than 320 px; a narrow window under a fine pointer; a coarse screen that is not compact | a phone-width alert other than 320 px in any theme without a caller `width`, a caller `width` overridden, a desktop width changed, an alert wider than the viewport, or a 320 px alert in a narrow fine-pointer window or on a non-compact coarse screen fails. No test exists yet; FR9 is not shipped. | `audit:AlertDialog/behavior` | +| AR1 | `AlertDialog.test.tsx` ("links title via aria-labelledby", "marks cancel as the initial focus target", "keeps the initial focus target on the least destructive action", "calls onOpenChange(false) on Escape", "stacks long destructive action labels above cancel on a narrow fine-pointer screen", "reaches both buttons by Tab, cancel first", "is a modal alertdialog", "does not claim the alertdialog role") and `useImperativeAlertDialog.test.tsx`; the Alert Dialog accessibility contract binding in `__tests__/AlertDialog.a11y.test.tsx` and `__tests__/AlertDialog.a11y.chromium.spec.ts` (role, description link, no backdrop dismiss, focus delegation) | open modal; after Escape; after Cancel | The role, name link, description link, or Cancel initial focus is lost, or a click outside dismisses the dialog. Gap: the contract keeps `alertdialog.focus.least-destructive-first` and `alertdialog.dismissal.click-beside-is-inert` advisory, so a regression in either only reports; `spec:AST-020` FR9 makes them required once this record is current. No test covers Tab wrapping inside the alert (Current-state impact). | `audit:AlertDialog/accessibility` | +| PR1 | Source review | Small and wide screens | Gap: no automated check fails if AlertDialog adds a listener beyond the small-screen media query. | `audit:AlertDialog/behavior` | ## Decision log