From 7691cb6efdcec4fd77e2d67e63c8714a35181aa4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 13:46:12 -0700 Subject: [PATCH 1/4] spec(useMenuPress): proposed: a finger's touch model, sub-menu rows, handed-off pointers, one open and one close (FR9 replacement, FR10, FR11, AR5) --- .../DropdownMenu/modules/useMenuPress.spec.md | 97 +++++++++++-------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index c11ad25bc665f..a259a06bbe70a 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -35,15 +35,15 @@ references: ## Contract at a glance -| Area | Contract | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | New public hook `useMenuPress` and the pure `menuPressStep` machine it drives, exported from `@astryxdesign/core/hooks`; the marker `data-astryx-menu-press` on every menu or listbox root that mounts it; the hook's trigger half (`onTriggerPress`, `triggerProps`, `isTriggerClickFromPress`, `longPressDelayMs`), released in 0.6.6. No consumer prop of DropdownMenu, ContextMenu, DropdownMenuSubMenu or Selector changes. | -| Behavior | The row under the pointer when it is RELEASED is the row that acts; the highlight follows a held mouse; a mouse released outside closes the menu; the browser's stray click for a tracked gesture never acts; the menu root declares `touch-action` by overflow. Where the primary pointer is coarse, a finger or pen's press follows `spec:AST-094` FR26–FR30; where it is fine, FR1–FR7 and FR9 here apply to it. PROPOSED (FR8, DEC-4): a mouse opens a DropdownMenu on press-down, with a settle rule on the opening release. | -| End-user impact | A finger that lands on one menu row and lifts on another acts on the second, once, instead of on the first; a drag inside an open menu picks where it lets go; a mouse that lets go outside dismisses, a finger does not. PROPOSED: a mouse can press the trigger, drag into the menu and let go on a row in one gesture. Opening under a finger is `spec:AST-094` FR29's. Keyboard behavior and sub-menu hover are unchanged. | -| Builder impact | None for consumers of the five surfaces: they mount the model themselves. A component CAN adopt this model only when it meets the eligibility criteria below, and its component contract MUST explicitly name this module and the governed surfaces before mounting `useMenuPress`. The builder supplies an enabled-row selector and a `touch-action` declaration. | -| Compatibility/readiness | Additive public hook; existing consumer props unchanged; shipped keyboard behavior unchanged. Shipped-behavior changes: DEC-3 (current; a mouse released outside a menu dismisses it) and PROPOSED DEC-4 (a mouse opens the menu on press rather than click; under a finger, `spec:AST-094` FR29). jsdom evidence is complete and real-Chromium evidence covers the touch pan, the finger slide and its stray click; iOS WebKit evidence is not required for this module. Authority: current for FR1–FR7, DEC-3, DEC-6, DEC-7 (`cixzhang`, 2026-10-02); FR8 and DEC-4 are proposed in this change and await the same owner (OQ1). | -| Review checks | Reject a row that acts on `pointerdown` or from the browser's click after a tracked release; a second activation reaching a row for one gesture; a highlight that lights two rows; a finger release outside that closes the menu, or a mouse release outside that leaves it open; a menu root without a `touch-action` declaration; a picker whose listbox takes DOM focus for the highlight; an opening release that acts before the pointer entered the menu or the press settled; a trigger whose press and click both toggle in one gesture. | -| Governing rules | `architecture:interaction-modality` INV2–INV4 (one operable path per modality, hover as enhancement, menus may move focus with the pointer); `architecture:public-component-api` for the new public hook; `architecture:layer-runtime` for the native light dismiss the model leaves in place; `family:overlay-dismissal` for Escape ordering, which this module does not change. | +| Area | Contract | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | New public hook `useMenuPress` and the pure `menuPressStep` machine it drives, exported from `@astryxdesign/core/hooks`; the marker `data-astryx-menu-press` on every menu or listbox root that mounts it; the hook's trigger half (`onTriggerPress`, `triggerProps`, `isTriggerClickFromPress`, `longPressDelayMs`), released in 0.6.6. No consumer prop of DropdownMenu, ContextMenu, DropdownMenuSubMenu or Selector changes. | +| Behavior | The row under the pointer when it is RELEASED is the row that acts; the highlight follows a held mouse; a mouse released outside closes the menu; the browser's stray click for a tracked gesture never acts; the menu root declares `touch-action` by overflow. Where the primary pointer is coarse, a finger or pen's press follows `spec:AST-094` FR26–FR30; where it is fine, FR1–FR7 and FR9 here apply to it. PROPOSED (FR8, DEC-4): a mouse opens a DropdownMenu on press-down, with a settle rule on the opening release. | +| End-user impact | A finger that lands on one menu row and lifts on another acts on the second, once, instead of on the first; a drag inside an open menu picks where it lets go; a mouse that lets go outside dismisses, a finger does not. PROPOSED: a mouse can press the trigger, drag into the menu and let go on a row in one gesture. Opening under a finger is `spec:AST-094` FR29's. Keyboard behavior and sub-menu hover are unchanged. | +| Builder impact | None for consumers of the five surfaces: they mount the model themselves. A component CAN adopt this model only when it meets the eligibility criteria below, and its component contract MUST explicitly name this module and the governed surfaces before mounting `useMenuPress`. The builder supplies an enabled-row selector and a `touch-action` declaration. | +| Compatibility/readiness | Additive public hook; existing consumer props unchanged; shipped keyboard behavior unchanged. Shipped-behavior changes: DEC-3 (current; a mouse released outside a menu dismisses it) and PROPOSED DEC-4 (a mouse opens the menu on press rather than click; under a finger, `spec:AST-094` FR29). jsdom evidence is complete and real-Chromium evidence covers the touch pan, the finger slide and its stray click; iOS WebKit evidence is not required for this module. Authority: current for FR1–FR7, DEC-3, DEC-6, DEC-7 (`cixzhang`, 2026-10-02); FR8 and DEC-4 are proposed and await the same owner (OQ1); FR9's replacement, FR10, FR11 and AR5 are proposed and await the same owner (OQ2). | +| Review checks | Reject a row that acts on `pointerdown` or from the browser's click after a tracked release; a second activation reaching a row for one gesture; a highlight that lights two rows; a finger release outside that closes the menu, or a mouse release outside that leaves it open; a menu root without a `touch-action` declaration; a picker whose listbox takes DOM focus for the highlight; an opening release that acts before the pointer entered the menu or the press settled; a trigger whose press and click both toggle in one gesture. | +| Governing rules | `architecture:interaction-modality` INV2–INV4 (one operable path per modality, hover as enhancement, menus may move focus with the pointer); `architecture:public-component-api` for the new public hook; `architecture:layer-runtime` for the native light dismiss the model leaves in place; `family:overlay-dismissal` for Escape ordering, which this module does not change. | This table is a review projection; the body below is authoritative. @@ -136,32 +136,35 @@ Consumer migration instructions belong in consumer docs and release notes. ## Public API and concepts -| Concept | Closed values or states | Meaning | Default | Owner | Stability | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------- | --------- | -| gesture phase | `idle`, `tracking`; proposed `triggerPress`, `open` | where the press in flight stands | `idle` | `module:DropdownMenu/useMenuPress` | stable | -| effect | `none`, `highlight`, `clear`, `act`, `settle`; proposed `open` | what one event makes the host do; `open` asks the trigger to open the menu under the held pointer; `settle` carries `stray` (swallow the browser's click) and `dismiss` (close the menu) | — | `module:DropdownMenu/useMenuPress` | stable | -| pointer type | `mouse`, `touch`, `pen` | the gesture's own pointer; `touch` and `pen` are "a finger" | from the event | `module:DropdownMenu/useMenuPress` | stable | -| enabled-row selector | caller string | the rows a release may act on; anything else inside the root highlights nothing | required | `module:DropdownMenu/useMenuPress` | stable | -| highlight | DOM focus, or a caller callback | one lit row; focus with `preventScroll` in a menu, `aria-activedescendant` in a picker | focus | `module:DropdownMenu/useMenuPress` | stable | -| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable | -| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed | -| long-press delay | released 500 ms when set; unset, as `spec:AST-094` FR29 states where the primary pointer is coarse (none in a fixed bar; 150 ms in a scroll view), and 500 ms where it is fine | how long a held finger waits on a trigger before the menu opens. A caller that sets `longPressDelayMs` gets that wait in a fixed bar and in a scroll view alike, which keeps the released behavior (`spec:AST-094` FR25). | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | stable | -| `data-astryx-menu-press` | present or absent | the root carries this model; a host controller of its own stands aside | present on every mounted root | `module:DropdownMenu/useMenuPress` | stable | -| activation click | `click` with `detail: 0`, the release's button and modifiers | how the model acts on a row, marked for the synchronous extent by `isMenuPressActivation()`; `detail === 0` alone therefore no longer means "keyboard" on a menu row | — | `module:DropdownMenu/useMenuPress` | stable | +| Concept | Closed values or states | Meaning | Default | Owner | Stability | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- | --------- | +| gesture phase | `idle`, `tracking`; proposed `triggerPress`, `open` | where the press in flight stands | `idle` | `module:DropdownMenu/useMenuPress` | stable | +| effect | `none`, `highlight`, `clear`, `act`, `settle`; proposed `open` | what one event makes the host do; `open` asks the trigger to open the menu under the held pointer; `settle` carries `stray` (swallow the browser's click) and `dismiss` (close the menu) | — | `module:DropdownMenu/useMenuPress` | stable | +| pointer type | `mouse`, `touch`, `pen` | the gesture's own pointer; `touch` and `pen` are "a finger" | from the event | `module:DropdownMenu/useMenuPress` | stable | +| enabled-row selector | caller string | the rows a release may act on; anything else inside the root highlights nothing | required | `module:DropdownMenu/useMenuPress` | stable | +| highlight | DOM focus, or a caller callback | one lit row; focus with `preventScroll` in a menu, `aria-activedescendant` in a picker | focus | `module:DropdownMenu/useMenuPress` | stable | +| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable | +| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed | +| long-press delay | released 500 ms when set; unset, as `spec:AST-094` FR29 states where the primary pointer is coarse (none in a fixed bar; 150 ms in a scroll view), and 500 ms where it is fine | how long a held finger waits on a trigger before the menu opens. A caller that sets `longPressDelayMs` gets that wait in a fixed bar and in a scroll view alike, which keeps the released behavior (`spec:AST-094` FR25). | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | stable | +| touch model | `pointer`, `uikit` | proposed: how a finger opens the menu from its trigger: `uikit` as `spec:AST-094` FR29 states; `pointer` through a tap's click or a press held for `longPressDelayMs`, under the settle rule. Reached through a module-internal entry, which also continues a pointer that went down elsewhere (FR11); the public hook has neither a touch-model option nor a continuation | `pointer`; DropdownMenu passes `uikit` with its touch presentations, not with `presentation="popover"` | `module:DropdownMenu/useMenuPress` | proposed | +| `data-astryx-menu-press` | present or absent | the root carries this model; a host controller of its own stands aside | present on every mounted root | `module:DropdownMenu/useMenuPress` | stable | +| activation click | `click` with `detail: 0`, the release's button and modifiers | how the model acts on a row, marked for the synchronous extent by `isMenuPressActivation()`; `detail === 0` alone therefore no longer means "keyboard" on a menu row | — | `module:DropdownMenu/useMenuPress` | stable | ## Behavioral contract -| ID | Invariant | Basis | Verification state | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| FR1 | A row MUST act when a press is released over it, never because the press began on it; a press that never left its row is a tap or click on that row. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR26 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify | -| FR2 | While a press is held inside a menu, the highlighted row MUST be the enabled row under the pointer, and nothing MUST be highlighted over a divider, a heading, a disabled row, or outside the menu. A mouse moving with no button down moves the highlight the same way (existing hover-to-focus). Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, and a second finger ends the gesture (FR4); where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `architecture:interaction-modality` allowed variation for menus; `useMenuPress.test.tsx` | verify | -| FR3 | A press that began inside a menu and is released outside it MUST act on nothing. Under a mouse the menu MUST then close; under a finger or pen it MUST stay open (where the primary pointer is coarse, as `spec:AST-094` FR28 states). | DEC-3; `useMenuPress.test.tsx`, `DropdownMenu.test.tsx`, `Selector.test.tsx` | verify | -| FR4 | A menu whose rows fit MUST declare `touch-action: pinch-zoom`; one that scrolls MUST declare `touch-action: pan-y pinch-zoom` and `overscroll-behavior: contain`. Neither admits a one-finger pan the menu does not scroll, and both leave a two-finger pinch to the browser, so the page stays zoomable over an open menu. The model MUST treat `pointercancel` and a second pointer as the end of the gesture with nothing acting, and MUST NOT re-implement scrolling. | Browser scroll ownership; WCAG 2.2 SC 1.4.4 under `spec:AST-020` FR1; `DropdownMenu.test.tsx` touch-action case, `useMenuPress.test.tsx`, `MenuPinchZoom.a11y.chromium.spec.ts` | verify | -| FR5 | The click the browser reports for a gesture this model tracked MUST NOT act on any row; only the release acts. A click with no tracked gesture behind it, or one whose `detail` is 0, MUST act on its target as before. The swallower arms at a tracked release and disarms at the next `pointerdown` or after the stray-click window. | One gesture, one activation; `useMenuPress.test.tsx` FR5 suite | verify | -| FR6 | At most one row of a menu is lit. Under a finger the row a press began on MUST NOT keep a pressed or hovered look once the pointer left it, so menu rows paint `:active` only under `(hover: hover)`. Where the primary pointer is coarse, `spec:AST-094` FR27 and FR11 govern a finger's lit row and its press look instead. | `DropdownMenuItem.tsx` styles | verify | -| FR7 | While a press is tracked in a menu that scrolls, a pointer resting within the edge zone of the menu's top or bottom MUST scroll the menu toward that edge and re-read the row under the pointer as the rows move. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR30 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `useMenuPress.test.tsx` autoscroll case | verify | -| FR8 | On a mouse a DropdownMenu trigger MUST open its menu on press-down (button 0, no Control), and a drag from the trigger into the menu that releases over an enabled row MUST act on it. The opening gesture's release MUST act only if the pointer entered the menu or the press lasted the settle time; otherwise nothing acts and the menu stays open. Pressing the trigger of an open menu MUST close it and MUST NOT reopen it in the same gesture. | Proposed DEC-4; `DropdownMenu.test.tsx` press model suite, `menuPressGesture.test.ts` trigger states | proposed; pending owner review | -| FR9 | Where the primary pointer is coarse, opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29. Where it is fine, a finger keeps the released behavior (`spec:AST-094` FR19): a tap on the trigger opens the menu through its click, and a press held on the trigger for the long-press delay opens the menu with the finger down, prevents the default of the finger's later `touchmove`s until the gesture ends, and continues under FR1–FR4. | `spec:AST-094` FR29 | as `spec:AST-094` states | +| ID | Invariant | Basis | Verification state | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| FR1 | A row MUST act when a press is released over it, never because the press began on it; a press that never left its row is a tap or click on that row. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR26 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify | +| FR2 | While a press is held inside a menu, the highlighted row MUST be the enabled row under the pointer, and nothing MUST be highlighted over a divider, a heading, a disabled row, or outside the menu. A mouse moving with no button down moves the highlight the same way (existing hover-to-focus). Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, and a second finger ends the gesture (FR4); where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `architecture:interaction-modality` allowed variation for menus; `useMenuPress.test.tsx` | verify | +| FR3 | A press that began inside a menu and is released outside it MUST act on nothing. Under a mouse the menu MUST then close; under a finger or pen it MUST stay open (where the primary pointer is coarse, as `spec:AST-094` FR28 states). | DEC-3; `useMenuPress.test.tsx`, `DropdownMenu.test.tsx`, `Selector.test.tsx` | verify | +| FR4 | A menu whose rows fit MUST declare `touch-action: pinch-zoom`; one that scrolls MUST declare `touch-action: pan-y pinch-zoom` and `overscroll-behavior: contain`. Neither admits a one-finger pan the menu does not scroll, and both leave a two-finger pinch to the browser, so the page stays zoomable over an open menu. The model MUST treat `pointercancel` and a second pointer as the end of the gesture with nothing acting, and MUST NOT re-implement scrolling. | Browser scroll ownership; WCAG 2.2 SC 1.4.4 under `spec:AST-020` FR1; `DropdownMenu.test.tsx` touch-action case, `useMenuPress.test.tsx`, `MenuPinchZoom.a11y.chromium.spec.ts` | verify | +| FR5 | The click the browser reports for a gesture this model tracked MUST NOT act on any row; only the release acts. A click with no tracked gesture behind it, or one whose `detail` is 0, MUST act on its target as before. The swallower arms at a tracked release and disarms at the next `pointerdown` or after the stray-click window. | One gesture, one activation; `useMenuPress.test.tsx` FR5 suite | verify | +| FR6 | At most one row of a menu is lit. Under a finger the row a press began on MUST NOT keep a pressed or hovered look once the pointer left it, so menu rows paint `:active` only under `(hover: hover)`. Where the primary pointer is coarse, `spec:AST-094` FR27 and FR11 govern a finger's lit row and its press look instead. | `DropdownMenuItem.tsx` styles | verify | +| FR7 | While a press is tracked in a menu that scrolls, a pointer resting within the edge zone of the menu's top or bottom MUST scroll the menu toward that edge and re-read the row under the pointer as the rows move. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR30 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `useMenuPress.test.tsx` autoscroll case | verify | +| FR8 | On a mouse a DropdownMenu trigger MUST open its menu on press-down (button 0, no Control), and a drag from the trigger into the menu that releases over an enabled row MUST act on it. The opening gesture's release MUST act only if the pointer entered the menu or the press lasted the settle time; otherwise nothing acts and the menu stays open. Pressing the trigger of an open menu MUST close it and MUST NOT reopen it in the same gesture. | Proposed DEC-4; `DropdownMenu.test.tsx` press model suite, `menuPressGesture.test.ts` trigger states | proposed; pending owner review | +| FR9 | Where the primary pointer is coarse, opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29. Where it is fine, a finger keeps the released behavior (`spec:AST-094` FR19): a tap on the trigger opens the menu through its click, and a press held on the trigger for the long-press delay opens the menu with the finger down, prevents the default of the finger's later `touchmove`s until the gesture ends, and continues under FR1–FR4. **Proposed replacement; pending owner review:** under a finger, with touch model `uikit`, opening a menu from its trigger (a tap, a hold-open, and the opening finger's handoff) is governed by `spec:AST-094` FR29. With touch model `pointer`, a tap MUST open it through its click, and a press held for `longPressDelayMs` MUST open it with the finger down, under the settle rule. Either way the open MUST prevent the default of the finger's later `touchmove`s until the gesture ends. | `spec:AST-094` FR29 | as `spec:AST-094` states | +| FR10 | **Proposed; pending owner review.** A finger released on a sub-menu's row MUST open that sub-menu, as the row's own click would (the lift acts, `spec:AST-094` FR26; on a compact touch screen an `adaptive` row drills in, FR34); a finger resting on the row MUST NOT open it. A finger that slides from a row onto the rows of an open flyout picks there under ORD1. | Proposed; `DropdownMenuSubMenu.test.tsx` press cases | proposed; pending owner review | +| FR11 | **Proposed; pending owner review.** A pointer that went down elsewhere, such as a context menu's long press, and is handed to the menu as it opens MUST be the opening press. A finger picks only after the handoff distance, counted from the first position the menu sees after it opened as `spec:AST-094` FR29 and FR33 count it, whatever the touch model, and its lift before that acts on nothing and leaves the menu open; a mouse follows FR8's settle rule. | Proposed; `useMenuPress.test.tsx` continuation suite | proposed; pending owner review | ### Transformation and precedence order @@ -217,6 +220,10 @@ Consumer migration instructions belong in consumer docs and release notes. - **AR4 — Focus after a pick is the component's.** The model activates the row as its own click would; where focus goes once the menu closes stays with the owning component record and is unchanged by this module. +- **AR5 (proposed) — One open, one close.** A finger opens the menu once, as + `spec:AST-094` FR29 states or through a tap's click, and nothing withdraws + an open: a screen reader hears the menu open once, and close once at a + pick, a dismissal or Escape. ## Design relationships @@ -243,16 +250,17 @@ owned by the item components. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| FR1–FR3, FR5 | `menuPressGesture.test.ts` (one case per transition of the diagram) | idle, tracking; mouse and touch; releases over a row, over chrome, on the trigger, outside; cancel | Acting on the press row, on the stray click, or dismissing under a finger fails a transition case | -| FR1–FR5, FR7, AR1, AR3 | `useMenuPress.test.tsx` with an injected hit test | release over another row, tap, stray click, `detail: 0`, mouse/finger outside, pointercancel, second pointer, autoscroll | A second activation, a swallowed assistive click, a wrong dismissal, or a dead autoscroll fails | -| FR1, FR3–FR5 | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, `ContextMenu.test.tsx` press suites | finger slide across rows, outside release by mouse and finger, `touch-action`, flyout release, root marker | Each removed behavior fails its named case | -| FR1, FR3, AR2 | `Selector.test.tsx` press model suite | finger release over another option, finger/mouse release outside, listbox marker | A listbox that takes focus, acts on the press option, or closes under a finger fails | -| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails | -| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails | -| FR8 (proposed), FR9 | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, settle before/after, cancel; finger opening per `spec:AST-094` FR29; press-open + drag-release, unsettled 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 | -| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| FR1–FR3, FR5 | `menuPressGesture.test.ts` (one case per transition of the diagram) | idle, tracking; mouse and touch; releases over a row, over chrome, on the trigger, outside; cancel | Acting on the press row, on the stray click, or dismissing under a finger fails a transition case | +| FR1–FR5, FR7, AR1, AR3 | `useMenuPress.test.tsx` with an injected hit test | release over another row, tap, stray click, `detail: 0`, mouse/finger outside, pointercancel, second pointer, autoscroll | A second activation, a swallowed assistive click, a wrong dismissal, or a dead autoscroll fails | +| FR1, FR3–FR5 | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, `ContextMenu.test.tsx` press suites | finger slide across rows, outside release by mouse and finger, `touch-action`, flyout release, root marker | Each removed behavior fails its named case | +| FR1, FR3, AR2 | `Selector.test.tsx` press model suite | finger release over another option, finger/mouse release outside, listbox marker | A listbox that takes focus, acts on the press option, or closes under a finger fails | +| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails | +| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails | +| FR8 (proposed), FR9 | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, settle before/after, cancel; finger opening per `spec:AST-094` FR29; press-open + drag-release, unsettled 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 | +| FR10, FR11, AR5 (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `useMenuPress.test.tsx` continuation suite | a finger released on and resting on a sub-menu row; a continued finger under either touch model, the distance from the first sample, a lift before it, a mouse; one open and one close announced | A sub-menu that opens on a resting finger, a continued finger that picks before the handoff, or an open withdrawn or announced twice fails | +| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. | ## Decision log @@ -310,6 +318,11 @@ availability alone does not make a component an adopter. mouse opens a DropdownMenu on press-down with the settle rule. Opening under a finger is `spec:AST-094` FR29's. Until the owner rules, the current record's FR1–FR7 stand on their own. +- **OQ2 — A finger's touch model, sub-menu rows, and handoff.** + (`human-design`) FR9's proposed replacement, FR10, FR11 and AR5 and the + touch-model concept are proposed. Options: adopt them as written; or keep + the current FR9, which keys a finger's opening on the primary pointer, and + leave sub-menu rows and handed-off pointers to `spec:AST-094`. The former browser-evidence question is resolved: the touch pan, the finger slide and its stray click cannot be exercised in jsdom, and real-Chromium From 0e8372f7784493f35e28e3dfa92c7315234fb355 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 13:50:27 -0700 Subject: [PATCH 2/4] spec(useMenuPress): FR10, FR11, AR5 decided (DEC-8); FR9 unchanged; touch-model proposal dropped --- .../DropdownMenu/modules/useMenuPress.spec.md | 115 ++++++++++-------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index a259a06bbe70a..b7bbf7aab4d16 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -35,15 +35,15 @@ references: ## Contract at a glance -| Area | Contract | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | New public hook `useMenuPress` and the pure `menuPressStep` machine it drives, exported from `@astryxdesign/core/hooks`; the marker `data-astryx-menu-press` on every menu or listbox root that mounts it; the hook's trigger half (`onTriggerPress`, `triggerProps`, `isTriggerClickFromPress`, `longPressDelayMs`), released in 0.6.6. No consumer prop of DropdownMenu, ContextMenu, DropdownMenuSubMenu or Selector changes. | -| Behavior | The row under the pointer when it is RELEASED is the row that acts; the highlight follows a held mouse; a mouse released outside closes the menu; the browser's stray click for a tracked gesture never acts; the menu root declares `touch-action` by overflow. Where the primary pointer is coarse, a finger or pen's press follows `spec:AST-094` FR26–FR30; where it is fine, FR1–FR7 and FR9 here apply to it. PROPOSED (FR8, DEC-4): a mouse opens a DropdownMenu on press-down, with a settle rule on the opening release. | -| End-user impact | A finger that lands on one menu row and lifts on another acts on the second, once, instead of on the first; a drag inside an open menu picks where it lets go; a mouse that lets go outside dismisses, a finger does not. PROPOSED: a mouse can press the trigger, drag into the menu and let go on a row in one gesture. Opening under a finger is `spec:AST-094` FR29's. Keyboard behavior and sub-menu hover are unchanged. | -| Builder impact | None for consumers of the five surfaces: they mount the model themselves. A component CAN adopt this model only when it meets the eligibility criteria below, and its component contract MUST explicitly name this module and the governed surfaces before mounting `useMenuPress`. The builder supplies an enabled-row selector and a `touch-action` declaration. | -| Compatibility/readiness | Additive public hook; existing consumer props unchanged; shipped keyboard behavior unchanged. Shipped-behavior changes: DEC-3 (current; a mouse released outside a menu dismisses it) and PROPOSED DEC-4 (a mouse opens the menu on press rather than click; under a finger, `spec:AST-094` FR29). jsdom evidence is complete and real-Chromium evidence covers the touch pan, the finger slide and its stray click; iOS WebKit evidence is not required for this module. Authority: current for FR1–FR7, DEC-3, DEC-6, DEC-7 (`cixzhang`, 2026-10-02); FR8 and DEC-4 are proposed and await the same owner (OQ1); FR9's replacement, FR10, FR11 and AR5 are proposed and await the same owner (OQ2). | -| Review checks | Reject a row that acts on `pointerdown` or from the browser's click after a tracked release; a second activation reaching a row for one gesture; a highlight that lights two rows; a finger release outside that closes the menu, or a mouse release outside that leaves it open; a menu root without a `touch-action` declaration; a picker whose listbox takes DOM focus for the highlight; an opening release that acts before the pointer entered the menu or the press settled; a trigger whose press and click both toggle in one gesture. | -| Governing rules | `architecture:interaction-modality` INV2–INV4 (one operable path per modality, hover as enhancement, menus may move focus with the pointer); `architecture:public-component-api` for the new public hook; `architecture:layer-runtime` for the native light dismiss the model leaves in place; `family:overlay-dismissal` for Escape ordering, which this module does not change. | +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | New public hook `useMenuPress` and the pure `menuPressStep` machine it drives, exported from `@astryxdesign/core/hooks`; the marker `data-astryx-menu-press` on every menu or listbox root that mounts it; the hook's trigger half (`onTriggerPress`, `triggerProps`, `isTriggerClickFromPress`, `longPressDelayMs`), released in 0.6.6. No consumer prop of DropdownMenu, ContextMenu, DropdownMenuSubMenu or Selector changes. | +| Behavior | The row under the pointer when it is RELEASED is the row that acts; the highlight follows a held mouse; a mouse released outside closes the menu; the browser's stray click for a tracked gesture never acts; the menu root declares `touch-action` by overflow. Where the primary pointer is coarse, a finger or pen's press follows `spec:AST-094` FR26–FR30; where it is fine, FR1–FR7 and FR9 here apply to it. PROPOSED (FR8, DEC-4): a mouse opens a DropdownMenu on press-down, with a settle rule on the opening release. | +| End-user impact | A finger that lands on one menu row and lifts on another acts on the second, once, instead of on the first; a drag inside an open menu picks where it lets go; a mouse that lets go outside dismisses, a finger does not. PROPOSED: a mouse can press the trigger, drag into the menu and let go on a row in one gesture. Opening under a finger is `spec:AST-094` FR29's. Keyboard behavior and sub-menu hover are unchanged. | +| Builder impact | None for consumers of the five surfaces: they mount the model themselves. A component CAN adopt this model only when it meets the eligibility criteria below, and its component contract MUST explicitly name this module and the governed surfaces before mounting `useMenuPress`. The builder supplies an enabled-row selector and a `touch-action` declaration. | +| Compatibility/readiness | Additive public hook; existing consumer props unchanged; shipped keyboard behavior unchanged. Shipped-behavior changes: DEC-3 (current; a mouse released outside a menu dismisses it) and PROPOSED DEC-4 (a mouse opens the menu on press rather than click; under a finger, `spec:AST-094` FR29). jsdom evidence is complete and real-Chromium evidence covers the touch pan, the finger slide and its stray click; iOS WebKit evidence is not required for this module. Authority: current for FR1–FR7, DEC-3, DEC-6, DEC-7 (`cixzhang`, 2026-10-02) and FR10, FR11, AR5, DEC-8 (`cixzhang`, 2026-10-25); FR8 and DEC-4 are proposed in this change and await the same owner (OQ1). | +| Review checks | Reject a row that acts on `pointerdown` or from the browser's click after a tracked release; a second activation reaching a row for one gesture; a highlight that lights two rows; a finger release outside that closes the menu, or a mouse release outside that leaves it open; a menu root without a `touch-action` declaration; a picker whose listbox takes DOM focus for the highlight; an opening release that acts before the pointer entered the menu or the press settled; a trigger whose press and click both toggle in one gesture. | +| Governing rules | `architecture:interaction-modality` INV2–INV4 (one operable path per modality, hover as enhancement, menus may move focus with the pointer); `architecture:public-component-api` for the new public hook; `architecture:layer-runtime` for the native light dismiss the model leaves in place; `family:overlay-dismissal` for Escape ordering, which this module does not change. | This table is a review projection; the body below is authoritative. @@ -136,35 +136,34 @@ Consumer migration instructions belong in consumer docs and release notes. ## Public API and concepts -| Concept | Closed values or states | Meaning | Default | Owner | Stability | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- | --------- | -| gesture phase | `idle`, `tracking`; proposed `triggerPress`, `open` | where the press in flight stands | `idle` | `module:DropdownMenu/useMenuPress` | stable | -| effect | `none`, `highlight`, `clear`, `act`, `settle`; proposed `open` | what one event makes the host do; `open` asks the trigger to open the menu under the held pointer; `settle` carries `stray` (swallow the browser's click) and `dismiss` (close the menu) | — | `module:DropdownMenu/useMenuPress` | stable | -| pointer type | `mouse`, `touch`, `pen` | the gesture's own pointer; `touch` and `pen` are "a finger" | from the event | `module:DropdownMenu/useMenuPress` | stable | -| enabled-row selector | caller string | the rows a release may act on; anything else inside the root highlights nothing | required | `module:DropdownMenu/useMenuPress` | stable | -| highlight | DOM focus, or a caller callback | one lit row; focus with `preventScroll` in a menu, `aria-activedescendant` in a picker | focus | `module:DropdownMenu/useMenuPress` | stable | -| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable | -| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed | -| long-press delay | released 500 ms when set; unset, as `spec:AST-094` FR29 states where the primary pointer is coarse (none in a fixed bar; 150 ms in a scroll view), and 500 ms where it is fine | how long a held finger waits on a trigger before the menu opens. A caller that sets `longPressDelayMs` gets that wait in a fixed bar and in a scroll view alike, which keeps the released behavior (`spec:AST-094` FR25). | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | stable | -| touch model | `pointer`, `uikit` | proposed: how a finger opens the menu from its trigger: `uikit` as `spec:AST-094` FR29 states; `pointer` through a tap's click or a press held for `longPressDelayMs`, under the settle rule. Reached through a module-internal entry, which also continues a pointer that went down elsewhere (FR11); the public hook has neither a touch-model option nor a continuation | `pointer`; DropdownMenu passes `uikit` with its touch presentations, not with `presentation="popover"` | `module:DropdownMenu/useMenuPress` | proposed | -| `data-astryx-menu-press` | present or absent | the root carries this model; a host controller of its own stands aside | present on every mounted root | `module:DropdownMenu/useMenuPress` | stable | -| activation click | `click` with `detail: 0`, the release's button and modifiers | how the model acts on a row, marked for the synchronous extent by `isMenuPressActivation()`; `detail === 0` alone therefore no longer means "keyboard" on a menu row | — | `module:DropdownMenu/useMenuPress` | stable | +| Concept | Closed values or states | Meaning | Default | Owner | Stability | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------- | --------- | +| gesture phase | `idle`, `tracking`; proposed `triggerPress`, `open` | where the press in flight stands | `idle` | `module:DropdownMenu/useMenuPress` | stable | +| effect | `none`, `highlight`, `clear`, `act`, `settle`; proposed `open` | what one event makes the host do; `open` asks the trigger to open the menu under the held pointer; `settle` carries `stray` (swallow the browser's click) and `dismiss` (close the menu) | — | `module:DropdownMenu/useMenuPress` | stable | +| pointer type | `mouse`, `touch`, `pen` | the gesture's own pointer; `touch` and `pen` are "a finger" | from the event | `module:DropdownMenu/useMenuPress` | stable | +| enabled-row selector | caller string | the rows a release may act on; anything else inside the root highlights nothing | required | `module:DropdownMenu/useMenuPress` | stable | +| highlight | DOM focus, or a caller callback | one lit row; focus with `preventScroll` in a menu, `aria-activedescendant` in a picker | focus | `module:DropdownMenu/useMenuPress` | stable | +| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable | +| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed | +| long-press delay | released 500 ms when set; unset, as `spec:AST-094` FR29 states where the primary pointer is coarse (none in a fixed bar; 150 ms in a scroll view), and 500 ms where it is fine | how long a held finger waits on a trigger before the menu opens. A caller that sets `longPressDelayMs` gets that wait in a fixed bar and in a scroll view alike, which keeps the released behavior (`spec:AST-094` FR25). | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | stable | +| `data-astryx-menu-press` | present or absent | the root carries this model; a host controller of its own stands aside | present on every mounted root | `module:DropdownMenu/useMenuPress` | stable | +| activation click | `click` with `detail: 0`, the release's button and modifiers | how the model acts on a row, marked for the synchronous extent by `isMenuPressActivation()`; `detail === 0` alone therefore no longer means "keyboard" on a menu row | — | `module:DropdownMenu/useMenuPress` | stable | ## Behavioral contract -| ID | Invariant | Basis | Verification state | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| FR1 | A row MUST act when a press is released over it, never because the press began on it; a press that never left its row is a tap or click on that row. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR26 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify | -| FR2 | While a press is held inside a menu, the highlighted row MUST be the enabled row under the pointer, and nothing MUST be highlighted over a divider, a heading, a disabled row, or outside the menu. A mouse moving with no button down moves the highlight the same way (existing hover-to-focus). Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, and a second finger ends the gesture (FR4); where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `architecture:interaction-modality` allowed variation for menus; `useMenuPress.test.tsx` | verify | -| FR3 | A press that began inside a menu and is released outside it MUST act on nothing. Under a mouse the menu MUST then close; under a finger or pen it MUST stay open (where the primary pointer is coarse, as `spec:AST-094` FR28 states). | DEC-3; `useMenuPress.test.tsx`, `DropdownMenu.test.tsx`, `Selector.test.tsx` | verify | -| FR4 | A menu whose rows fit MUST declare `touch-action: pinch-zoom`; one that scrolls MUST declare `touch-action: pan-y pinch-zoom` and `overscroll-behavior: contain`. Neither admits a one-finger pan the menu does not scroll, and both leave a two-finger pinch to the browser, so the page stays zoomable over an open menu. The model MUST treat `pointercancel` and a second pointer as the end of the gesture with nothing acting, and MUST NOT re-implement scrolling. | Browser scroll ownership; WCAG 2.2 SC 1.4.4 under `spec:AST-020` FR1; `DropdownMenu.test.tsx` touch-action case, `useMenuPress.test.tsx`, `MenuPinchZoom.a11y.chromium.spec.ts` | verify | -| FR5 | The click the browser reports for a gesture this model tracked MUST NOT act on any row; only the release acts. A click with no tracked gesture behind it, or one whose `detail` is 0, MUST act on its target as before. The swallower arms at a tracked release and disarms at the next `pointerdown` or after the stray-click window. | One gesture, one activation; `useMenuPress.test.tsx` FR5 suite | verify | -| FR6 | At most one row of a menu is lit. Under a finger the row a press began on MUST NOT keep a pressed or hovered look once the pointer left it, so menu rows paint `:active` only under `(hover: hover)`. Where the primary pointer is coarse, `spec:AST-094` FR27 and FR11 govern a finger's lit row and its press look instead. | `DropdownMenuItem.tsx` styles | verify | -| FR7 | While a press is tracked in a menu that scrolls, a pointer resting within the edge zone of the menu's top or bottom MUST scroll the menu toward that edge and re-read the row under the pointer as the rows move. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR30 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `useMenuPress.test.tsx` autoscroll case | verify | -| FR8 | On a mouse a DropdownMenu trigger MUST open its menu on press-down (button 0, no Control), and a drag from the trigger into the menu that releases over an enabled row MUST act on it. The opening gesture's release MUST act only if the pointer entered the menu or the press lasted the settle time; otherwise nothing acts and the menu stays open. Pressing the trigger of an open menu MUST close it and MUST NOT reopen it in the same gesture. | Proposed DEC-4; `DropdownMenu.test.tsx` press model suite, `menuPressGesture.test.ts` trigger states | proposed; pending owner review | -| FR9 | Where the primary pointer is coarse, opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29. Where it is fine, a finger keeps the released behavior (`spec:AST-094` FR19): a tap on the trigger opens the menu through its click, and a press held on the trigger for the long-press delay opens the menu with the finger down, prevents the default of the finger's later `touchmove`s until the gesture ends, and continues under FR1–FR4. **Proposed replacement; pending owner review:** under a finger, with touch model `uikit`, opening a menu from its trigger (a tap, a hold-open, and the opening finger's handoff) is governed by `spec:AST-094` FR29. With touch model `pointer`, a tap MUST open it through its click, and a press held for `longPressDelayMs` MUST open it with the finger down, under the settle rule. Either way the open MUST prevent the default of the finger's later `touchmove`s until the gesture ends. | `spec:AST-094` FR29 | as `spec:AST-094` states | -| FR10 | **Proposed; pending owner review.** A finger released on a sub-menu's row MUST open that sub-menu, as the row's own click would (the lift acts, `spec:AST-094` FR26; on a compact touch screen an `adaptive` row drills in, FR34); a finger resting on the row MUST NOT open it. A finger that slides from a row onto the rows of an open flyout picks there under ORD1. | Proposed; `DropdownMenuSubMenu.test.tsx` press cases | proposed; pending owner review | -| FR11 | **Proposed; pending owner review.** A pointer that went down elsewhere, such as a context menu's long press, and is handed to the menu as it opens MUST be the opening press. A finger picks only after the handoff distance, counted from the first position the menu sees after it opened as `spec:AST-094` FR29 and FR33 count it, whatever the touch model, and its lift before that acts on nothing and leaves the menu open; a mouse follows FR8's settle rule. | Proposed; `useMenuPress.test.tsx` continuation suite | proposed; pending owner review | +| ID | Invariant | Basis | Verification state | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| FR1 | A row MUST act when a press is released over it, never because the press began on it; a press that never left its row is a tap or click on that row. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR26 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify | +| FR2 | While a press is held inside a menu, the highlighted row MUST be the enabled row under the pointer, and nothing MUST be highlighted over a divider, a heading, a disabled row, or outside the menu. A mouse moving with no button down moves the highlight the same way (existing hover-to-focus). Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, and a second finger ends the gesture (FR4); where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `architecture:interaction-modality` allowed variation for menus; `useMenuPress.test.tsx` | verify | +| FR3 | A press that began inside a menu and is released outside it MUST act on nothing. Under a mouse the menu MUST then close; under a finger or pen it MUST stay open (where the primary pointer is coarse, as `spec:AST-094` FR28 states). | DEC-3; `useMenuPress.test.tsx`, `DropdownMenu.test.tsx`, `Selector.test.tsx` | verify | +| FR4 | A menu whose rows fit MUST declare `touch-action: pinch-zoom`; one that scrolls MUST declare `touch-action: pan-y pinch-zoom` and `overscroll-behavior: contain`. Neither admits a one-finger pan the menu does not scroll, and both leave a two-finger pinch to the browser, so the page stays zoomable over an open menu. The model MUST treat `pointercancel` and a second pointer as the end of the gesture with nothing acting, and MUST NOT re-implement scrolling. | Browser scroll ownership; WCAG 2.2 SC 1.4.4 under `spec:AST-020` FR1; `DropdownMenu.test.tsx` touch-action case, `useMenuPress.test.tsx`, `MenuPinchZoom.a11y.chromium.spec.ts` | verify | +| FR5 | The click the browser reports for a gesture this model tracked MUST NOT act on any row; only the release acts. A click with no tracked gesture behind it, or one whose `detail` is 0, MUST act on its target as before. The swallower arms at a tracked release and disarms at the next `pointerdown` or after the stray-click window. | One gesture, one activation; `useMenuPress.test.tsx` FR5 suite | verify | +| FR6 | At most one row of a menu is lit. Under a finger the row a press began on MUST NOT keep a pressed or hovered look once the pointer left it, so menu rows paint `:active` only under `(hover: hover)`. Where the primary pointer is coarse, `spec:AST-094` FR27 and FR11 govern a finger's lit row and its press look instead. | `DropdownMenuItem.tsx` styles | verify | +| FR7 | While a press is tracked in a menu that scrolls, a pointer resting within the edge zone of the menu's top or bottom MUST scroll the menu toward that edge and re-read the row under the pointer as the rows move. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR30 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `useMenuPress.test.tsx` autoscroll case | verify | +| FR8 | On a mouse a DropdownMenu trigger MUST open its menu on press-down (button 0, no Control), and a drag from the trigger into the menu that releases over an enabled row MUST act on it. The opening gesture's release MUST act only if the pointer entered the menu or the press lasted the settle time; otherwise nothing acts and the menu stays open. Pressing the trigger of an open menu MUST close it and MUST NOT reopen it in the same gesture. | Proposed DEC-4; `DropdownMenu.test.tsx` press model suite, `menuPressGesture.test.ts` trigger states | proposed; pending owner review | +| FR9 | Where the primary pointer is coarse, opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29. Where it is fine, a finger keeps the released behavior (`spec:AST-094` FR19): a tap on the trigger opens the menu through its click, and a press held on the trigger for the long-press delay opens the menu with the finger down, prevents the default of the finger's later `touchmove`s until the gesture ends, and continues under FR1–FR4. | `spec:AST-094` FR29 | as `spec:AST-094` states | +| FR10 | A finger released on a sub-menu's row MUST open that sub-menu, as the row's own click would (the lift acts, `spec:AST-094` FR26; on a compact touch screen an `adaptive` row drills in, FR34); a finger resting on the row MUST NOT open it. A finger that slides from a row onto the rows of an open flyout picks there under ORD1. | DEC-8; `DropdownMenuSubMenu.test.tsx` press cases | verify | +| FR11 | A pointer that went down elsewhere, such as a context menu's long press, and is handed to the menu as it opens MUST be the opening press. A finger picks only after the handoff distance, counted from the first position the menu sees after it opened as `spec:AST-094` FR33 counts it, and its lift before that acts on nothing and leaves the menu open; a mouse follows FR8's settle rule. | DEC-8; `useMenuPress.test.tsx` continuation suite | verify | ### Transformation and precedence order @@ -220,10 +219,10 @@ Consumer migration instructions belong in consumer docs and release notes. - **AR4 — Focus after a pick is the component's.** The model activates the row as its own click would; where focus goes once the menu closes stays with the owning component record and is unchanged by this module. -- **AR5 (proposed) — One open, one close.** A finger opens the menu once, as +- **AR5 — One open, one close.** A finger opens the menu once, as `spec:AST-094` FR29 states or through a tap's click, and nothing withdraws an open: a screen reader hears the menu open once, and close once at a - pick, a dismissal or Escape. + pick, a dismissal or Escape (DEC-8). ## Design relationships @@ -250,17 +249,17 @@ owned by the item components. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1–FR3, FR5 | `menuPressGesture.test.ts` (one case per transition of the diagram) | idle, tracking; mouse and touch; releases over a row, over chrome, on the trigger, outside; cancel | Acting on the press row, on the stray click, or dismissing under a finger fails a transition case | -| FR1–FR5, FR7, AR1, AR3 | `useMenuPress.test.tsx` with an injected hit test | release over another row, tap, stray click, `detail: 0`, mouse/finger outside, pointercancel, second pointer, autoscroll | A second activation, a swallowed assistive click, a wrong dismissal, or a dead autoscroll fails | -| FR1, FR3–FR5 | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, `ContextMenu.test.tsx` press suites | finger slide across rows, outside release by mouse and finger, `touch-action`, flyout release, root marker | Each removed behavior fails its named case | -| FR1, FR3, AR2 | `Selector.test.tsx` press model suite | finger release over another option, finger/mouse release outside, listbox marker | A listbox that takes focus, acts on the press option, or closes under a finger fails | -| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails | -| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails | -| FR8 (proposed), FR9 | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, settle before/after, cancel; finger opening per `spec:AST-094` FR29; press-open + drag-release, unsettled 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 | -| FR10, FR11, AR5 (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `useMenuPress.test.tsx` continuation suite | a finger released on and resting on a sub-menu row; a continued finger under either touch model, the distance from the first sample, a lift before it, a mouse; one open and one close announced | A sub-menu that opens on a resting finger, a continued finger that picks before the handoff, or an open withdrawn or announced twice fails | -| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| FR1–FR3, FR5 | `menuPressGesture.test.ts` (one case per transition of the diagram) | idle, tracking; mouse and touch; releases over a row, over chrome, on the trigger, outside; cancel | Acting on the press row, on the stray click, or dismissing under a finger fails a transition case | +| FR1–FR5, FR7, AR1, AR3 | `useMenuPress.test.tsx` with an injected hit test | release over another row, tap, stray click, `detail: 0`, mouse/finger outside, pointercancel, second pointer, autoscroll | A second activation, a swallowed assistive click, a wrong dismissal, or a dead autoscroll fails | +| FR1, FR3–FR5 | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, `ContextMenu.test.tsx` press suites | finger slide across rows, outside release by mouse and finger, `touch-action`, flyout release, root marker | Each removed behavior fails its named case | +| FR1, FR3, AR2 | `Selector.test.tsx` press model suite | finger release over another option, finger/mouse release outside, listbox marker | A listbox that takes focus, acts on the press option, or closes under a finger fails | +| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails | +| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails | +| FR8 (proposed), FR9 | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, settle before/after, cancel; finger opening per `spec:AST-094` FR29; press-open + drag-release, unsettled 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 | +| FR10, FR11, AR5 | `DropdownMenuSubMenu.test.tsx` press cases; `useMenuPress.test.tsx` continuation suite | a finger released on and resting on a sub-menu row; a continued finger, the distance from the first sample, a lift before it, a mouse; one open and one close announced | A sub-menu that opens on a resting finger, a continued finger that picks before the handoff, or an open withdrawn or announced twice fails | +| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. | ## Decision log @@ -311,6 +310,21 @@ not eligible. Eligibility never causes adoption: each component contract names this module and the surfaces and claims it governs. The public hook's availability alone does not make a component an adopter. +### DEC-8 — A finger opens a sub-menu by lifting, and a handed-off finger is the opening press + +**Reference:** `module:DropdownMenu/useMenuPress/DEC-8` +**Decider:** `cixzhang`, `2026-10-25` + +A finger that lifts on a sub-menu's row opens it, as a tap would, and a finger +resting on the row never opens it, so a sub-menu does not open from under a +finger that is still choosing. A finger handed to a menu as it opens, such as +the long press that opened a context menu, is that menu's opening press: it +picks once it has travelled the handoff distance, so the press that opened the +menu never picks by itself. A finger opens a menu once, and nothing withdraws +the open, so a screen reader hears one open and one close. + +Rejected: opening a sub-menu when a finger rests on its row. + ## Open questions - **OQ1 — Is press-to-open the menu family's trigger model?** (`human-design`) @@ -318,11 +332,6 @@ availability alone does not make a component an adopter. mouse opens a DropdownMenu on press-down with the settle rule. Opening under a finger is `spec:AST-094` FR29's. Until the owner rules, the current record's FR1–FR7 stand on their own. -- **OQ2 — A finger's touch model, sub-menu rows, and handoff.** - (`human-design`) FR9's proposed replacement, FR10, FR11 and AR5 and the - touch-model concept are proposed. Options: adopt them as written; or keep - the current FR9, which keys a finger's opening on the primary pointer, and - leave sub-menu rows and handed-off pointers to `spec:AST-094`. The former browser-evidence question is resolved: the touch pan, the finger slide and its stray click cannot be exercised in jsdom, and real-Chromium From 3ed14560f29bc65ceb6d1c935cba807c10e12cec Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 14:29:58 -0700 Subject: [PATCH 3/4] spec(useMenuPress): FR11's finger rule applies where the primary pointer is coarse; mouse handoff moves to FR8; evidence and AR5 verification --- .../DropdownMenu/modules/useMenuPress.spec.md | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index b7bbf7aab4d16..b85f70977ae20 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -35,15 +35,15 @@ references: ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | New public hook `useMenuPress` and the pure `menuPressStep` machine it drives, exported from `@astryxdesign/core/hooks`; the marker `data-astryx-menu-press` on every menu or listbox root that mounts it; the hook's trigger half (`onTriggerPress`, `triggerProps`, `isTriggerClickFromPress`, `longPressDelayMs`), released in 0.6.6. No consumer prop of DropdownMenu, ContextMenu, DropdownMenuSubMenu or Selector changes. | -| Behavior | The row under the pointer when it is RELEASED is the row that acts; the highlight follows a held mouse; a mouse released outside closes the menu; the browser's stray click for a tracked gesture never acts; the menu root declares `touch-action` by overflow. Where the primary pointer is coarse, a finger or pen's press follows `spec:AST-094` FR26–FR30; where it is fine, FR1–FR7 and FR9 here apply to it. PROPOSED (FR8, DEC-4): a mouse opens a DropdownMenu on press-down, with a settle rule on the opening release. | -| End-user impact | A finger that lands on one menu row and lifts on another acts on the second, once, instead of on the first; a drag inside an open menu picks where it lets go; a mouse that lets go outside dismisses, a finger does not. PROPOSED: a mouse can press the trigger, drag into the menu and let go on a row in one gesture. Opening under a finger is `spec:AST-094` FR29's. Keyboard behavior and sub-menu hover are unchanged. | -| Builder impact | None for consumers of the five surfaces: they mount the model themselves. A component CAN adopt this model only when it meets the eligibility criteria below, and its component contract MUST explicitly name this module and the governed surfaces before mounting `useMenuPress`. The builder supplies an enabled-row selector and a `touch-action` declaration. | -| Compatibility/readiness | Additive public hook; existing consumer props unchanged; shipped keyboard behavior unchanged. Shipped-behavior changes: DEC-3 (current; a mouse released outside a menu dismisses it) and PROPOSED DEC-4 (a mouse opens the menu on press rather than click; under a finger, `spec:AST-094` FR29). jsdom evidence is complete and real-Chromium evidence covers the touch pan, the finger slide and its stray click; iOS WebKit evidence is not required for this module. Authority: current for FR1–FR7, DEC-3, DEC-6, DEC-7 (`cixzhang`, 2026-10-02) and FR10, FR11, AR5, DEC-8 (`cixzhang`, 2026-10-25); FR8 and DEC-4 are proposed in this change and await the same owner (OQ1). | -| Review checks | Reject a row that acts on `pointerdown` or from the browser's click after a tracked release; a second activation reaching a row for one gesture; a highlight that lights two rows; a finger release outside that closes the menu, or a mouse release outside that leaves it open; a menu root without a `touch-action` declaration; a picker whose listbox takes DOM focus for the highlight; an opening release that acts before the pointer entered the menu or the press settled; a trigger whose press and click both toggle in one gesture. | -| Governing rules | `architecture:interaction-modality` INV2–INV4 (one operable path per modality, hover as enhancement, menus may move focus with the pointer); `architecture:public-component-api` for the new public hook; `architecture:layer-runtime` for the native light dismiss the model leaves in place; `family:overlay-dismissal` for Escape ordering, which this module does not change. | +| Area | Contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | New public hook `useMenuPress` and the pure `menuPressStep` machine it drives, exported from `@astryxdesign/core/hooks`; the marker `data-astryx-menu-press` on every menu or listbox root that mounts it; the hook's trigger half (`onTriggerPress`, `triggerProps`, `isTriggerClickFromPress`, `longPressDelayMs`), released in 0.6.6. No consumer prop of DropdownMenu, ContextMenu, DropdownMenuSubMenu or Selector changes. | +| Behavior | The row under the pointer when it is RELEASED is the row that acts; the highlight follows a held mouse; a mouse released outside closes the menu; the browser's stray click for a tracked gesture never acts; the menu root declares `touch-action` by overflow. Where the primary pointer is coarse, a finger or pen's press follows `spec:AST-094` FR26–FR30; where it is fine, FR1–FR7 and FR9 here apply to it. PROPOSED (FR8, DEC-4): a mouse opens a DropdownMenu on press-down, with a settle rule on the opening release. | +| End-user impact | A finger that lands on one menu row and lifts on another acts on the second, once, instead of on the first; a drag inside an open menu picks where it lets go; a mouse that lets go outside dismisses, a finger does not. PROPOSED: a mouse can press the trigger, drag into the menu and let go on a row in one gesture. Opening under a finger is `spec:AST-094` FR29's. Keyboard behavior and sub-menu hover are unchanged. | +| Builder impact | None for consumers of the five surfaces: they mount the model themselves. A component CAN adopt this model only when it meets the eligibility criteria below, and its component contract MUST explicitly name this module and the governed surfaces before mounting `useMenuPress`. The builder supplies an enabled-row selector and a `touch-action` declaration. | +| Compatibility/readiness | Additive public hook; existing consumer props unchanged; shipped keyboard behavior unchanged. Shipped-behavior changes: DEC-3 (current; a mouse released outside a menu dismisses it) and PROPOSED DEC-4 (a mouse opens the menu on press rather than click; under a finger, `spec:AST-094` FR29). jsdom evidence is complete for FR1–FR7, and real-Chromium evidence covers the touch pan, the finger slide and its stray click; FR10's resting finger, FR11's handoff and AR5's single open and close need real-browser evidence; iOS WebKit evidence is not required for this module. Authority: current for FR1–FR7, DEC-3, DEC-6, DEC-7 (`cixzhang`, 2026-10-02) and FR10, FR11, AR5, DEC-8 (`cixzhang`, 2026-10-25); FR8 and DEC-4 are proposed in this change and await the same owner (OQ1). | +| Review checks | Reject a row that acts on `pointerdown` or from the browser's click after a tracked release; a second activation reaching a row for one gesture; a highlight that lights two rows; a finger release outside that closes the menu, or a mouse release outside that leaves it open; a menu root without a `touch-action` declaration; a picker whose listbox takes DOM focus for the highlight; an opening release that acts before the pointer entered the menu or the press settled; a trigger whose press and click both toggle in one gesture. | +| Governing rules | `architecture:interaction-modality` INV2–INV4 (one operable path per modality, hover as enhancement, menus may move focus with the pointer); `architecture:public-component-api` for the new public hook; `architecture:layer-runtime` for the native light dismiss the model leaves in place; `family:overlay-dismissal` for Escape ordering, which this module does not change. | This table is a review projection; the body below is authoritative. @@ -151,19 +151,19 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral contract -| ID | Invariant | Basis | Verification state | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| FR1 | A row MUST act when a press is released over it, never because the press began on it; a press that never left its row is a tap or click on that row. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR26 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify | -| FR2 | While a press is held inside a menu, the highlighted row MUST be the enabled row under the pointer, and nothing MUST be highlighted over a divider, a heading, a disabled row, or outside the menu. A mouse moving with no button down moves the highlight the same way (existing hover-to-focus). Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, and a second finger ends the gesture (FR4); where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `architecture:interaction-modality` allowed variation for menus; `useMenuPress.test.tsx` | verify | -| FR3 | A press that began inside a menu and is released outside it MUST act on nothing. Under a mouse the menu MUST then close; under a finger or pen it MUST stay open (where the primary pointer is coarse, as `spec:AST-094` FR28 states). | DEC-3; `useMenuPress.test.tsx`, `DropdownMenu.test.tsx`, `Selector.test.tsx` | verify | -| FR4 | A menu whose rows fit MUST declare `touch-action: pinch-zoom`; one that scrolls MUST declare `touch-action: pan-y pinch-zoom` and `overscroll-behavior: contain`. Neither admits a one-finger pan the menu does not scroll, and both leave a two-finger pinch to the browser, so the page stays zoomable over an open menu. The model MUST treat `pointercancel` and a second pointer as the end of the gesture with nothing acting, and MUST NOT re-implement scrolling. | Browser scroll ownership; WCAG 2.2 SC 1.4.4 under `spec:AST-020` FR1; `DropdownMenu.test.tsx` touch-action case, `useMenuPress.test.tsx`, `MenuPinchZoom.a11y.chromium.spec.ts` | verify | -| FR5 | The click the browser reports for a gesture this model tracked MUST NOT act on any row; only the release acts. A click with no tracked gesture behind it, or one whose `detail` is 0, MUST act on its target as before. The swallower arms at a tracked release and disarms at the next `pointerdown` or after the stray-click window. | One gesture, one activation; `useMenuPress.test.tsx` FR5 suite | verify | -| FR6 | At most one row of a menu is lit. Under a finger the row a press began on MUST NOT keep a pressed or hovered look once the pointer left it, so menu rows paint `:active` only under `(hover: hover)`. Where the primary pointer is coarse, `spec:AST-094` FR27 and FR11 govern a finger's lit row and its press look instead. | `DropdownMenuItem.tsx` styles | verify | -| FR7 | While a press is tracked in a menu that scrolls, a pointer resting within the edge zone of the menu's top or bottom MUST scroll the menu toward that edge and re-read the row under the pointer as the rows move. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR30 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `useMenuPress.test.tsx` autoscroll case | verify | -| FR8 | On a mouse a DropdownMenu trigger MUST open its menu on press-down (button 0, no Control), and a drag from the trigger into the menu that releases over an enabled row MUST act on it. The opening gesture's release MUST act only if the pointer entered the menu or the press lasted the settle time; otherwise nothing acts and the menu stays open. Pressing the trigger of an open menu MUST close it and MUST NOT reopen it in the same gesture. | Proposed DEC-4; `DropdownMenu.test.tsx` press model suite, `menuPressGesture.test.ts` trigger states | proposed; pending owner review | -| FR9 | Where the primary pointer is coarse, opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29. Where it is fine, a finger keeps the released behavior (`spec:AST-094` FR19): a tap on the trigger opens the menu through its click, and a press held on the trigger for the long-press delay opens the menu with the finger down, prevents the default of the finger's later `touchmove`s until the gesture ends, and continues under FR1–FR4. | `spec:AST-094` FR29 | as `spec:AST-094` states | -| FR10 | A finger released on a sub-menu's row MUST open that sub-menu, as the row's own click would (the lift acts, `spec:AST-094` FR26; on a compact touch screen an `adaptive` row drills in, FR34); a finger resting on the row MUST NOT open it. A finger that slides from a row onto the rows of an open flyout picks there under ORD1. | DEC-8; `DropdownMenuSubMenu.test.tsx` press cases | verify | -| FR11 | A pointer that went down elsewhere, such as a context menu's long press, and is handed to the menu as it opens MUST be the opening press. A finger picks only after the handoff distance, counted from the first position the menu sees after it opened as `spec:AST-094` FR33 counts it, and its lift before that acts on nothing and leaves the menu open; a mouse follows FR8's settle rule. | DEC-8; `useMenuPress.test.tsx` continuation suite | verify | +| ID | Invariant | Basis | Verification state | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| FR1 | A row MUST act when a press is released over it, never because the press began on it; a press that never left its row is a tap or click on that row. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR26 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | macOS and iOS menu behavior; `menuPressGesture.test.ts`, `useMenuPress.test.tsx` | verify | +| FR2 | While a press is held inside a menu, the highlighted row MUST be the enabled row under the pointer, and nothing MUST be highlighted over a divider, a heading, a disabled row, or outside the menu. A mouse moving with no button down moves the highlight the same way (existing hover-to-focus). Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, and a second finger ends the gesture (FR4); where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `architecture:interaction-modality` allowed variation for menus; `useMenuPress.test.tsx` | verify | +| FR3 | A press that began inside a menu and is released outside it MUST act on nothing. Under a mouse the menu MUST then close; under a finger or pen it MUST stay open (where the primary pointer is coarse, as `spec:AST-094` FR28 states). | DEC-3; `useMenuPress.test.tsx`, `DropdownMenu.test.tsx`, `Selector.test.tsx` | verify | +| FR4 | A menu whose rows fit MUST declare `touch-action: pinch-zoom`; one that scrolls MUST declare `touch-action: pan-y pinch-zoom` and `overscroll-behavior: contain`. Neither admits a one-finger pan the menu does not scroll, and both leave a two-finger pinch to the browser, so the page stays zoomable over an open menu. The model MUST treat `pointercancel` and a second pointer as the end of the gesture with nothing acting, and MUST NOT re-implement scrolling. | Browser scroll ownership; WCAG 2.2 SC 1.4.4 under `spec:AST-020` FR1; `DropdownMenu.test.tsx` touch-action case, `useMenuPress.test.tsx`, `MenuPinchZoom.a11y.chromium.spec.ts` | verify | +| FR5 | The click the browser reports for a gesture this model tracked MUST NOT act on any row; only the release acts. A click with no tracked gesture behind it, or one whose `detail` is 0, MUST act on its target as before. The swallower arms at a tracked release and disarms at the next `pointerdown` or after the stray-click window. | One gesture, one activation; `useMenuPress.test.tsx` FR5 suite | verify | +| FR6 | At most one row of a menu is lit. Under a finger the row a press began on MUST NOT keep a pressed or hovered look once the pointer left it, so menu rows paint `:active` only under `(hover: hover)`. Where the primary pointer is coarse, `spec:AST-094` FR27 and FR11 govern a finger's lit row and its press look instead. | `DropdownMenuItem.tsx` styles | verify | +| FR7 | While a press is tracked in a menu that scrolls, a pointer resting within the edge zone of the menu's top or bottom MUST scroll the menu toward that edge and re-read the row under the pointer as the rows move. Where the primary pointer is coarse, a finger or pen follows `spec:AST-094` FR30 instead; where it is fine, a finger or pen keeps this rule (`spec:AST-094` FR19). | `useMenuPress.test.tsx` autoscroll case | verify | +| FR8 | On a mouse a DropdownMenu trigger MUST open its menu on press-down (button 0, no Control), and a drag from the trigger into the menu that releases over an enabled row MUST act on it. The opening gesture's release MUST act only if the pointer entered the menu or the press lasted the settle time; otherwise nothing acts and the menu stays open. Pressing the trigger of an open menu MUST close it and MUST NOT reopen it in the same gesture. A mouse pointer handed to the menu as it opens (FR11) follows the same settle rule. | Proposed DEC-4; `DropdownMenu.test.tsx` press model suite, `menuPressGesture.test.ts` trigger states | proposed; pending owner review | +| FR9 | Where the primary pointer is coarse, opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29. Where it is fine, a finger keeps the released behavior (`spec:AST-094` FR19): a tap on the trigger opens the menu through its click, and a press held on the trigger for the long-press delay opens the menu with the finger down, prevents the default of the finger's later `touchmove`s until the gesture ends, and continues under FR1–FR4. | `spec:AST-094` FR29 | as `spec:AST-094` states | +| FR10 | A finger released on a sub-menu's row MUST open that sub-menu, as the row's own click would (the lift acts, `spec:AST-094` FR26; on a compact touch screen an `adaptive` row drills in, FR34); a finger resting on the row MUST NOT open it. A finger that slides from a row onto the rows of an open flyout picks there under ORD1. | DEC-8; `DropdownMenuSubMenu.test.tsx` press cases | verify | +| FR11 | A pointer that went down elsewhere, such as a context menu's long press, and is handed to the menu as it opens MUST be the opening press. Where the primary pointer is coarse, a finger picks only after the handoff distance, counted from the first position the menu sees after it opened as `spec:AST-094` FR33 counts it, and its lift before that acts on nothing and leaves the menu open. | DEC-8; `useMenuPress.test.tsx` continuation suite | verify | ### Transformation and precedence order @@ -249,17 +249,17 @@ owned by the item components. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1–FR3, FR5 | `menuPressGesture.test.ts` (one case per transition of the diagram) | idle, tracking; mouse and touch; releases over a row, over chrome, on the trigger, outside; cancel | Acting on the press row, on the stray click, or dismissing under a finger fails a transition case | -| FR1–FR5, FR7, AR1, AR3 | `useMenuPress.test.tsx` with an injected hit test | release over another row, tap, stray click, `detail: 0`, mouse/finger outside, pointercancel, second pointer, autoscroll | A second activation, a swallowed assistive click, a wrong dismissal, or a dead autoscroll fails | -| FR1, FR3–FR5 | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, `ContextMenu.test.tsx` press suites | finger slide across rows, outside release by mouse and finger, `touch-action`, flyout release, root marker | Each removed behavior fails its named case | -| FR1, FR3, AR2 | `Selector.test.tsx` press model suite | finger release over another option, finger/mouse release outside, listbox marker | A listbox that takes focus, acts on the press option, or closes under a finger fails | -| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails | -| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails | -| FR8 (proposed), FR9 | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, settle before/after, cancel; finger opening per `spec:AST-094` FR29; press-open + drag-release, unsettled 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 | -| FR10, FR11, AR5 | `DropdownMenuSubMenu.test.tsx` press cases; `useMenuPress.test.tsx` continuation suite | a finger released on and resting on a sub-menu row; a continued finger, the distance from the first sample, a lift before it, a mouse; one open and one close announced | A sub-menu that opens on a resting finger, a continued finger that picks before the handoff, or an open withdrawn or announced twice fails | -| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1–FR3, FR5 | `menuPressGesture.test.ts` (one case per transition of the diagram) | idle, tracking; mouse and touch; releases over a row, over chrome, on the trigger, outside; cancel | Acting on the press row, on the stray click, or dismissing under a finger fails a transition case | +| FR1–FR5, FR7, AR1, AR3 | `useMenuPress.test.tsx` with an injected hit test | release over another row, tap, stray click, `detail: 0`, mouse/finger outside, pointercancel, second pointer, autoscroll | A second activation, a swallowed assistive click, a wrong dismissal, or a dead autoscroll fails | +| FR1, FR3–FR5 | `DropdownMenu.test.tsx`, `DropdownMenuSubMenu.test.tsx`, `ContextMenu.test.tsx` press suites | finger slide across rows, outside release by mouse and finger, `touch-action`, flyout release, root marker | Each removed behavior fails its named case | +| FR1, FR3, AR2 | `Selector.test.tsx` press model suite | finger release over another option, finger/mouse release outside, listbox marker | A listbox that takes focus, acts on the press option, or closes under a finger fails | +| FR4 (scroll) | `MenuPress.a11y.chromium.spec.ts` (real Chromium) | Chromium touch pan in an overflowing menu; touch-action by overflow; finger slide and its stray click | Not provable in jsdom; a pan that acts on a row, or a slide that acts on the press row, fails | +| FR4 (pinch) | `MenuPinchZoom.a11y.chromium.spec.ts` (real Chromium, phone emulation) | two-finger pinch over a menu whose rows fit, over one that scrolls, and over a picker listbox | Not provable in jsdom; a pinch that leaves the page at scale 1, or acts on a row, fails | +| FR8 (proposed), FR9 | `menuPressGesture.test.ts` trigger states; `useMenuPress.test.tsx` trigger suite; `DropdownMenu.test.tsx` press-open cases | mouse opens at once, settle before/after, cancel; finger opening per `spec:AST-094` FR29; press-open + drag-release, unsettled 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 | +| FR10, FR11, AR5 | `DropdownMenuSubMenu.test.tsx` press cases and a real-browser finger case for the resting row; `useMenuPress.test.tsx` continuation suite plus a real-browser handoff case; open and close event counts for AR5 | a finger released on and resting on a sub-menu row; a continued finger, the distance from the first sample, a lift before it, a mouse; the menu's open and close each observed once | A sub-menu that opens on a resting finger, a continued finger that picks before the handoff, or an open withdrawn, or opened or closed twice, fails | +| ORD1a (proposed) | `DropdownMenuSubMenu.test.tsx` press cases; `SubMenuHeaderFooter.a11y.chromium.spec.ts` press-drag case | a mouse press on a menu row released on a row of a sub-menu rendered beside the menu; the same press released on that sub-menu's header field | A release on such a row that acts on nothing or closes the menu, or a release on its header field that acts, fails. | ## Decision log From 3108132109def3770b335a5a78718d3bd6058b16 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sun, 25 Oct 2026 15:02:27 -0700 Subject: [PATCH 4/4] spec(useMenuPress): DEC-8 handoff is coarse-pointer; AR5 covers every menu opening; AST-094 OQ6 and OQ8 point to DEC-8 --- docs/specs/AST-094-touch-presentations/spec.md | 14 ++++++-------- .../src/DropdownMenu/modules/useMenuPress.spec.md | 10 ++++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 8fe4d4f46d400..b55f6a554c210 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -592,17 +592,15 @@ that reacts to scrolling keeps hearing it. sits between a 12 pt move that opened the menu and a 14 pt move that did not. Is that bracket its justification, or does it need a finer measurement? (`owner decision`) -- **OQ6 — Opening a sub-menu without a lift.** A sub-menu drills in when a - finger lifts on its row (FR26, FR34). Can a held finger also open it - by resting on that row, and keep picking inside it without lifting? - `module:DropdownMenu/useMenuPress` ORD1a covers only a mouse. - (`owner decision`) +- **OQ6 — Opening a sub-menu without a lift.** Resolved by + `module:DropdownMenu/useMenuPress` DEC-8: a finger resting on a sub-menu row + never opens it; a lift on the row does (its FR10). - **OQ7 — The opening haptic.** UIKit plays a haptic as a held finger opens a menu; a page in iOS Safari cannot (FR23). Where does the menu record that gap? (`owner decision`) -- **OQ8 — Withdrawing a tentative open.** What does a screen reader hear when - a menu that a held finger opened is withdrawn by a cancel before the - finger reached it? (`owner decision`) +- **OQ8 — Withdrawing a tentative open.** Resolved by + `module:DropdownMenu/useMenuPress` DEC-8: nothing withdraws an open, so a + screen reader hears one open and one close (its AR5). - **OQ9 — Where a menu sits on a compact touch screen.** Should an anchored menu on a compact touch screen (`spec:AST-012` FR9) cover its trigger, centered on it and top-aligned with it, or bottom-aligned with it when the diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index b85f70977ae20..21669f6c39d4e 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -219,8 +219,9 @@ Consumer migration instructions belong in consumer docs and release notes. - **AR4 — Focus after a pick is the component's.** The model activates the row as its own click would; where focus goes once the menu closes stays with the owning component record and is unchanged by this module. -- **AR5 — One open, one close.** A finger opens the menu once, as - `spec:AST-094` FR29 states or through a tap's click, and nothing withdraws +- **AR5 — One open, one close.** A finger opens a menu once, through + `spec:AST-094` FR29's opening, a context menu's long press (`spec:AST-094` + FR33), FR9's held press, or a tap's click, and nothing withdraws an open: a screen reader hears the menu open once, and close once at a pick, a dismissal or Escape (DEC-8). @@ -318,8 +319,9 @@ availability alone does not make a component an adopter. A finger that lifts on a sub-menu's row opens it, as a tap would, and a finger resting on the row never opens it, so a sub-menu does not open from under a finger that is still choosing. A finger handed to a menu as it opens, such as -the long press that opened a context menu, is that menu's opening press: it -picks once it has travelled the handoff distance, so the press that opened the +the long press that opened a context menu, is that menu's opening press: where +the primary pointer is coarse, it picks once it has travelled the handoff +distance, so the press that opened the menu never picks by itself. A finger opens a menu once, and nothing withdraws the open, so a screen reader hears one open and one close.