From ac10a130799239eae226e430128457a4f95999c2 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 22:08:47 -0700 Subject: [PATCH 1/9] docs(spec): DropdownMenu records its shipped contract as current --- .../src/DropdownMenu/DropdownMenu.spec.md | 402 +++++++++--------- 1 file changed, 191 insertions(+), 211 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index 0cb451da9659c..a44f740ec9b09 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -1,32 +1,29 @@ --- schema_version: 3 -template_version: 3 +template_version: 7 kind: component id: component:DropdownMenu -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-10-31 owners: [cixzhang] review_triggers: [accessibility, behavior, layout, public-api, theming] verified_by: [ packages/core/src/DropdownMenu/DropdownMenu.test.tsx, - packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx, packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx, packages/core/src/DropdownMenu/__tests__/DropdownMenuDrillIn.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/MenuPress.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts, - packages/core/src/BottomSheet/BottomSheet.test.tsx, - packages/core/src/List/List.test.tsx, - packages/core/src/Indicator/Indicator.test.tsx, - packages/core/src/Icon/Icon.test.tsx, + packages/core/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts, + packages/core/src/theme/themingTargets.test.ts, scripts/check-knowledge.mjs, ] modules: [module:DropdownMenu/useMenuPress, module:DropdownMenu/useMenuHover] families: [family:overlay-dismissal, family:navigation-destinations] -design_specs: [] +design_specs: [design:reduced-motion] architecture: [ architecture:component-theming-surface, @@ -34,34 +31,42 @@ architecture: architecture:layer-runtime, ] contributing: [] -system_specs: [] +system_specs: [spec:AST-020/FR15, spec:AST-094, spec:AST-102] --- # DropdownMenu component contract +## Contract at a glance + +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | +| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | +| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu does not grow from its trigger and a pan outside does not close it. | +| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Touch grow and pan dismissal (FR21) and the delegated Trigger Button gaps are listed under Current-state impact. | +| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | +| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | + +This table is a review projection; the body below is authoritative. + ## Intent -DropdownMenu presents actions from a visible Button trigger in either an -anchored pointer menu or a BottomSheet-hosted touch action list. This draft -records current consumer anatomy, presentation-specific ownership, and theming -reachability. Its press model — the row under the release acts and the -highlight follows the pointer — is contracted by -`module:DropdownMenu/useMenuPress`, which both presentations mount. +DropdownMenu presents actions from a visible trigger in either an anchored +pointer menu or a BottomSheet-hosted touch action list. Its press model — the +row under the release acts and the highlight follows the pointer — is +contracted by `module:DropdownMenu/useMenuPress`, which both presentations +mount. ## Compatibility and migration -- Released default preserved: `no` under a finger. The omitted - `presentation`, released as `'popover'`, follows `spec:AST-094` FR29 (open - on press) and this record's FR21 (the grow) under a finger; `presentation="popover"` keeps the released - behavior (`spec:AST-094` FR25). Mouse and keyboard are unchanged. -- Compatibility class: compatible behavior correction; public API, DOM - shape, styling, and targets are preserved; the default under a finger - changes as above, and the press-model behavior delta is recorded in - `module:DropdownMenu/useMenuPress` -- Controlled/uncontrolled behavior: unchanged -- Migration decision: `module:DropdownMenu/useMenuPress/DEC-3`; proposed - `module:DropdownMenu/useMenuPress/DEC-4`; `spec:AST-094/DEC-4` (a caller - that needs the released tap-open passes `presentation="popover"`) +- Released default preserved: yes +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change +- Controlled/uncontrolled behavior: `isMenuOpen` with `onOpenChange`, or + uncontrolled +- Migration decision: none; a caller that needs the released tap-open under a + finger passes `presentation="popover"` (`spec:AST-094/DEC-4`) Consumer migration instructions belong in consumer docs and release notes. @@ -69,16 +74,17 @@ Consumer migration instructions belong in consumer docs and release notes. **Owns** -- The Pointer menu surface and Touch menu surface and their shared current +- The Pointer menu surface and Touch menu surface and their shared `dropdown-menu` target. - Pointer action rows, pointer-only section headings and dividers, radio marker specialization, and submenu indicator presentation through the other five - current DropdownMenu targets. -- Selecting between the current pointer and touch render paths after presentation + DropdownMenu targets. +- Selecting between the pointer and touch render paths after presentation policy resolves. -- Mounting the shared press model on both menu surfaces and (proposed) on - the Trigger button for mouse press-open and held-finger open; the model - itself is owned by `module:DropdownMenu/useMenuPress`. +- Mounting the shared press model on both menu surfaces and on the Trigger + button; the model itself is owned by `module:DropdownMenu/useMenuPress`. +- The `header` and `footer` regions, the dialog around them, and the drilled-in + view. **Does not own / non-goals** @@ -87,55 +93,59 @@ Consumer migration instructions belong in consumer docs and release notes. - Touch action-list and row presentation — delegated to `component:List`. - Shared checkbox-indicator and ordinary icon presentation — delegated to `component:Indicator` and `component:Icon`. +- Each row's own contract — owned by `component:DropdownMenuItem`, + `component:DropdownMenuCheckboxItem`, `component:DropdownMenuRadioGroup`, + `component:DropdownMenuRadioItem`, `component:DropdownMenuSubMenu`, + `component:DropdownMenuGroup`, and `component:DropdownMenuDivider`. +- The flyout hover timing — owned by the menu hover module (`useMenuHover`). - Shared layer lifecycle or dismissal policy. ## Public concepts Consumer props, item shapes, subcomponents, and presentation policy remain -documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Four component-local concepts are added, by DEC-2, DEC-3, DEC-8 and proposed DEC-13; each -keeps its released default. - -| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | -| ----------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | -------- | ------------------------ | --------- | --------------------------------------------------- | -| Menu height cap | `menuMaxHeight`: a number of pixels | Lifts the 300px cap for a menu that must fit its rows; the viewport still bounds it. | Pointer presentation | `300px` | `component:DropdownMenu` | stable | Ignored by the touch sheet. | -| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. | -| Header and footer | `header`, `footer`: any React node | Content above and below the rows, beside the menu inside the same popover or sheet — never inside the `role="menu"` element. With either, the pointer popover is a modal dialog around the menu. | Both presentations | none | `component:DropdownMenu` | stable | A false or empty value renders no region. | -| Sub-menu regions | `header`, `footer` on a sub-menu: any React node | Content above and below a sub-menu's rows, beside its menu inside its own surface; with either, that surface is a modal dialog named by the row (proposed FR20). | Popover presentation | none | `component:DropdownMenu` | proposed | A false or empty value renders no region. | +documented in `DropdownMenu.doc.mjs` and the subcomponent docs. These are the +component-local concepts; each keeps its released default. + +| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | +| --------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------------------- | ------------------------ | --------- | ---------------------------------------------------------------- | +| Menu height cap | `menuMaxHeight`: a number of pixels | Lifts the 300px cap for a menu that must fit its rows; the viewport still bounds it. | Pointer presentation | `300px` | `component:DropdownMenu` | stable | Ignored by the touch sheet. | +| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `renderTrigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `renderTrigger` wins and a dev warning fires. | +| Header and footer | `header`, `footer`: any React node | Content above and below the rows, beside the menu inside the same popover or sheet — never inside the `role="menu"` element. With either, the pointer popover is a modal dialog around the menu. | Both presentations | none | `component:DropdownMenu` | stable | A false or empty value renders no region. | +| Sub-menu regions | `header`, `footer` on a sub-menu: any React node | Content above and below a sub-menu's rows, beside its menu inside its own surface; with either, that surface is a modal dialog named by the row (FR20). | Popover presentation | none | `component:DropdownMenu` | stable | A false or empty value renders no region. | +| Row destination | `href` (+ `target`, `rel`) on an action item | The row's act is navigation: the row itself is the link (`family:navigation-destinations`). | Pointer action row and Touch action row, compound and data mode | none (an action row) | `component:DropdownMenu` | stable | Blocked schemes follow the family rule; a disabled row has none. | +| Sub-menu presentation | `presentation` on a sub-menu row: `flyout`, `drill-in`, `adaptive` | Whether a sub-menu opens beside its row or replaces the menu's rows with its own and a Back row, in the same box. `adaptive` drills in when a compact touch display opened the menu. | Pointer presentation, compound and data mode | `adaptive` | `component:DropdownMenu` | stable | An unknown value reads as `adaptive`. | ## Behavioral and layout contract -| ID | Candidate invariant | Basis | Draft review state | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| FR1 | Every current render contains a Trigger button. Pointer presentation renders a Pointer menu surface with pointer-owned rows and optional pointer headings, dividers, indicators, and nested flyouts. Touch presentation renders a Touch sheet frame containing a Touch menu surface, Touch heading, Touch action list, and Touch action rows. | Current source, docs, and tests | Verified current behavior; no new behavior decided | -| FR2 | The six current local targets are `dropdown-menu`, `dropdown-menu-item`, `dropdown-menu-radio`, `dropdown-menu-section-heading`, `dropdown-menu-divider`, and `dropdown-menu-indicator-icon`; every target remains on its current painted element. | Current source, docs, and tests | Verified current inventory; no target change | -| FR3 | Button owns the Trigger button, BottomSheet owns the Touch sheet frame, List owns the Touch action list and Touch action rows, Indicator owns checkbox chrome, and Icon owns ordinary rendered icons. | Current source and owner docs | Verified current delegation; no ownership change | -| FR4 | The same `dropdown-menu` target reaches the alternative Pointer menu surface and Touch menu surface. Pointer action rows retain `dropdown-menu-item`; touch action rows instead use List's `list-item` target. | Current source and tests | Verified modality split; no target change | -| FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. Under a finger the press, the menu's grow and collapse, and its dismissal follow `spec:AST-094` FR26–FR35 (pending implementation). Trigger opening and keyboard navigation are unchanged. | `module:DropdownMenu/useMenuPress`; `DropdownMenu.test.tsx` press model suite | Proposed; verified in jsdom and real Chromium | -| FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard; a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; proposed in this change; `DropdownMenu.test.tsx` closing suite | Proposed; verified in jsdom, pending owner review | -| FR7 | `menuMaxHeight` MUST replace the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | `component:DropdownMenu/DEC-3` | Proposed; verified in jsdom; owner to confirm DEC-3 | -| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with the module's settle rule on the opening release, and under a finger as `spec:AST-094` FR29 states; pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before (`module:DropdownMenu/useMenuPress` proposed FR8, FR9). | Proposed `module:DropdownMenu/useMenuPress/DEC-4`; `DropdownMenu.test.tsx` press model suite | Proposed; verified in jsdom, pending owner review | -| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. | -| FR9 | With `renderTrigger`, the rendered control MUST carry `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and MUST receive the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. `button` and `trigger` are mutually exclusive: both given, `trigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner to confirm DEC-2 | -| Row destination | `href` (+ `target`, `rel`) on an action item | The row's act is navigation: the row itself is the link (`family:navigation-destinations`). | Pointer action row and Touch action row, compound and data mode | none (an action row) | `component:DropdownMenu` | stable | Blocked schemes follow the family rule; a disabled row has none. | -| FR10 | An action row with `href` MUST render as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click MUST run `onClick`, then close the menu (`hasCloseOnSelect`), then let the browser navigate; a modified click or middle click MUST be left to the browser and MUST NOT run `onClick`; Enter and Space MUST activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`. | `component:DropdownMenu/DEC-2`; `family:navigation-destinations` | Proposed; verified in jsdom; owner to confirm DEC-1 | -| Sub-menu presentation | `presentation` on a sub-menu row: `flyout`, `drill-in`, `adaptive` | Whether a sub-menu opens beside its row or replaces the menu's rows with its own and a Back row, in the same box. `adaptive` drills in when a coarse pointer opened the menu. | Pointer presentation, compound and data mode | `adaptive` | `component:DropdownMenu` | stable | An unknown value reads as `adaptive`. | -| FR11 | When a compact touch display opened the pointer menu (`COMPACT_TOUCH_PRESENTATION_QUERY`, the same query the root presentation uses for its bottom sheet, sampled once as it opens) an `adaptive` sub-menu row MUST drill in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft MUST return to the row with focus on it; a pick inside closes the whole menu. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | `component:DropdownMenu/DEC-2`; `architecture:interaction-modality` | Proposed; verified in jsdom and Chromium (coarse pointer); owner to confirm DEC-5 | -| FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Proposed in this change; `DropdownMenu.test.tsx` keyboard suite, `useListFocus.test.tsx`, `useTypeahead.test.tsx` | Proposed; verified in jsdom, pending owner review | -| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | Proposed in this change; `DropdownMenuSubMenu.test.tsx` safe-triangle suite, `useMenuHover.test.tsx` | Proposed; verified in jsdom, pending owner review | -| FR14 | A compound `DropdownMenuGroup` is one `role="group"` named by its heading through `aria-labelledby`; the heading carries `dropdown-menu-section-heading`, is plain text rather than a menu item, and is skipped by roving focus and typeahead. An untitled group is an unnamed `role="group"`. | DEC-1, docs, and tests | Proposed; awaiting owner approval | -| FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval | -| FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved | -| FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 | -| FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review | -| FR21 | Under a finger, in the touch modes (`presentation` not pinned to `'popover'`), the pointer menu grows out of the Trigger button and collapses back into it (`spec:AST-094` FR31), and closes on a pan that starts outside it (FR32); a pinned `'popover'` keeps the layer's own entrance. A pan dismissal is the menu's own close: focus that was in the menu returns to the Trigger button, whose `aria-expanded` turns false. A mouse keeps the layer's own entrance and exit. | Owner decision, 2026-10-26; `spec:AST-094` FR31, FR32; `family:overlay-dismissal` AV6 | Decided; pending implementation | +| ID | Invariant | Basis | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| FR1 | Every render contains a Trigger button. Pointer presentation renders a Pointer menu surface with pointer-owned rows and optional pointer headings, dividers, indicators, and nested flyouts. Touch presentation renders a Touch sheet frame containing a Touch menu surface, Touch heading, Touch action list, and Touch action rows. | Current source, docs, and tests | +| FR2 | The six local targets are `dropdown-menu`, `dropdown-menu-item`, `dropdown-menu-radio`, `dropdown-menu-section-heading`, `dropdown-menu-divider`, and `dropdown-menu-indicator-icon`; every target stays on its painted element. | `architecture:component-theming-surface`; current source | +| FR3 | Button owns the Trigger button, BottomSheet owns the Touch sheet frame, List owns the Touch action list and Touch action rows, Indicator owns checkbox chrome, and Icon owns ordinary rendered icons. The Trigger button carries `component:Button`'s gaps: its press scale applies under reduced motion (`design:reduced-motion` DR1) and its height is fixed (`spec:AST-102` FR4). | Current source and owner docs; `design:reduced-motion` DR1; `spec:AST-102` FR4 | +| FR4 | The same `dropdown-menu` target reaches the alternative Pointer menu surface and Touch menu surface. Pointer action rows carry `dropdown-menu-item`; touch action rows instead use List's `list-item` target. | Current source and tests | +| FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7, FR10, and FR11: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. The grow, collapse, and pan dismissal under a finger are FR21. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR28 | +| FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard; a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; DEC-1 | +| FR7 | `menuMaxHeight` replaces the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | DEC-2 | +| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with the module's settle rule on the opening release, and under a finger as `spec:AST-094` FR29 states; pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. | `module:DropdownMenu/useMenuPress` FR8, FR9 | +| FR9 | With `renderTrigger`, the rendered control carries `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and receives the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. A disabled custom trigger never opens the menu or the sheet. `button` and `renderTrigger` are mutually exclusive: both given, `renderTrigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | DEC-3 | +| FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click is left to the browser and does not run `onClick`; a middle click closes the menu and runs the caller's `onAuxClick`; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | +| FR11 | When a compact touch display opened the pointer menu (the query the root presentation uses for its bottom sheet, sampled once as it opens), an `adaptive` sub-menu row drills in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft return to the row with focus on it; a pick inside closes the whole menu, and closing the menu resets the drilled view. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | DEC-4; `spec:AST-094` FR34 | +| FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Current source and tests | +| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | DEC-7 | +| FR14 | Compound groups follow `component:DropdownMenuGroup`; a data-mode `{type: 'section'}` entry is a `role="group"` named by its title, and an untitled section renders its rows without a heading. | DEC-6; current tests | +| FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10 | +| FR16 | Checkbox rows follow `component:DropdownMenuCheckboxItem`. | Current source | +| FR17 | Arrow keys and typeahead skip disabled rows, including checkbox and radio rows. Shipped behavior, not a requirement until OQ1 is ruled. | Current source and tests | +| FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Current behavior; FR15; AR5 | +| FR21 | Under a finger, in the touch modes (`presentation` not pinned to `'popover'`), the pointer menu MUST grow out of the Trigger button and collapse back into it (`spec:AST-094` FR31), and MUST close as a pan that starts outside it begins (FR32); a pinned `'popover'` keeps the layer's own entrance. A pan dismissal is the menu's own close: focus that was in the menu returns to the Trigger button, whose `aria-expanded` turns false. A mouse keeps the layer's own entrance and exit. Implementation gap: the menu keeps the layer's own entrance and exit under a finger too, and has no pan-outside dismissal of its own. | `spec:AST-094` FR31, FR32; `family:overlay-dismissal` AV6 | ### Allowed variation - **AV1 — Presentation.** `popover`, `bottom-sheet`, and resolved `adaptive` - presentation may select the current pointer or touch anatomy without changing - the ownership of either branch. + presentation may select the pointer or touch anatomy without changing the + ownership of either branch. - **AV2 — Content mode.** Data-driven and compound pointer menus may vary their - rows while preserving the current target owners. Touch presentation remains + rows while preserving the target owners. Touch presentation remains data-driven. - **AV3 — Optional content.** Icons, selectable indicators, headings, dividers, and nested actions render only when the supplied menu content requires them. @@ -145,36 +155,38 @@ keeps its released default. | State | Required invariant | Allowed variation | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | Anchored root menu | Trigger button and Pointer menu surface render with pointer-owned rows. | Width, placement, alignment, and optional item content vary. | -| Anchored nested flyout | Pointer action row opens another Pointer menu surface and may show the submenu indicator icon. | Loading may replace the indicator with the current Spinner. | +| Anchored nested flyout | Pointer action row opens another Pointer menu surface and may show the submenu indicator icon. | Loading may replace the indicator with the Spinner. | +| Drilled-in sub-menu | The Pointer menu surface shows a Back row then the sub-menu's rows; the parent rows are hidden, not unmounted. | Depth; which pointer opened the menu; a forced `presentation`. | | Bottom-sheet root actions | Trigger button opens Touch sheet frame, Touch menu surface, heading, action list, and action rows. | Icons, sections, dividers, and action descriptions may vary. | -| Bottom-sheet nested actions | The same frame and list owners remain while heading and rows change to the selected action level. | Back control and drill-in indicator follow current navigation. | -| Selectable pointer action | Pointer action row may contain shared checkbox or radio chrome. | Checked and disabled state follow the current item contracts. | +| Bottom-sheet nested actions | The same frame and list owners remain while heading and rows change to the selected action level. | Back control and drill-in indicator follow navigation. | +| Selectable pointer action | Pointer action row may contain shared checkbox or radio chrome. | Checked and disabled state follow the item contracts. | | Custom-trigger menu | The caller's control carries the trigger ARIA and names the menu; the Pointer menu surface is unchanged. | Any focusable control: icon button, chip, avatar, list row. | | Link row | The Pointer action row (or Touch action row) is the anchor; `onClick`, close and navigation run in that order on a plain click. | `target`, `rel`, icon, description and destructive variant vary. | +| Header or footer | The popover is a named modal dialog holding header, menu, and footer; Tab wraps. | Region content. | ### Transformation and precedence order -- **ORD2 — Link row activation.** modified click → the browser alone; else +- **ORD1 — Link row activation.** modified click → the browser alone; else `onClick` → close (`hasCloseOnSelect`) → the browser navigates. - | Drilled-in sub-menu | The Pointer menu surface shows a Back row then the sub-menu's rows; the parent rows are hidden, not unmounted. | Depth; which pointer opened the menu; a forced `presentation`. | -- No other presentation-resolution, open-state, positioning, or styling - precedence rule is introduced. +- **ORD2 — Trigger source.** `renderTrigger` → `button`. ### Performance and resources -- No new loading, listener, measurement, or render constraint is introduced. +- **PR1 — Bounded resources.** While open, the pointer menu observes its own + size and listens for window and visual-viewport resizes and image loads to + decide whether it scrolls. A mouse press-open adds document pointer-up and + pointer-cancel listeners for the length of the press. The compact-touch query + is read once as the menu opens, with no listener. ## Accessibility contract -This draft does not change DropdownMenu's trigger naming, menu and dialog -roles, keyboard navigation, item semantics, or dismissal ordering. While a -pointer is held, the highlight is DOM focus per +While a pointer is held, the highlight is DOM focus per `module:DropdownMenu/useMenuPress` AR1. Focus return on close follows FR6 and `architecture:interaction-modality` INV1 through the shared focus-return visibility helper. -- **AR1 — A custom trigger still names the menu.** With `trigger`, the menu - carries `aria-labelledby` pointing at the control's `id` (the Button path +- **AR1 — A custom trigger still names the menu.** With `renderTrigger`, the + menu carries `aria-labelledby` pointing at the control's `id` (the Button path keeps `aria-label`), and the control carries `aria-haspopup="menu"`, `aria-expanded` and `aria-controls`. - **AR2 — A row that navigates is a link element.** A row with an `href` is @@ -194,7 +206,7 @@ visibility helper. Space opens the menu with focus on the first enabled row. As a named exception under that rule, the trigger also opens on ArrowDown (focus on the first enabled row) and ArrowUp (focus on the last enabled row), the keyboard - this component already ships. A `renderTrigger` control receives + this component already ships (DEC-9). A `renderTrigger` control receives `aria-haspopup`, `aria-expanded`, `aria-controls`, and the keyboard and focus handlers through the spread props (FR9); the caller's control supplies its own `button` role and accessible name. The bottom-sheet presentation and a @@ -204,25 +216,22 @@ visibility helper. (Modal) pattern's required parts** (`spec:AST-020/FR15`): `role="dialog"`, `aria-modal="true"`, and an accessible name, the menu's; focus moves into the dialog on open, to the menu or its first enabled row as FR6 and FR12 - already state; Tab and Shift+Tab cycle within it (FR15); Escape closes it - and focus returns to the trigger. The menu inside keeps AR1–AR3. The - trigger keeps `aria-expanded`; its `aria-haspopup` says `dialog`, and its - `aria-controls` names the popover that holds the dialog rather than the - menu, as Popover's own trigger does. -- **AR6 — Checkbox rows remain one control.** The row owns - `menuitemcheckbox`, its accessible name, checked state, focus, and activation; - Indicator's checkbox marker is decorative. Disabled activation is inert. - Current shared navigation skips disabled rows instead of making them - discoverable as the WAI-ARIA Menu pattern recommends; FR17 and OQ1 retain that - gap without inventing a component-local focus path. -- **AR7 (proposed) — A sub-menu with a header or footer adopts the same APG - Dialog (Modal) parts for its own surface** (`spec:AST-020/FR15`): - `role="dialog"`, `aria-modal="true"`, and an accessible name, its row's; - focus moves into the dialog on open, to the sub-menu's first row or its - menu as FR11 and FR12 already state; Tab and Shift+Tab cycle within it - (FR20); Escape closes it and focus returns to the row. The sub-menu's menu - inside keeps AR3. The row keeps `aria-expanded`; its `aria-haspopup` says - `dialog` and its `aria-controls` names the dialog. + state; Tab and Shift+Tab cycle within it (FR15); Escape closes it and focus + returns to the trigger. The menu inside keeps AR1–AR3. The trigger keeps + `aria-expanded`; its `aria-haspopup` says `dialog`, and its `aria-controls` + names the popover that holds the dialog rather than the menu, as Popover's + own trigger does. +- **AR6 — Disabled rows are inert.** A disabled row runs no caller activation + handler and does not close the menu; FR17 records that the arrow keys skip + it. +- **AR7 — A sub-menu with a header or footer adopts the same APG Dialog + (Modal) parts for its own surface** (`spec:AST-020/FR15`): `role="dialog"`, + `aria-modal="true"`, and an accessible name, its row's; focus moves into the + dialog on open, to the sub-menu's first row or its menu as FR11 and FR12 + state; Tab and Shift+Tab cycle within it (FR20); Escape closes it and focus + returns to the row. The sub-menu's menu inside keeps AR3. The row keeps + `aria-expanded`; its `aria-haspopup` says `dialog` and its `aria-controls` + names the dialog. ## Design relationships @@ -310,38 +319,52 @@ than adding a DropdownMenu-owned heading target. - `architecture:component-theming-surface` owns anatomy qualification, local target mapping, presentation-specific rows, and delegation rules. - `architecture:interaction-modality` owns the shared pointer, touch, keyboard, - and adaptive-presentation boundaries; this draft changes none of them. -- `architecture:layer-runtime` owns the current `usePopover` host, native - light-dismiss reconciliation, anchor positioning, and nested `useLayer` - flyouts. Touch presentation delegates hosting to BottomSheet. + and adaptive-presentation boundaries. +- `architecture:layer-runtime` owns the popover host, native light-dismiss + reconciliation, anchor positioning, and nested flyout layers. Touch + presentation delegates hosting to BottomSheet. - `family:overlay-dismissal` owns shared Escape and platform-close ordering. - DropdownMenu root participates through its Popover focus trap; the composed - BottomSheet and DropdownMenuSubMenu retain their recorded local-only adoption - gaps. + The DropdownMenu root participates through its popover focus trap; the + composed BottomSheet and DropdownMenuSubMenu retain their recorded local-only + adoption gaps. +- `family:navigation-destinations` owns link-row destinations and blocked + schemes (FR10). +- `spec:AST-094` owns the touch press, open, grow, and dismissal rules (FR5, + FR8, FR11, FR21). ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| FR1, FR4 | `DropdownMenu.test.tsx` pointer, bottom-sheet, adaptive, section, divider, and drill-in suites | Root pointer, nested pointer, root touch, drill-in | Collapsing presentation-specific parts or owners fails structure, target, or role assertions. | `audit:DropdownMenu/anatomy` | -| FR2 | DropdownMenu target suites, source inspection, and theming target inventories | All six local targets | Removing, renaming, or assigning a current target to the wrong part fails evidence or inventory. | `audit:DropdownMenu/theming` | -| FR3 | `DropdownMenuSelectable.test.tsx` plus BottomSheet, List, Indicator, Icon, and Divider owner tests | Trigger, touch actions, icons, checkbox, radio | A composed part loses its owner target or is documented as a new local target. | `audit:DropdownMenu/theming` | -| Layer relationships | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, and current layer/dismissal architecture records | Light dismiss, nested flyout, sheet dismissal | Documentation claims a shared owner where current source retains local behavior, or the reverse. | `audit:DropdownMenu/behavior` | -| FR5 | `DropdownMenu.test.tsx` press model suite; the module record's own map | Finger slide across rows, outside release by mouse and finger, `touch-action`, root marker | A row acting on the press or the stray click, a lost `touch-action`, or a finger release that closes fails. | `audit:DropdownMenu/behavior` | -| FR6 | `DropdownMenu.test.tsx` dismissal, closing and press model suites; `__tests__/MenuPress.a11y.chromium.spec.ts` in real Chromium | keyboard/pointer dismissal, Escape after press-open, press outside on a control, pointer pick, closed menu | Focus left on the body after a pointer pick, on the control focused before a press-open, or a ring after pointer input, fails. | `audit:DropdownMenu/behavior` | -| FR7 | `menuMaxHeight` MUST replace the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | `component:DropdownMenu/DEC-3` | Proposed; verified in jsdom; owner to confirm DEC-3 | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and current target inventory | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:DropdownMenu/theming` | -| FR8 | `DropdownMenu.test.tsx` press model suite (trigger cases) | Press-open + drag-release, unsettled opening release, trigger toggle, held touch, tap | A menu that does not open on a mouse press, an unsettled release that acts, or a trigger that reopens in the same gesture fails. | `audit:DropdownMenu/behavior` | -| FR9 | `DropdownMenu.test.tsx` custom trigger suite; `DropdownMenuSubMenu.test.tsx` custom-trigger Back row cases | Icon button, chip, list row; open, toggle, keyboard open, name; drill-in Back row | A rendered control missing `aria-haspopup`, `aria-expanded` or `aria-controls`, an unnamed menu, a lost keyboard open, or a "Back to Menu" row, fails. | `audit:DropdownMenu/anatomy` | -| AR4 | `DropdownMenu.test.tsx` cases "has aria-haspopup and aria-expanded attributes", "keyboard open via Enter focuses the first enabled item", "walks into a mounted-open menu with ArrowDown from the focused trigger", "keyboard open via ArrowDown skips a disabled leading item", "ArrowUp on the trigger opens with the last row highlighted", and "a custom trigger opens from the keyboard and lands on the first row"; Space opening and `aria-controls` under the Menu Button accessibility contract (`internal/a11y-spec`, pending) bound to the anchored trigger | Button and `renderTrigger` controls; closed/open; Enter, Space, ArrowDown, ArrowUp open | The trigger loses its name, `aria-haspopup="menu"`, `aria-expanded` or `aria-controls`; a keyboard open lands focus somewhere other than the first (or, for ArrowUp, last) enabled row | `audit:DropdownMenu/accessibility` | -| FR8, AR2 | `DropdownMenu.test.tsx` "DropdownMenuItem href" suite (pointer menu and bottom sheet) | Plain click, ⌘-click, Enter with modifiers, disabled link row | A row rendering an inner anchor, running `onClick` on a modified click, or a synthesized click dropping the modifiers fails. | `audit:DropdownMenu/behavior` | -| FR12, AR3 | `DropdownMenuSubMenu.test.tsx` "drill-in on a phone" suite; `__tests__/DropdownMenuDrillIn.a11y.chromium.spec.ts` (real Chromium, coarse pointer) | Drill in, Back, Escape, ArrowLeft, nested level, scoped typeahead, forced presentations, close resets | A flyout opening on a coarse pointer, a missing Back row, sibling rows staying in the order, or focus not returning to the row fails. | `audit:DropdownMenu/behavior` | -| FR10 | `DropdownMenuSubMenu.test.tsx` safe-triangle suite; `useMenuHover.test.tsx` `isPointInSafeTriangle` and click-guard suites | diagonal path toward the flyout, a pause inside it, path away, click-opened and hover-opened guard windows | A flyout that closes while the pointer is inside the triangle or paused in it, one that never closes after leaving it, or a changed guard-window rule, fails. | `audit:DropdownMenu/behavior` | -| FR15 | `DropdownMenu.test.tsx` header and footer suite; `__tests__/MenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium | dialog role, modality, name and trigger wiring; regions outside the menu element; typing in a header field; Tab walk header → menu → footer with wrap at both ends; Tab and Shift+Tab from an open sub-menu; hidden and hidden-wrapper controls; Escape from the header; a finger tap on a footer button; bottom sheet order; no regions | A region rendered inside the menu element, a popover with regions that is not a named modal dialog, a trigger still saying `aria-haspopup="menu"` with regions, a header key taken by typeahead, a Tab that leaves the dialog or closes it, focus stranded outside the dialog, or a footer tap swallowed by the press model fails. | -| FR16 | `DropdownMenuSelectable.test.tsx`, source inspection, scoped `pr-a11y` over the `a11y/Checkbox pattern` stories, and owned Storybook states | checked, unchecked, disabled, caller click/pointer handlers, marker size and ownership | A dropped consumer handler, wrong checked state, nested semantic checkbox, disabled activation, or menu that closes by default fails. | `audit:DropdownMenuCheckboxItem/behavior` | -| FR21 | `DropdownMenu.test.tsx` touch motion and pan-outside cases; `menuSurfaceMotion.test.ts`; `__tests__/DropdownMenuTouchMotion.interaction.browser.spec.ts` | open, close, an interrupted open and close, Reduce Motion, a mouse; a tap, a hold and a pan outside; focus in the menu at a pan dismissal | A pinned `'popover'` or a mouse open that grows from the trigger, a pan outside that leaves the menu open, or focus left in a hidden menu fails | -| FR17, AR6 | Shared navigation source and DropdownMenu keyboard tests | disabled checkbox row first, middle, and last | Current tests prove the shared hook skips the row; no keyboard-discoverability proof exists until OQ1 is settled. | `audit:DropdownMenuCheckboxItem/accessibility` | -| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` "header and footer" suite; `ContextMenu.test.tsx` rows-alone case; `__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium (fine pointer, coarse pointer) | flyout and phone view; dialog role, name, row wiring; regions outside menus; Tab walk and wrap from a field, row, deeper flyout; field keys, presses; Escape, IME and handled Escapes; keyboard open; pointer leaving, focus in then out; parent closing a nested flyout; view tracking its regions; ContextMenu rows alone | A region inside a menu element, a surface with regions that is not a named modal dialog, a row still saying `aria-haspopup="menu"`, a field key taken by the menu, a Tab that leaves or closes the dialog, a flyout closed under focus by a pointer leaving, a flyout outliving its parent, or the root menu beside a view, fails. | `audit:DropdownMenu/behavior` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| FR1, FR4 | `DropdownMenu.test.tsx` rendering cases ("renders menu with role=\"menu\"", "renders data-driven actions in a bottom sheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "uses the shared menu BottomSheet frame", "keeps adaptive presentation anchored without compact touch") and section, divider, and compound cases | Root pointer, root touch, adaptive | A missing surface, sheet frame, or menu role fails. Evidence gap: no test asserts the `dropdown-menu` target on the Touch menu surface or `list-item` on touch rows. | `audit:DropdownMenu/anatomy` | +| FR2 | `DropdownMenu.test.tsx` theming-slot cases ("exposes a themeable slot on the section heading", "exposes a themeable slot on the menu divider"); `DropdownMenuSubMenu.test.tsx` "exposes a themeable slot on the submenu indicator icon"; `themingTargets.test.ts` | Heading, divider, submenu indicator | A missing heading, divider, or indicator target fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts `dropdown-menu`, `dropdown-menu-item`, or `dropdown-menu-radio` placement. | `audit:DropdownMenu/theming` | +| FR3 | `DropdownMenu.test.tsx` button customization, icon-only, and hasChevron cases | Button variants and sizes; icon-only; no chevron | A Button prop that stops reaching the trigger fails. Evidence gap: the delegated Trigger Button gaps are asserted by `component:Button`'s own records, and no test here asserts the BottomSheet, List, Indicator, or Icon delegation beyond rendering. | `audit:DropdownMenu/theming` | +| FR5 | `DropdownMenu.test.tsx` press-model cases ("a finger that lands on one row and lifts on another acts on the second, once", "a mouse released outside closes the menu; a finger released outside leaves it open", "marks the menu as carrying the press model and owns touch scrolling"); `MenuPress.a11y.chromium.spec.ts`; the module record's own map | Finger and mouse release; scrolling menu | A row that acts on press-down, a mouse release outside that keeps the menu, or a missing press marker fails. | `audit:DropdownMenu/behavior` | +| FR6 | `DropdownMenu.test.tsx` focus-return cases ("restores focus to the trigger after keyboard dismissal", "returns focus to the trigger after pointer dismissal without a focus ring", "leaves focus on the control a press outside landed on") and the "focus return after a pointer pick" and "a trigger the caller renders" cases | Keyboard and pointer close; Button and custom trigger | A lost focus return, a ring after a pointer close, no ring after a keyboard close, or focus pulled off a control pressed outside fails. | `audit:DropdownMenu/accessibility` | +| FR7 | `DropdownMenu.test.tsx` menuMaxHeight cases | A cap above 300px; viewport bound | A menu clipped to 300px under a raised cap, or one that escapes the viewport, fails. Evidence gap: no test asserts that the touch sheet ignores the cap. | `audit:DropdownMenu/layout` | +| FR8 | `DropdownMenu.test.tsx` press-model trigger cases ("a mouse press on the trigger opens the menu and a drag-release acts on the row under it", "the opening release before the settle time acts on nothing and the menu stays", "pressing the trigger of an open menu closes it and does not reopen it in the same gesture", "a held touch on the trigger opens with the finger down and a slide picks", "a tap on the trigger still opens through its click") | Mouse press-open; held finger; tap | A mouse that opens only on click, an opening release that acts early, or a re-open in the closing gesture fails. | `audit:DropdownMenu/behavior` | +| FR9, AR1 | `DropdownMenu.test.tsx` custom trigger cases; `DropdownMenuSubMenu.test.tsx` "a menu hung off a custom trigger" cases | Custom trigger open, keyboard, mouse, disabled; both props given; Back row names | A custom trigger that does not open, toggle, or name the menu, a disabled one that opens, a missing warning, or a wrong Back row name fails. | `audit:DropdownMenu/accessibility` | +| FR10, AR2, ORD1 | `DropdownMenu.test.tsx` "DropdownMenuItem href" and "link rows — the browser's own clicks" cases | Plain, modified, middle, and right click; Enter; bottom sheet; disabled; blocked | A row that is not an anchor, a modified click that runs `onClick`, a middle click that does not close, a disabled row with an address, or a live blocked destination fails. Evidence gap: no test asserts the order of `onClick`, close, and navigation. | `audit:DropdownMenu/behavior` | +| FR11, AR3 | `DropdownMenuSubMenu.test.tsx` "drill-in on a phone" cases; `DropdownMenuDrillIn.a11y.chromium.spec.ts` | Phone and laptop; forced presentations; theme breakpoint; reset on close | A flyout on a phone, a drill-in on a laptop by default, a wrong name, a Back that loses focus, or a drilled view that survives a close fails. | `audit:DropdownMenu/accessibility` | +| FR12, AR4 | `DropdownMenu.test.tsx` keyboard cases ("ArrowDown on the last row wraps to the first, ArrowUp on the first to the last", "ArrowUp on the trigger opens with the last row highlighted", "PageDown moves to the last row and PageUp back to the first", "the opening key's auto-repeat does not act on the highlighted row"), open-focus cases, typeahead cases, and "has aria-haspopup and aria-expanded attributes" | Wrap, Page keys, open keys, auto-repeat, typeahead | A missing wrap, a Page key that does not move, an ArrowUp open on the first row, an auto-repeat that acts, or a missing typeahead match fails. Evidence gap: no test asserts PageDown past the visible rows, typeahead ignoring chords or composition, or `aria-controls` on the Button trigger. | `audit:DropdownMenu/accessibility` | +| FR13 | `DropdownMenuSubMenu.test.tsx` safe-triangle cases | Diagonal path, pause inside, leaving both | A flyout that closes inside the triangle or stays open after leaving it fails. | `audit:DropdownMenu/behavior` | +| FR14 | `DropdownMenu.test.tsx` section cases | Titled and untitled sections | A missing group role or title fails. | `audit:DropdownMenu/accessibility` | +| FR15, AR5 | `DropdownMenu.test.tsx` header and footer cases, including the hidden-control, open-sub-menu, and header radio-group cases; `MenuHeaderFooter.a11y.chromium.spec.ts` | Regions present and absent; hidden controls; open flyout; custom trigger; bottom sheet | A region inside the menu, a missing or unnamed dialog, a wrong `aria-haspopup`, a Tab that leaves the popover, a hidden control taken as a stop, or a region whose keys the menu takes fails. | `audit:DropdownMenu/accessibility` | +| FR17, AR6 | `DropdownMenu.test.tsx` "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" and "typeahead skips an aria-disabled item and matches the next enabled label" (the shipped behavior OQ1 leaves open); disabled-row cases | Disabled rows | A disabled row that runs a caller handler fails. | `audit:DropdownMenu/accessibility` | +| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` header and footer cases, including "on a phone"; `SubMenuHeaderFooter.a11y.chromium.spec.ts` | Fine pointer and phone; Escape paths; nested flyouts; pointer leaving | A region inside a menu, an unnamed dialog, a Tab walk that does not wrap, an Escape that closes the root, or a flyout that closes while focus is inside fails. Evidence gap: no test here asserts the rows-alone case under ContextMenu. | `audit:DropdownMenu/accessibility` | +| FR21 | Evidence gap: no test, matching the FR21 gap | — | — | `audit:DropdownMenu/behavior` | +| PR1 | Evidence gap: no test asserts the listener and observer bounds | — | — | `audit:DropdownMenu/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and targets | Missing, extra, prefixed, stale, or alias-backed mappings fail repository validation. | `audit:DropdownMenu/theming` | + +## Current-state impact + +- **Touch grow and pan dismissal (FR21).** Under a finger the menu appears and + leaves with the layer's own entrance and exit instead of growing out of and + back into the Trigger button, and a pan that starts outside does not close + it, against `spec:AST-094` FR31 and FR32. +- **Delegated Trigger Button gaps (FR3).** The Trigger button scales on a press + under reduced motion and holds a fixed height, as `component:Button` records, + against `design:reduced-motion` DR1 and `spec:AST-102` FR4. ## Decision log @@ -351,16 +374,32 @@ than adding a DropdownMenu-owned heading target. **Decider:** `cixzhang`, 2026-10-02 -The shipped behavior blurred the trigger after a pointer dismissal so Safari -would not paint a ring after a touch pick. Focus falling to the page loses a -keyboard user who reaches for the arrows next. Focus now returns to the -trigger and the shared focus-return visibility helper suppresses the ring +Blurring the trigger after a pointer dismissal would keep Safari from painting +a ring after a touch pick, but focus falling to the page loses a keyboard user +who reaches for the arrows next. Focus returns to the trigger and the shared focus-return visibility helper suppresses the ring after pointer input (`architecture:interaction-modality` INV1), as the bottom-sheet presentation already did. The press model's own decisions remain in `module:DropdownMenu/useMenuPress`. ### DEC-2 — `menuMaxHeight` is a number of pixels +**Reference:** `component:DropdownMenu/DEC-2` +**Decider:** `cixzhang`, `2026-10-02` + +`menuMaxHeight` replaces only the 300px term of the cap; the viewport gutters +still bound the menu, so a menu that must show all of its rows (a docked phone +menu of eleven 44px rows) can, without ever overflowing the screen. The dynamic +value uses `100dvb` alone; every browser with anchor positioning has it. + +A prop rather than a theme variable, because the need is situational — one +menu with many rows — rather than a product-wide preference, and a prop reads +more clearly at the callsite that has the problem. + +The value is a number of pixels. Rejected: an arbitrary CSS length (`50vh`, +`calc(...)`), which is product-shaped tuning on a shared component that +`spec:AST-002` does not admit, and which lets a caller write a cap the +viewport term cannot reason about. + ### DEC-3 — Any control can open a menu **Reference:** `component:DropdownMenu/DEC-3` @@ -425,48 +464,6 @@ menu; drill-in in data mode only — every menu in the app is compound; a `{label, children}` snapshot pushed onto the stack — unmounts the row and breaks focus return. -## Open questions - -- **OQ1 — Which shared owner should make disabled menu rows keyboard-discoverable - without changing disabled behavior in non-menu composites?** (`human-api`) - WAI-ARIA's Menu pattern keeps disabled menu items focusable but inert. Current - `useListFocus` deliberately skips every `aria-disabled` item for all consumers, - and DropdownMenu's enabled-only focus-on-open and navigation tests depend on - that result. The requirement is objective; choosing a menu-specific hook policy - or a DropdownMenu-owned path changes a shared API and ownership boundary. - -## Content boundary - -This file does not duplicate consumer prop tables, item examples, focus and -positioning algorithms, implementation steps, or shared modality, layer, -dismissal, and theming rules. It links to their owners. - -### DEC-5 — A row that goes somewhere is a link - -**Reference:** `component:DropdownMenu/DEC-5` - -**Decider:** vjeux, 2026-09-27 (owner confirmation pending) - -A menu row whose act is navigation renders as the anchor itself, not a row with -an anchor inside, so a ⌘-click, Ctrl-click or middle click keeps the browser's -meaning and `LinkProvider` routes it; `onClick` runs before navigation on a -plain click and is skipped for a modified one, which the browser owns. The menu -still closes on a modified click — the row acted. Keyboard activation -synthesizes a click carrying the key's modifiers so ⌘-Enter opens a tab. - -Rejected: an `onClick` that calls `navigate()` — loses new-tab, middle-click -and the router's prefetch; an inner anchor — two controls in one row. - -## Open questions - -OQ1 above remains the only open question. - -## Content boundary - -This file does not duplicate consumer prop tables, item examples, focus and -positioning algorithms, implementation steps, or shared modality, layer, -dismissal, and theming rules. It links to their owners. - ### DEC-6 — A titled group of rows is `DropdownMenuGroup` **Reference:** `component:DropdownMenu/DEC-6` @@ -499,19 +496,7 @@ that and a case pins the behavior instead. **Reference:** `component:DropdownMenu/DEC-7` **Decider:** `cixzhang`, `2026-10-02` -`menuMaxHeight` replaces only the 300px term of the cap; the viewport gutters -still bound the menu, so a menu that must show all of its rows (a docked phone -menu of eleven 44px rows) can, without ever overflowing the screen. The dynamic -value uses `100dvb` alone; every browser with anchor positioning has it. - -A prop rather than a theme variable, because the need is situational — one -menu with many rows — rather than a product-wide preference, and a prop reads -more clearly at the callsite that has the problem. - -The value is a number of pixels. Rejected: an arbitrary CSS length (`50vh`, -`calc(...)`), which is product-shaped tuning on a shared component that -`spec:AST-002` does not admit, and which lets a caller write a cap the -viewport term cannot reason about. +FR13 states the behavior this decision chose. ### DEC-8 — Controls beside the rows go in `header` and `footer` @@ -560,23 +545,18 @@ pattern requires; Escape and a press outside close it. Without either prop the component is unchanged: a Menu Button whose Tab closes the menu. Rejected: closing on Tab past either end, which leaves a modal dialog by Tab. -### DEC-13 (proposed) — A sub-menu's header and footer make its surface a dialog beside the menu - -**Reference:** `component:DropdownMenu/DEC-13` -**Decider:** proposed by `vjeux`, 2026-10-14; pending owner review - -A filter field above a sub-menu's rows, or a door below them, is a control -that is not a menu item, so DEC-8 and DEC-10 apply to the sub-menu's own -surface as they apply to the root's: the sub-menu's menu holds only its rows, -and the surface around it is a named modal dialog whose Tab order wraps. A -flyout renders inside the menu that holds its row, so a dialog there would -still sit inside that menu; the `DropdownMenu` therefore renders a host beside its menu, -inside its popover, and a sub-menu with regions renders its surface there (the -flyout layer, or the drilled-in view, which then takes the root menu's place). -Rendered there, a flyout no longer hides with the flyout that holds its row, -so it closes with it. The pointer leaving that flyout does not close it while -focus is inside, because what was typed into its field would go with it. -Rejected: regions inside the flyout beside the sub-menu's menu, -which puts them inside the parent menu; a separate DropdownMenu for the level, -which loses the row, the drill-in and the single press model; and closing the -flyout when the pointer leaves it as any flyout does. +## Open questions + +- **OQ1 — Which shared owner should make disabled menu rows keyboard-discoverable + without changing disabled behavior in non-menu composites?** (`human-api`) + WAI-ARIA's Menu pattern keeps disabled menu items focusable but inert. Current + `useListFocus` deliberately skips every `aria-disabled` item for all consumers, + and DropdownMenu's enabled-only focus-on-open and navigation tests depend on + that result. The requirement is objective; choosing a menu-specific hook policy + or a DropdownMenu-owned path changes a shared API and ownership boundary. + +## Content boundary + +This file does not duplicate consumer prop tables, item examples, focus and +positioning algorithms, implementation steps, or shared modality, layer, +dismissal, and theming rules. It links to their owners. From 2b2c6e95b8c3cbdb158fa45fd13ef26265acdde3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 22:13:17 -0700 Subject: [PATCH 2/9] docs(spec): DropdownMenu sub-component records record their shipped contracts as current --- .../DropdownMenu/DropdownMenuDivider.spec.md | 56 +++++----- .../src/DropdownMenu/DropdownMenuItem.spec.md | 89 ++++++++------- .../DropdownMenuRadioGroup.spec.md | 71 ++++++------ .../DropdownMenuRadioItem.spec.md | 105 +++++++++--------- .../DropdownMenu/DropdownMenuSubMenu.spec.md | 86 +++++++------- 5 files changed, 203 insertions(+), 204 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenuDivider.spec.md b/packages/core/src/DropdownMenu/DropdownMenuDivider.spec.md index c7fc5a2cb0663..b5e6c07884a13 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuDivider.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuDivider.spec.md @@ -3,11 +3,11 @@ schema_version: 3 template_version: 7 kind: component id: component:DropdownMenuDivider -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-10-31 owners: [cixzhang] review_triggers: [public-api, behavior, theming, accessibility, testing] verified_by: @@ -16,7 +16,6 @@ verified_by: packages/core/src/ContextMenu/ContextMenu.test.tsx, packages/core/src/Breadcrumbs/Breadcrumbs.test.tsx, packages/core/src/theme/themingTargets.test.ts, - apps/storybook/stories/DropdownMenuDivider.stories.tsx, scripts/check-knowledge.mjs, ] modules: [] @@ -29,7 +28,7 @@ architecture: architecture:component-test-sufficiency, ] contributing: [] -system_specs: [spec:AST-020, spec:AST-029] +system_specs: [spec:AST-020] --- # DropdownMenuDivider component contract @@ -42,9 +41,9 @@ system_specs: [spec:AST-020, spec:AST-029] | Behavior | One unlabelled horizontal separator divides pointer-menu rows and stays out of arrow-key order; data and compound pointer modes render the same component. | | End-user impact | Pointer-menu sections remain visually separated without adding a focus stop or changing row operation. | | Builder impact | No new caller choice. Host attributes, handlers, and styling inputs continue to compose on the separator. | -| Compatibility/readiness | Patch-compatible observational contract. Runtime behavior, exports, defaults, and target names remain unchanged; authority stays `draft` until exact-head owner approval. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | | Review checks | Reject a focusable separator, changed role/orientation, pointer data/compound divergence, dropped root inputs, or a target moved off the visible separator. | -| Governing rules | `architecture:public-component-api/INV1,INV5–INV9`; `architecture:component-theming-surface/INV3–INV6`; WCAG 2.2 SC 1.3.1 and 4.1.2 through `spec:AST-020/FR1`. | +| Governing rules | `architecture:public-component-api/INV1,INV5–INV9`; `architecture:component-theming-surface/INV3–INV6`; `spec:AST-020`. | This table is a review projection; the body below is authoritative. @@ -63,7 +62,8 @@ Consumer syntax and examples belong in `DropdownMenuDivider.doc.mjs`. ## Compatibility and migration - Released default preserved: yes — one horizontal, unlabelled separator. -- Compatibility class: observational patch; no behavior or public surface changes. +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. - Controlled/uncontrolled behavior: not applicable. - Migration decision: none. @@ -98,16 +98,13 @@ 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. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ | -| FR1 | The component MUST render one unlabelled horizontal separator with pointer-menu-specific block spacing. | Released source/docs and focused tests | verify | -| FR2 | The root MUST expose `role="separator"` and `aria-orientation="horizontal"`, remain outside menu-item collection and arrow-key order, and never become focusable. | WCAG 2.2 SC 1.3.1 and 4.1.2; focused tests | verify | -| FR3 | In pointer-menu presentation, `{type: "divider"}` in the data API and the compound divider component or alias MUST render the same component DOM, spacing, semantics, and targets. | Released docs/source; data/compound parity test | verify | -| FR4 | The visible root MUST carry both the delegated `divider` target and the menu-specific `dropdown-menu-divider` target; menu spacing applies before caller `xstyle`. | `architecture:component-theming-surface/INV3–INV6`; source/tests | verify | -| FR5 | Accepted ref, DOM/data/ARIA, class, style, events, and `xstyle` MUST reach or compose on the separator root without replacing component-owned role or orientation. | `architecture:public-component-api/INV5–INV8` | verify | +| ID | Invariant | Basis | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| FR1 | The component renders one unlabelled horizontal separator with pointer-menu-specific block spacing. | Released source/docs and focused tests | +| FR2 | The root exposes `role="separator"` and `aria-orientation="horizontal"`, stays outside menu-item collection and arrow-key order, and is never focusable. | `spec:AST-020`; current tests | +| FR3 | In pointer-menu presentation, `{type: "divider"}` in the data API and the compound divider component or alias render the same component DOM, spacing, semantics, and targets. | Released docs/source; data/compound parity test | +| FR4 | The visible root carries both the delegated `divider` target and the menu-specific `dropdown-menu-divider` target; menu spacing applies before caller `xstyle`. | `architecture:component-theming-surface`; current source and tests | +| FR5 | Accepted ref, DOM/data/ARIA, class, style, events, and `xstyle` reach or compose on the separator root without replacing component-owned role or orientation. | `architecture:public-component-api` INV5–INV8; current tests | ### Allowed variation @@ -151,7 +148,7 @@ intentional decision. | ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------- | ------------------ | | Menu separator | Visually separates adjacent menu row groups without competing with row content. | Current source and delegated Divider tokens; no component-local visual decision added | supporting | FR1–FR4 | -No component-local visual decision is settled by this draft. +No component-local visual decision is recorded. ## Family and system relationships @@ -164,22 +161,20 @@ No component-local visual decision is settled by this draft. props aliases; their pointer presentations retain the same component contract. - Touch and bottom-sheet data dividers bypass this component and delegate directly to Divider under the parent menu contract. -- Divider provides the base separator implementation; its draft record is - observational context, not authority for this contract. -- `spec:AST-029` keeps this observational draft separate from the audit receipt. +- Divider provides the base separator implementation. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ---------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -| FR1–FR3, AR1–AR2 | `DropdownMenu.test.tsx`; ContextMenu and Breadcrumbs alias tests; dedicated Storybook state; scoped axe | pointer data/compound, aliases, open menu, keyboard traversal | Pointer DOM modes drift, an alias disappears, the role/orientation changes, or the separator enters arrow-key order | `audit:DropdownMenuDivider/§1,§3,§4,§6` | -| FR4 | Focused root-composition test; exact-head Neutral/Butter browser frames | light/dark themes; menu and delegated targets | A target leaves the visible root or caller `xstyle` stops composing after menu spacing | `audit:DropdownMenuDivider/§2,§5` | -| FR5 | Focused root-composition test; lint and typecheck | ref, host attributes, class/style/xstyle/events | An accepted input is dropped or owned semantics are overwritten | `audit:DropdownMenuDivider/§3` | -| PR1 | Source review | every state | A lifecycle mechanism appears without a resource contract | `audit:DropdownMenuDivider/§7` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | +| FR1, FR2, AR1, AR2 | `DropdownMenu.test.tsx` divider and parity cases ("renders dividers between items", "renders an identical divider from either mode", "skips a compound divider in the arrow-key order", "forwards host attributes to a compound divider while keeping its role"); `ContextMenu.test.tsx` divider cases; `Breadcrumbs.test.tsx` "renders the separator and props types through the BreadcrumbMenuDivider aliases" | Data and compound pointer menus; aliases; arrow traversal | A missing separator, a changed role, a separator in the arrow-key order, or a lost alias fails. Evidence gap: no test asserts `aria-orientation` or the menu block spacing. | `audit:DropdownMenuDivider/semantics` | +| FR3 | `DropdownMenu.test.tsx` "renders an identical divider from either mode" | Data and compound | Different DOM between the modes fails. | `audit:DropdownMenuDivider/behavior` | +| FR4, FR5 | `DropdownMenu.test.tsx` "composes the complete public root surface on the separator" and the parity case's `dropdown-menu-divider` class assertion; `themingTargets.test.ts` | Ref, class, style, xstyle, handler | A dropped ref, class, style, `xstyle`, or handler, or a missing `dropdown-menu-divider` target, fails. Evidence gap: no test asserts the delegated `divider` target or that menu spacing applies before caller `xstyle`. | `audit:DropdownMenuDivider/api` | +| PR1 | Evidence gap: no test asserts the absence of lifecycle resources | — | — | `audit:DropdownMenuDivider/behavior` | ## Decision log -None. This draft records current facts and introduces no product decision. +No component-local decision. ## Open questions @@ -188,5 +183,4 @@ None. ## Content boundary This file does not duplicate consumer prop tables/examples, current audit scores, -screenshot matrices, implementation steps, or system rules. The audit pull request -owns the closed run evidence required by `spec:AST-029/FR5`. +screenshot matrices, implementation steps, or system rules. It links to their owners. diff --git a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md index d229cdf8dffe4..55e6f431284ff 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md @@ -3,11 +3,11 @@ schema_version: 3 template_version: 7 kind: component id: component:DropdownMenuItem -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-10-31 owners: [cixzhang] review_triggers: [public-api, behavior, accessibility, theming, testing] verified_by: @@ -15,7 +15,6 @@ verified_by: packages/core/src/DropdownMenu/DropdownMenu.test.tsx, packages/core/src/DropdownMenu/__tests__/Menu.a11y.test.tsx, packages/core/src/theme/themingTargets.test.ts, - apps/storybook/stories/DropdownMenuItem.stories.tsx, apps/storybook/rtl-audit/verified-not-applicable.json, scripts/check-knowledge.mjs, ] @@ -30,7 +29,7 @@ architecture: architecture:interaction-modality, ] contributing: [] -system_specs: [spec:AST-020, spec:AST-029] +system_specs: [spec:AST-020] --- # DropdownMenuItem component contract @@ -43,7 +42,7 @@ system_specs: [spec:AST-020, spec:AST-029] | Behavior | The parent menu owns roving focus and keyboard activation; the row owns click order, disabled suppression, close-on-select, destination handoff, hover focus, and its visible content composition. | | End-user impact | Actions and destinations preserve keyboard, pointer, and browser link behavior while unavailable rows remain inert and destructive actions remain identifiable. | | Builder impact | Destinations use `href`; supporting content stays non-interactive; `hasCloseOnSelect=false` is reserved for in-place result feedback. | -| Compatibility/readiness | Patch-compatible observational contract. Runtime, exports, defaults, and theme targets remain unchanged; authority stays `draft` until exact-head owner approval. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | | Review checks | Reject nested controls, disabled activation, lost destination safety, broken modified-click behavior, dropped root inputs, a second keyboard owner, or state/target reflection moved off the row. | | Governing rules | `family:navigation-destinations/FR1–FR5`; `architecture:public-component-api/INV5–INV9`; `architecture:component-theming-surface/INV3–INV6`; `spec:AST-020/FR1,FR2,FR6,FR15`. | @@ -61,7 +60,8 @@ lifecycle. ## Compatibility and migration - Released default preserved: yes. -- Compatibility class: observational patch; no behavior or public surface changes. +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. - Controlled/uncontrolled behavior: not applicable. - Migration decision: none. @@ -101,19 +101,16 @@ 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. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ | -| FR1 | The pointer row MUST expose one `role="menuitem"` root. Label, description, icon, and end content remain presentational content of that row rather than nested controls. | WAI-ARIA menu pattern; released source/docs | verify | -| FR2 | Enabled activation MUST run the row callback before optional close. Disabled rows MUST expose `aria-disabled=true`, remove link destinations, and run neither callback nor close. | Released behavior; focused tests | verify | -| FR3 | A destination row MUST render as the link root through LinkProvider. Plain activation runs the callback then closes and navigates; modified and middle clicks preserve browser behavior without running the callback; blocked schemes are inert. | `family:navigation-destinations/FR1–FR5`; released tests | verify | -| FR4 | The parent menu MUST remain the only keyboard owner. It synthesizes click activation with modifiers, moves focus, and runs typeahead; the row MAY focus itself on pointer hover but MUST NOT create a second roving-focus model. | `architecture:interaction-modality`; current parent tests | verify | -| FR5 | The visible root MUST carry `dropdown-menu-item`; size and destructive state MUST reflect through `themeProps`; semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface/INV3–INV6`; source/tests | verify | -| FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` MUST reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api/INV5–INV9`; focused tests | verify | -| FR7 | Disabled menu rows remain inert. Whether the menu keyboard model should visit them is unresolved at the shared owner and MUST NOT be changed by a component-local audit. | WAI-ARIA menu pattern; parent `component:DropdownMenu/OQ1` | human API decision | -| FR8 | Keyboard focus MUST remain perceivable, including in forced-colors mode. The current background-only treatment is an objective gap; a remediation must preserve modality and row-content visibility without inventing new ordinary-theme appearance. | WCAG 2.2 SC 1.4.11 and 2.4.7; current rendered behavior | objective gap; owner must confirm the shared visual boundary | +| ID | Invariant | Basis | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| FR1 | The pointer row exposes one `role="menuitem"` root. Label, description, icon, and end content are presentational content of that row rather than nested controls. | `spec:AST-020` FR15; current source and docs | +| FR2 | Enabled activation runs the row callback before optional close. Disabled rows expose `aria-disabled=true`, remove link destinations, and run neither callback nor close. | Current behavior and tests | +| FR3 | A destination row renders as the link root through LinkProvider. Plain activation runs the callback then closes and navigates; modified clicks preserve browser behavior without running the callback; a middle click runs the caller's `onAuxClick` and closes the menu; blocked schemes are inert. | `family:navigation-destinations` FR1–FR5; current tests | +| FR4 | The parent menu is the only keyboard owner. It synthesizes click activation with modifiers, moves focus, and runs typeahead; the row focuses itself on mouse hover and creates no second roving-focus model. | `architecture:interaction-modality`; current parent tests | +| FR5 | The visible root carries `dropdown-menu-item`; size and destructive state reflect on it (`data-size`, `data-variant`); semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface`; current source and tests | +| FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api` INV5–INV9; current tests | +| FR7 | Disabled menu rows are inert, and the parent menu's arrow keys and typeahead skip them. Shipped behavior, not a requirement until OQ1 is ruled. | `component:DropdownMenu` FR17, OQ1 | +| FR8 | Keyboard focus paints the row's hover background with no outline; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the forced-colors treatment is OQ2. | Current source | ### Allowed variation @@ -145,10 +142,9 @@ intentional decision. ### Performance and resources -- **PR1 — Shared press lifecycle.** DropdownMenuItem uses the shared counted press - controller through `usePressFeedback`; the first mounted pressable installs the - window listeners and the last removes them. The item adds no per-row listener, - observer, timer, request, or local resource owner. +- **PR1 — Shared press listeners.** The row shares the press controller's + window and document listeners; it adds no per-row listener, observer, timer, + or request. ## Accessibility contract @@ -157,18 +153,18 @@ intentional decision. - **AR2 — Disabled state.** Disabled rows expose state and cannot activate or navigate. - **AR3 — Keyboard ownership.** DropdownMenu owns focus collection and activation; the item remains a single row in that model. -- **AR4 — Focus perception.** Focus remains visually perceivable in supported themes - and platform accessibility modes; the open forced-colors gap remains explicit. +- **AR4 — Focus perception.** Focus paints the row background in ordinary + themes; FR8 records that forced-colors mode shows no indicator (OQ2). ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| ---------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------- | ------------------ | -| Row | One full-width action/destination target with tokenized insets and start alignment. | Current source and parent menu composition | prominent | FR1–FR6 | -| Supporting text | Description and end content remain subordinate to the row label. | Delegated Item layout and current consumer docs | supporting | FR1 | -| Interaction | Hover, focus, press, disabled, and destructive states remain distinguishable. | Current source; forced-colors focus is an unresolved objective gap | prominent | FR5, FR8, AR4 | +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------- | -------------- | ------------------ | +| Row | One full-width action/destination target with tokenized insets and start alignment. | Current source and parent menu composition | prominent | FR1–FR6 | +| Supporting text | Description and end content remain subordinate to the row label. | Delegated Item layout and current consumer docs | supporting | FR1 | +| Interaction | Hover, focus, press, disabled, and destructive states remain distinguishable. | Current source; forced-colors focus is OQ2 | prominent | FR5, FR8, AR4 | -No new component-local visual decision is settled by this draft. +No component-local visual decision is recorded. ## Family and system relationships @@ -180,20 +176,29 @@ No new component-local visual decision is settled by this draft. - `architecture:component-test-sufficiency` owns proportionate observable evidence. - `architecture:interaction-modality` and DropdownMenu own parent keyboard behavior. - `spec:AST-020` supplies WCAG/APG adoption and evidence-layer boundaries. -- `spec:AST-029` keeps this observational draft separate from the audit receipt. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ---------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1–FR4, AR1–AR3 | `DropdownMenu.test.tsx`; reusable Menu accessibility tests; scoped axe | action, destination, disabled, keyboard, modified clicks | A nested control, activation while disabled, lost browser affordance, or second keyboard owner fails observable tests | `audit:DropdownMenuItem/§1,§4,§6` | -| FR5, FR8, AR4 | theming-target tests; exact-head Neutral/Butter/RTL/narrow/focus/hover frames | default, destructive, disabled, focus, hover | Target/state reflection disappears, paint is absent, or focus becomes unobservable | `audit:DropdownMenuItem/§2,§5` | -| FR6 | root/ref/handler tests; lint and typecheck | ref, host attributes, class/style/xstyle, pointer handlers | An accepted input is dropped or overrides component-owned semantics | `audit:DropdownMenuItem/§3` | -| RTL / responsive | source-hashed verified-N/A receipt plus exact-head browser frames | LTR, RTL, 320px | Physical direction leaks into the row or narrow layout overflows | `audit:DropdownMenuItem/§9,§10` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| FR1, AR1 | `DropdownMenu.test.tsx` compound-mode cases ("renders JSX children as menu items", "renders endContent after the item label"); `Menu.a11y.test.tsx` menu binding cases | Action row with content | A row that is not a `menuitem` or loses its content fails. Evidence gap: no test asserts that supporting content holds no nested control. | `audit:DropdownMenuItem/semantics` | +| FR2, AR2, ORD1 | `DropdownMenu.test.tsx` item cases ("calls onClick when item is clicked", "closes the menu after an item is activated", "keeps the menu open when the item opts out of closing", "does not call onClick when disabled"), "has aria-disabled on disabled compound items", and "a disabled href row renders no address" | Enabled, disabled, close and stay-open | A callback while disabled, a missing `aria-disabled`, a close that ignores the policy, or a disabled row with an address fails. Evidence gap: no test asserts the callback-before-close order. | `audit:DropdownMenuItem/behavior` | +| FR3 | `DropdownMenu.test.tsx` "DropdownMenuItem href" and "link rows — the browser's own clicks" cases; "a middle click on a link row runs the caller's onAuxClick and still closes the menu" | Plain, modified, middle, right click; blocked destination | A non-anchor row, a modified click that runs the callback, a middle click that does not close, or a live blocked destination fails. | `audit:DropdownMenuItem/behavior` | +| FR4, AR3 | `DropdownMenu.test.tsx` keyboard-access cases ("moves focus to the item the mouse hovers, keeping a single highlight", "does not move focus on hover for a disabled item", "does not move focus for a non-mouse (touch) pointer") and "keeps the row's own role, tab stop and hover focus beside a caller's onPointerMove" | Mouse hover, touch, disabled | A second highlight, hover focus on a disabled row or under touch, or a lost tab stop fails. | `audit:DropdownMenuItem/accessibility` | +| FR5 | `DropdownMenu.test.tsx` destructive-variant cases; `themingTargets.test.ts` | Default and destructive; data and compound mode | A missing `data-variant` fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts the `dropdown-menu-item` class or `data-size`. | `audit:DropdownMenuItem/theming` | +| FR6 | `DropdownMenu.test.tsx` ref and host-attribute cases ("a ref reaches the menuitem element", "puts aria-_, data-_ and id on the row root, a link row included", "runs the DOM handlers the caller attaches, such as a drop target") | Ref; host attributes; caller handlers | A dropped ref, attribute, or handler fails. Evidence gap: no test asserts `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuItem/api` | +| FR7 | `DropdownMenu.test.tsx` "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" (the shipped behavior OQ1 leaves open) | Disabled rows | A disabled row that activates fails. | `audit:DropdownMenuItem/accessibility` | +| FR8, AR4, PR1 | Evidence gap: no test asserts the focus paint, forced-colors focus, or the listener bound | — | — | `audit:DropdownMenuItem/visual` | +| RTL | `apps/storybook/rtl-audit/verified-not-applicable.json` DropdownMenuItem entry | LTR and RTL | A changed source hash fails the receipt. | `audit:DropdownMenuItem/rtl` | + +## Current-state impact + +- **Forced-colors focus (FR8).** Keyboard focus on a row has no visible + indicator in forced-colors mode; the treatment is OQ2. ## Decision log -None. This draft records current facts and introduces no product decision. +No component-local decision. ## Open questions @@ -205,5 +210,5 @@ None. This draft records current facts and introduces no product decision. ## Content boundary This file does not duplicate consumer prop tables/examples, current audit scores, -screenshot matrices, implementation steps, or family/system rules. The audit pull -request owns the closed run evidence required by `spec:AST-029/FR5`. +screenshot matrices, implementation steps, or family/system rules. It links to +their owners. diff --git a/packages/core/src/DropdownMenu/DropdownMenuRadioGroup.spec.md b/packages/core/src/DropdownMenu/DropdownMenuRadioGroup.spec.md index 178dda62e2a64..7a190cb4030d8 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuRadioGroup.spec.md @@ -3,11 +3,11 @@ schema_version: 3 template_version: 7 kind: component id: component:DropdownMenuRadioGroup -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-10-31 owners: [cixzhang] review_triggers: [public-api, behavior, accessibility, theming, testing] verified_by: @@ -15,7 +15,6 @@ verified_by: packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx, packages/core/src/DropdownMenu/DropdownMenu.test.tsx, packages/core/src/DropdownMenu/__tests__/Menu.a11y.test.tsx, - apps/storybook/stories/DropdownMenuRadioGroup.stories.tsx, apps/storybook/rtl-audit/verified-not-applicable.json, scripts/check-knowledge.mjs, ] @@ -29,22 +28,22 @@ architecture: architecture:interaction-modality, ] contributing: [] -system_specs: [spec:AST-020, spec:AST-029] +system_specs: [spec:AST-020] --- # DropdownMenuRadioGroup component contract ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | One controlled, named single-selection group of `DropdownMenuRadioItem` children with value, change callback, close policy, root inputs, and an explicit React 19 ref. | -| Behavior | The group supplies selection and close-policy context; radio items emit values; the parent DropdownMenu owns keyboard navigation and menu lifecycle. | -| End-user impact | Assistive technology encounters one named group with exactly one current choice when a value matches, while pointer and keyboard selection stay aligned. | -| Builder impact | Keep the value controlled, provide a stable accessible label, and place only DropdownMenuRadioItem options inside the group. | -| Compatibility/readiness | Patch-compatible additive `ref` prop on the existing group root. Existing runtime behavior, exports, defaults, and theme surfaces remain unchanged; authority stays `draft` until owner approval. | -| Review checks | Reject unnamed groups, local selection state, dropped root inputs, a second keyboard owner, or a close policy that bypasses the controlled callback. | -| Governing rules | WAI-ARIA menu pattern; `architecture:public-component-api/INV5–INV9`; `architecture:component-test-sufficiency/INV1–INV7`; `spec:AST-020/FR1,FR2,FR6`. | +| Area | Contract | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | One controlled, named single-selection group of `DropdownMenuRadioItem` children with value, change callback, close policy, root inputs, and a ref. | +| Behavior | The group supplies selection and close-policy context; radio items emit values; the parent DropdownMenu owns keyboard navigation and menu lifecycle. | +| End-user impact | Assistive technology encounters one named group with exactly one current choice when a value matches, while pointer and keyboard selection stay aligned. | +| Builder impact | Keep the value controlled, provide a stable accessible label, and place only DropdownMenuRadioItem options inside the group. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | +| Review checks | Reject unnamed groups, local selection state, dropped root inputs, a second keyboard owner, or a close policy that bypasses the controlled callback. | +| Governing rules | `spec:AST-020`; `architecture:public-component-api/INV5–INV9`; `architecture:component-test-sufficiency/INV1–INV7`; `spec:AST-020/FR1,FR2,FR6`. | This table is a review projection; the body below is authoritative. @@ -60,8 +59,8 @@ and menu open/close lifecycle. ## Compatibility and migration - Released default preserved: yes (`hasCloseOnSelect=true`). -- Compatibility class: additive patch; declares and forwards the React 19 `ref` - prop to the existing group root without changing existing behavior. +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. - Controlled behavior: `value` is caller-owned and `onChange` is the only commit path. - Migration decision: none. @@ -98,17 +97,14 @@ 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. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ | -| FR1 | The root MUST expose `role="group"` and a non-empty accessible name. The required `label` supplies `aria-label`; caller `aria-labelledby` MAY reference visible text. | WAI-ARIA menu pattern; released source/docs | verify | -| FR2 | The group MUST remain controlled. It MUST compare each radio item's value with caller `value` and MUST emit a selected child value only through caller `onChange`. | Released public API and focused tests | verify | -| FR3 | `hasCloseOnSelect` MUST default to true. When false, successful selection MUST update through `onChange` while the parent menu remains open. | Released behavior and focused tests | verify | -| FR4 | DropdownMenu MUST remain the only keyboard owner. The group adds no tab stop, key handler, typeahead, or click synthesis; it only provides context to child rows. | `architecture:interaction-modality`; parent tests | verify | -| FR5 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` MUST reach or compose on the group root without replacing the owned role or `aria-label`. | `architecture:public-component-api/INV5–INV9`; source/tests | verify | -| FR6 | The wrapper MUST remain a logical column whose tokenized gap matches the surrounding menu's item rhythm; it MUST NOT add component-specific directional paint or navigation. | Current source; token and bidirectionality rules | verify | +| ID | Invariant | Basis | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| FR1 | The root exposes `role="group"` and a non-empty accessible name. The required `label` supplies `aria-label`; a caller `aria-labelledby` may reference visible text. | `spec:AST-020`; current source and tests | +| FR2 | The group is controlled. It compares each radio item's value with caller `value` and emits a selected child value only through caller `onChange`; a radio item outside a group throws. | Current public API and tests | +| FR3 | `hasCloseOnSelect` defaults to true. When false, a selection updates through `onChange` while the parent menu stays open. | Current behavior and tests | +| FR4 | DropdownMenu is the only keyboard owner. The group adds no tab stop, key handler, typeahead, or click synthesis; it only provides context to child rows. | `architecture:interaction-modality`; parent tests | +| FR5 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the group root without replacing the owned role or `aria-label`. | `architecture:public-component-api` INV5–INV9; current tests | +| FR6 | The wrapper is a logical column whose tokenized gap matches the surrounding menu's item rhythm; it adds no component-specific directional paint or navigation. | Current source | ### Allowed variation @@ -157,7 +153,7 @@ intentional decision. | Selected child | One child reflects caller value through `aria-checked`. | DropdownMenuRadioItem | prominent | FR2, AR2 | | Direction/narrow | Child order and gap hold without physical-direction assumptions. | Bidirectionality and parent layout | structural | FR6 | -No new component-local visual decision is settled by this draft. +No component-local visual decision is recorded. ## Family and system relationships @@ -165,19 +161,21 @@ No new component-local visual decision is settled by this draft. - `architecture:component-test-sufficiency` owns proportionate observable evidence. - `architecture:interaction-modality` and DropdownMenu own keyboard behavior. - `spec:AST-020` supplies WCAG/APG adoption and evidence-layer boundaries. -- `spec:AST-029` keeps this observational draft separate from the audit receipt. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------------- | ------------------------------------------------------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------- | -| FR1–FR4, AR1–3 | `DropdownMenuSelectable.test.tsx`; reusable Menu contracts; scoped axe | matching, none, close, retained-open | An unnamed group, wrong checked row, skipped callback, unwanted close, or key owner fails | `audit:DropdownMenuRadioGroup/§1,§4,§6` | -| FR5 | root/ref/handler tests; lint and typecheck | ref, host attributes, class/style | An accepted input is dropped or overrides the owned role/name | `audit:DropdownMenuRadioGroup/§3` | -| FR6 | source-hashed RTL N/A receipt plus exact-head Neutral/Butter/RTL/narrow frames | LTR, RTL, 320px, light/dark | Physical direction, lost tokenized gap, collapsed geometry, or horizontal overflow fails | `audit:DropdownMenuRadioGroup/§2,§5,§9,§10` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| FR1, AR1 | `DropdownMenuSelectable.test.tsx` "names and composes the group root when no value matches" | Label and caller `aria-labelledby` | A missing group role, `aria-label`, or caller `aria-labelledby` fails. | `audit:DropdownMenuRadioGroup/semantics` | +| FR2 | `DropdownMenuSelectable.test.tsx` "calls onChange with the selected value", "names and composes the group root when no value matches" (no item checked), and "throws when a radio item is used outside a group" | Matching and no matching value; item outside a group | A wrong checked row, a selection that skips `onChange`, or an item that works outside a group fails. | `audit:DropdownMenuRadioGroup/behavior` | +| FR3 | `DropdownMenuSelectable.test.tsx` "keeps the menu open while controlled selection updates when requested" | `hasCloseOnSelect={false}` | A close despite the opt-out fails. Evidence gap: no test here asserts the default close after a selection. | `audit:DropdownMenuRadioGroup/behavior` | +| FR4, AR2, AR3 | `DropdownMenu.test.tsx` "arrow navigation reaches consumer-rendered menuitemradio and menuitemcheckbox items" and "activates a focused menuitemradio with Enter and a menuitemcheckbox with Space" | Arrow keys and Enter on radio rows | A radio row the arrows skip or Enter does not activate fails. Evidence gap: no test asserts that the group adds no tab stop or key handler. | `audit:DropdownMenuRadioGroup/accessibility` | +| FR5 | `DropdownMenuSelectable.test.tsx` "names and composes the group root when no value matches" | Ref, host attribute, class, style, handler | A dropped ref, attribute, class, style, or handler, or a replaced role or label, fails. Evidence gap: no test asserts `xstyle` on the group. | `audit:DropdownMenuRadioGroup/api` | +| FR6, PR1 | `apps/storybook/rtl-audit/verified-not-applicable.json` DropdownMenuRadioGroup entry. Evidence gap: no test asserts the column gap or the absence of lifecycle resources | LTR and RTL | A changed source hash fails the receipt. | `audit:DropdownMenuRadioGroup/layout` | ## Decision log -None. This draft records current facts and introduces no product decision. +No component-local decision. ## Open questions @@ -186,5 +184,4 @@ None. ## Content boundary This file does not duplicate consumer prop tables/examples, current audit scores, -screenshot matrices, implementation steps, or parent/item contracts. The audit -pull request owns the closed run evidence required by `spec:AST-029/FR5`. +screenshot matrices, implementation steps, or parent/item contracts. It links to their owners. diff --git a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md index 1f8dbff77d38e..77959aa533596 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md @@ -3,11 +3,11 @@ schema_version: 3 template_version: 7 kind: component id: component:DropdownMenuRadioItem -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-10-31 owners: [cixzhang] review_triggers: [public-api, behavior, accessibility, theming, testing] verified_by: @@ -16,7 +16,6 @@ verified_by: packages/core/src/DropdownMenu/__tests__/Menu.a11y.test.tsx, packages/core/src/RadioList/__tests__/RadioGroup.a11y.test.tsx, packages/core/src/DropdownMenu/__tests__/DropdownMenuRadioItemAudit.a11y.chromium.spec.ts, - apps/storybook/stories/DropdownMenuRadioItem.stories.tsx, apps/storybook/rtl-audit/verified-not-applicable.json, scripts/check-knowledge.mjs, ] @@ -31,22 +30,22 @@ architecture: architecture:interaction-modality, ] contributing: [] -system_specs: [spec:AST-020, spec:AST-029] +system_specs: [spec:AST-020] --- # DropdownMenuRadioItem component contract ## Contract at a glance -| Area | Contract | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | One `menuitemradio` row inside a DropdownMenuRadioGroup with a required value and label, optional description, icon, end content, disabled state, root inputs, and an explicit React 19 ref. | -| Behavior | Enabled activation emits the row value through the group, applies the group close policy, then runs the caller click handler; disabled activation does none of these. | -| End-user impact | Assistive technology hears one radio option with its checked state; pointer and keyboard selection commit the same value, and a disabled option cannot be chosen. | -| Builder impact | Place the row inside a DropdownMenuRadioGroup and give it a unique value and a label; caller handlers run alongside the row's own behavior. | -| Compatibility/readiness | Released subcomponent; observational draft of current behavior. Authority stays `draft` until owner approval. | -| Review checks | Reject a nested semantic radio, a second keyboard owner, local selection state, a dropped caller input, or disabled activation that selects, closes, or calls the caller click handler. | -| Governing rules | WAI-ARIA menu pattern; `architecture:public-component-api/INV5–INV8`; `architecture:component-theming-surface/INV4–INV6`; `spec:AST-020/FR1,FR2`. | +| Area | Contract | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | One `menuitemradio` row inside a DropdownMenuRadioGroup with a required value and label, optional description, icon, end content, disabled state, root inputs, and a ref. | +| Behavior | Enabled activation emits the row value through the group, applies the group close policy, then runs the caller click handler; disabled activation does none of these. | +| End-user impact | Assistive technology hears one radio option with its checked state; pointer and keyboard selection commit the same value, and a disabled option cannot be chosen. | +| Builder impact | Place the row inside a DropdownMenuRadioGroup and give it a unique value and a label; caller handlers run alongside the row's own behavior. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | +| Review checks | Reject a nested semantic radio, a second keyboard owner, local selection state, a dropped caller input, or disabled activation that selects, closes, or calls the caller click handler. | +| Governing rules | `spec:AST-020` FR15; `architecture:public-component-api/INV5–INV8`; `architecture:component-theming-surface/INV4–INV6`; `spec:AST-020/FR1,FR2`. | This table is a review projection; the body below is authoritative. @@ -62,8 +61,8 @@ activation, and the menu lifecycle. ## Compatibility and migration - Released default preserved: yes (`isDisabled=false`). -- Compatibility class: no public API change; caller `onClick` and - `onPointerMove` compose with the row's own behavior. +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. - Controlled behavior: the checked state always derives from the group `value`; the row holds no selection state. - Migration decision: none. @@ -106,19 +105,16 @@ 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. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | -| FR1 | The row MUST render one element with `role="menuitemradio"` whose `aria-checked` is true exactly when the group value equals the row value. It MUST NOT nest a native or ARIA radio. | WAI-ARIA menu pattern; current source and focused tests | verify | -| FR2 | Rendering the row outside a DropdownMenuRadioGroup MUST throw. | Current source and focused tests | verify | -| FR3 | Enabled activation, by pointer or by the parent's synthesized Enter/Space click, MUST call the group `onChange` with the row value, then ask DropdownMenu to close when the group's close policy is on, then call the caller `onClick`. | Released behavior; `architecture:public-component-api/INV7`; focused tests | verify | -| FR4 | A disabled row MUST expose `aria-disabled="true"`, render no `tabindex`, take no hover focus, and on any activation, including an assistive-technology click, MUST NOT call `onChange`, close the menu, or call the caller `onClick`. It paints at half opacity with a default cursor. | Current source, focused tests, and browser evidence | verify | -| FR5 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` MUST reach or compose on the row root. Caller `onPointerMove` MUST run after the row's hover focus, and caller handlers MUST NOT replace the row's own behavior. | `architecture:public-component-api/INV5–INV8`; focused tests | verify | -| FR6 | The marker MUST be the shared radio Indicator, hidden from assistive technology, never the pointer target, sized `sm` in a small menu and `md` otherwise, at the row's inline start on a fine pointer and its inline end on a coarse pointer. | Current source, focused tests, and browser evidence | verify | -| FR7 | The row MUST carry the `dropdown-menu-item` target with the menu size and the marker MUST carry the `dropdown-menu-radio` target with size, checked, and disabled reflections. | `architecture:component-theming-surface/INV4–INV6`; current source/docs | verify | -| FR8 | The row MUST add no key handler or tab stop of its own: enabled rows are `tabindex="-1"` for the parent's roving focus, and keyboard selection arrives as the parent's synthesized click. | `architecture:interaction-modality`; parent keyboard tests | verify | +| ID | Invariant | Basis | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| FR1 | The row renders one element with `role="menuitemradio"` whose `aria-checked` is true exactly when the group value equals the row value. It nests no native or ARIA radio. | `spec:AST-020`; current source and tests | +| FR2 | Rendering the row outside a DropdownMenuRadioGroup throws. | Current source and focused tests | +| FR3 | Enabled activation, by pointer or by the parent's synthesized Enter/Space click, calls the group `onChange` with the row value, then asks DropdownMenu to close when the group's close policy is on, then calls the caller `onClick`. | `architecture:public-component-api` INV7; current behavior and tests | +| FR4 | A disabled row exposes `aria-disabled="true"`, renders no `tabindex`, takes no hover focus, and on any activation, including an assistive-technology click, calls neither `onChange` nor the caller `onClick` and does not close the menu. It paints at half opacity with a default cursor. | Current source and tests | +| FR5 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root. Caller `onPointerMove` runs after the row's hover focus, and caller handlers do not replace the row's own behavior. | `architecture:public-component-api` INV5–INV8; current tests | +| FR6 | The marker is the shared radio Indicator, hidden from assistive technology, never the pointer target, sized `sm` in a small menu and `md` otherwise, at the row's inline start on a fine pointer and its inline end on a coarse pointer. | Current source and tests | +| FR7 | The row carries the `dropdown-menu-item` target with the menu size and the marker carries the `dropdown-menu-radio` target with size, checked, and disabled reflections. | `architecture:component-theming-surface`; current source and docs | +| FR8 | The row adds no key handler or tab stop of its own: enabled rows are `tabindex="-1"` for the parent's roving focus, and keyboard selection arrives as the parent's synthesized click. | `architecture:interaction-modality`; parent keyboard tests | ### Allowed variation @@ -165,23 +161,20 @@ intentional decision. - **AR3 — Keyboard ownership.** DropdownMenu owns focus movement and activation; the row adds no key handling. Whether disabled rows stay keyboard-discoverable is owned by `component:DropdownMenu` (its OQ1). -- **AR4 — Focus visibility (objective gap).** Keyboard focus MUST be - perceivable (WCAG 2.2 SC 2.4.7 and 1.4.11). The row currently paints focus - only as the menu overlay tint with no outline, which measures about 1.1:1 - against the menu surface and paints nothing under forced colors. The - remediation is a shared row-visual decision with `component:DropdownMenuItem` - (its OQ2); this draft records the gap and does not settle it. +- **AR4 — Focus visibility.** Keyboard focus paints only the menu overlay + tint with no outline, and nothing under forced colors. The shared row + treatment is `component:DropdownMenuItem` OQ2. ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| ---------------- | ---------------------------------------------------------------- | -------------------------------- | -------------- | ------------------ | -| Row | Menu row paint and radius; focus paint is the AR4 objective gap. | Current source and menu tokens | structural | FR7, AR4 | -| Radio marker | Shared radio Indicator at the menu's control size. | Shared radio Indicator | prominent | FR1, FR6 | -| Disabled | Half-opacity row with a default cursor. | Current source | supporting | FR4 | -| Direction/touch | Logical marker placement that mirrors and moves on touch. | Bidirectionality and current CSS | structural | FR6 | +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | --------------------------------------------------------- | -------------------------------- | -------------- | ------------------ | +| Row | Menu row paint and radius; focus paint is AR4. | Current source and menu tokens | structural | FR7, AR4 | +| Radio marker | Shared radio Indicator at the menu's control size. | Shared radio Indicator | prominent | FR1, FR6 | +| Disabled | Half-opacity row with a default cursor. | Current source | supporting | FR4 | +| Direction/touch | Logical marker placement that mirrors and moves on touch. | Bidirectionality and current CSS | structural | FR6 | -No new component-local visual decision is settled by this draft. +No component-local visual decision is recorded. ## Family and system relationships @@ -195,21 +188,28 @@ No new component-local visual decision is settled by this draft. reflection. - `architecture:component-test-sufficiency` owns proportionate observable evidence. - `spec:AST-020` supplies WCAG/APG adoption and evidence-layer boundaries. -- `spec:AST-029` keeps this observational draft separate from the audit receipt. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| FR1, FR2, AR1 | `DropdownMenuSelectable.test.tsx`; reusable radio-group and Menu contracts; scoped axe | checked, unchecked, outside a group | A nested radio, wrong checked row, extra announced control, or missing throw fails | `audit:DropdownMenuRadioItem/§1,§4,§6` | -| FR3, FR5, FR8 | `DropdownMenuSelectable.test.tsx` handler, keyboard, close, and ref cases; browser fixture counters | pointer and keyboard activation, close on/off, caller handlers | A dropped caller handler, wrong order, or missing close fails | `audit:DropdownMenuRadioItem/§3,§4,§6` | -| FR4, AR2 | Disabled focused test with an assistive-technology click; browser disabled-hover frame | disabled checked/unchecked | Removing the disabled guard makes the focused test fail | `audit:DropdownMenuRadioItem/§1,§4` | -| AR4 | Exact-head keyboard-focus and forced-colors frames with rendered contrast | Neutral/Butter light/dark focus, forced colors | Current frames record the gap; a remediation passes only with a perceivable indicator in every theme and under forced colors | `audit:DropdownMenuRadioItem/§1,§5` | -| FR6, FR7 | Marker and target tests; exact-head Neutral/Butter/RTL/narrow/size/coarse/focus frames | sm/md/lg, fine/coarse, LTR/RTL, light/dark | A lost target or reflection, a marker that is announced, wrongly sized, or misplaced fails | `audit:DropdownMenuRadioItem/§2,§5,§9,§10` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| FR1, AR1 | `DropdownMenuSelectable.test.tsx` "renders the shared radio indicator in the menu marker" and "composes caller root inputs without replacing the row semantics"; `Menu.a11y.test.tsx` menu binding cases | Checked and unchecked | A row that is not `menuitemradio` or a wrong `aria-checked` fails. Evidence gap: no test asserts the absence of a nested radio. | `audit:DropdownMenuRadioItem/semantics` | +| FR2 | `DropdownMenuSelectable.test.tsx` "throws when a radio item is used outside a group" | Outside a group | A row that renders outside a group fails. | `audit:DropdownMenuRadioItem/behavior` | +| FR3, FR8 | `DropdownMenuSelectable.test.tsx` "calls onChange with the selected value", "composes the caller click handler with the selection", "composes the caller click handler on keyboard activation", "keeps the menu open while controlled selection updates when requested"; `DropdownMenu.test.tsx` "activates a focused menuitemradio with Enter and a menuitemcheckbox with Space" | Pointer and keyboard activation; close off | A missing `onChange`, a dropped caller `onClick`, or a close despite the opt-out fails. Evidence gap: no test asserts the `onChange`, close, `onClick` order or the `tabindex="-1"` on enabled rows. | `audit:DropdownMenuRadioItem/behavior` | +| FR4, AR2 | `DropdownMenuSelectable.test.tsx` "does not select, close, or call the caller click handler when disabled"; `DropdownMenu.test.tsx` "does not move focus on hover for a disabled item" | Disabled | A selection, close, or caller `onClick` while disabled, or hover focus on a disabled row, fails. Evidence gap: no test asserts the absent `tabindex` or the half-opacity paint. | `audit:DropdownMenuRadioItem/behavior` | +| FR5 | `DropdownMenuSelectable.test.tsx` "composes caller root inputs without replacing the row semantics", "composes the caller pointer-move handler with menu hover focus", and "a ref reaches the menuitemradio element" | Ref, host attributes, class, style, pointer move | A dropped ref, attribute, class, style, or handler fails. Evidence gap: no test asserts `xstyle` on the row. | `audit:DropdownMenuRadioItem/api` | +| FR6, FR7 | `DropdownMenuSelectable.test.tsx` "renders the shared radio indicator in the menu marker"; `DropdownMenuRadioItemAudit.a11y.chromium.spec.ts` | Marker in the row | A missing shared radio marker fails. Evidence gap: no test asserts the marker size by menu size, its fine and coarse placement, or the `dropdown-menu-radio` reflections. | `audit:DropdownMenuRadioItem/visual` | +| AR4, PR1 | Evidence gap: no test asserts the focus paint, forced-colors focus, or the absence of lifecycle resources | — | — | `audit:DropdownMenuRadioItem/visual` | + +## Current-state impact + +- **Forced-colors focus (AR4).** Keyboard focus on a radio row has no visible + indicator in forced-colors mode; the shared row treatment is + `component:DropdownMenuItem` OQ2. ## Decision log -None. This draft records current facts and introduces no product decision. +No component-local decision. ## Open questions @@ -222,5 +222,4 @@ None. This draft records current facts and introduces no product decision. ## Content boundary This file does not duplicate consumer prop tables/examples, current audit scores, -screenshot matrices, implementation steps, or group/parent contracts. The audit -pull request owns the closed run evidence required by `spec:AST-029/FR5`. +screenshot matrices, implementation steps, or group/parent contracts. It links to their owners. diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md index 08d0562a97dc6..cd975a0a0ef8f 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md @@ -3,20 +3,20 @@ schema_version: 3 template_version: 7 kind: component id: component:DropdownMenuSubMenu -authority: draft +authority: current archive_reason: null superseded_by: null -approved_by: null -approved_at: null +approved_by: cixzhang +approved_at: 2026-10-31 owners: [cixzhang] review_triggers: [public-api, behavior, accessibility, theming, layout, testing] verified_by: [ packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx, + packages/core/src/__tests__/BusyOwnerAnnouncement.test.tsx, packages/core/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/DropdownMenuDrillIn.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/DropdownMenuSubMenuAudit.a11y.chromium.spec.ts, - apps/storybook/stories/DropdownMenuSubMenu.stories.tsx, apps/storybook/rtl-audit/targets.json, scripts/check-knowledge.mjs, ] @@ -33,7 +33,7 @@ architecture: architecture:react-component-runtime, ] contributing: [] -system_specs: [spec:AST-020, spec:AST-029, spec:AST-086] +system_specs: [spec:AST-020, spec:AST-086] --- # DropdownMenuSubMenu component contract @@ -46,9 +46,9 @@ system_specs: [spec:AST-020, spec:AST-029, spec:AST-086] | Behavior | The row reveals its children as a flyout beside it or as a drilled-in view led by a Back row; with a header or footer that surface is a modal dialog named by the row. | | End-user impact | Pointer, keyboard, and touch users reach nested commands from one row; assistive technology hears the row's popup kind and expanded state, and Escape or the close arrow returns to the row. | | Builder impact | Place the row inside a DropdownMenu or ContextMenu and give it a label and rows; data-driven menus get it from a nested `items` array. | -| Compatibility/readiness | Released subcomponent; observational draft of current behavior. Authority stays `draft` until owner approval. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | | Review checks | Reject a second keyboard owner, a lost popup state, a flyout that does not flip under RTL, a disabled row that opens, dropped caller inputs, or an open callback that misses a transition. | -| Governing rules | WAI-ARIA menu pattern; `architecture:public-component-api/INV5–INV8`; `architecture:react-component-runtime/INV2`; `architecture:layer-runtime`; `spec:AST-020/FR1,FR2`; `spec:AST-086/FR8`. | +| Governing rules | `spec:AST-020` FR15; `architecture:public-component-api/INV5–INV8`; `architecture:react-component-runtime/INV2`; `architecture:layer-runtime`; `spec:AST-020/FR1,FR2`; `spec:AST-086/FR8`. | This table is a review projection; the body below is authoritative. @@ -66,7 +66,8 @@ host that a sub-menu with a header or footer renders into. - Released default preserved: yes (`isDisabled=false`, `hasSpinner=false`, `presentation='adaptive'`). -- Compatibility class: no public API change. +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. - Controlled behavior: the open state is internal; `onOpenChange` reports it. - Migration decision: none. @@ -107,20 +108,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. - -| ID | Candidate invariant | Basis | Draft review state | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| FR1 | The row MUST expose `role="menuitem"`, `aria-haspopup="menu"` (`"dialog"` with a header or footer), `aria-expanded` matching its surface, and `aria-controls` naming the surface while open. The surface MUST be labelled by the row. | WAI-ARIA menu pattern; current source and focused tests | verify | -| FR2 | A click, Enter, Space, or the inline-end arrow (inline-start under RTL) MUST open the surface; a keyboard open MUST focus its first row. Escape or the close arrow MUST close only this level and return focus to the row. A hover MUST open a flyout after an intent delay. | WAI-ARIA menu pattern; `architecture:interaction-modality`; tests | verify | -| FR3 | A flyout MUST sit beside the row on the inline-end side, flip with direction, and stay within the viewport; its block size MUST cap at the smaller of its own cap and the viewport. | `architecture:layer-runtime`; current source and browser evidence | verify | -| FR4 | A drilled-in view MUST replace the root's rows with a Back row named after the parent menu and then this row's children; Back, Escape, and the close arrow MUST return to the row. | Current source and focused tests | verify | -| FR5 | Selecting a leaf row inside the surface MUST close every level and the root menu. | Current source, focused tests, and browser evidence | verify | -| FR6 | `onOpenChange` MUST report `true` once when the surface opens and `false` once when it closes, from the action that caused the change. | Documented promise; `architecture:react-component-runtime/INV2` | objective gap: a drilled-in view reset by the root menu closing reports no `false` (owner: DropdownMenu) | -| FR7 | A disabled row MUST expose `aria-disabled="true"`, render no `tabindex`, take no hover focus, open on no input, and run no caller `onClick`. | Current source, focused tests, and browser evidence | verify | -| FR8 | With `hasSpinner` the row MUST show a decorative spinner in place of the caret and announce the busy start once the root menu shows. | `spec:AST-086/FR8`; current source | verify | -| FR9 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` MUST reach or compose on the row root after the row's own behavior; the open keys and a disabled click stop before the caller's handler. | `architecture:public-component-api/INV5–INV8`; focused tests | verify | +| ID | Invariant | Basis | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| FR1 | The row exposes `role="menuitem"`, `aria-haspopup="menu"` (`"dialog"` with a header or footer), `aria-expanded` matching its surface, and `aria-controls` naming the surface while open. The surface is labelled by the row. | `spec:AST-020`; current source and tests | +| FR2 | A click, Enter, Space, or the inline-end arrow (inline-start under RTL) opens the surface; a keyboard open focuses its first row. Escape or the close arrow closes only this level and returns focus to the row. A hover opens a flyout after an intent delay. | `spec:AST-020`; `architecture:interaction-modality`; current tests | +| FR3 | A flyout sits beside the row on the inline-end side, flips with direction, and stays within the viewport; its block size caps at the smaller of its own cap and the viewport. | `architecture:layer-runtime`; current source | +| FR4 | A drilled-in view replaces the root's rows with a Back row named after the parent menu and then this row's children; Back, Escape, and the close arrow return to the row. | Current source and focused tests | +| FR5 | Selecting a leaf row inside the surface closes every level and the root menu. | Current source and tests | +| FR6 | `onOpenChange` MUST report `true` once when the surface opens and `false` once when it closes, from the action that caused the change. Implementation gap: a drilled-in view reset by the root menu closing reports no `false` (OQ1). | Documented promise; `architecture:react-component-runtime` INV2 | +| FR7 | A disabled row exposes `aria-disabled="true"`, renders no `tabindex`, takes no hover focus, opens on no input, and runs no caller `onClick`. | Current source and tests | +| FR8 | With `hasSpinner` the row shows a decorative spinner in place of the caret and announces the busy start once the root menu shows. | `spec:AST-086` FR8; current source and tests | +| FR9 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root after the row's own behavior; the open keys and a disabled click stop before the caller's handler. | `architecture:public-component-api` INV5–INV8; current tests | ### Allowed variation @@ -164,12 +162,9 @@ intentional decision. Escape; a deeper level's keys never act on its parent. - **AR3 — Disabled discoverability.** Whether disabled rows stay keyboard-discoverable is owned by `component:DropdownMenu` (its OQ1). -- **AR4 — Focus visibility (objective gap).** Keyboard focus MUST be - perceivable (WCAG 2.2 SC 2.4.7 and 1.4.11). The row currently paints focus - only as the menu overlay tint with no outline, and paints nothing under - forced colors. The remediation is a shared row-visual decision with - `component:DropdownMenuItem` (its OQ2); this draft records the gap and does - not settle it. +- **AR4 — Focus visibility.** Keyboard focus paints only the menu overlay + tint with no outline, and nothing under forced colors. The shared row + treatment is `component:DropdownMenuItem` OQ2. ## Design relationships @@ -181,7 +176,7 @@ intentional decision. | Drilled view | Rows inside the root's box, led by a mirrored Back row. | Current source | structural | FR4 | | Disabled | Half-opacity row with a default cursor. | Current source | supporting | FR7 | -No new component-local visual decision is settled by this draft. +No component-local visual decision is recorded. ## Family and system relationships @@ -193,22 +188,32 @@ No new component-local visual decision is settled by this draft. - `architecture:public-component-api` owns root input and handler composition. - `architecture:react-component-runtime` owns callback timing. - `spec:AST-086` owns the loading announcement. -- `spec:AST-029` keeps this observational draft separate from the audit receipt. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------- | ----------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------- | -| FR1, FR2, AR2 | `DropdownMenuSubMenu.test.tsx` keyboard, hover, typeahead, and nesting suites | open, close, nested, loading | A lost popup state, wrong focus target, or double-stepped level fails | `audit:DropdownMenuSubMenu/§1,§4,§6` | -| FR3 | RTL D4 flyout target; exact-head LTR/RTL/narrow open frames | LTR, RTL, 320px | A flyout on the wrong side or outside the viewport fails | `audit:DropdownMenuSubMenu/§5,§9,§10` | -| FR4, FR5 | Drill-in and selection tests; exact-head drill-in and select frames | drilled, nested selection | A missing Back row or a stack that stays open fails | `audit:DropdownMenuSubMenu/§4` | -| FR6 | onOpenChange test (mutation-sensitive); browser select-frame counters | flyout open and close | Dropping the close report fails; the drill-in reset gap is recorded, not asserted | `audit:DropdownMenuSubMenu/§3,§4` | -| FR7, FR9 | Host-attribute, handler, and disabled tests; disabled-hover frames | disabled, caller handlers, test ids | A disabled open or dropped caller input fails | `audit:DropdownMenuSubMenu/§1,§3` | -| AR4 | Exact-head keyboard-focus and forced-colors frames with rendered contrast | Neutral/Butter focus, forced colors | Current frames record the gap; a remediation passes only with a perceivable indicator | `audit:DropdownMenuSubMenu/§1,§5` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| FR1, AR1 | `DropdownMenuSubMenu.test.tsx` "renders the trigger with aria-haspopup and collapsed aria-expanded", "names the flyout from its trigger via aria-labelledby", "the row keeps its own popup state and tab stop"; header-and-footer case "holds the header, the rows and the footer in a dialog named by the row, outside every menu" | Closed and open; with regions | A missing `aria-haspopup`, `aria-expanded`, or row label on the surface fails. Evidence gap: no test asserts `aria-controls` while open. | `audit:DropdownMenuSubMenu/semantics` | +| FR2, AR2 | `DropdownMenuSubMenu.test.tsx` "opens the flyout on trigger click and exposes its items", "opens on ArrowRight and returns focus to the trigger on ArrowLeft", "mirrors its indicator and keyboard directions under RTL", "closes only the submenu on Escape and restores focus to the trigger", "supports first-character type-ahead within the flyout", "navigates a two-level nested submenu without double-stepping", "moves focus to the submenu trigger on hover, keeping a single highlight"; hover/click-guard cases | Click, arrows, RTL, Escape, hover, nesting | A key that does not open or close, a wrong focus target, a double-stepped level, or a reversed RTL arrow fails. Evidence gap: no test asserts Enter or Space opening the row. | `audit:DropdownMenuSubMenu/accessibility` | +| FR3 | `DropdownMenuSubMenu.test.tsx` "caps the flyout width and height to the available viewport"; `apps/storybook/rtl-audit/targets.json` DropdownMenuSubMenu entry | Viewport cap; RTL | A flyout that escapes the viewport fails. Evidence gap: no test asserts the inline-end placement or its flip. | `audit:DropdownMenuSubMenu/layout` | +| FR4 | `DropdownMenuSubMenu.test.tsx` drill-in cases; `DropdownMenuDrillIn.a11y.chromium.spec.ts` | Drilled view; Back | A missing Back row or a Back that does not return fails. | `audit:DropdownMenuSubMenu/behavior` | +| FR5 | `DropdownMenuSubMenu.test.tsx` "closes the whole menu after selecting a nested item" and "activates a nested item with the Enter key and closes the menu" | Pointer and Enter | A level left open after a leaf pick fails. | `audit:DropdownMenuSubMenu/behavior` | +| FR6 | `DropdownMenuSubMenu.test.tsx` "reports each flyout open and close to onOpenChange once" | Flyout open and close | A missing or repeated report fails. Evidence gap: the drill-in reset gap is not asserted. | `audit:DropdownMenuSubMenu/behavior` | +| FR7, FR9 | `DropdownMenuSubMenu.test.tsx` "does not open a disabled submenu", "a disabled row does not pass a click on to the caller's onClick", host-attribute and ref cases ("aria-_, data-_ and id reach the trigger row, and the flyout stays labelled by it", "a ref reaches the trigger row", "a caller's handlers run after the row's own, and the row still opens and takes the hover focus") | Disabled; caller handlers and attributes | A disabled open or caller click, or a dropped attribute, ref, or handler, fails. Evidence gap: no test asserts `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuSubMenu/api` | +| FR8 | `BusyOwnerAnnouncement.test.tsx` "DropdownMenuSubMenu loading state" cases; `DropdownMenuSubMenu.test.tsx` loading-flyout cases | Loading closed and open | A busy start announced while closed, twice, or not at all, or an announced Spinner, fails. | `audit:DropdownMenuSubMenu/accessibility` | +| AR4, PR1 | Evidence gap: no test asserts the focus paint, forced-colors focus, or the timer bounds | — | — | `audit:DropdownMenuSubMenu/visual` | + +## Current-state impact + +- **Drill-in close report (FR6).** When the root menu closes a drilled-in view, + `onOpenChange(false)` does not run (OQ1). +- **Forced-colors focus (AR4).** Keyboard focus on the row has no visible + indicator in forced-colors mode; the shared row treatment is + `component:DropdownMenuItem` OQ2. ## Decision log -None. This draft records current facts and introduces no product decision. +No component-local decision. ## Open questions @@ -221,5 +226,4 @@ None. This draft records current facts and introduces no product decision. ## Content boundary This file does not duplicate consumer prop tables/examples, current audit scores, -screenshot matrices, implementation steps, or root/leaf contracts. The audit -pull request owns the closed run evidence required by `spec:AST-029/FR5`. +screenshot matrices, implementation steps, or root/leaf contracts. It links to their owners. From f906e8c4e37570c1f1967a4ec5218b7e93a51b9c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 22:18:49 -0700 Subject: [PATCH 3/9] docs(spec): DropdownMenuCheckboxItem and DropdownMenuGroup record their shipped contracts --- .../DropdownMenuCheckboxItem.spec.md | 175 ++++++++++++++++++ .../DropdownMenu/DropdownMenuGroup.spec.md | 145 +++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md create mode 100644 packages/core/src/DropdownMenu/DropdownMenuGroup.spec.md diff --git a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md new file mode 100644 index 0000000000000..05db9b0f93986 --- /dev/null +++ b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md @@ -0,0 +1,175 @@ +--- +schema_version: 3 +template_version: 7 +kind: component +id: component:DropdownMenuCheckboxItem +authority: current +archive_reason: null +superseded_by: null +approved_by: cixzhang +approved_at: 2026-10-31 +owners: [cixzhang] +review_triggers: [public-api, behavior, accessibility, theming] +verified_by: + [ + packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx, + packages/core/src/DropdownMenu/DropdownMenu.test.tsx, + ] +modules: [] +families: [] +design_specs: [] +architecture: + [ + architecture:public-component-api, + architecture:component-theming-surface, + architecture:interaction-modality, + ] +contributing: [] +system_specs: [spec:AST-020] +--- + +# DropdownMenuCheckboxItem component contract + +## Contract at a glance + +| Area | Contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | One pointer-menu row that toggles an independent boolean: `label`, `description`, `icon`, `endContent`, controlled `value` and `onChange`, `isDisabled`, `hasCloseOnSelect` (default `false`), root inputs, and a ref. | +| Behavior | The row is one `menuitemcheckbox` whose `aria-checked` is its `value`; an enabled activation reports `!value` and keeps the menu open unless asked to close; a disabled row is inert. | +| End-user impact | People toggle options without the menu closing on each change, and screen-reader users hear one checkbox per row. | +| Builder impact | Builders own the value; the row composes their click and pointer-move handlers with its own activation and hover focus. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | +| Review checks | Reject a nested announced checkbox, a row that closes by default, a dropped caller handler, or an activation while disabled. | +| Governing rules | `spec:AST-020` FR15; `architecture:public-component-api` INV5–INV8; `architecture:interaction-modality`. | + +This table is a review projection; the body below is authoritative. + +## Intent + +DropdownMenuCheckboxItem renders one compound-mode row that toggles an +independent boolean inside the pointer presentation of DropdownMenu. +DropdownMenu owns focus movement, typeahead, keyboard click synthesis, and the +close lifecycle. + +## Compatibility and migration + +- Released default preserved: yes (`hasCloseOnSelect=false`). +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. +- Controlled/uncontrolled behavior: controlled; `value` is caller-owned. +- Migration decision: none. + +Consumer migration instructions belong in consumer docs and release notes. + +## Ownership boundary + +**Owns** + +- One `role="menuitemcheckbox"` root, its `aria-checked`, label, description, + icon, end content, disabled state, ref, supported host inputs, and styling + inputs. +- Activation: reporting `!value`, the close policy, and composing the caller's + click and pointer-move handlers. +- The decorative checkbox marker and the row's `dropdown-menu-item` target. + +**Does not own / non-goals** + +- Focus entry, roving focus, typeahead, and Enter/Space click synthesis — owned + by `component:DropdownMenu`. +- Checkbox chrome — delegated to `component:Indicator`. +- Whether disabled rows stay keyboard-discoverable — `component:DropdownMenu` + OQ1. + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | +| ------------- | ----------------------- | ------------------------------------------ | ----------------- | ----------- | --------- | --------- | ----------------------------- | +| Checked state | `true`, `false` | The boolean the row shows and toggles. | Every row | required | caller | stable | Required by the public type. | +| Close policy | close; remain open | Whether an enabled toggle closes the menu. | Every enabled row | remain open | component | stable | Omission keeps the menu open. | +| Availability | enabled; disabled | Whether the row can activate. | Every row | enabled | component | stable | Disabled blocks activation. | + +## Behavioral and layout contract + +| ID | Invariant | Basis | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| FR1 | The row renders one element with `role="menuitemcheckbox"` whose `aria-checked` equals `value`; the checkbox marker is decorative (`aria-hidden`), so the row is the only announced control. | `spec:AST-020` FR15; current source and tests | +| FR2 | An enabled activation, by pointer or by the parent's synthesized Enter/Space click, calls `onChange(!value)` and the caller's `onClick`; the menu stays open unless `hasCloseOnSelect` is set, in which case it closes after the toggle. | `architecture:public-component-api` INV7; current tests | +| FR3 | A disabled row exposes `aria-disabled="true"`, renders no `tabindex`, and on activation calls neither `onChange` nor the caller's activation handlers. | Current source and tests | +| FR4 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root; caller `onPointerMove` composes with the row's hover focus. | `architecture:public-component-api` INV5–INV8; current tests | +| FR5 | The marker is the shared checkbox Indicator, `sm` in a small menu; the row carries the `dropdown-menu-item` target with the menu size. | `architecture:component-theming-surface`; current source and tests | +| FR6 | The row adds no key handler or tab stop of its own: enabled rows are `tabindex="-1"` for the parent's roving focus, and the parent's arrow keys skip a disabled row (`component:DropdownMenu` FR17). | `architecture:interaction-modality`; parent tests | + +### Allowed variation + +- **AV1 — Content.** Label, description, icon, and end content are caller + content. +- **AV2 — Theme paint.** Themes may restyle the row and marker targets. + +### Representative states + +| State | Required invariant | Allowed variation | +| --------------- | -------------------------------------- | ---------------------- | +| Unchecked | `aria-checked="false"`; marker empty. | Content. | +| Checked | `aria-checked="true"`; marker checked. | Content. | +| Close on select | An enabled toggle closes the menu. | — | +| Disabled | `aria-disabled="true"`; no activation. | Content stays visible. | + +### Transformation and precedence order + +- **ORD1 — Activation.** Disabled guard → `onChange(!value)` and caller + `onClick` → close when `hasCloseOnSelect`. + +### Performance and resources + +- **PR1 — Shared press listeners.** The row shares the press controller's + window and document listeners; it adds no per-row listener, observer, timer, + or request. + +## Accessibility contract + +- **AR1 — One checkbox control.** The row owns `menuitemcheckbox`, its + accessible name, checked state, focus, and activation; the marker is + decorative. +- **AR2 — Disabled state.** A disabled row is inert. + +## Design relationships + +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | -------------------------- | ------------------------ | -------------- | ------------------ | +| Row | One full-width toggle row. | Current source | Prominent | FR1, FR5 | +| Marker | Shows the checked state. | `component:Indicator` | Supporting | FR1, FR5 | + +The shared `DropdownMenu.doc.mjs` anatomy and `component:DropdownMenu` theming +anatomy map the row and checkbox indicator, so this record carries no +anatomy-theming map. + +## Family and system relationships + +- `component:DropdownMenu` owns the keyboard model and close lifecycle. +- `component:Indicator` owns the checkbox chrome. +- `architecture:public-component-api` owns root composition and handler order. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| FR1, AR1 | `DropdownMenuSelectable.test.tsx` "keeps the checkbox indicator decorative (row is the only announced control)"; "a ref reaches the menuitemcheckbox element" | Checked and unchecked | An announced marker or a row that is not `menuitemcheckbox` fails. | `audit:DropdownMenuCheckboxItem/semantics` | +| FR2, ORD1 | `DropdownMenuSelectable.test.tsx` "calls onChange with the toggled value on click", "composes the caller click handler with the checked-state change", "composes the caller click handler on keyboard activation", "closes after a successful toggle only when requested"; `DropdownMenu.test.tsx` "activates a focused menuitemradio with Enter and a menuitemcheckbox with Space" | Pointer and keyboard; close on and off | A wrong value, a dropped caller `onClick`, or a close without the opt-in fails. | `audit:DropdownMenuCheckboxItem/behavior` | +| FR3, AR2 | `DropdownMenuSelectable.test.tsx` "does not call consumer activation handlers when disabled" and "does not toggle when disabled" | Disabled | A toggle or caller handler while disabled fails. Evidence gap: no test asserts the absent `tabindex`. | `audit:DropdownMenuCheckboxItem/behavior` | +| FR4 | `DropdownMenuSelectable.test.tsx` "composes the caller pointer-move handler with menu hover focus" and "a ref reaches the menuitemcheckbox element" | Ref; pointer move | A dropped ref or handler fails. Evidence gap: no test asserts host attributes, `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuCheckboxItem/api` | +| FR5 | `DropdownMenuSelectable.test.tsx` "maps a small menu to the small shared checkbox marker" | Small menu | A marker that ignores the menu size fails. Evidence gap: no test asserts the `dropdown-menu-item` target on this row. | `audit:DropdownMenuCheckboxItem/theming` | +| FR6 | `DropdownMenu.test.tsx` "arrow navigation reaches consumer-rendered menuitemradio and menuitemcheckbox items" and "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" | Enabled and disabled rows | A row the arrows miss, or a disabled row they land on, fails. | `audit:DropdownMenuCheckboxItem/accessibility` | +| PR1 | Evidence gap: no test asserts the listener bound | — | — | `audit:DropdownMenuCheckboxItem/behavior` | + +## Decision log + +No component-local decision. + +## Open questions + +None. + +## Content boundary + +This file does not duplicate consumer prop tables/examples, current audit +results, implementation steps, or parent contracts. It links to their owners. diff --git a/packages/core/src/DropdownMenu/DropdownMenuGroup.spec.md b/packages/core/src/DropdownMenu/DropdownMenuGroup.spec.md new file mode 100644 index 0000000000000..45e15bc1ea789 --- /dev/null +++ b/packages/core/src/DropdownMenu/DropdownMenuGroup.spec.md @@ -0,0 +1,145 @@ +--- +schema_version: 3 +template_version: 7 +kind: component +id: component:DropdownMenuGroup +authority: current +archive_reason: null +superseded_by: null +approved_by: cixzhang +approved_at: 2026-10-31 +owners: [cixzhang] +review_triggers: [public-api, behavior, accessibility, theming] +verified_by: [packages/core/src/DropdownMenu/DropdownMenu.test.tsx] +modules: [] +families: [] +design_specs: [] +architecture: + [architecture:public-component-api, architecture:component-theming-surface] +contributing: [] +system_specs: [spec:AST-020] +--- + +# DropdownMenuGroup component contract + +## Contract at a glance + +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Public contract | A compound-mode group of rows with an optional `title`, `children`, root inputs, and a ref. | +| Behavior | Renders one `role="group"` named by its visible heading; the heading is plain text that roving focus and typeahead skip. An untitled group is an unnamed group unless the caller names it. | +| End-user impact | Screen-reader users hear a titled cluster of rows as one named group, as sighted users see it; arrow keys move across group boundaries as if the rows were flat. | +| Builder impact | Builders title compound rows with the same heading look as a data-mode section. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | +| Review checks | Reject a heading that becomes a menu item or a focus stop, a titled group not named by its heading, or a lost section-heading target. | +| Governing rules | `component:DropdownMenu/DEC-6`; `architecture:public-component-api`; `architecture:component-theming-surface`. | + +This table is a review projection; the body below is authoritative. + +## Intent + +DropdownMenuGroup gives compound-mode menus the titled grouping that data-mode +`{type: 'section'}` entries already have (`component:DropdownMenu/DEC-6`). + +## Compatibility and migration + +- Released default preserved: yes. +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change. +- Controlled/uncontrolled behavior: not applicable. +- Migration decision: none. + +Consumer migration instructions belong in consumer docs and release notes. + +## Ownership boundary + +**Owns** + +- One `role="group"` root, its name from the heading, and the heading itself + with the `dropdown-menu-section-heading` target. +- Root composition of ref, host attributes, and handlers. + +**Does not own / non-goals** + +- Roving focus and typeahead — owned by `component:DropdownMenu`. +- The rows inside — owned by each row component. +- Focusable content inside the heading: arrow keys cannot reach it. + +## Public concepts + +| Concept | Closed values or states | Meaning | Availability | Default | Owner | Stability | Invalid-value behavior | +| ------- | ----------------------- | --------------------------------------- | ------------- | ------- | --------- | --------- | ------------------------------------------------- | +| Title | absent; any React node | A visible heading that names the group. | Compound mode | absent | component | stable | Absent renders no heading and no name of its own. | + +## Behavioral and layout contract + +| ID | Invariant | Basis | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| FR1 | With a `title`, the group is one `role="group"` whose `aria-labelledby` names its visible heading; the heading carries `dropdown-menu-section-heading`, is plain text rather than a menu item, and is skipped by roving focus and typeahead. | DEC-6 of `component:DropdownMenu`; current tests | +| FR2 | Without a `title`, the group is an unnamed `role="group"`, or takes the name a caller supplies. | Current tests | +| FR3 | Arrow keys step across a group boundary as if the rows were flat; a focusable node inside the heading is not reachable by them. | Current tests | +| FR4 | A ref and host attributes and handlers reach the group element while it keeps its role and heading name. | `architecture:public-component-api`; current tests | + +### Allowed variation + +- **AV1 — Heading content.** The title may be any React node. + +### Representative states + +| State | Required invariant | Allowed variation | +| -------- | -------------------------------- | ----------------- | +| Titled | Named by its heading. | Title content. | +| Untitled | Unnamed, or named by the caller. | — | + +### Transformation and precedence order + +- **ORD1 — Name.** Title heading → caller name → none. + +### Performance and resources + +- **PR1 — Render only.** The group owns no state, effect, listener, or + observer. + +## Accessibility contract + +- **AR1 — Named group.** A titled group is announced with its heading's text; + the heading is not a menu item. + +## Design relationships + +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | ---------------------------------------------------------- | ------------------------ | -------------- | ------------------ | +| Heading | Labels a cluster of rows like a data-mode section heading. | `component:DropdownMenu` | Supporting | FR1 | + +`component:DropdownMenu` maps the `dropdown-menu-section-heading` target in its +theming anatomy, so this record carries no anatomy-theming map. + +## Family and system relationships + +- `component:DropdownMenu` owns the keyboard model and the section-heading + target mapping. +- `architecture:public-component-api` owns root composition. + +## Verification map + +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| FR1, AR1, ORD1 | `DropdownMenu.test.tsx` DropdownMenuGroup cases ("renders a role=\"group\" named by its heading", "renders the heading with the data-mode theme class", "the heading is not a menuitem and roving focus skips it", "typeahead never lands on the heading") | Titled group | A group not named by its heading, a missing heading target, or a heading that takes focus or a typeahead match fails. | `audit:DropdownMenuGroup/semantics` | +| FR2 | `DropdownMenu.test.tsx` "an untitled group is an unnamed role=\"group\"" and "an untitled group takes a caller-supplied accessible name" | Untitled, with and without a caller name | A name invented for an untitled group, or a dropped caller name, fails. | `audit:DropdownMenuGroup/semantics` | +| FR3 | `DropdownMenu.test.tsx` "arrow navigation steps across a group boundary as if the rows were flat" and "a focusable node in the heading is not reachable by the arrow keys" | Two groups; focusable heading content | A stop at the boundary or a focusable heading node reached by arrows fails. | `audit:DropdownMenuGroup/accessibility` | +| FR4 | `DropdownMenu.test.tsx` "forwards a ref to the group element" and "forwards host attributes to the group while keeping its role and heading name" | Ref; host attributes | A dropped ref or attribute, or a replaced role or name, fails. | `audit:DropdownMenuGroup/api` | +| PR1 | Evidence gap: no test asserts the absence of lifecycle resources | — | — | `audit:DropdownMenuGroup/behavior` | + +## Decision log + +No component-local decision; `component:DropdownMenu/DEC-6` decides the +component. + +## Open questions + +None. + +## Content boundary + +This file does not duplicate consumer prop tables/examples, current audit +results, implementation steps, or parent contracts. It links to their owners. From d92c6e673a3328ede1baf3791d1fc6626879df6e Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 23:55:52 -0700 Subject: [PATCH 4/9] docs(spec): DropdownMenu records the finger press-open gap, the modified-click close, and the checkbox Enter gap --- .../src/DropdownMenu/DropdownMenu.spec.md | 31 ++++++----- .../DropdownMenuCheckboxItem.spec.md | 51 +++++++++++-------- .../src/DropdownMenu/DropdownMenuItem.spec.md | 20 ++++---- 3 files changed, 57 insertions(+), 45 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index a44f740ec9b09..823df44f19513 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -38,15 +38,15 @@ system_specs: [spec:AST-020/FR15, spec:AST-094, spec:AST-102] ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | -| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | -| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu does not grow from its trigger and a pan outside does not close it. | -| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | -| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Touch grow and pan dismissal (FR21) and the delegated Trigger Button gaps are listed under Current-state impact. | -| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | -| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | +| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | +| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu opens only after a held press, does not grow from its trigger, and a pan outside does not close it. | +| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Finger press-open (FR8), touch grow and pan dismissal (FR21), and the delegated Trigger Button gaps are listed under Current-state impact. | +| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | +| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | This table is a review projection; the body below is authoritative. @@ -126,9 +126,9 @@ component-local concepts; each keeps its released default. | FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7, FR10, and FR11: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. The grow, collapse, and pan dismissal under a finger are FR21. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR28 | | FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard; a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; DEC-1 | | FR7 | `menuMaxHeight` replaces the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | DEC-2 | -| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with the module's settle rule on the opening release, and under a finger as `spec:AST-094` FR29 states; pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. | `module:DropdownMenu/useMenuPress` FR8, FR9 | +| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with a settle rule on the opening release, and pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. The mouse press-open is shipped behavior, not a requirement until `module:DropdownMenu/useMenuPress` DEC-4 is ruled. Under a finger the trigger MUST open the menu as the finger presses (`spec:AST-094` FR29). Implementation gap: a held finger opens it after a 500 ms hold. | `module:DropdownMenu/useMenuPress` FR8, FR9; `spec:AST-094` FR29 | | FR9 | With `renderTrigger`, the rendered control carries `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and receives the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. A disabled custom trigger never opens the menu or the sheet. `button` and `renderTrigger` are mutually exclusive: both given, `renderTrigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | DEC-3 | -| FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click is left to the browser and does not run `onClick`; a middle click closes the menu and runs the caller's `onAuxClick`; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | +| FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click does not run `onClick`, closes the menu, and is left to the browser; a middle click closes the menu and runs the caller's `onAuxClick`; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | | FR11 | When a compact touch display opened the pointer menu (the query the root presentation uses for its bottom sheet, sampled once as it opens), an `adaptive` sub-menu row drills in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft return to the row with focus on it; a pick inside closes the whole menu, and closing the menu resets the drilled view. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | DEC-4; `spec:AST-094` FR34 | | FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Current source and tests | | FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | DEC-7 | @@ -166,8 +166,9 @@ component-local concepts; each keeps its released default. ### Transformation and precedence order -- **ORD1 — Link row activation.** modified click → the browser alone; else - `onClick` → close (`hasCloseOnSelect`) → the browser navigates. +- **ORD1 — Link row activation.** modified click → no `onClick`, close + (`hasCloseOnSelect`), the browser opens it; else `onClick` → close + (`hasCloseOnSelect`) → the browser navigates. - **ORD2 — Trigger source.** `renderTrigger` → `button`. ### Performance and resources @@ -342,7 +343,7 @@ than adding a DropdownMenu-owned heading target. | FR5 | `DropdownMenu.test.tsx` press-model cases ("a finger that lands on one row and lifts on another acts on the second, once", "a mouse released outside closes the menu; a finger released outside leaves it open", "marks the menu as carrying the press model and owns touch scrolling"); `MenuPress.a11y.chromium.spec.ts`; the module record's own map | Finger and mouse release; scrolling menu | A row that acts on press-down, a mouse release outside that keeps the menu, or a missing press marker fails. | `audit:DropdownMenu/behavior` | | FR6 | `DropdownMenu.test.tsx` focus-return cases ("restores focus to the trigger after keyboard dismissal", "returns focus to the trigger after pointer dismissal without a focus ring", "leaves focus on the control a press outside landed on") and the "focus return after a pointer pick" and "a trigger the caller renders" cases | Keyboard and pointer close; Button and custom trigger | A lost focus return, a ring after a pointer close, no ring after a keyboard close, or focus pulled off a control pressed outside fails. | `audit:DropdownMenu/accessibility` | | FR7 | `DropdownMenu.test.tsx` menuMaxHeight cases | A cap above 300px; viewport bound | A menu clipped to 300px under a raised cap, or one that escapes the viewport, fails. Evidence gap: no test asserts that the touch sheet ignores the cap. | `audit:DropdownMenu/layout` | -| FR8 | `DropdownMenu.test.tsx` press-model trigger cases ("a mouse press on the trigger opens the menu and a drag-release acts on the row under it", "the opening release before the settle time acts on nothing and the menu stays", "pressing the trigger of an open menu closes it and does not reopen it in the same gesture", "a held touch on the trigger opens with the finger down and a slide picks", "a tap on the trigger still opens through its click") | Mouse press-open; held finger; tap | A mouse that opens only on click, an opening release that acts early, or a re-open in the closing gesture fails. | `audit:DropdownMenu/behavior` | +| FR8 | `DropdownMenu.test.tsx` press-model trigger cases ("a mouse press on the trigger opens the menu and a drag-release acts on the row under it", "the opening release before the settle time acts on nothing and the menu stays", "pressing the trigger of an open menu closes it and does not reopen it in the same gesture", "a held touch on the trigger opens with the finger down and a slide picks", "a tap on the trigger still opens through its click") | Mouse press-open; held finger; tap | A mouse that opens only on click, an opening release that acts early, or a re-open in the closing gesture fails. The held-touch case asserts the current 500 ms hold, the FR8 gap pending conformance. | `audit:DropdownMenu/behavior` | | FR9, AR1 | `DropdownMenu.test.tsx` custom trigger cases; `DropdownMenuSubMenu.test.tsx` "a menu hung off a custom trigger" cases | Custom trigger open, keyboard, mouse, disabled; both props given; Back row names | A custom trigger that does not open, toggle, or name the menu, a disabled one that opens, a missing warning, or a wrong Back row name fails. | `audit:DropdownMenu/accessibility` | | FR10, AR2, ORD1 | `DropdownMenu.test.tsx` "DropdownMenuItem href" and "link rows — the browser's own clicks" cases | Plain, modified, middle, and right click; Enter; bottom sheet; disabled; blocked | A row that is not an anchor, a modified click that runs `onClick`, a middle click that does not close, a disabled row with an address, or a live blocked destination fails. Evidence gap: no test asserts the order of `onClick`, close, and navigation. | `audit:DropdownMenu/behavior` | | FR11, AR3 | `DropdownMenuSubMenu.test.tsx` "drill-in on a phone" cases; `DropdownMenuDrillIn.a11y.chromium.spec.ts` | Phone and laptop; forced presentations; theme breakpoint; reset on close | A flyout on a phone, a drill-in on a laptop by default, a wrong name, a Back that loses focus, or a drilled view that survives a close fails. | `audit:DropdownMenu/accessibility` | @@ -358,6 +359,8 @@ than adding a DropdownMenu-owned heading target. ## Current-state impact +- **Finger press-open (FR8).** A held finger opens the menu after a 500 ms + hold instead of as it presses, against `spec:AST-094` FR29. - **Touch grow and pan dismissal (FR21).** Under a finger the menu appears and leaves with the layer's own entrance and exit instead of growing out of and back into the Trigger button, and a pan that starts outside does not close diff --git a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md index 05db9b0f93986..5e2880ed59f43 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md @@ -38,7 +38,7 @@ system_specs: [spec:AST-020] | Behavior | The row is one `menuitemcheckbox` whose `aria-checked` is its `value`; an enabled activation reports `!value` and keeps the menu open unless asked to close; a disabled row is inert. | | End-user impact | People toggle options without the menu closing on each change, and screen-reader users hear one checkbox per row. | | Builder impact | Builders own the value; the row composes their click and pointer-move handlers with its own activation and hover focus. | -| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Enter keeping the menu open is listed under Current-state impact (OQ1). | | Review checks | Reject a nested announced checkbox, a row that closes by default, a dropped caller handler, or an activation while disabled. | | Governing rules | `spec:AST-020` FR15; `architecture:public-component-api` INV5–INV8; `architecture:interaction-modality`. | @@ -90,14 +90,14 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| FR1 | The row renders one element with `role="menuitemcheckbox"` whose `aria-checked` equals `value`; the checkbox marker is decorative (`aria-hidden`), so the row is the only announced control. | `spec:AST-020` FR15; current source and tests | -| FR2 | An enabled activation, by pointer or by the parent's synthesized Enter/Space click, calls `onChange(!value)` and the caller's `onClick`; the menu stays open unless `hasCloseOnSelect` is set, in which case it closes after the toggle. | `architecture:public-component-api` INV7; current tests | -| FR3 | A disabled row exposes `aria-disabled="true"`, renders no `tabindex`, and on activation calls neither `onChange` nor the caller's activation handlers. | Current source and tests | -| FR4 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root; caller `onPointerMove` composes with the row's hover focus. | `architecture:public-component-api` INV5–INV8; current tests | -| FR5 | The marker is the shared checkbox Indicator, `sm` in a small menu; the row carries the `dropdown-menu-item` target with the menu size. | `architecture:component-theming-surface`; current source and tests | -| FR6 | The row adds no key handler or tab stop of its own: enabled rows are `tabindex="-1"` for the parent's roving focus, and the parent's arrow keys skip a disabled row (`component:DropdownMenu` FR17). | `architecture:interaction-modality`; parent tests | +| ID | Invariant | Basis | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| FR1 | The row renders one element with `role="menuitemcheckbox"` whose `aria-checked` equals `value`; the checkbox marker is decorative (`aria-hidden`), so the row is the only announced control. | `spec:AST-020` FR15; current source and tests | +| FR2 | An enabled activation, by pointer or by the parent's synthesized Enter/Space click, calls `onChange(!value)`, then closes the menu when `hasCloseOnSelect` is set, then calls the caller's `onClick`; without `hasCloseOnSelect` the menu stays open. The APG Menu pattern's Enter closes the menu, and `spec:AST-020` FR15 requires this record to name any required part it does not adopt. Implementation gap: Enter keeps the menu open by default, and no exception is named (OQ1). | `spec:AST-020` FR15; `architecture:public-component-api` INV7; current tests | +| FR3 | A disabled row exposes `aria-disabled="true"`, renders no `tabindex`, and on activation calls neither `onChange` nor the caller's activation handlers. | Current source and tests | +| FR4 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root; caller `onPointerMove` composes with the row's hover focus. | `architecture:public-component-api` INV5–INV8; current tests | +| FR5 | The marker is the shared checkbox Indicator, `sm` in a small menu; the row carries the `dropdown-menu-item` target with the menu size. | `architecture:component-theming-surface`; current source and tests | +| FR6 | The row adds no key handler or tab stop of its own: enabled rows are `tabindex="-1"` for the parent's roving focus, and the parent's arrow keys skip a disabled row (`component:DropdownMenu` FR17). | `architecture:interaction-modality`; parent tests | ### Allowed variation @@ -116,8 +116,8 @@ Consumer migration instructions belong in consumer docs and release notes. ### Transformation and precedence order -- **ORD1 — Activation.** Disabled guard → `onChange(!value)` and caller - `onClick` → close when `hasCloseOnSelect`. +- **ORD1 — Activation.** Disabled guard → `onChange(!value)` → close when + `hasCloseOnSelect` → caller `onClick`. ### Performance and resources @@ -151,15 +151,21 @@ anatomy-theming map. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| FR1, AR1 | `DropdownMenuSelectable.test.tsx` "keeps the checkbox indicator decorative (row is the only announced control)"; "a ref reaches the menuitemcheckbox element" | Checked and unchecked | An announced marker or a row that is not `menuitemcheckbox` fails. | `audit:DropdownMenuCheckboxItem/semantics` | -| FR2, ORD1 | `DropdownMenuSelectable.test.tsx` "calls onChange with the toggled value on click", "composes the caller click handler with the checked-state change", "composes the caller click handler on keyboard activation", "closes after a successful toggle only when requested"; `DropdownMenu.test.tsx` "activates a focused menuitemradio with Enter and a menuitemcheckbox with Space" | Pointer and keyboard; close on and off | A wrong value, a dropped caller `onClick`, or a close without the opt-in fails. | `audit:DropdownMenuCheckboxItem/behavior` | -| FR3, AR2 | `DropdownMenuSelectable.test.tsx` "does not call consumer activation handlers when disabled" and "does not toggle when disabled" | Disabled | A toggle or caller handler while disabled fails. Evidence gap: no test asserts the absent `tabindex`. | `audit:DropdownMenuCheckboxItem/behavior` | -| FR4 | `DropdownMenuSelectable.test.tsx` "composes the caller pointer-move handler with menu hover focus" and "a ref reaches the menuitemcheckbox element" | Ref; pointer move | A dropped ref or handler fails. Evidence gap: no test asserts host attributes, `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuCheckboxItem/api` | -| FR5 | `DropdownMenuSelectable.test.tsx` "maps a small menu to the small shared checkbox marker" | Small menu | A marker that ignores the menu size fails. Evidence gap: no test asserts the `dropdown-menu-item` target on this row. | `audit:DropdownMenuCheckboxItem/theming` | -| FR6 | `DropdownMenu.test.tsx` "arrow navigation reaches consumer-rendered menuitemradio and menuitemcheckbox items" and "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" | Enabled and disabled rows | A row the arrows miss, or a disabled row they land on, fails. | `audit:DropdownMenuCheckboxItem/accessibility` | -| PR1 | Evidence gap: no test asserts the listener bound | — | — | `audit:DropdownMenuCheckboxItem/behavior` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| FR1, AR1 | `DropdownMenuSelectable.test.tsx` "keeps the checkbox indicator decorative (row is the only announced control)"; "a ref reaches the menuitemcheckbox element" | Checked and unchecked | An announced marker or a row that is not `menuitemcheckbox` fails. | `audit:DropdownMenuCheckboxItem/semantics` | +| FR2, ORD1 | `DropdownMenuSelectable.test.tsx` "calls onChange with the toggled value on click", "composes the caller click handler with the checked-state change", "composes the caller click handler on keyboard activation", "closes after a successful toggle only when requested"; `DropdownMenu.test.tsx` "activates a focused menuitemradio with Enter and a menuitemcheckbox with Space" | Pointer and keyboard; close on and off | A wrong value, a dropped caller `onClick`, or a close without the opt-in fails. Evidence gap: no test asserts the `onChange`, close, `onClick` order. | `audit:DropdownMenuCheckboxItem/behavior` | +| FR3, AR2 | `DropdownMenuSelectable.test.tsx` "does not call consumer activation handlers when disabled" and "does not toggle when disabled" | Disabled | A toggle or caller handler while disabled fails. Evidence gap: no test asserts the absent `tabindex`. | `audit:DropdownMenuCheckboxItem/behavior` | +| FR4 | `DropdownMenuSelectable.test.tsx` "composes the caller pointer-move handler with menu hover focus" and "a ref reaches the menuitemcheckbox element" | Ref; pointer move | A dropped ref or handler fails. Evidence gap: no test asserts host attributes, `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuCheckboxItem/api` | +| FR5 | `DropdownMenuSelectable.test.tsx` "maps a small menu to the small shared checkbox marker" | Small menu | A marker that ignores the menu size fails. Evidence gap: no test asserts the `dropdown-menu-item` target on this row. | `audit:DropdownMenuCheckboxItem/theming` | +| FR6 | `DropdownMenu.test.tsx` "arrow navigation reaches consumer-rendered menuitemradio and menuitemcheckbox items" and "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" | Enabled and disabled rows | A row the arrows miss, or a disabled row they land on, fails. | `audit:DropdownMenuCheckboxItem/accessibility` | +| PR1 | Evidence gap: no test asserts the listener bound | — | — | `audit:DropdownMenuCheckboxItem/behavior` | + +## Current-state impact + +- **Enter keeps the menu open (FR2).** Enter on a checkbox row toggles it and + leaves the menu open by default, against the Menu pattern's Enter that + `spec:AST-020` FR15 adopts, with no named exception (OQ1). ## Decision log @@ -167,7 +173,10 @@ No component-local decision. ## Open questions -None. +- **OQ1 — Does a checkbox row keep the menu open on Enter as a named + exception to the Menu pattern, or close like other rows?** (`human-api`) + Keeping it open lets a person toggle several options in one visit; closing + matches the pattern's Enter and the other row types. ## Content boundary diff --git a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md index 55e6f431284ff..c83fbb04a08fb 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md @@ -101,16 +101,16 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| FR1 | The pointer row exposes one `role="menuitem"` root. Label, description, icon, and end content are presentational content of that row rather than nested controls. | `spec:AST-020` FR15; current source and docs | -| FR2 | Enabled activation runs the row callback before optional close. Disabled rows expose `aria-disabled=true`, remove link destinations, and run neither callback nor close. | Current behavior and tests | -| FR3 | A destination row renders as the link root through LinkProvider. Plain activation runs the callback then closes and navigates; modified clicks preserve browser behavior without running the callback; a middle click runs the caller's `onAuxClick` and closes the menu; blocked schemes are inert. | `family:navigation-destinations` FR1–FR5; current tests | -| FR4 | The parent menu is the only keyboard owner. It synthesizes click activation with modifiers, moves focus, and runs typeahead; the row focuses itself on mouse hover and creates no second roving-focus model. | `architecture:interaction-modality`; current parent tests | -| FR5 | The visible root carries `dropdown-menu-item`; size and destructive state reflect on it (`data-size`, `data-variant`); semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface`; current source and tests | -| FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api` INV5–INV9; current tests | -| FR7 | Disabled menu rows are inert, and the parent menu's arrow keys and typeahead skip them. Shipped behavior, not a requirement until OQ1 is ruled. | `component:DropdownMenu` FR17, OQ1 | -| FR8 | Keyboard focus paints the row's hover background with no outline; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the forced-colors treatment is OQ2. | Current source | +| ID | Invariant | Basis | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| FR1 | The pointer row exposes one `role="menuitem"` root. Label, description, icon, and end content are presentational content of that row rather than nested controls. | `spec:AST-020` FR15; current source and docs | +| FR2 | Enabled activation runs the row callback before optional close. Disabled rows expose `aria-disabled=true`, remove link destinations, and run neither callback nor close. | Current behavior and tests | +| FR3 | A destination row renders as the link root through LinkProvider. Plain activation runs the callback then closes and navigates; a modified click does not run the callback, closes the menu, and is left to the browser; a middle click runs the caller's `onAuxClick` and closes the menu; blocked schemes are inert. | `family:navigation-destinations` FR1–FR5; current tests | +| FR4 | The parent menu is the only keyboard owner. It synthesizes click activation with modifiers, moves focus, and runs typeahead; the row focuses itself on mouse hover and creates no second roving-focus model. | `architecture:interaction-modality`; current parent tests | +| FR5 | The visible root carries `dropdown-menu-item`; size and destructive state reflect on it (`data-size`, `data-variant`); semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface`; current source and tests | +| FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api` INV5–INV9; current tests | +| FR7 | Disabled menu rows are inert, and the parent menu's arrow keys and typeahead skip them. Shipped behavior, not a requirement until OQ1 is ruled. | `component:DropdownMenu` FR17, OQ1 | +| FR8 | Keyboard focus paints the row's hover background with no outline; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the forced-colors treatment is OQ2. | Current source | ### Allowed variation From dd46f635e6d528a7e2576dde829040f9266aca34 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 00:24:17 -0700 Subject: [PATCH 5/9] docs(spec): DropdownMenu scopes finger press-open to AST-094 FR29 and drops the undecided DEC-7 --- .../src/DropdownMenu/DropdownMenu.spec.md | 31 +++++++------------ .../src/DropdownMenu/DropdownMenuItem.spec.md | 2 +- .../DropdownMenu/DropdownMenuSubMenu.spec.md | 2 +- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index 823df44f19513..1d34e97712ed1 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -38,15 +38,15 @@ system_specs: [spec:AST-020/FR15, spec:AST-094, spec:AST-102] ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | -| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | -| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu opens only after a held press, does not grow from its trigger, and a pan outside does not close it. | -| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | -| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Finger press-open (FR8), touch grow and pan dismissal (FR21), and the delegated Trigger Button gaps are listed under Current-state impact. | -| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | -| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | +| Area | Contract | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | None changes. DropdownMenu takes a `button` (Button props) or `renderTrigger`, data-driven `items` or compound children, `presentation`, `isMenuOpen` and `onOpenChange`, placement and alignment, `menuMaxHeight`, `header`, and `footer`. | +| Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | +| End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu opens after a 500 ms hold or at a tap's click, does not grow from its trigger, and a pan outside does not close it. | +| Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Finger press-open (FR8), touch grow and pan dismissal (FR21), and the delegated Trigger Button gaps are listed under Current-state impact. | +| Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | +| Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | This table is a review projection; the body below is authoritative. @@ -126,12 +126,12 @@ component-local concepts; each keeps its released default. | FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7, FR10, and FR11: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. The grow, collapse, and pan dismissal under a finger are FR21. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR28 | | FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard; a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; DEC-1 | | FR7 | `menuMaxHeight` replaces the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | DEC-2 | -| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with a settle rule on the opening release, and pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. The mouse press-open is shipped behavior, not a requirement until `module:DropdownMenu/useMenuPress` DEC-4 is ruled. Under a finger the trigger MUST open the menu as the finger presses (`spec:AST-094` FR29). Implementation gap: a held finger opens it after a 500 ms hold. | `module:DropdownMenu/useMenuPress` FR8, FR9; `spec:AST-094` FR29 | +| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with a settle rule on the opening release, and pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. The mouse press-open is shipped behavior, not a requirement until `module:DropdownMenu/useMenuPress` DEC-4 is ruled. Under a finger the trigger MUST open the menu as `spec:AST-094` FR29 states: as the finger presses in a fixed bar, after the 150 ms content-touch delay or at a tap's lift inside a scroll view; `presentation="popover"` keeps the tap open. Implementation gap: a held finger opens it after a 500 ms hold, and a tap opens it at its click. | `module:DropdownMenu/useMenuPress` FR8, FR9; `spec:AST-094` FR29 | | FR9 | With `renderTrigger`, the rendered control carries `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and receives the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. A disabled custom trigger never opens the menu or the sheet. `button` and `renderTrigger` are mutually exclusive: both given, `renderTrigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | DEC-3 | | FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click does not run `onClick`, closes the menu, and is left to the browser; a middle click closes the menu and runs the caller's `onAuxClick`; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | | FR11 | When a compact touch display opened the pointer menu (the query the root presentation uses for its bottom sheet, sampled once as it opens), an `adaptive` sub-menu row drills in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft return to the row with focus on it; a pick inside closes the whole menu, and closing the menu resets the drilled view. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | DEC-4; `spec:AST-094` FR34 | | FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Current source and tests | -| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | DEC-7 | +| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | Current behavior | | FR14 | Compound groups follow `component:DropdownMenuGroup`; a data-mode `{type: 'section'}` entry is a `role="group"` named by its title, and an untitled section renders its rows without a heading. | DEC-6; current tests | | FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10 | | FR16 | Checkbox rows follow `component:DropdownMenuCheckboxItem`. | Current source | @@ -360,7 +360,7 @@ than adding a DropdownMenu-owned heading target. ## Current-state impact - **Finger press-open (FR8).** A held finger opens the menu after a 500 ms - hold instead of as it presses, against `spec:AST-094` FR29. + hold, and a tap at its click, instead of as `spec:AST-094` FR29 states. - **Touch grow and pan dismissal (FR21).** Under a finger the menu appears and leaves with the layer's own entrance and exit instead of growing out of and back into the Trigger button, and a pan that starts outside does not close @@ -494,13 +494,6 @@ focusable node inside a heading lands in the group but outside the roving focus order, so the arrow keys cannot reach it; the type does not prevent that and a case pins the behavior instead. -### DEC-7 — A safe triangle protects the diagonal to a flyout - -**Reference:** `component:DropdownMenu/DEC-7` -**Decider:** `cixzhang`, `2026-10-02` - -FR13 states the behavior this decision chose. - ### DEC-8 — Controls beside the rows go in `header` and `footer` **Reference:** `component:DropdownMenu/DEC-8` diff --git a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md index c83fbb04a08fb..23e0f11e29870 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md @@ -186,7 +186,7 @@ No component-local visual decision is recorded. | FR3 | `DropdownMenu.test.tsx` "DropdownMenuItem href" and "link rows — the browser's own clicks" cases; "a middle click on a link row runs the caller's onAuxClick and still closes the menu" | Plain, modified, middle, right click; blocked destination | A non-anchor row, a modified click that runs the callback, a middle click that does not close, or a live blocked destination fails. | `audit:DropdownMenuItem/behavior` | | FR4, AR3 | `DropdownMenu.test.tsx` keyboard-access cases ("moves focus to the item the mouse hovers, keeping a single highlight", "does not move focus on hover for a disabled item", "does not move focus for a non-mouse (touch) pointer") and "keeps the row's own role, tab stop and hover focus beside a caller's onPointerMove" | Mouse hover, touch, disabled | A second highlight, hover focus on a disabled row or under touch, or a lost tab stop fails. | `audit:DropdownMenuItem/accessibility` | | FR5 | `DropdownMenu.test.tsx` destructive-variant cases; `themingTargets.test.ts` | Default and destructive; data and compound mode | A missing `data-variant` fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts the `dropdown-menu-item` class or `data-size`. | `audit:DropdownMenuItem/theming` | -| FR6 | `DropdownMenu.test.tsx` ref and host-attribute cases ("a ref reaches the menuitem element", "puts aria-_, data-_ and id on the row root, a link row included", "runs the DOM handlers the caller attaches, such as a drop target") | Ref; host attributes; caller handlers | A dropped ref, attribute, or handler fails. Evidence gap: no test asserts `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuItem/api` | +| FR6 | `DropdownMenu.test.tsx` ref and host-attribute cases ("a ref reaches the menuitem element", "puts aria-\*, data-\* and id on the row root, a link row included", "runs the DOM handlers the caller attaches, such as a drop target") | Ref; host attributes; caller handlers | A dropped ref, attribute, or handler fails. Evidence gap: no test asserts `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuItem/api` | | FR7 | `DropdownMenu.test.tsx` "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" (the shipped behavior OQ1 leaves open) | Disabled rows | A disabled row that activates fails. | `audit:DropdownMenuItem/accessibility` | | FR8, AR4, PR1 | Evidence gap: no test asserts the focus paint, forced-colors focus, or the listener bound | — | — | `audit:DropdownMenuItem/visual` | | RTL | `apps/storybook/rtl-audit/verified-not-applicable.json` DropdownMenuItem entry | LTR and RTL | A changed source hash fails the receipt. | `audit:DropdownMenuItem/rtl` | diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md index cd975a0a0ef8f..fea2fd98304b9 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md @@ -199,7 +199,7 @@ No component-local visual decision is recorded. | FR4 | `DropdownMenuSubMenu.test.tsx` drill-in cases; `DropdownMenuDrillIn.a11y.chromium.spec.ts` | Drilled view; Back | A missing Back row or a Back that does not return fails. | `audit:DropdownMenuSubMenu/behavior` | | FR5 | `DropdownMenuSubMenu.test.tsx` "closes the whole menu after selecting a nested item" and "activates a nested item with the Enter key and closes the menu" | Pointer and Enter | A level left open after a leaf pick fails. | `audit:DropdownMenuSubMenu/behavior` | | FR6 | `DropdownMenuSubMenu.test.tsx` "reports each flyout open and close to onOpenChange once" | Flyout open and close | A missing or repeated report fails. Evidence gap: the drill-in reset gap is not asserted. | `audit:DropdownMenuSubMenu/behavior` | -| FR7, FR9 | `DropdownMenuSubMenu.test.tsx` "does not open a disabled submenu", "a disabled row does not pass a click on to the caller's onClick", host-attribute and ref cases ("aria-_, data-_ and id reach the trigger row, and the flyout stays labelled by it", "a ref reaches the trigger row", "a caller's handlers run after the row's own, and the row still opens and takes the hover focus") | Disabled; caller handlers and attributes | A disabled open or caller click, or a dropped attribute, ref, or handler, fails. Evidence gap: no test asserts `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuSubMenu/api` | +| FR7, FR9 | `DropdownMenuSubMenu.test.tsx` "does not open a disabled submenu", "a disabled row does not pass a click on to the caller's onClick", host-attribute and ref cases ("aria-\*, data-\* and id reach the trigger row, and the flyout stays labelled by it", "a ref reaches the trigger row", "a caller's handlers run after the row's own, and the row still opens and takes the hover focus") | Disabled; caller handlers and attributes | A disabled open or caller click, or a dropped attribute, ref, or handler, fails. Evidence gap: no test asserts `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuSubMenu/api` | | FR8 | `BusyOwnerAnnouncement.test.tsx` "DropdownMenuSubMenu loading state" cases; `DropdownMenuSubMenu.test.tsx` loading-flyout cases | Loading closed and open | A busy start announced while closed, twice, or not at all, or an announced Spinner, fails. | `audit:DropdownMenuSubMenu/accessibility` | | AR4, PR1 | Evidence gap: no test asserts the focus paint, forced-colors focus, or the timer bounds | — | — | `audit:DropdownMenuSubMenu/visual` | From ef277a4c26fcaca20013096d4a0b0c0b0ed24c05 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 00:52:54 -0700 Subject: [PATCH 6/9] docs(spec): DropdownMenu states the moved finger default and cites the ContextMenu rows-alone case --- .../core/src/DropdownMenu/DropdownMenu.spec.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index 1d34e97712ed1..27a07f91ac442 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -18,6 +18,7 @@ verified_by: packages/core/src/DropdownMenu/__tests__/MenuPress.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts, packages/core/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts, + packages/core/src/ContextMenu/ContextMenu.test.tsx, packages/core/src/theme/themingTargets.test.ts, scripts/check-knowledge.mjs, ] @@ -44,7 +45,7 @@ system_specs: [spec:AST-020/FR15, spec:AST-094, spec:AST-102] | Behavior | A Menu Button trigger opens an anchored pointer menu or a bottom-sheet action list. Rows act on the release under the shared press model; sub-menus fly out beside their row or drill in on a compact touch display; a header or footer makes the pointer popover a modal dialog around the menu. | | End-user impact | People pick actions by mouse, finger, or keyboard; keyboard users get a focus return with a ring and pointer users without one; on a phone sub-menus replace the rows instead of flying off-screen. Under a finger the menu opens after a 500 ms hold or at a tap's click, does not grow from its trigger, and a pan outside does not close it. | | Builder impact | Builders choose the trigger, the rows, and the regions; rows stay menu items and every non-item control goes in `header` or `footer`. | -| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Finger press-open (FR8), touch grow and pan dismissal (FR21), and the delegated Trigger Button gaps are listed under Current-state impact. | +| Compatibility/readiness | Records shipped behavior; no API or DOM change; the released default under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. Finger press-open (FR8), touch grow and pan dismissal (FR21), and the delegated Trigger Button gaps are listed under Current-state impact. | | Review checks | Reject a control placed among the rows, a lost trigger ARIA wiring, a drilled-in view that leaves the menu box, a header or footer without the dialog, a lost focus-return modality, or a target moved off its painted element. | | Governing rules | `spec:AST-020` FR15; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `architecture:interaction-modality` INV1; `architecture:component-theming-surface`; `architecture:layer-runtime`; `family:overlay-dismissal`; `family:navigation-destinations`. | @@ -60,13 +61,14 @@ mount. ## Compatibility and migration -- Released default preserved: yes -- Compatibility class: records shipped behavior; no public prop, default, - export, or DOM change +- Released default preserved: `no` under a finger: the omitted `presentation` + follows `spec:AST-094` FR29, FR31, and FR32 (FR8, FR21); + `presentation="popover"` keeps the released behavior (`spec:AST-094` FR25) +- Compatibility class: records shipped behavior; no public prop, export, or + DOM change - Controlled/uncontrolled behavior: `isMenuOpen` with `onOpenChange`, or uncontrolled -- Migration decision: none; a caller that needs the released tap-open under a - finger passes `presentation="popover"` (`spec:AST-094/DEC-4`) +- Migration decision: `spec:AST-094` FR25 and DEC-4 Consumer migration instructions belong in consumer docs and release notes. @@ -352,7 +354,7 @@ than adding a DropdownMenu-owned heading target. | FR14 | `DropdownMenu.test.tsx` section cases | Titled and untitled sections | A missing group role or title fails. | `audit:DropdownMenu/accessibility` | | FR15, AR5 | `DropdownMenu.test.tsx` header and footer cases, including the hidden-control, open-sub-menu, and header radio-group cases; `MenuHeaderFooter.a11y.chromium.spec.ts` | Regions present and absent; hidden controls; open flyout; custom trigger; bottom sheet | A region inside the menu, a missing or unnamed dialog, a wrong `aria-haspopup`, a Tab that leaves the popover, a hidden control taken as a stop, or a region whose keys the menu takes fails. | `audit:DropdownMenu/accessibility` | | FR17, AR6 | `DropdownMenu.test.tsx` "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" and "typeahead skips an aria-disabled item and matches the next enabled label" (the shipped behavior OQ1 leaves open); disabled-row cases | Disabled rows | A disabled row that runs a caller handler fails. | `audit:DropdownMenu/accessibility` | -| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` header and footer cases, including "on a phone"; `SubMenuHeaderFooter.a11y.chromium.spec.ts` | Fine pointer and phone; Escape paths; nested flyouts; pointer leaving | A region inside a menu, an unnamed dialog, a Tab walk that does not wrap, an Escape that closes the root, or a flyout that closes while focus is inside fails. Evidence gap: no test here asserts the rows-alone case under ContextMenu. | `audit:DropdownMenu/accessibility` | +| FR20, AR7 | `DropdownMenuSubMenu.test.tsx` header and footer cases, including "on a phone"; `SubMenuHeaderFooter.a11y.chromium.spec.ts`; `ContextMenu.test.tsx` "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Fine pointer and phone; Escape paths; nested flyouts; pointer leaving | A region inside a menu, an unnamed dialog, a Tab walk that does not wrap, an Escape that closes the root, or a flyout that closes while focus is inside fails. | `audit:DropdownMenu/accessibility` | | FR21 | Evidence gap: no test, matching the FR21 gap | — | — | `audit:DropdownMenu/behavior` | | PR1 | Evidence gap: no test asserts the listener and observer bounds | — | — | `audit:DropdownMenu/behavior` | | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and targets | Missing, extra, prefixed, stale, or alias-backed mappings fail repository validation. | `audit:DropdownMenu/theming` | From 0fba084638573a4f1ad6ded833efd55b7e64b860 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 01:20:13 -0700 Subject: [PATCH 7/9] docs(spec): DropdownMenu names the low-contrast row focus tint and the menu's observers --- packages/core/src/DropdownMenu/DropdownMenu.spec.md | 9 +++++---- .../src/DropdownMenu/DropdownMenuCheckboxItem.spec.md | 10 +++++++++- .../core/src/DropdownMenu/DropdownMenuItem.spec.md | 11 ++++++----- .../src/DropdownMenu/DropdownMenuRadioItem.spec.md | 8 +++++--- .../core/src/DropdownMenu/DropdownMenuSubMenu.spec.md | 8 +++++--- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index 27a07f91ac442..f0fa6fe7b9037 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -176,10 +176,11 @@ component-local concepts; each keeps its released default. ### Performance and resources - **PR1 — Bounded resources.** While open, the pointer menu observes its own - size and listens for window and visual-viewport resizes and image loads to - decide whether it scrolls. A mouse press-open adds document pointer-up and - pointer-cancel listeners for the length of the press. The compact-touch query - is read once as the menu opens, with no listener. + size and content changes and listens for window and visual-viewport resizes + and image loads to decide whether it scrolls. A mouse press-open adds document + pointer-up and pointer-cancel listeners for the length of the press. With + `presentation="adaptive"` the root subscribes to the compact-touch query while + mounted; the drill-in reads it once as the menu opens. ## Accessibility contract diff --git a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md index 5e2880ed59f43..67092e9a6fb0b 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.spec.md @@ -131,6 +131,10 @@ Consumer migration instructions belong in consumer docs and release notes. accessible name, checked state, focus, and activation; the marker is decorative. - **AR2 — Disabled state.** A disabled row is inert. +- **AR3 — Focus visibility.** Keyboard focus paints only the menu overlay + tint, about 1.1:1 against the menu surface in the default themes, with no + outline, and nothing under forced colors. The shared row treatment is + `component:DropdownMenuItem` OQ2. ## Design relationships @@ -159,10 +163,14 @@ anatomy-theming map. | FR4 | `DropdownMenuSelectable.test.tsx` "composes the caller pointer-move handler with menu hover focus" and "a ref reaches the menuitemcheckbox element" | Ref; pointer move | A dropped ref or handler fails. Evidence gap: no test asserts host attributes, `className`, `style`, or `xstyle` on the row. | `audit:DropdownMenuCheckboxItem/api` | | FR5 | `DropdownMenuSelectable.test.tsx` "maps a small menu to the small shared checkbox marker" | Small menu | A marker that ignores the menu size fails. Evidence gap: no test asserts the `dropdown-menu-item` target on this row. | `audit:DropdownMenuCheckboxItem/theming` | | FR6 | `DropdownMenu.test.tsx` "arrow navigation reaches consumer-rendered menuitemradio and menuitemcheckbox items" and "skips aria-disabled menuitemradio and menuitemcheckbox items during arrow navigation" | Enabled and disabled rows | A row the arrows miss, or a disabled row they land on, fails. | `audit:DropdownMenuCheckboxItem/accessibility` | -| PR1 | Evidence gap: no test asserts the listener bound | — | — | `audit:DropdownMenuCheckboxItem/behavior` | +| AR3, PR1 | Evidence gap: no test asserts the focus paint or the listener bound | — | — | `audit:DropdownMenuCheckboxItem/behavior` | ## Current-state impact +- **Focus visibility (AR3).** Keyboard focus on a checkbox row shows only an + overlay tint about 1.1:1 against the menu surface, and no indicator in + forced-colors mode; the shared row treatment is `component:DropdownMenuItem` + OQ2. - **Enter keeps the menu open (FR2).** Enter on a checkbox row toggles it and leaves the menu open by default, against the Menu pattern's Enter that `spec:AST-020` FR15 adopts, with no named exception (OQ1). diff --git a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md index 23e0f11e29870..f26fcccdbdc0e 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md @@ -110,7 +110,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR5 | The visible root carries `dropdown-menu-item`; size and destructive state reflect on it (`data-size`, `data-variant`); semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface`; current source and tests | | FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api` INV5–INV9; current tests | | FR7 | Disabled menu rows are inert, and the parent menu's arrow keys and typeahead skip them. Shipped behavior, not a requirement until OQ1 is ruled. | `component:DropdownMenu` FR17, OQ1 | -| FR8 | Keyboard focus paints the row's hover background with no outline; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the forced-colors treatment is OQ2. | Current source | +| FR8 | Keyboard focus paints the row's hover background with no outline, about 1.1:1 against the menu surface in the default themes; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the focus treatment is OQ2. | Current source | ### Allowed variation @@ -153,8 +153,8 @@ Consumer migration instructions belong in consumer docs and release notes. - **AR2 — Disabled state.** Disabled rows expose state and cannot activate or navigate. - **AR3 — Keyboard ownership.** DropdownMenu owns focus collection and activation; the item remains a single row in that model. -- **AR4 — Focus perception.** Focus paints the row background in ordinary - themes; FR8 records that forced-colors mode shows no indicator (OQ2). +- **AR4 — Focus perception.** Focus paints a low-contrast row background in + ordinary themes and no indicator in forced-colors mode (FR8, OQ2). ## Design relationships @@ -193,8 +193,9 @@ No component-local visual decision is recorded. ## Current-state impact -- **Forced-colors focus (FR8).** Keyboard focus on a row has no visible - indicator in forced-colors mode; the treatment is OQ2. +- **Focus visibility (FR8).** Keyboard focus on a row shows only a background + tint about 1.1:1 against the menu surface, and no indicator in forced-colors + mode; the treatment is OQ2. ## Decision log diff --git a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md index 77959aa533596..095a9f3333abd 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.spec.md @@ -162,7 +162,8 @@ Consumer migration instructions belong in consumer docs and release notes. the row adds no key handling. Whether disabled rows stay keyboard-discoverable is owned by `component:DropdownMenu` (its OQ1). - **AR4 — Focus visibility.** Keyboard focus paints only the menu overlay - tint with no outline, and nothing under forced colors. The shared row + tint, about 1.1:1 against the menu surface in the default themes, with no + outline, and nothing under forced colors. The shared row treatment is `component:DropdownMenuItem` OQ2. ## Design relationships @@ -203,8 +204,9 @@ No component-local visual decision is recorded. ## Current-state impact -- **Forced-colors focus (AR4).** Keyboard focus on a radio row has no visible - indicator in forced-colors mode; the shared row treatment is +- **Focus visibility (AR4).** Keyboard focus on a radio row shows only an + overlay tint about 1.1:1 against the menu surface, and no indicator in + forced-colors mode; the shared row treatment is `component:DropdownMenuItem` OQ2. ## Decision log diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md index fea2fd98304b9..ea57fc2cbfb2a 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.spec.md @@ -163,7 +163,8 @@ Consumer migration instructions belong in consumer docs and release notes. - **AR3 — Disabled discoverability.** Whether disabled rows stay keyboard-discoverable is owned by `component:DropdownMenu` (its OQ1). - **AR4 — Focus visibility.** Keyboard focus paints only the menu overlay - tint with no outline, and nothing under forced colors. The shared row + tint, about 1.1:1 against the menu surface in the default themes, with no + outline, and nothing under forced colors. The shared row treatment is `component:DropdownMenuItem` OQ2. ## Design relationships @@ -207,8 +208,9 @@ No component-local visual decision is recorded. - **Drill-in close report (FR6).** When the root menu closes a drilled-in view, `onOpenChange(false)` does not run (OQ1). -- **Forced-colors focus (AR4).** Keyboard focus on the row has no visible - indicator in forced-colors mode; the shared row treatment is +- **Focus visibility (AR4).** Keyboard focus on the row shows only an + overlay tint about 1.1:1 against the menu surface, and no indicator in + forced-colors mode; the shared row treatment is `component:DropdownMenuItem` OQ2. ## Decision log From 5e39a8eea294936d2571aea11b465df678780983 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 01:46:58 -0700 Subject: [PATCH 8/9] docs(spec): DropdownMenu PR1 counts the held-finger press-open listeners --- packages/core/src/DropdownMenu/DropdownMenu.spec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index f0fa6fe7b9037..1f8944e0e4af5 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -177,8 +177,9 @@ component-local concepts; each keeps its released default. - **PR1 — Bounded resources.** While open, the pointer menu observes its own size and content changes and listens for window and visual-viewport resizes - and image loads to decide whether it scrolls. A mouse press-open adds document - pointer-up and pointer-cancel listeners for the length of the press. With + and image loads to decide whether it scrolls. A press-open, by a mouse press or a + held finger, adds document pointer-up and pointer-cancel listeners for the + length of the press. With `presentation="adaptive"` the root subscribes to the compact-touch query while mounted; the drill-in reads it once as the menu opens. From 90d9a7beba39cf30dcca4d21ff54ea35ed10bfd8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 02:46:48 -0700 Subject: [PATCH 9/9] docs(spec): DropdownMenu scopes the middle-click close to the pointer menu --- .../src/DropdownMenu/DropdownMenu.spec.md | 2 +- .../src/DropdownMenu/DropdownMenuItem.spec.md | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index 1f8944e0e4af5..6a3fe04726102 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -130,7 +130,7 @@ component-local concepts; each keeps its released default. | FR7 | `menuMaxHeight` replaces the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | DEC-2 | | FR8 | The Trigger button opens the pointer menu on a mouse press-down, with a settle rule on the opening release, and pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before. The mouse press-open is shipped behavior, not a requirement until `module:DropdownMenu/useMenuPress` DEC-4 is ruled. Under a finger the trigger MUST open the menu as `spec:AST-094` FR29 states: as the finger presses in a fixed bar, after the 150 ms content-touch delay or at a tap's lift inside a scroll view; `presentation="popover"` keeps the tap open. Implementation gap: a held finger opens it after a 500 ms hold, and a tap opens it at its click. | `module:DropdownMenu/useMenuPress` FR8, FR9; `spec:AST-094` FR29 | | FR9 | With `renderTrigger`, the rendered control carries `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and receives the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. A disabled custom trigger never opens the menu or the sheet. `button` and `renderTrigger` are mutually exclusive: both given, `renderTrigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | DEC-3 | -| FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click does not run `onClick`, closes the menu, and is left to the browser; a middle click closes the menu and runs the caller's `onAuxClick`; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | +| FR10 | An action row with `href` renders as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click runs `onClick`, then closes the menu (`hasCloseOnSelect`), then lets the browser navigate; a modified click does not run `onClick`, closes the menu, and is left to the browser; in the pointer menu a middle click closes the menu (`hasCloseOnSelect`), then runs the caller's `onAuxClick`, while the touch sheet leaves a middle click to the browser and stays open; a right click is left to the context menu; Enter and Space activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`; a blocked destination renders inertly. | Current behavior; `family:navigation-destinations` | | FR11 | When a compact touch display opened the pointer menu (the query the root presentation uses for its bottom sheet, sampled once as it opens), an `adaptive` sub-menu row drills in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft return to the row with focus on it; a pick inside closes the whole menu, and closing the menu resets the drilled view. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | DEC-4; `spec:AST-094` FR34 | | FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Current source and tests | | FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | Current behavior | diff --git a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md index f26fcccdbdc0e..d9ea1f8cfa580 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenuItem.spec.md @@ -101,16 +101,16 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| FR1 | The pointer row exposes one `role="menuitem"` root. Label, description, icon, and end content are presentational content of that row rather than nested controls. | `spec:AST-020` FR15; current source and docs | -| FR2 | Enabled activation runs the row callback before optional close. Disabled rows expose `aria-disabled=true`, remove link destinations, and run neither callback nor close. | Current behavior and tests | -| FR3 | A destination row renders as the link root through LinkProvider. Plain activation runs the callback then closes and navigates; a modified click does not run the callback, closes the menu, and is left to the browser; a middle click runs the caller's `onAuxClick` and closes the menu; blocked schemes are inert. | `family:navigation-destinations` FR1–FR5; current tests | -| FR4 | The parent menu is the only keyboard owner. It synthesizes click activation with modifiers, moves focus, and runs typeahead; the row focuses itself on mouse hover and creates no second roving-focus model. | `architecture:interaction-modality`; current parent tests | -| FR5 | The visible root carries `dropdown-menu-item`; size and destructive state reflect on it (`data-size`, `data-variant`); semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface`; current source and tests | -| FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api` INV5–INV9; current tests | -| FR7 | Disabled menu rows are inert, and the parent menu's arrow keys and typeahead skip them. Shipped behavior, not a requirement until OQ1 is ruled. | `component:DropdownMenu` FR17, OQ1 | -| FR8 | Keyboard focus paints the row's hover background with no outline, about 1.1:1 against the menu surface in the default themes; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the focus treatment is OQ2. | Current source | +| ID | Invariant | Basis | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| FR1 | The pointer row exposes one `role="menuitem"` root. Label, description, icon, and end content are presentational content of that row rather than nested controls. | `spec:AST-020` FR15; current source and docs | +| FR2 | Enabled activation runs the row callback before optional close. Disabled rows expose `aria-disabled=true`, remove link destinations, and run neither callback nor close. | Current behavior and tests | +| FR3 | A destination row renders as the link root through LinkProvider. Plain activation runs the callback then closes and navigates; a modified click does not run the callback, closes the menu, and is left to the browser; a middle click closes the menu (`hasCloseOnSelect`), then runs the caller's `onAuxClick`; blocked schemes are inert. | `family:navigation-destinations` FR1–FR5; current tests | +| FR4 | The parent menu is the only keyboard owner. It synthesizes click activation with modifiers, moves focus, and runs typeahead; the row focuses itself on mouse hover and creates no second roving-focus model. | `architecture:interaction-modality`; current parent tests | +| FR5 | The visible root carries `dropdown-menu-item`; size and destructive state reflect on it (`data-size`, `data-variant`); semantic tokens own layout and paint before caller styling inputs compose. | `architecture:component-theming-surface`; current source and tests | +| FR6 | Ref, supported host attributes and handlers, `className`, `style`, and `xstyle` reach or compose on the row root without replacing owned role, tab stop, disabled behavior, destination handling, or activation order. | `architecture:public-component-api` INV5–INV9; current tests | +| FR7 | Disabled menu rows are inert, and the parent menu's arrow keys and typeahead skip them. Shipped behavior, not a requirement until OQ1 is ruled. | `component:DropdownMenu` FR17, OQ1 | +| FR8 | Keyboard focus paints the row's hover background with no outline, about 1.1:1 against the menu surface in the default themes; in forced-colors mode that background is not drawn, so focus has no visible indicator. Shipped behavior; the focus treatment is OQ2. | Current source | ### Allowed variation