From 10d85dd9ffa3a986ff158acce98cf9f8422d28ef Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 20:40:15 -0700 Subject: [PATCH 01/12] spec(AST-094, AST-057): gestures work on the web for every pointer (FR43, DEC-21) --- docs/architecture/react-component-runtime.md | 7 ++- docs/specs/AST-057-row-actions/spec.md | 8 ++- .../specs/AST-094-touch-presentations/spec.md | 61 +++++++++++++------ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index c5af06119ec36..dc7f65ff032ed 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -183,9 +183,10 @@ those contracts. keeps one copy per page however many components use them. There is no install call, no public entry, and no public installed check; tests reset the modules through an internal hook only. A component's own touch logic is - small glue in its own build over these modules. Where the primary pointer - is fine, no recognizer attaches and no touch listener is registered - (`spec:AST-094` FR19, FR24). The modules are not public exports and are not + small glue in its own build over these modules. The gesture host attaches for + every pointer, with no touch-only query, and pointer and hover queries live + with the environment queries; under a fine pointer only a component that + uses a gesture listens (`spec:AST-094` FR19, FR24, FR43). The modules are not public exports and are not documented for consumers; Core and Lab components reach them through `@astryxdesign/core/internal/touch`, a subpath reserved for Astryx's own packages: undocumented, outside the compatibility promise, versioned in diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index e889602ad6c4d..bb25174654035 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -247,9 +247,11 @@ constants. activate it; while closed the panel MUST be out of the accessibility tree and the tab order. Under `commit` the panel MUST be presentational (`aria-hidden`) at all times, because nothing in it is ever tappable. -- **AR2 — The gesture touches nothing on a pointer that can hover.** - `swipeActions` MUST have no effect on the tab order, the accessible tree, - or the row's paint on such a pointer. Hover MUST NOT uncover the panel. +- **AR2 — A mouse never swipes a row.** On a pointer that can hover, + `swipeActions` MUST have no effect on the tab order or the accessible tree, + a mouse drag MUST NOT reveal the panel, and hover MUST NOT uncover it. A + trackpad's horizontal scroll swipe MAY reveal the panel, as a pan that takes + trackpad scrolling (`spec:AST-094` FR43). - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no stop while closed. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 94fda268d7a00..8fd99804faa38 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -42,8 +42,9 @@ a measured, sourced difference exists. ## Non-goals -- Pointer (desktop) presentations. Their timing and easing stay with the - theme's duration and easing tokens (`architecture:theme-tokens`). +- Pointer (desktop) presentations' timing and easing, which stay with the + theme's duration and easing tokens (`architecture:theme-tokens`). Gestures + and press tracking themselves apply to every pointer (FR43). - Platform-matched look and feel: glass versus material surfaces, glyphs and chrome. This record governs behavior only. - WebKit embedded in a native app and the native Swift package. This record @@ -186,6 +187,8 @@ a measured, sourced difference exists. views and activates by the browser's click on a lift inside. On the web a control's press ends when the browser takes the touch (FR20), and its activation stays the browser's click. + Under a fine pointer a control uses a third profile with no delay: its + press paints at press-down and clears at the release (DEC-21). A cell (a table or collection cell) highlights 150 ms after touch-down inside a scroll view, drops the highlight when the scroll wins (FR20), and activates on a lift inside it with no scroll taking the touch (FR20). A control is inside a scroll view when it sits in a scrollable container, @@ -257,11 +260,8 @@ a measured, sourced difference exists. decides at 15 px of travel (UIKit's screen-edge hysteresis); outside the band the author's scrolling is untouched, and FR19's 10 px governs every other claim (DEC-13). -- **FR19 — Claims are made in time and only by an owner.** Recognizers attach only where the primary pointer - is coarse (FR13, DEC-6). Where the primary pointer is fine, no recognizer - attaches and no touch listener is registered (FR24), so a finger on a - touchscreen whose primary pointer is fine gets the browser's defaults for - this record's behaviors (`spec:AST-017` FR59, DEC-25). This rule governs +- **FR19 — Claims are made in time and only by an owner.** Recognizers attach for every pointer, with FR43's + exceptions. This rule governs this record's behaviors only; `spec:AST-057` keeps its per-event pointer read for the row swipe, as its record states. A recognizer that begins MUST claim its touches by cancelling `touchmove` through a non-passive listener on its element; a watcher, @@ -307,13 +307,11 @@ a measured, sourced difference exists. FR26–FR28 and FR30 apply to every surface that carries the press model of `module:DropdownMenu/useMenuPress` (DropdownMenu, ContextMenu, their sub-menus, -and Selector's listbox, whose options count as rows) when a finger or pen -drives it and the primary pointer is coarse (FR19); where the primary pointer -is fine, a finger in a menu keeps today's behavior (`spec:AST-017` FR59, -DEC-25), and menus make no per-gesture pointer read; FR29 applies to DropdownMenu's trigger under the same +and Selector's listbox, whose options count as rows) under every pointer +(FR43); FR29 applies to DropdownMenu's trigger under the same condition; FR34 applies to menus; FR31 and FR35 apply to anchored menus and to Popover under a finger; FR32 and -FR33 apply to menus only. A mouse keeps today's behavior. +FR33 apply to menus only. - **FR26 — The lift decides.** A menu row MUST act when the finger lifts over it, never because the press began on it; a press that never left its row is @@ -442,11 +440,11 @@ FR33 apply to menus only. A mouse keeps today's behavior. ### Resources and compatibility -- **FR24 — Touch work costs nothing where it is not used.** Where the - primary pointer is fine (FR19), and while a touch presentation is - disabled, no touch-only listener, observer or timer is active, except the - released `module:DropdownMenu/useMenuPress` behavior a finger keeps there - (its FR9). The number of `document`- or `window`-level +- **FR24 — Gesture work costs nothing where it is not used.** A component + that uses no gesture registers no gesture listener, observer or timer, and + while a touch presentation is disabled none is active. Under a fine pointer + a component that uses a gesture keeps one listener on its element and adds + document listeners only during a press, removing them at the release (FR43). The number of `document`- or `window`-level touch listeners MUST NOT grow with the number of component instances, a repeated row or cell adds no touch observer of its own, and nothing touch-related remains after the last instance unmounts @@ -464,6 +462,19 @@ FR33 apply to menus only. A mouse keeps today's behavior. This replaces `component:Selector` DEC-1's width-and-pointer query with the FR13 rule for `adaptive`. +- **FR43 — Gestures work on the web.** Gestures attach for every pointer, a + mouse, a pen, and a finger, not only a coarse primary pointer. These stay + off: swipe actions under a mouse, which would take the browser's text + selection and native drag (`spec:AST-057` AR2); a pinch under a pen; the + screen-edge pan on every web input; and the 44 px touch target, which stays + coarse-only and never overlaps a neighbor's target. On the web, a pan also + takes wheel and trackpad scrolling; a pinch takes a ctrl-wheel; one shared + context request answers a long press, a right click, and Shift+F10; the + press model has a fine-pointer profile with no delay (FR11); and a reorder + starts on a drag from its handle without a hold. Under a fine pointer only a + component that uses a gesture listens (FR24), and the change adds no + stylesheet bytes (`spec:AST-080` FR8) (DEC-21). + ### Platform support - Supported engines: Astryx's supported browsers (`spec:AST-013`) with touch @@ -551,6 +562,7 @@ FR33 apply to menus only. A mouse keeps today's behavior. | FR34, FR35 | Browser tests and storyboards under a finger | a sub-menu drilling in and its Back row returning; a tap and a drag during a popover's and a menu's growth; a pinch during growth; a mouse | a drill-in that leaves the parent's rows in place, a change between levels that does not play on its roles, a tap during growth that closes, scrolls or moves focus, a pinch blocked other than by the author's `touch-action`, or a changed mouse path fails | | FR36–FR40 | Release tests on synthetic touch streams with explicit timestamps, per use | below and past each commit point; a flick that projects past it; a slow drag past it; a release moving back; Reduce Motion; keyboard and mouse paths | a decision that changes mid-settle, a release moving back that commits, a settle that does not start at the release velocity, or an action with no keyboard or mouse path fails | | FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | +| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse swipe action, a pen pinch, a web edge pan, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | | all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect | ## Decision log @@ -702,6 +714,21 @@ iOS 26.2 simulator measures it. Rejected: the 150 ms delay on every menu row. +### DEC-21 — Gestures work on the web, not only under a finger + +**Reference:** `spec:AST-094/DEC-21` +**Decider:** `cixzhang`, `2026-10-26` + +Most of these gestures work as well with a mouse, a trackpad, or a pen, so +they stay on for every pointer, with web inputs mapped in: wheel and trackpad +scrolling for a pan, a ctrl-wheel for a pinch, and right click or Shift+F10 +for a long press. What would fight the browser stays off: a mouse's swipe +actions, a pen's pinch, and the screen-edge pan. The 44 px target stays +touch-only. This supersedes the coarse-only press controller: a mouse press +now uses the press model with no delay instead of the browser's `:active`. + +Rejected: keeping gestures and press tracking touch-only. + ## Open questions - **OQ1 — Scroll view bounce-back curve.** What curve brings a scroll view From c037c2f73400a975dc93b1c208d697d1dab29ba7 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 20:55:58 -0700 Subject: [PATCH 02/12] spec(AST-094): the fine-pointer press paints through the press controller at once; the scroll brake stays touch-only --- docs/specs/AST-094-touch-presentations/spec.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 8fd99804faa38..baff1c3479548 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -188,7 +188,9 @@ a measured, sourced difference exists. control's press ends when the browser takes the touch (FR20), and its activation stays the browser's click. Under a fine pointer a control uses a third profile with no delay: its - press paints at press-down and clears at the release (DEC-21). + press paint starts at once on `pointerdown`, through the same press + controller and release fade as a finger's, in place of CSS `:active`; the + scroll brake stays touch-only (DEC-21). A cell (a table or collection cell) highlights 150 ms after touch-down inside a scroll view, drops the highlight when the scroll wins (FR20), and activates on a lift inside it with no scroll taking the touch (FR20). A control is inside a scroll view when it sits in a scrollable container, From 03290964090947ef0d56562eb9fc4f12e323f575 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 21:43:49 -0700 Subject: [PATCH 03/12] =?UTF-8?q?spec:=20gestures=20on=20web=20=E2=80=94?= =?UTF-8?q?=20useMenuPress,=20user-states,=20buttons=20follow=20every=20po?= =?UTF-8?q?inter;=20FR11=20three=20profiles;=20row=20swipes=20stay=20touch?= =?UTF-8?q?-only;=20FR24=20and=20INV13=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/react-component-runtime.md | 24 +++++++------- docs/design/user-states.md | 4 +-- docs/families/buttons.md | 18 +++++------ docs/specs/AST-057-row-actions/spec.md | 8 ++--- .../specs/AST-094-touch-presentations/spec.md | 20 +++++++----- .../DropdownMenu/modules/useMenuPress.spec.md | 32 +++++++++---------- 6 files changed, 54 insertions(+), 52 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index dc7f65ff032ed..80f161df39345 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -328,15 +328,15 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | -| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | -| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | -| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | -| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a touch listener under a fine primary pointer, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | +| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | +| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | +| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | +| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a gesture listener in a component that uses no gesture, or an idle document listener outside a press, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | diff --git a/docs/design/user-states.md b/docs/design/user-states.md index 1f2b156049225..191373429df7d 100644 --- a/docs/design/user-states.md +++ b/docs/design/user-states.md @@ -52,12 +52,12 @@ surface. - **DR1 — Press the manipulated surface.** During pointer hold or drag, each enabled surface in the matrix below MUST paint `--color-overlay-pressed` on the named visual part. The overlay is transient and MUST clear when the press - or drag ends. Under a finger, the overlay's timing follows `spec:AST-094` FR11's press profiles. + or drag ends. The overlay's timing follows `spec:AST-094` FR11's press profiles under every pointer. - **DR2 — Disabled means no response.** A disabled control in the matrix MUST remain inoperable and MUST NOT gain a pressed visual treatment. - **DR3 — Persistent selection wins.** A selected SegmentedControl item MUST keep its raised selected surface during pointer hold; only an unselected item - receives the pressed overlay. Under a finger the item keeps that surface, and its press timing follows + receives the pressed overlay. Under every pointer the item keeps that surface, and its press timing follows `spec:AST-094` FR11. ## Anatomy and hierarchy diff --git a/docs/families/buttons.md b/docs/families/buttons.md index 6ef0a725e986e..b86444dc362dd 100644 --- a/docs/families/buttons.md +++ b/docs/families/buttons.md @@ -93,15 +93,15 @@ Membership follows public responsibility, not an import of Button or a rendered ## Canonical concepts -| Concept | Values or states | Default semantics | Stability | -| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -| activation model | momentary action, navigation, persistent press | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction | -| content mode | visible label, custom visible content, icon-only | every control has a required accessible `label`; icon-only hides it visually | shipped family rule | -| size | `sm`, `md`, `lg` | `md`; explicit member size wins over an inherited group size | shipped family axis | -| visual state | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose; under a finger, press feedback follows `spec:AST-094` FR11's control profile | shipped family rule | -| async action | absent, fire-once, interruptible persistent action | ordinary actions deduplicate while pending; persistent toggles remain reversible | shipped family distinction | -| elevation | `none`, `low`, `med`, `high` | `none`; the element painting the visible surface owns the shadow | shipped family axis; ToggleButton adoption approved | -| grouping | standalone, spaced set, connected surface | semantics and painted containment decide ownership; grouping alone does not | family rule | +| Concept | Values or states | Default semantics | Stability | +| ---------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| activation model | momentary action, navigation, persistent press | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction | +| content mode | visible label, custom visible content, icon-only | every control has a required accessible `label`; icon-only hides it visually | shipped family rule | +| size | `sm`, `md`, `lg` | `md`; explicit member size wins over an inherited group size | shipped family axis | +| visual state | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose; press feedback follows `spec:AST-094` FR11's control profile under a finger and its fine-pointer profile under a mouse or a pen | shipped family rule | +| async action | absent, fire-once, interruptible persistent action | ordinary actions deduplicate while pending; persistent toggles remain reversible | shipped family distinction | +| elevation | `none`, `low`, `med`, `high` | `none`; the element painting the visible surface owns the shadow | shipped family axis; ToggleButton adoption approved | +| grouping | standalone, spaced set, connected surface | semantics and painted containment decide ownership; grouping alone does not | family rule | ## Cross-component invariants diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index bb25174654035..e889602ad6c4d 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -247,11 +247,9 @@ constants. activate it; while closed the panel MUST be out of the accessibility tree and the tab order. Under `commit` the panel MUST be presentational (`aria-hidden`) at all times, because nothing in it is ever tappable. -- **AR2 — A mouse never swipes a row.** On a pointer that can hover, - `swipeActions` MUST have no effect on the tab order or the accessible tree, - a mouse drag MUST NOT reveal the panel, and hover MUST NOT uncover it. A - trackpad's horizontal scroll swipe MAY reveal the panel, as a pan that takes - trackpad scrolling (`spec:AST-094` FR43). +- **AR2 — The gesture touches nothing on a pointer that can hover.** + `swipeActions` MUST have no effect on the tab order, the accessible tree, + or the row's paint on such a pointer. Hover MUST NOT uncover the panel. - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no stop while closed. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index baff1c3479548..8d2b100834576 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -182,7 +182,7 @@ a measured, sourced difference exists. `module:DropdownMenu/useMenuPress` FR4: a second pointer or `pointercancel` ends its gesture with nothing acting, which keeps the page zoomable over an open menu. -- **FR11 — Two press profiles.** Press feedback MUST follow one of two native +- **FR11 — Three press profiles.** Press feedback MUST follow one of three profiles. A control (UIControl) highlights on touch-down outside scroll views and activates by the browser's click on a lift inside. On the web a control's press ends when the browser takes the touch (FR20), and its @@ -313,7 +313,8 @@ and Selector's listbox, whose options count as rows) under every pointer (FR43); FR29 applies to DropdownMenu's trigger under the same condition; FR34 applies to menus; FR31 and FR35 apply to anchored menus and to Popover under a finger; FR32 and -FR33 apply to menus only. +FR33 apply to menus only. A mouse keeps `module:DropdownMenu/useMenuPress` +FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. - **FR26 — The lift decides.** A menu row MUST act when the finger lifts over it, never because the press began on it; a press that never left its row is @@ -446,7 +447,9 @@ FR33 apply to menus only. that uses no gesture registers no gesture listener, observer or timer, and while a touch presentation is disabled none is active. Under a fine pointer a component that uses a gesture keeps one listener on its element and adds - document listeners only during a press, removing them at the release (FR43). The number of `document`- or `window`-level + document listeners only during a press, removing them at the release (FR43); + the shared press controller, installed once per page, counts once and not + per component. The number of `document`- or `window`-level touch listeners MUST NOT grow with the number of component instances, a repeated row or cell adds no touch observer of its own, and nothing touch-related remains after the last instance unmounts @@ -466,10 +469,11 @@ FR33 apply to menus only. - **FR43 — Gestures work on the web.** Gestures attach for every pointer, a mouse, a pen, and a finger, not only a coarse primary pointer. These stay - off: swipe actions under a mouse, which would take the browser's text - selection and native drag (`spec:AST-057` AR2); a pinch under a pen; the - screen-edge pan on every web input; and the 44 px touch target, which stays - coarse-only and never overlaps a neighbor's target. On the web, a pan also + off: row swipe actions under a mouse or a trackpad, which would take the + browser's text selection and native drag (`spec:AST-057` AR2, DEC-4); a + pinch under a pen; the screen-edge pan under a mouse, a trackpad, or a pen; + and the touch target size, which stays coarse-only and never overlaps a + neighbor's target. On the web, a pan also takes wheel and trackpad scrolling; a pinch takes a ctrl-wheel; one shared context request answers a long press, a right click, and Shift+F10; the press model has a fine-pointer profile with no delay (FR11); and a reorder @@ -556,7 +560,7 @@ FR33 apply to menus only. | FR15–FR17 | Recognizer unit tests on synthetic streams with explicit timestamps | exclusive, simultaneous, require-to-fail, nested; a watcher beside a claiming winner; a blocked main thread; a pan at 10 pt per axis | a wrong winner, a late deadline, or a translation that keeps the hysteresis, or a watcher failed or cancelled by a claiming winner fails | | FR18–FR22 | Browser tests with real touch input (`spec:AST-013` FR10) | per-axis pans beside native scrolling, page zoom, cancelled touches, clicks after taps, keyboard activation, a modal scroll lock, a control that keeps its press through a drag; a still finger with sub-pixel jitter over a scroll view; a finger moved 9 px and 10 px straight, and (7, 7) and (8, 8) diagonally; a recognizer on an element whose author `touch-action` is narrower, including one without `pinch-zoom`; a screen-edge pan landing at 40 px and at 41 px from the edge, at 14 px and 15 px of travel, and a horizontal scroll that starts outside the band | a ghost click, a scroll or zoom blocked other than by the author's `touch-action`, or a cancelled unowned touchmove outside FR19's two exceptions, a tap cancelled by sub-pixel movement or by under 10 px of travel, a scroll that does not take the touch at 10 px, or a derived `touch-action` wider than the author's, a screen-edge pan that claims outside its band or before 15 px, or has not claimed at 15 px, or an author scroll outside the band that is blocked fails | | FR23 | Haptics tests on the recorded moments | tap, drag, lift | a haptic outside a native moment fails | -| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a touch listener on a fine pointer, a per-instance `document` or `window` listener, or a leak fails | +| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a gesture listener in a component that uses no gesture, an idle document listener outside a press, a second press controller, a per-instance `document` or `window` listener, or a leak fails | | FR25 | Component tests of each moved default and its `presentation` value | default; `presentation` set to the released surface | a moved default that `presentation` does not restore, or a public threshold, fails | | FR26–FR28, FR30 | Menu press tests on synthetic touch streams and a real-Chromium touch test | a slide from one row to another; a lift outside; a cancel; a second finger; edge autoscroll | an act on the press-start row, two acts for one gesture, a lift outside that closes, or a lit row after a cancel fails | | FR29 | Trigger tests with explicit timestamps | a press in a fixed bar; a press in scrolling content held 149 and 150 ms; a quick tap in scrolling content; a scroll taking the touch before 150 ms; a drift of 12 and 14 pt; moves before and after the handoff; `presentation="popover"` | a fixed-bar press that waits, a held finger's scroll-view open before 150 ms or after a scroll, a quick tap that does not open at its lift, an open past the drift allowance, a lift that acts before the handoff, or a `popover` trigger that opens on press fails | diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index 21669f6c39d4e..cf1c4d922778c 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -136,32 +136,32 @@ 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 (none in a fixed bar; 150 ms in a scroll view) | 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 | +| 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. A finger or pen follows `spec:AST-094` FR26 instead, under every primary pointer (`spec:AST-094` FR43); a mouse keeps this rule. | 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). A finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, under every primary pointer, and a second finger ends the gesture (FR4); a mouse keeps this rule. | `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 | +| 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. A finger or pen follows `spec:AST-094` FR30 instead, under every primary pointer; a mouse keeps this rule. | `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 | +| FR9 | Opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29, under every primary pointer (`spec:AST-094` FR43); a mouse opens the menu as `component:DropdownMenu` FR8 states. | `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 | From 91bc49173473b7db5cccbbbda9bf8bf35bf2147f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 21:47:41 -0700 Subject: [PATCH 04/12] spec(AST-057, AST-094): a trackpad's horizontal scroll swipes a row; a mouse never does (DEC-7) --- docs/specs/AST-057-row-actions/spec.md | 78 +++++++++++-------- .../specs/AST-094-touch-presentations/spec.md | 5 +- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index e889602ad6c4d..4ae99f07d1af5 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -44,15 +44,15 @@ review_triggers: [public-api, accessibility, behavior] ## Contract at a glance -| Area | Contract | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | `Item` carries touch swipe actions. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal (DEC-4). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | -| Behavior | On a coarse pointer a sideways drag moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | -| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. Nothing changes for anyone else: a mouse, a keyboard, or a screen reader meets the row exactly as today. | -| Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | -| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | -| Review checks | Reject a hover-revealed panel; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | -| Governing rules | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5. | +| Area | Contract | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `Item` carries touch swipe actions. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal and no mouse swipe (DEC-4); a trackpad's horizontal scroll swipes as a finger does (DEC-7). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | +| Behavior | On a coarse pointer a sideways drag, or a trackpad's horizontal scroll, moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | +| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. Nothing changes for anyone else: a mouse, a keyboard, or a screen reader meets the row exactly as today. | +| Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | +| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | +| Review checks | Reject a hover-revealed panel or a mouse drag that reveals one; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | +| Governing rules | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5. | This table is a review projection; the body below is authoritative. @@ -67,8 +67,9 @@ and not on one whose role forbids them. That sorts the list row and the standalone row in, and the option, menu-item and radio rows out, without naming a component; a row type nobody has written yet sorts itself. -The gesture is touch-only and is an accelerator: it adds a faster path to a -verb on a coarse pointer and changes nothing on any other input. Each side's +The gesture is an accelerator for a finger and a trackpad: it adds a faster +path to a verb under a finger or a trackpad's horizontal scroll, and changes +nothing for a mouse, hover, or the keyboard. Each side's verbs are declared as data — a label that names the state the verb reaches, an icon, a handler, how it looks, and whether the row holds out afterwards — so the component knows everything it needs before the finger moves: what to @@ -84,14 +85,13 @@ and its panels stay put, so no element is added to any row. - The rule for where swipe actions are available: the row's role. - The two models, `reveal` and `commit`, meaning the same thing wherever swipe actions are available. -- The coarse-pointer behavior: rest or fire, the accelerator, where the row +- The finger and trackpad behavior: rest or fire, the accelerator, where the row rests after an action, one open row at a time, and how a row closes. - The anatomy the gesture uses: two out-of-flow positions on `Item`'s existing root, the root's translation and the panels' counter-translation, and the group's inline clip. - The accessibility contract of the gesture itself: what the panel exposes - while resting and while closed, and that the gesture touches nothing on a - pointer that can hover. + while resting and while closed, and that a mouse and hover touch nothing. **Why no existing record can hold it** @@ -135,7 +135,7 @@ and its panels stay put, so no element is added to any row. and spring durations, resistance past the panel: behavior constants in source, not public API and not design tokens (the same ruling the touch press model received for its clocks). -- **Vertical swipes**, two-finger trackpad swipes, and a swipe whose only +- **Vertical swipes**, and a swipe whose only meaning is dismissal (Toast already has that in `useToastGesture`). - **Public out-of-flow slots on `Item`.** Deliberately not admitted: the panel positions are internal, rendered from `swipeActions`. A second @@ -232,7 +232,7 @@ constants. | FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | | FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | | FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | -| FR4 | A sideways drag on a coarse pointer MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | +| FR4 | A sideways drag on a coarse pointer, or a trackpad's horizontal scroll, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | | FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2), because the swipe's own touch pointer started it. `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | | FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | | FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | @@ -247,9 +247,11 @@ constants. activate it; while closed the panel MUST be out of the accessibility tree and the tab order. Under `commit` the panel MUST be presentational (`aria-hidden`) at all times, because nothing in it is ever tappable. -- **AR2 — The gesture touches nothing on a pointer that can hover.** - `swipeActions` MUST have no effect on the tab order, the accessible tree, - or the row's paint on such a pointer. Hover MUST NOT uncover the panel. +- **AR2 — A mouse never swipes a row.** A mouse drag and hover MUST NOT + uncover the panel, and on a pointer that can hover a closed row's + `swipeActions` MUST have no effect on the tab order, the accessible tree, or + the row's paint. A trackpad's horizontal scroll swipe MAY uncover the panel + (DEC-7), and a row it leaves resting open follows AR1. - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no stop while closed. @@ -263,8 +265,8 @@ constants. - Supported feature/engine floor: every supported renderer and browser. Pointer type is read from the pointer event. -- Unsupported behavior: a device that never produces a coarse pointer gets - no swipe and nothing else changes. +- Unsupported behavior: a device with neither a touch screen nor a trackpad + gets no swipe and nothing else changes. - Browser evidence: FR2 and FR4–FR9 are paint and gesture claims. jsdom has neither; real-Chromium evidence with dispatched touch is required, and FR5/FR6 additionally need a physical touch device, because Chromium evidence @@ -294,15 +296,15 @@ constants. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | -| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | -| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | -| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | -| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | -| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, or any effect on a fine pointer fails. | -| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | +| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | +| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | +| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | +| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | +| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer; a trackpad horizontal scroll that opens a row; a mouse drag | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, a panel a mouse drag or hover uncovers, or any effect on a closed row under a hovering pointer fails. | +| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | Known verification gap: none of the suites above exist on `main`, and no component implements the contract. This record is `draft`, does not govern @@ -419,8 +421,8 @@ owns the one coordination case. **Reference:** `spec:AST-057/DEC-4` **Decider:** `cixzhang`, `2026-10-02` -On a pointer that can hover, `swipeActions` does nothing. The gesture is a -touch accelerator and changes nothing on any other input; what a row offers +A mouse drag and hover do nothing with `swipeActions`; a trackpad's +horizontal scroll is the one non-touch input that swipes a row (DEC-7); what a row offers a mouse or a keyboard is the row's own content, which this record does not govern. @@ -493,6 +495,18 @@ decorative colours (`blue`, `cyan`, …) that a verb slot must not admit. Rejected: the menu row's two-value `'default' | 'destructive'` — it has no word for the one action a panel exists to offer. +### DEC-7 — A trackpad swipes a row; a mouse does not + +**Reference:** `spec:AST-057/DEC-7` +**Decider:** `cixzhang`, `2026-10-26` + +A trackpad's two-finger horizontal scroll is the same motion as a finger's +swipe and takes nothing else from the page, so it reveals and fires row +actions as a finger does (`spec:AST-094` FR43). A mouse drag would take the +browser's text selection and native drag, so a mouse never swipes a row. + +Rejected: keeping swipe actions touch-only. + ## Open questions None. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 8d2b100834576..449c64c5c8eb7 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -469,8 +469,9 @@ FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. - **FR43 — Gestures work on the web.** Gestures attach for every pointer, a mouse, a pen, and a finger, not only a coarse primary pointer. These stay - off: row swipe actions under a mouse or a trackpad, which would take the - browser's text selection and native drag (`spec:AST-057` AR2, DEC-4); a + off: row swipe actions under a mouse, which would take the browser's text + selection and native drag, while a trackpad's horizontal scroll swipes a + row (`spec:AST-057` AR2, DEC-7); a pinch under a pen; the screen-edge pan under a mouse, a trackpad, or a pen; and the touch target size, which stays coarse-only and never overlaps a neighbor's target. On the web, a pan also From a3f189c394b07b3c66e9b331f9bcdc188e668c70 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 01:14:58 -0700 Subject: [PATCH 05/12] Merge main into spec/gestures-on-web --- docs/architecture/react-component-runtime.md | 26 ++++++------ docs/specs/AST-057-row-actions/spec.md | 40 ++++++++++--------- .../specs/AST-094-touch-presentations/spec.md | 26 ++++++++---- .../DropdownMenu/modules/useMenuPress.spec.md | 13 +++--- 4 files changed, 59 insertions(+), 46 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 38d320eea56d2..bb79a1843b0a3 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -341,16 +341,16 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | -| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | -| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | -| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | -| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a gesture listener in a component that uses no gesture, or an idle document listener outside a press, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | -| INV15 | Unit tests of the animator's value mode and real-browser proofs of its adopters | A value off its motion role, a count past the component's swap size, a count or cross-fade under reduced motion with a coarse or a fine pointer, an intermediate value announced, or a public export fails | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | +| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | +| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | +| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | +| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a gesture listener in a component that uses no gesture, or an idle document listener outside a press other than the one shared press controller, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | +| INV15 | Unit tests of the animator's value mode and real-browser proofs of its adopters | A value off its motion role, a count past the component's swap size, a count or cross-fade under reduced motion with a coarse or a fine pointer, an intermediate value announced, or a public export fails | diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index 4ae99f07d1af5..69089de04d0b7 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -48,7 +48,7 @@ review_triggers: [public-api, accessibility, behavior] | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Public contract | `Item` carries touch swipe actions. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal and no mouse swipe (DEC-4); a trackpad's horizontal scroll swipes as a finger does (DEC-7). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | | Behavior | On a coarse pointer a sideways drag, or a trackpad's horizontal scroll, moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | -| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. Nothing changes for anyone else: a mouse, a keyboard, or a screen reader meets the row exactly as today. | +| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. A trackpad's horizontal scroll swipes the row the same way; a mouse drag, a keyboard, or a screen reader meets the row exactly as today. | | Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | | Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | | Review checks | Reject a hover-revealed panel or a mouse drag that reveals one; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | @@ -217,7 +217,7 @@ and its panels stay put, so no element is added to any row. | the drag's travel | a private custom property on the row root | Written by the gesture during a drag. The root's `transform` reads it; each panel's `transform` reads its negation, so the row moves and the panel appears fixed in the space the row vacates. | `0px` | `spec:AST-057` | proposed | | the group's clip | `overflow-inline: clip` on the element containing the rows | A moving row paints past its group's inline edge unless the group clips. `List` sets it; a host that renders `Item`s outside `List` sets it once. `Item` cannot clip itself: it is the thing moving. | — | `component:List` / the host | proposed | | open state | closed, resting open | The row's own. A pointer landing anywhere outside a resting row closes it, which is also how a drag on a neighbour closes it; no group state exists. | closed | `spec:AST-057` | proposed | -| the modality split | internal | A mouse never starts the drag; a coarse pointer does. Read from the pointer event, not a prop or a media query (`architecture:interaction-modality` INV6). | — | `spec:AST-057` | proposed | +| the modality split | internal | A mouse drag never starts the swipe; a finger's pointer event or a horizontal wheel gesture does, read from the input event, not a prop or a media query (`architecture:interaction-modality` INV6). | — | `spec:AST-057` | proposed | Not public: axis-lock distance, commit ratio, fling velocity, durations, resistance, panel widths, the custom property's name. They are behavior @@ -227,17 +227,17 @@ constants. ### Behavioral contract -| ID | Invariant | Basis | Verification state | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | -| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | -| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | -| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | -| FR4 | A sideways drag on a coarse pointer, or a trackpad's horizontal scroll, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | -| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2), because the swipe's own touch pointer started it. `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | -| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | -| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | -| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | -| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | +| ID | Invariant | Basis | Verification state | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | +| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | +| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | +| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | +| FR4 | A sideways drag on a coarse pointer, or a trackpad's horizontal scroll, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | +| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2) when a finger started the swipe, or arrives at once (DR3) when a trackpad did. `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | +| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | +| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | +| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | +| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | ### Accessibility contract @@ -250,8 +250,8 @@ constants. - **AR2 — A mouse never swipes a row.** A mouse drag and hover MUST NOT uncover the panel, and on a pointer that can hover a closed row's `swipeActions` MUST have no effect on the tab order, the accessible tree, or - the row's paint. A trackpad's horizontal scroll swipe MAY uncover the panel - (DEC-7), and a row it leaves resting open follows AR1. + the row's paint. A trackpad's horizontal scroll swipe, or a mouse's horizontal wheel, + which sends the same events, MAY uncover the panel (DEC-7), and a row it leaves resting open follows AR1. - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no stop while closed. @@ -264,7 +264,10 @@ constants. ### Platform support - Supported feature/engine floor: every supported renderer and browser. - Pointer type is read from the pointer event. + A finger's swipe is read from its pointer event; a + trackpad's from a horizontal `wheel` gesture, which has no pointer type. A + mouse's horizontal wheel sends the same events and swipes the same way, + since the page cannot tell the two apart; a mouse drag never swipes. - Unsupported behavior: a device with neither a touch screen nor a trackpad gets no swipe and nothing else changes. - Browser evidence: FR2 and FR4–FR9 are paint and gesture claims. jsdom has @@ -495,7 +498,7 @@ decorative colours (`blue`, `cyan`, …) that a verb slot must not admit. Rejected: the menu row's two-value `'default' | 'destructive'` — it has no word for the one action a panel exists to offer. -### DEC-7 — A trackpad swipes a row; a mouse does not +### DEC-7 — A trackpad swipes a row; a mouse drag does not **Reference:** `spec:AST-057/DEC-7` **Decider:** `cixzhang`, `2026-10-26` @@ -503,7 +506,8 @@ word for the one action a panel exists to offer. A trackpad's two-finger horizontal scroll is the same motion as a finger's swipe and takes nothing else from the page, so it reveals and fires row actions as a finger does (`spec:AST-094` FR43). A mouse drag would take the -browser's text selection and native drag, so a mouse never swipes a row. +browser's text selection and native drag, so a mouse drag never swipes a row. A mouse's horizontal wheel sends the +same events as a trackpad and swipes the same way. Rejected: keeping swipe actions touch-only. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 6d7d9f6d4e5e4..38ef73384efc1 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -460,7 +460,8 @@ FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. a component that uses a gesture keeps one listener on its element and adds document listeners only during a press, removing them at the release (FR43); the shared press controller, installed once per page, counts once and not - per component. The number of `document`- or `window`-level + per component, and a claiming recognizer's `touchmove` listener for a finger + (FR19) counts outside this budget. The number of `document`- or `window`-level touch listeners MUST NOT grow with the number of component instances, a repeated row or cell adds no touch observer of its own, and nothing touch-related remains after the last instance unmounts @@ -484,13 +485,16 @@ FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. selection and native drag, while a trackpad's horizontal scroll swipes a row (`spec:AST-057` AR2, DEC-7); a pinch under a pen; the screen-edge pan under a mouse, a trackpad, or a pen; - and the touch target size, which stays coarse-only and never overlaps a + and the 44 px coarse-pointer touch target, which stays coarse-only and never overlaps a neighbor's target. On the web, a pan also takes wheel and trackpad scrolling; a pinch takes a ctrl-wheel; one shared context request answers a long press, a right click, and Shift+F10; the - press model has a fine-pointer profile with no delay (FR11); and a reorder - starts on a drag from its handle without a hold. Under a fine pointer only a - component that uses a gesture listens (FR24), and the change adds no + press model has a fine-pointer profile with no delay (FR11); and under a + mouse, a trackpad, or a pen a reorder starts on a drag from its handle + without a hold. Under a fine pointer only a + component that uses a gesture listens (FR24), and under reduced motion a + release from a mouse, a trackpad, or a pen completes at once + (`design:reduced-motion` DR3); and the change adds no stylesheet bytes (`spec:AST-080` FR8) (DEC-21). ### Platform support @@ -542,6 +546,12 @@ FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. and FR9 under a finger) are stated here (FR26–FR30); FR6's last sentence gives way to the cell press profile (FR11); its mouse rules (FR8 and the mouse halves of FR1–FR3) and its non-gesture rules stay in the module. +- `spec:AST-057`: a trackpad's horizontal scroll swipes a row (DEC-7); a + mouse drag never does. +- `architecture:react-component-runtime` INV13: gestures attach for every + pointer, within FR24's listener budget. +- `design:user-states` DR1, DR3 and `family:buttons`: press timing follows + FR11's profiles under every pointer, the fine-pointer profile included. - `component:DropdownMenu`, `component:ContextMenu` and `component:Popover`: under a finger they follow FR26–FR35 as each applies. - BottomSheet, Toast, Lightbox, NavigationStack and keyboard dismissal: a @@ -572,7 +582,7 @@ FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. | FR15–FR17 | Recognizer unit tests on synthetic streams with explicit timestamps | exclusive, simultaneous, require-to-fail, nested; a watcher beside a claiming winner; a blocked main thread; a pan at 10 pt per axis | a wrong winner, a late deadline, or a translation that keeps the hysteresis, or a watcher failed or cancelled by a claiming winner fails | | FR18–FR22 | Browser tests with real touch input (`spec:AST-013` FR10) | per-axis pans beside native scrolling, page zoom, cancelled touches, clicks after taps, keyboard activation, a modal scroll lock, a control that keeps its press through a drag; a still finger with sub-pixel jitter over a scroll view; a finger moved 9 px and 10 px straight, and (7, 7) and (8, 8) diagonally; a recognizer on an element whose author `touch-action` is narrower, including one without `pinch-zoom`; a screen-edge pan landing at 40 px and at 41 px from the edge, at 14 px and 15 px of travel, and a horizontal scroll that starts outside the band; a screen-edge pan on an element within the band, and on one that reaches beyond it; a sheet's pull from a list at its top, and from a list scrolled away from its top | a ghost click, a scroll or zoom blocked other than by the author's `touch-action`, or a cancelled unowned touchmove outside FR19's three exceptions, a tap cancelled by sub-pixel movement or by under 10 px of travel, a scroll that does not take the touch at 10 px, or a derived `touch-action` wider than the author's, a screen-edge pan that claims outside its band or before 15 px on an element within the band, or has not claimed at 15 px, or an author scroll outside the band that is blocked, a band claim of `pan-x` on an element that reaches beyond the band, or a 15 px start there, or a scroll-edge pan that cancels a move a scroller could scroll, or begins before 10 pt, fails | | FR23 | Haptics tests on the recorded moments | tap, drag, lift | a haptic outside a native moment fails | -| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a gesture listener in a component that uses no gesture, an idle document listener outside a press, a second press controller, a per-instance `document` or `window` listener, or a leak fails | +| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a gesture listener in a component that uses no gesture, an idle document listener outside a press other than the one shared press controller, a second press controller, a per-instance `document` or `window` listener, or a leak fails | | FR25 | Component tests of each moved default and its `presentation` value | default; `presentation` set to the released surface | a moved default that `presentation` does not restore, or a public threshold, fails | | FR26–FR28, FR30 | Menu press tests on synthetic touch streams and a real-Chromium touch test | a slide from one row to another; a lift outside; a cancel; a second finger; edge autoscroll | an act on the press-start row, two acts for one gesture, a lift outside that closes, or a lit row after a cancel fails | | FR29 | Trigger tests with explicit timestamps | a press in a fixed bar; a press in scrolling content held 149 and 150 ms; a quick tap in scrolling content; a scroll taking the touch before 150 ms; a drift of 12 and 14 pt; moves before and after the handoff; `presentation="popover"` | a fixed-bar press that waits, a held finger's scroll-view open before 150 ms or after a scroll, a quick tap that does not open at its lift, an open past the drift allowance, a lift that acts before the handoff, or a `popover` trigger that opens on press fails | @@ -580,7 +590,7 @@ FR1–FR3 and DEC-3, so a mouse released outside a menu closes it. | FR34, FR35 | Browser tests and storyboards under a finger | a sub-menu drilling in and its Back row returning; a tap and a drag during a popover's and a menu's growth; a pinch during growth; a mouse | a drill-in that leaves the parent's rows in place, a change between levels that does not play on its roles, a tap during growth that closes, scrolls or moves focus, a pinch blocked other than by the author's `touch-action`, or a changed mouse path fails | | FR36–FR40 | Release tests on synthetic touch streams with explicit timestamps, per use | below and past each commit point; a flick that projects past it; a slow drag past it; a release moving back; Reduce Motion; keyboard and mouse paths | a decision that changes mid-settle, a release moving back that commits, a settle that does not start at the release velocity, or an action with no keyboard or mouse path fails | | FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | -| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse swipe action, a pen pinch, a web edge pan, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | +| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | | all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect | ## Decision log @@ -743,7 +753,7 @@ Most of these gestures work as well with a mouse, a trackpad, or a pen, so they stay on for every pointer, with web inputs mapped in: wheel and trackpad scrolling for a pan, a ctrl-wheel for a pinch, and right click or Shift+F10 for a long press. What would fight the browser stays off: a mouse's swipe -actions, a pen's pinch, and the screen-edge pan. The 44 px target stays +actions, a pen's pinch, and the screen-edge pan under a mouse, a trackpad, or a pen. The 44 px target stays touch-only. This supersedes the coarse-only press controller: a mouse press now uses the press model with no delay instead of the browser's `:active`. diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index cf1c4d922778c..e892bbd9147b9 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -38,7 +38,7 @@ references: | 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. | +| 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. A finger or pen's press follows `spec:AST-094` FR26–FR30 under every primary pointer; 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). | @@ -84,7 +84,7 @@ adopter. - Released default preserved: `no` for two behaviors: DEC-3 (a mouse released outside a menu now dismisses it), and an unset `longPressDelayMs`, whose released 500 ms wait gives way to `spec:AST-094` FR29's timing - where the primary pointer is coarse; + under a finger; `yes` for every consumer prop and every item, divider, section and presentation API - Compatibility class: additive public hook and types; behavior change inside @@ -155,15 +155,15 @@ Consumer migration instructions belong in consumer docs and release notes. | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | 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. A finger or pen follows `spec:AST-094` FR26 instead, under every primary pointer (`spec:AST-094` FR43); a mouse keeps this rule. | 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). A finger or pen follows `spec:AST-094` FR27 and FR11's cell profile instead, under every primary pointer, and a second finger ends the gesture (FR4); a mouse keeps this rule. | `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 | +| 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 (`spec:AST-094` FR28). | 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 | +| 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)`. A finger or pen follows `spec:AST-094` FR27 and FR11 instead, under every primary pointer; under a mouse a row paints its press at once through the press controller (`spec:AST-094` FR11's fine-pointer profile, DEC-21). | `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. A finger or pen follows `spec:AST-094` FR30 instead, under every primary pointer; a mouse keeps this rule. | `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 | Opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29, under every primary pointer (`spec:AST-094` FR43); a mouse opens the menu as `component:DropdownMenu` FR8 states. | `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 | +| 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. Under every primary pointer, 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 @@ -319,8 +319,7 @@ 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: where -the primary pointer is coarse, it picks once it has travelled the handoff +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. From c46ace4bdd13077170df54aaef4d2cb05d089e27 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 01:15:14 -0700 Subject: [PATCH 06/12] spec(AST-057): DEC-4 names horizontal wheel input --- docs/specs/AST-057-row-actions/spec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index 69089de04d0b7..c742824d4e52b 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -424,8 +424,9 @@ owns the one coordination case. **Reference:** `spec:AST-057/DEC-4` **Decider:** `cixzhang`, `2026-10-02` -A mouse drag and hover do nothing with `swipeActions`; a trackpad's -horizontal scroll is the one non-touch input that swipes a row (DEC-7); what a row offers +A mouse drag and hover do nothing with `swipeActions`; horizontal wheel +input, a trackpad's scroll or a mouse's horizontal wheel, is the one +non-touch input that swipes a row (DEC-7); what a row offers a mouse or a keyboard is the row's own content, which this record does not govern. From d5aee72082743044819ece63b8a19e1f855382d8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 03:36:21 -0700 Subject: [PATCH 07/12] Merge main into spec/gestures-on-web --- docs/architecture/react-component-runtime.md | 26 +++++----- docs/specs/AST-057-row-actions/spec.md | 50 +++++++++---------- .../specs/AST-094-touch-presentations/spec.md | 26 +++++----- docs/specs/AST-103-reorderable/spec.md | 3 +- .../DropdownMenu/modules/useMenuPress.spec.md | 2 +- 5 files changed, 55 insertions(+), 52 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index bb79a1843b0a3..1e8803faa9f03 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -341,16 +341,16 @@ observable-behavior predicates require review or runtime evidence. Likewise, a focused test proves only its named behavior and mutation expectation; a green component suite is not blanket conformance with every invariant. -| Invariant | Evidence | Failure signal | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | -| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | -| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | -| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | -| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | -| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | -| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | -| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | -| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | -| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a gesture listener in a component that uses no gesture, or an idle document listener outside a press other than the one shared press controller, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | -| INV15 | Unit tests of the animator's value mode and real-browser proofs of its adopters | A value off its motion role, a count past the component's swap size, a count or cross-fade under reduced motion with a coarse or a fine pointer, an intermediate value announced, or a public export fails | +| Invariant | Evidence | Failure signal | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| INV1–INV2 | Initial-render and interaction tests | Output flashes a known-wrong state, or one action is delayed or repeated through an Effect | +| INV3, INV5 | StrictMode setup/cleanup and dependency-change tests | Duplicate callback, leaked work, stale subscription, or missing resynchronization | +| INV1, INV4, INV6 | Ref attach/null/replacement, DOM-in-state rejection, and old-node cleanup-before-new-attach mutations | A DOM node or collection enters state, old node remains active, cleanup misses replacement, or ref mutation changes semantics silently | +| INV7 | Real-browser geometry plus N-consumer observer-instance/subscription mutations | Geometry is invalid, observer instances grow with repeated consumers, one unsubscribe silences peers, or cleanup removes another subscriber | +| INV8 | Owner-observable behavior across documented composition replacement; optional focused internal mechanism tests | Behavior disappears or changes owner when a documented subpart is replaced | +| INV9 | Provider/external-store identity and render-count tests | Unchanged semantic payload changes identity, or snapshot identity creates a render loop | +| INV10 | Shared test over every adopter of the announcement module | An adopter holding its own Effect, observer, timer, or listener for its own-content announcement | +| INV11 | Source boundary check plus hook tests across StrictMode replay and unmount | A second keyboard listener or measurement in Core; a consumer reading a CSS custom property on the root or a shared ancestor for the height; an export of the hook; a component outside Core measuring by a rule other than AST-099 FR2, or riding the keyboard; a listener left after unmount; a Core rider driven by JavaScript per frame or by its own transition; or more than one layout read per keyboard change | +| INV12 | Source boundary check plus real-browser tests on a throttled phone profile over a 10-level by 50-sibling tree and two changes in one frame | A per-frame height or layout, a walk past the area or into a subtree other than `display: contents`, an element moved twice for one frame, an added element, or an exported reporting function fails | +| INV13 | Source boundary check plus bundle analysis of a page with several touch components, and real-browser tests on a throttled phone profile, under a fine pointer, and on a hybrid device | A second copy of a touch module on one page, a component bundling its own copy of a module's primitive, an `import()` of a module in a component, a public export or doc entry for the modules, an install call or installed check in the public API, a gesture listener in a component that uses no gesture, or an idle document listener outside a press other than the one shared press controller and the one stray-click swallower, or an import of `@astryxdesign/core/internal/*` outside Core and Lab fails | +| INV15 | Unit tests of the animator's value mode and real-browser proofs of its adopters | A value off its motion role, a count past the component's swap size, a count or cross-fade under reduced motion with a coarse or a fine pointer, an intermediate value announced, or a public export fails | diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index ce3b2f76bc115..fa767570bf888 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -69,7 +69,7 @@ naming a component; a row type nobody has written yet sorts itself. The gesture is an accelerator for a finger and a trackpad: it adds a faster path to a verb under a finger or a trackpad's horizontal scroll, and changes -nothing for a mouse, hover, or the keyboard. Each side's +nothing for a mouse drag, hover, or the keyboard. Each side's verbs are declared as data — a label that names the state the verb reaches, an icon, a handler, how it looks, and whether the row holds out afterwards — so the component knows everything it needs before the finger moves: what to @@ -91,7 +91,7 @@ and its panels stay put, so no element is added to any row. existing root, the root's translation and the panels' counter-translation, and the group's inline clip. - The accessibility contract of the gesture itself: what the panel exposes - while resting and while closed, and that a mouse and hover touch nothing. + while resting and while closed, and that a mouse drag and hover touch nothing. **Why no existing record can hold it** @@ -227,18 +227,17 @@ constants. ### Behavioral contract -| ID | Invariant | Basis | Verification state | -| ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | -| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | -| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | -| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | -| FR4 | A sideways drag on a coarse pointer, or a trackpad's horizontal scroll, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | -| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2) when a finger started the swipe, or, when a trackpad did, cross-fades on a coarse primary pointer (DR2) and | -| arrives at once otherwise (DR3). `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | -| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | -| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | -| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | -| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | +| ID | Invariant | Basis | Verification state | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | +| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | +| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | +| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | +| FR4 | A sideways drag on a coarse pointer, or a trackpad's horizontal scroll, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | +| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2) when a finger started the swipe, or, when a trackpad did, cross-fades on a coarse primary pointer (DR2) and arrives at once otherwise (DR3). `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | +| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | +| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | +| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | +| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | ### Accessibility contract @@ -269,8 +268,9 @@ constants. trackpad's from a horizontal `wheel` gesture, which has no pointer type. A mouse's horizontal wheel sends the same events and swipes the same way, since the page cannot tell the two apart; a mouse drag never swipes. -- Unsupported behavior: a device with neither a touch screen nor a trackpad - gets no swipe and nothing else changes. +- Unsupported behavior: a device with neither a touch screen nor horizontal + wheel input gets no swipe, and a mouse drag never swipes; nothing else + changes. - Browser evidence: FR2 and FR4–FR9 are paint and gesture claims. jsdom has neither; real-Chromium evidence with dispatched touch is required, and FR5/FR6 additionally need a physical touch device, because Chromium evidence @@ -300,15 +300,15 @@ constants. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| ------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | -| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | -| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | -| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | -| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | -| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer; a trackpad horizontal scroll that opens a row; a mouse drag | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, a panel a mouse drag or hover uncovers, or any effect on a closed row under a hovering pointer fails. | -| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | +| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | +| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL; a trackpad's horizontal scroll and a mouse's horizontal wheel; a mouse drag, which must not swipe | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | +| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | +| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | +| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer; a trackpad horizontal scroll that opens a row; a mouse drag | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, a panel a mouse drag or hover uncovers, or any effect on a closed row under a hovering pointer fails. | +| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | Known verification gap: none of the suites above exist on `main`, and no component implements the contract. This record is `draft`, does not govern diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 4beaad48ce46b..6aef16a50ca0b 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -204,9 +204,9 @@ a measured, sourced difference exists. DR4). On every touch platform this amends `design:user-states` DR1 (the pressed overlay during a hold) and DR3 (a held, selected SegmentedControl item keeps its raised selected surface; only its press timing follows the control profile) for touch - presentations, the press treatment of `family:buttons`, and the last sentence of - `module:DropdownMenu/useMenuPress` FR6 (menu rows paint `:active` only under - `(hover: hover)`): under a finger, menu rows take the cell profile. A menu whose + presentations, the press treatment of `family:buttons`, and + `module:DropdownMenu/useMenuPress` FR6: under a finger, menu rows take the + cell profile. A menu whose rows fit (`module:DropdownMenu/useMenuPress` FR4), so it cannot scroll, gives its rows no 150 ms delay: they highlight on touch-down. A list that can scroll keeps the delay. A press in a list that no scroll can claim stays @@ -467,8 +467,9 @@ outside a menu closes it. while a touch presentation is disabled none is active. Under a fine pointer a component that uses a gesture keeps one listener on its element and adds document listeners only during a press, removing them at the release (FR43); - the shared press controller, installed once per page, counts once and not - per component, and a claiming recognizer's `touchmove` listener for a finger + the shared press controller and the menu press model's stray-click + swallower (`module:DropdownMenu/useMenuPress` PR2), each installed once per + page, count once and not per component, and a claiming recognizer's `touchmove` listener for a finger (FR19) counts outside this budget. The number of `document`- or `window`-level touch listeners MUST NOT grow with the number of component instances, a repeated row or cell adds no touch observer of its own, and nothing @@ -517,12 +518,13 @@ outside a menu closes it. component that uses a gesture listens (FR24), and under reduced motion a release from a mouse, a trackpad, or a pen follows `design:reduced-motion` DR2 on a coarse primary pointer and DR3 otherwise; and gestures add no - stylesheet bytes (`spec:AST-080` FR8) (DEC-21). + stylesheet bytes (DEC-21). ### Platform support - Supported engines: Astryx's supported browsers (`spec:AST-013`) with touch - input, on iOS and Android. UIKit on the reference release is the parity + input, on iOS and Android, and, for FR43, desktop browsers with a mouse, a + trackpad, or a pen. UIKit on the reference release is the parity reference, not a support floor; an older iOS Safari gets the same behavior wherever its engine supports it, and Android Chrome gets the same behavior. This amends `spec:AST-013` FR3 with an explicit Android Chrome support row @@ -618,7 +620,7 @@ outside a menu closes it. | FR15–FR17 | Recognizer unit tests on synthetic streams with explicit timestamps | exclusive, simultaneous, require-to-fail, nested; a watcher beside a claiming winner; a blocked main thread; a pan at 10 pt per axis | a wrong winner, a late deadline, or a translation that keeps the hysteresis, or a watcher failed or cancelled by a claiming winner fails | | FR18–FR22 | Browser tests with real touch input (`spec:AST-013` FR10) | per-axis pans beside native scrolling, page zoom, cancelled touches, clicks after taps, keyboard activation, a modal scroll lock, a control that keeps its press through a drag; a still finger with sub-pixel jitter over a scroll view; a finger moved 9 px and 10 px straight, and (7, 7) and (8, 8) diagonally; a recognizer on an element whose author `touch-action` is narrower, including one without `pinch-zoom`; a screen-edge pan landing at 40 px and at 41 px from the edge, at 14 px and 15 px of travel, and a horizontal scroll that starts outside the band; a screen-edge pan on an element within the band, and on one that reaches beyond it; a sheet's pull from a list at its top, and from a list scrolled away from its top | a ghost click, a scroll or zoom blocked other than by the author's `touch-action`, or a cancelled unowned touchmove outside FR19's three exceptions, a tap cancelled by sub-pixel movement or by under 10 px of travel, a scroll that does not take the touch at 10 px, or a derived `touch-action` wider than the author's, a screen-edge pan that claims outside its band or before 15 px on an element within the band, or has not claimed at 15 px, or an author scroll outside the band that is blocked, a band claim of `pan-x` on an element that reaches beyond the band, or a 15 px start there, or a scroll-edge pan that cancels a move a scroller could scroll, or begins before 10 pt, fails | | FR23 | Haptics tests on the recorded moments | tap, drag, lift | a haptic outside a native moment fails | -| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a gesture listener in a component that uses no gesture, an idle document listener outside a press other than the one shared press controller, a second press controller, a per-instance `document` or `window` listener, or a leak fails | +| FR24 | Resource tests counting listeners, observers and timers | fine primary pointer; coarse pointer enabled and disabled; many instances; unmount | a gesture listener in a component that uses no gesture, an idle document listener outside a press other than the one shared press controller and the one stray-click swallower, a second press controller, a per-instance `document` or `window` listener, or a leak fails | | FR25 | Component tests of each moved default and its `presentation` value | default; `presentation` set to the released surface | a moved default that `presentation` does not restore, or a public threshold, fails | | FR26–FR28, FR30 | Menu press tests on synthetic touch streams and a real-Chromium touch test | a slide from one row to another; a lift outside; a cancel; a second finger; edge autoscroll | an act on the press-start row, two acts for one gesture, a lift outside that closes, or a lit row after a cancel fails | | FR29 | Trigger tests with explicit timestamps | a press in a fixed bar; a press in scrolling content held 149 and 150 ms; a quick tap in scrolling content; a scroll taking the touch before 150 ms; a drift of 12 and 14 pt; moves before and after the handoff; `presentation="popover"` | a fixed-bar press that waits, a held finger's scroll-view open before 150 ms or after a scroll, a quick tap that does not open at its lift, an open past the drift allowance, a lift that acts before the handoff, or a `popover` trigger that opens on press fails | @@ -789,10 +791,10 @@ Rejected: the 150 ms delay on every menu row. Most of these gestures work as well with a mouse, a trackpad, or a pen, so they stay on for every pointer, with web inputs mapped in: wheel and trackpad scrolling for a pan, a ctrl-wheel for a pinch, and right click or Shift+F10 -for a long press. What would fight the browser stays off: a mouse drag's swipe -actions, a pen's pinch, and the screen-edge pan under a mouse, a trackpad, or a pen. The 44 px target stays -touch-only. This supersedes the coarse-only press controller: a mouse press -now uses the press model with no delay instead of the browser's `:active`. +for a long press. What would fight the browser is off: a mouse drag's swipe +actions, a pen's pinch, and the screen-edge pan under a mouse, a trackpad, or a pen. The 44 px target is +touch-only. A mouse press uses the press model with no delay instead of the +browser's `:active`; this supersedes the coarse-only press controller. Rejected: keeping gestures and press tracking touch-only. diff --git a/docs/specs/AST-103-reorderable/spec.md b/docs/specs/AST-103-reorderable/spec.md index 40e9dd855b932..6d5db5cddd1a5 100644 --- a/docs/specs/AST-103-reorderable/spec.md +++ b/docs/specs/AST-103-reorderable/spec.md @@ -136,7 +136,8 @@ announcements are those of FR3 and FR4, and the tree adds no option. for it; a cancelled drag calls nothing; - under Reduce Motion the moving preview still follows the finger or pointer, and the other rows and the drop take their places without travel - (`design:reduced-motion` DR1–DR3; a mouse drop follows DR3). + (`design:reduced-motion` DR1–DR3; a mouse drop follows DR2 on a coarse primary pointer and DR3 + otherwise). - **FR4 — Reorder mode is the one path without a drag.** Each reorderable row's handle is a toggle button named "Reorder" and the row's label, with `aria-pressed`. Activating it, by click, Enter, or Space, enters reorder diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index f41304007acb5..8fa142ade150f 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -161,7 +161,7 @@ Consumer migration instructions belong in consumer docs and release notes. | 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. A finger or pen follows `spec:AST-094` FR27 and FR11 instead, under every primary pointer; under a mouse a row paints its press at once through the press controller (`spec:AST-094` FR11's fine-pointer profile, DEC-21). | `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. A finger or pen follows `spec:AST-094` FR30 instead, under every primary pointer; a mouse keeps this rule. | `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 | Opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29, under every primary pointer (`spec:AST-094` FR43); a mouse opens the menu on press-down. | `spec:AST-094` FR29 | as `spec:AST-094` states | +| FR9 | Opening a menu from its trigger under a finger and the opening finger's handoff are governed by `spec:AST-094` FR29, under every primary pointer (`spec:AST-094` FR43). | `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. Under every primary pointer, 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 | From da36283fb3900b64fb2c89fb7ef053d4c6e2156e Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 04:26:36 -0700 Subject: [PATCH 08/12] Merge main into spec/gestures-on-web --- docs/design/reduced-motion.md | 7 ++++--- docs/specs/AST-055-caller-rendered-trigger/spec.md | 6 +++--- docs/specs/AST-057-row-actions/spec.md | 11 ++++++----- docs/specs/AST-094-touch-presentations/spec.md | 10 +++++----- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/design/reduced-motion.md b/docs/design/reduced-motion.md index 509499aac7beb..00f3edeaaac81 100644 --- a/docs/design/reduced-motion.md +++ b/docs/design/reduced-motion.md @@ -87,8 +87,9 @@ Content that moves with the person's own pointer or finger during a drag follows it as direct manipulation and is not a DR1 transition, and neither is the momentum that continues a throw (DR2). A transition a component starts when a touch drag is released, such as a swiped row sliding -out or returning (`spec:AST-057` FR5), cross-fades as DR2 says, because a -finger started it. Any other transition, such as one sheet handing off to +out or returning after a finger's swipe (`spec:AST-057` FR5), cross-fades as +DR2 says, because a finger started it; one a trackpad started follows DR2 on +a coarse primary pointer and DR3 otherwise. Any other transition, such as one sheet handing off to another (`component:BottomSheetSwitcher` FR3), follows DR2 on a coarse primary pointer and DR3 otherwise. @@ -113,7 +114,7 @@ No normative assets are included. ## Component contract links -- `spec:AST-057` FR5 implements DR2 for a swiped row's slide out, +- `spec:AST-057` FR5 implements DR2 and DR3 for a swiped row's slide out, spring back, and hold-out. - `component:BottomSheetSwitcher` FR3 and ORD1 implement DR2 and DR3 for a handoff between sheets. diff --git a/docs/specs/AST-055-caller-rendered-trigger/spec.md b/docs/specs/AST-055-caller-rendered-trigger/spec.md index bbe7d6938e2a1..cd2cf5cdd000c 100644 --- a/docs/specs/AST-055-caller-rendered-trigger/spec.md +++ b/docs/specs/AST-055-caller-rendered-trigger/spec.md @@ -287,9 +287,9 @@ reaches CSS through the rendered attribute instead of through new public API. Because no current core component styles off `aria-expanded` — every current use only emits the attribute — the route has no in-repo example to copy, so FR6 requires it to be documented on the prop. The same documentation states that -`:active` is not a substitute: it does not behave the same under a coarse -pointer, which is why `module:DropdownMenu/useMenuPress` FR6 drops coarse-pointer -`:active` paint on menu rows. +`:active` is not a substitute: it does not behave the same under a finger, +which is why press paint follows `spec:AST-094` FR11's profiles instead of +`:active`. ### DEC-4 — Hook trigger props are a different convention diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index fa767570bf888..a58a0c11ca07b 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -217,7 +217,7 @@ and its panels stay put, so no element is added to any row. | the drag's travel | a private custom property on the row root | Written by the gesture during a drag. The root's `transform` reads it; each panel's `transform` reads its negation, so the row moves and the panel appears fixed in the space the row vacates. | `0px` | `spec:AST-057` | proposed | | the group's clip | `overflow-inline: clip` on the element containing the rows | A moving row paints past its group's inline edge unless the group clips. `List` sets it; a host that renders `Item`s outside `List` sets it once. `Item` cannot clip itself: it is the thing moving. | — | `component:List` / the host | proposed | | open state | closed, resting open | The row's own. A pointer landing anywhere outside a resting row closes it, which is also how a drag on a neighbour closes it; no group state exists. | closed | `spec:AST-057` | proposed | -| the modality split | internal | A mouse drag never starts the swipe; a finger's pointer event or a horizontal wheel gesture does, read from the input event, not a prop or a media query (`architecture:interaction-modality` INV6). | — | `spec:AST-057` | proposed | +| the modality split | internal | A mouse drag never starts the swipe; a finger's or a pen's pointer event or a horizontal wheel gesture does, read from the input event, not a prop or a media query (`architecture:interaction-modality` INV6). | — | `spec:AST-057` | proposed | Not public: axis-lock distance, commit ratio, fling velocity, durations, resistance, panel widths, the custom property's name. They are behavior @@ -256,7 +256,8 @@ constants. change the primary element's role, name, or activation, and MUST add no stop while closed. - **Stated in documentation, not enforced:** a swipe is a touch accelerator, - so a verb reachable only by swipe is unreachable by keyboard and by mouse. + so a verb reachable only by swipe is unreachable by keyboard and by a mouse + drag. The consumer documentation of `swipeActions` MUST say so and MUST show the verb also reachable through the row's own content. The component does not inspect what else the row offers. @@ -264,7 +265,7 @@ constants. ### Platform support - Supported feature/engine floor: every supported renderer and browser. - A finger's swipe is read from its pointer event; a + A finger's or a pen's swipe is read from its pointer event; a trackpad's from a horizontal `wheel` gesture, which has no pointer type. A mouse's horizontal wheel sends the same events and swipes the same way, since the page cannot tell the two apart; a mouse drag never swipes. @@ -426,8 +427,8 @@ owns the one coordination case. **Decider:** `cixzhang`, `2026-10-02` A mouse drag and hover do nothing with `swipeActions`; horizontal wheel -input, a trackpad's scroll or a mouse's horizontal wheel, is the one -non-touch input that swipes a row (DEC-7); what a row offers +input, a trackpad's scroll or a mouse's horizontal wheel, and a pen are the +non-finger inputs that swipe a row (DEC-7); what a row offers a mouse or a keyboard is the row's own content, which this record does not govern. diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 6aef16a50ca0b..256938e388391 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -566,10 +566,10 @@ outside a menu closes it. finger; `presentation="popover"` keeps the released behavior (FR25). - `module:DropdownMenu/useMenuPress`: the released 500 ms `longPressDelayMs` default gives way to FR29's timing; a caller that passes it keeps it (FR25). -- `module:DropdownMenu/useMenuPress`: its finger rules (FR1–FR3, FR6, FR7 - and FR9 under a finger) are stated here (FR26–FR30); FR6's last sentence - gives way to the cell press profile (FR11); its mouse rules (FR8 and the - mouse halves of FR1–FR3) and its non-gesture rules stay in the module. +- `module:DropdownMenu/useMenuPress`: its finger and pen rules (FR1–FR3, FR6, + FR7, FR9, FR11) follow FR26–FR30, FR33, and FR11's profiles under every + primary pointer; its mouse rules (the mouse halves of FR1–FR3) and its + non-gesture rules stay in the module. - `spec:AST-057`: a trackpad's horizontal scroll swipes a row (DEC-7); a mouse drag never does. - `architecture:react-component-runtime` INV13: gestures attach for every @@ -793,7 +793,7 @@ they stay on for every pointer, with web inputs mapped in: wheel and trackpad scrolling for a pan, a ctrl-wheel for a pinch, and right click or Shift+F10 for a long press. What would fight the browser is off: a mouse drag's swipe actions, a pen's pinch, and the screen-edge pan under a mouse, a trackpad, or a pen. The 44 px target is -touch-only. A mouse press uses the press model with no delay instead of the +touch-only, and none of this adds stylesheet bytes. A mouse press uses the press model with no delay instead of the browser's `:active`; this supersedes the coarse-only press controller. Rejected: keeping gestures and press tracking touch-only. From 4fb6ea298449859138c10590067f9bb7e422cd53 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 04:26:54 -0700 Subject: [PATCH 09/12] spec(AST-057): intent names the pen --- docs/specs/AST-057-row-actions/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index a58a0c11ca07b..cc7d6c11771d5 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -67,8 +67,8 @@ and not on one whose role forbids them. That sorts the list row and the standalone row in, and the option, menu-item and radio rows out, without naming a component; a row type nobody has written yet sorts itself. -The gesture is an accelerator for a finger and a trackpad: it adds a faster -path to a verb under a finger or a trackpad's horizontal scroll, and changes +The gesture is an accelerator for a finger, a pen, and a trackpad: it adds a +faster path to a verb under a finger, a pen, or a trackpad's horizontal scroll, and changes nothing for a mouse drag, hover, or the keyboard. Each side's verbs are declared as data — a label that names the state the verb reaches, an icon, a handler, how it looks, and whether the row holds out afterwards — From 4084b67a6ea6432adbab755d9efaed03763458df Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 05:19:01 -0700 Subject: [PATCH 10/12] Merge main into spec/gestures-on-web --- .../AST-055-caller-rendered-trigger/spec.md | 2 +- docs/specs/AST-057-row-actions/spec.md | 30 +++++++++---------- .../specs/AST-094-touch-presentations/spec.md | 2 +- packages/core/src/Button/Button.spec.md | 2 +- .../DropdownMenu/modules/useMenuPress.spec.md | 10 +++---- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/specs/AST-055-caller-rendered-trigger/spec.md b/docs/specs/AST-055-caller-rendered-trigger/spec.md index cd2cf5cdd000c..6d931d9dd0b37 100644 --- a/docs/specs/AST-055-caller-rendered-trigger/spec.md +++ b/docs/specs/AST-055-caller-rendered-trigger/spec.md @@ -157,7 +157,7 @@ INV2), so the decision lives here and those records cite it. | FR3 | A caller-supplied control MUST NOT be taken as a `ReactNode` slot that the component renders inside an interactive element of its own. Callers routinely pass an interactive component, and that shape nests one interactive element inside another (AR2). A content slot rendered inside a component-owned control remains valid for non-interactive content and is unchanged. | DEC-1; AR2 | Accepted; jsdom evidence pending | | FR4 | The caller's control MUST own a box. A component MUST NOT render it inside a wrapper with no box of its own (`display: contents`), and MUST NOT depend on such a wrapper where it paints or offsets a focus indicator on the trigger, or positions the overlay against the trigger's rect: there is no rect to anchor to and no box on which to paint. FR1 removes the need for any wrapper. | DEC-2; `architecture:layer-runtime` anchor contract | Accepted; real-browser evidence required | | FR5 | Hover, pressed, and every other pointer paint on the caller's control MUST remain the caller's own CSS. The component MUST NOT write paint onto an element it does not render. The component MUST publish the overlay's open state on the handed-back props as `aria-expanded`, so a caller styles the open state from the rendered attribute and no additional public API is added for it. | DEC-3; `spec:AST-002` FR1, FR2 | Accepted; jsdom evidence pending | -| FR6 | The prop's consumer documentation MUST show styling the open state from `aria-expanded` on the caller's control, and MUST state that a pressed look keyed to `:active` is not a substitute, because `:active` does not behave the same under a coarse pointer — `module:DropdownMenu/useMenuPress` FR6 drops coarse-pointer `:active` paint on menu rows for that reason. | DEC-3; no current core component styles off `aria-expanded`, so callers have no example to copy | Accepted; documentation evidence pending | +| FR6 | The prop's consumer documentation MUST show styling the open state from `aria-expanded` on the caller's control, and MUST state that a pressed look keyed to `:active` is not a substitute, because `:active` does not behave the same under a finger, and press paint follows `spec:AST-094` FR11's profiles instead. | DEC-3; no current core component styles off `aria-expanded`, so callers have no example to copy | Accepted; documentation evidence pending | | FR7 | Where a component offers both a built-in control and this prop, exactly one control MUST render. The caller's control MUST win, identically in every presentation the component supports, and a development warning MUST fire; the component's own chrome for the replaced control MUST NOT render. Silently ignoring a supplied prop, or rendering both, is rejected. | `spec:AST-002` FR15 (never silently render a broken state); `architecture:public-component-api` INV3 | Accepted; jsdom evidence pending | | FR8 | A hook that returns props for a trigger — `triggerProps` and `getTriggerProps` on `useLightbox`, `usePopover`, `useMenuHover`, and `useHoverCard` — follows hook conventions and is unchanged by this record. Those return-value names are NOT precedent for a component prop's name or shape. This record does not require a hook to grow a render prop, nor a component to republish its trigger props as a hook return. | DEC-4 | Settled by DEC-4; no code change | diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index cc7d6c11771d5..912f26bd2a9a9 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -227,17 +227,17 @@ constants. ### Behavioral contract -| ID | Invariant | Basis | Verification state | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | -| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | -| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | -| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | -| FR4 | A sideways drag on a coarse pointer, or a trackpad's horizontal scroll, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | -| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2) when a finger started the swipe, or, when a trackpad did, cross-fades on a coarse primary pointer (DR2) and arrives at once otherwise (DR3). `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | -| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | -| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | -| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | -| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | +| ID | Invariant | Basis | Verification state | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | +| FR1 | `Item` MUST take its swipe actions as `swipeActions: {leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}`, where `ItemSwipeAction` is `{id?, label: string, icon?: ReactNode, onActivate, isDisabled?, variant?: 'neutral' \| 'accent' \| 'destructive', hasRemoval?: boolean}` with `variant` defaulting to `accent`. An entry MUST NOT carry a node the component renders as the control. `ListItem` MUST pass the prop through unchanged. | DEC-1; `DropdownMenuItemData`; `useMenuPress.onActivate`; `ProgressBar.variant` | Proposed; no evidence on `main` | +| FR2 | `Item` MUST render each side's panel from its entries into an out-of-flow position on its existing root and MUST add no element to the row for it. The gesture MUST drive the row's travel by writing a custom property on the root; the root's transform MUST read it and each panel's transform its negation. | DEC-5; `useToastGesture` | Proposed; real-Chromium evidence required | +| FR3 | `swipeBehavior` MUST be one enum with the closed values `reveal` and `commit`, default `reveal`. `commit` with more than one entry on a side MUST warn in development, because the other entries have no touch path. | DEC-2; `spec:AST-002` FR15 | Proposed | +| FR4 | A sideways drag by a finger or a pen, or a horizontal wheel gesture, MUST move the row and uncover the side's panel, showing each entry's `label` and `icon` as a filled surface in its `variant`'s colour; the panel MUST NOT be visible at rest, by any pixel. | Real-browser finding: a zero-width panel still paints its padding | Proposed; real-Chromium evidence required | +| FR5 | Under `reveal`, a release past the panel's width MUST leave the row resting open with every entry tappable, and a drag past the commit point or a fling MUST fire the **outermost** entry and no other. Under `commit`, a release past the commit point or a fling MUST slide the row out and fire the outermost entry; nothing MUST rest. After any entry fires, under either model, the row MUST spring back unless the entry has `hasRemoval`, in which case it MUST hold out at the edge it travelled toward. That bit is the only thing an entry declares about the row; the component MUST NOT wait on, observe, or act on what the caller does next, and MUST NOT warn if a held-out row is never removed. A release short of the threshold MUST spring back under both models. Under reduced motion the slide out, the spring back, and the hold-out follow `design:reduced-motion`: the row reaches its resting place with no travel or spring and cross-fades (DR2) when a finger started the swipe, or, when a pen or a trackpad did, cross-fades on a coarse primary pointer (DR2) and arrives at once otherwise (DR3). `onActivate` MUST fire at activation and may return a Promise. | DEC-1, DEC-2; UIKit `performsFirstActionWithFullSwipe` | Proposed; real-device evidence required | +| FR6 | The drag MUST decide its axis once, early, and a mostly vertical drag MUST stay the scroller's. A mouse MUST NOT start the drag. The click the browser synthesizes after a drag MUST NOT fire the row's primary action. | Press-model ORD1 | Proposed; real-device evidence required | +| FR7 | A resting row MUST close on a pointer landing anywhere outside it, on Escape with focus inside it, on firing an entry, and on unmount. Because a drag on a neighbour is a pointer outside it, at most one row rests open at a time without any shared state. | DEC-3; SwiftUI `swipeActionsContainer()` for the outcome | Proposed | +| FR8 | Directions MUST be logical: `trailing` is uncovered by a drag toward the inline start and sits at the inline end; `leading` the reverse; so the finger, the uncovered edge, and the panel agree under RTL. | `architecture:public-component-api` INV2 (logical direction) | Proposed | +| FR9 | The element containing swipe-capable rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`. `List` MUST set it; `Item`'s consumer documentation MUST state the obligation for any other host. | DEC-5; `List` root styles | Proposed | ### Accessibility contract @@ -250,8 +250,8 @@ constants. - **AR2 — A mouse drag never swipes a row.** A mouse drag and hover MUST NOT uncover the panel, and on a pointer that can hover a closed row's `swipeActions` MUST have no effect on the tab order, the accessible tree, or - the row's paint. A trackpad's horizontal scroll swipe, or a mouse's horizontal wheel, - which sends the same events, MAY uncover the panel (DEC-7), and a row it leaves resting open follows AR1. + the row's paint. A pen's drag, a trackpad's horizontal scroll swipe, or a mouse's + horizontal wheel, which sends the same events, MAY uncover the panel (DEC-7), and a row it leaves resting open follows AR1. - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no stop while closed. @@ -269,8 +269,8 @@ constants. trackpad's from a horizontal `wheel` gesture, which has no pointer type. A mouse's horizontal wheel sends the same events and swipes the same way, since the page cannot tell the two apart; a mouse drag never swipes. -- Unsupported behavior: a device with neither a touch screen nor horizontal - wheel input gets no swipe, and a mouse drag never swipes; nothing else +- Unsupported behavior: a device with no touch screen, no pen, and no + horizontal wheel input gets no swipe, and a mouse drag never swipes; nothing else changes. - Browser evidence: FR2 and FR4–FR9 are paint and gesture claims. jsdom has neither; real-Chromium evidence with dispatched touch is required, and diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 256938e388391..4506bd0d7bf1e 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -465,7 +465,7 @@ outside a menu closes it. - **FR24 — Gesture work costs nothing where it is not used.** A component that uses no gesture registers no gesture listener, observer or timer, and while a touch presentation is disabled none is active. Under a fine pointer - a component that uses a gesture keeps one listener on its element and adds + a component that uses a gesture listens only on its own element and adds document listeners only during a press, removing them at the release (FR43); the shared press controller and the menu press model's stray-click swallower (`module:DropdownMenu/useMenuPress` PR2), each installed once per diff --git a/packages/core/src/Button/Button.spec.md b/packages/core/src/Button/Button.spec.md index d74820befb840..b9a82b1e92788 100644 --- a/packages/core/src/Button/Button.spec.md +++ b/packages/core/src/Button/Button.spec.md @@ -131,7 +131,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR12 | With `tooltip`, the tooltip attaches to the Button element itself (no wrapper element), opens above it, and its content is added to `aria-describedby` after any caller value. | Current source; real-browser accessibility test | | FR13 | A standalone Button paints the shadow token for its `elevation`. Inside a ButtonGroup it paints no shadow and reflects `data-elevation="none"`, whatever `elevation` says. | `family:buttons` FR8–FR10; current source and tests | | FR14 | The root carries `astryx-button` and reflects the effective `data-variant`, `data-size`, and `data-elevation`. | `family:buttons` FR10; `architecture:component-theming-surface`; tests | -| FR15 | Hover paints the shared interaction overlay on hover-capable pointers, and a mouse press paints the shared pressed overlay. Under a coarse pointer a recognized touch press paints the pressed overlay, which fades out over 200 ms on release. Without a reduced-motion preference, a standalone Button also scales to 0.98 on a mouse press, or while the touch press reads on; under reduced motion it MUST NOT scale (`design:reduced-motion` DR1). All of this paint follows the element's exposed disabled state: a Button that is natively disabled or exposes `aria-disabled="true"` (busy-only, disabled with a tooltip, or a caller's own `aria-disabled`) shows no overlay and no press scale, even when it stays operable. Inside a ButtonGroup the press scale does not apply. Button's hover and press transitions are fast, and instant under reduced motion. Implementation gap: the 0.98 press scale also applies under reduced motion; only its transition becomes instant. | Current source and tests | +| FR15 | Hover paints the shared interaction overlay on hover-capable pointers, and a press paints the shared pressed overlay through the press controller: at once under a mouse or a pen (`spec:AST-094` FR11's fine-pointer profile) and on FR11's control profile under a finger, fading out over 200 ms on release. Without a reduced-motion preference, a standalone Button also scales to 0.98 on a mouse press, or while the touch press reads on; under reduced motion it MUST NOT scale (`design:reduced-motion` DR1). All of this paint follows the element's exposed disabled state: a Button that is natively disabled or exposes `aria-disabled="true"` (busy-only, disabled with a tooltip, or a caller's own `aria-disabled`) shows no overlay and no press scale, even when it stays operable. Inside a ButtonGroup the press scale does not apply. Button's hover and press transitions are fast, and instant under reduced motion. Implementation gap: the 0.98 press scale also applies under reduced motion; only its transition becomes instant. | Current source and tests | | FR16 | The ghost variant carries the edge-compensation marker so an enclosing container can align it flush with its edge; other variants do not. | Current source; edge-compensation tests | | FR17 | `ref`, `className`, `style`, `xstyle`, and other DOM props reach the root element. A caller's own `aria-busy` and `aria-disabled` are kept beside Button's own (either one sets the attribute); a caller `aria-disabled` changes exposure and paint only and does not make the Button inert. The tooltip trigger shares the caller's `ref`, which stays attached across rerenders. | Current source and tests | diff --git a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md index 8fa142ade150f..3425622bb0673 100644 --- a/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md +++ b/packages/core/src/DropdownMenu/modules/useMenuPress.spec.md @@ -227,9 +227,9 @@ Consumer migration instructions belong in consumer docs and release notes. ## Design relationships -No visual representation is introduced. FR6 removes the coarse-pointer -pressed paint from menu rows so the single focus highlight is the only lit -row under a finger; the highlight paint itself is the existing focus overlay +No visual representation is introduced. FR6 keeps a finger's row press on +`spec:AST-094` FR11's cell profile so the single focus highlight is the only +lit row under a finger; the highlight paint itself is the existing focus overlay owned by the item components. ## Parent and system relationships @@ -294,8 +294,8 @@ opens under the pointer would pick a row nobody chose. Menus already keep one highlight by moving focus on hover; the finger moves the same focus with `preventScroll`, so keyboard, mouse and touch share one state and one paint. A picker's list belongs to a combobox and must not take -focus from it. The coarse-pointer pressed paint on menu rows is dropped -because it would light a second row (FR6). This record does not decide where +focus from it. A row a finger left keeps no pressed paint, because it +would light a second row (FR6). This record does not decide where focus goes after a pick. ### DEC-7 — Eligibility permits adoption; component contracts opt in From 18da5ed6f36dd32b60490c0d8fc93717ad8653f0 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 06:43:14 -0700 Subject: [PATCH 11/12] Merge main into spec/gestures-on-web --- docs/specs/AST-094-touch-presentations/spec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 4506bd0d7bf1e..57f812e96ab1a 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -512,9 +512,10 @@ outside a menu closes it. neighbor's target. On the web, a pan also takes wheel and trackpad scrolling; a pinch takes a ctrl-wheel; one shared context request answers a long press, a right click, and Shift+F10; the - press model has a fine-pointer profile with no delay (FR11); and under a - mouse, a trackpad, or a pen a reorder starts on a drag from its handle - without a hold. Under a fine pointer only a + press model has a fine-pointer profile with no delay (FR11); and with a + mouse, a trackpad, or a pen where the primary pointer is fine a reorder + starts on a drag from its handle without a hold, while a pen where the + primary pointer is coarse keeps the finger's hold (`spec:AST-103` FR3). Under a fine pointer only a component that uses a gesture listens (FR24), and under reduced motion a release from a mouse, a trackpad, or a pen follows `design:reduced-motion` DR2 on a coarse primary pointer and DR3 otherwise; and gestures add no From b61be386710e230b37d552e0715ee69668380e61 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 27 Oct 2026 07:41:09 -0700 Subject: [PATCH 12/12] Merge main into spec/gestures-on-web --- docs/architecture/react-component-runtime.md | 3 +- docs/families/buttons.md | 18 +++--- docs/specs/AST-057-row-actions/spec.md | 38 ++++++------ .../specs/AST-094-touch-presentations/spec.md | 13 +++-- packages/core/src/Button/Button.spec.md | 58 ++++++++++--------- .../DropdownMenu/modules/useMenuPress.spec.md | 20 +++---- 6 files changed, 77 insertions(+), 73 deletions(-) diff --git a/docs/architecture/react-component-runtime.md b/docs/architecture/react-component-runtime.md index 1e8803faa9f03..31e4af0d9ff64 100644 --- a/docs/architecture/react-component-runtime.md +++ b/docs/architecture/react-component-runtime.md @@ -185,8 +185,7 @@ those contracts. install call, no public entry, and no public installed check; tests reset the modules through an internal hook only. A component's own touch logic is small glue in its own build over these modules. The gesture host attaches for - every pointer, with no touch-only query, and pointer and hover queries live - with the environment queries; under a fine pointer only a component that + every pointer, with no touch-only query; under a fine pointer only a component that uses a gesture listens (`spec:AST-094` FR19, FR24, FR43). The modules are not public exports and are not documented for consumers; Core and Lab components reach them through `@astryxdesign/core/internal/touch`, a subpath reserved for Astryx's own diff --git a/docs/families/buttons.md b/docs/families/buttons.md index b86444dc362dd..e325abba8083d 100644 --- a/docs/families/buttons.md +++ b/docs/families/buttons.md @@ -93,15 +93,15 @@ Membership follows public responsibility, not an import of Button or a rendered ## Canonical concepts -| Concept | Values or states | Default semantics | Stability | -| ---------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -| activation model | momentary action, navigation, persistent press | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction | -| content mode | visible label, custom visible content, icon-only | every control has a required accessible `label`; icon-only hides it visually | shipped family rule | -| size | `sm`, `md`, `lg` | `md`; explicit member size wins over an inherited group size | shipped family axis | -| visual state | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose; press feedback follows `spec:AST-094` FR11's control profile under a finger and its fine-pointer profile under a mouse or a pen | shipped family rule | -| async action | absent, fire-once, interruptible persistent action | ordinary actions deduplicate while pending; persistent toggles remain reversible | shipped family distinction | -| elevation | `none`, `low`, `med`, `high` | `none`; the element painting the visible surface owns the shadow | shipped family axis; ToggleButton adoption approved | -| grouping | standalone, spaced set, connected surface | semantics and painted containment decide ownership; grouping alone does not | family rule | +| Concept | Values or states | Default semantics | Stability | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | +| activation model | momentary action, navigation, persistent press | Button and IconButton activate once; ToggleButton represents retained pressed state | shipped distinction | +| content mode | visible label, custom visible content, icon-only | every control has a required accessible `label`; icon-only hides it visually | shipped family rule | +| size | `sm`, `md`, `lg` | `md`; explicit member size wins over an inherited group size | shipped family axis | +| visual state | rest, hover, focus, active, disabled, loading; pressed where applicable | states preserve control geometry and accessible purpose; press feedback follows `spec:AST-094` FR11's control profile under a finger and its fine-pointer profile under a mouse or a pen (not implemented: a mouse press still paints `:active`) | shipped family rule | +| async action | absent, fire-once, interruptible persistent action | ordinary actions deduplicate while pending; persistent toggles remain reversible | shipped family distinction | +| elevation | `none`, `low`, `med`, `high` | `none`; the element painting the visible surface owns the shadow | shipped family axis; ToggleButton adoption approved | +| grouping | standalone, spaced set, connected surface | semantics and painted containment decide ownership; grouping alone does not | family rule | ## Cross-component invariants diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index 912f26bd2a9a9..2b3f6c57495e1 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -44,15 +44,15 @@ review_triggers: [public-api, accessibility, behavior] ## Contract at a glance -| Area | Contract | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | `Item` carries touch swipe actions. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal and no mouse-drag swipe (DEC-4); a trackpad's horizontal scroll swipes as a finger does (DEC-7). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | -| Behavior | On a coarse pointer a sideways drag, or a trackpad's horizontal scroll, moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | -| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. A trackpad's horizontal scroll swipes the row the same way; a mouse drag, a keyboard, or a screen reader meets the row exactly as today. | -| Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | -| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | -| Review checks | Reject a hover-revealed panel or a mouse drag that reveals one; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | -| Governing rules | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5. | +| Area | Contract | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | `Item` carries swipe actions for a finger, a pen, and horizontal wheel input. `swipeActions` declares, per side, the verbs a sideways drag uncovers as `ItemSwipeAction[]` — `label`, `icon`, `onActivate`, `variant`, `hasRemoval` — and `Item` renders the panel from them (DEC-1). `swipeBehavior` is `reveal` (rest open) or `commit` (fire on release) (DEC-2). The capability is `Item`'s, available on any row whose role permits interactive descendants — a `listitem`, a role-less row — and out of scope where it does not (`option`, `menuitem*`, a radio) (DEC-3). No hover reveal and no mouse-drag swipe (DEC-4); a trackpad's horizontal scroll swipes as a finger does (DEC-7). No new DOM: the row's root translates and its panels counter-translate (DEC-5). | +| Behavior | A sideways drag by a finger or a pen, or a horizontal wheel gesture, moves the row and uncovers the side's panel. Under `reveal` (default) the row rests open so each action is tappable and a long drag fires the outermost; under `commit` the row slides out and the outermost fires; nothing rests. After an action fires the row springs back, or holds out when the action has `hasRemoval` — one bit the component cannot infer, independent of the model. The component waits for nothing afterwards. A short release springs back under both. A mouse never starts the drag; a mostly vertical drag stays the scroller's; a pointer anywhere outside a resting row closes it. | +| End-user impact | A person on a phone gets the flick they expect on a mail row or a settings row, with the row's own look. A pen, or a trackpad's horizontal scroll, swipes the row the same way; a mouse drag, a keyboard, or a screen reader meets the row exactly as today. | +| Builder impact | One prop on `Item` (and so on `ListItem`), `swipeActions={{trailing: [{label: 'Archive', icon: , onActivate: archive, hasRemoval: true}]}}`; optionally `swipeBehavior`. Every field is known before the gesture begins, so the component paints the panel from data. The group around the rows clips in the inline axis — `List` does it; any other host does it once. The consumer documentation states that a verb reachable only by swipe is unreachable by keyboard. | +| Compatibility/readiness | Additive: both props are absent by default; every current row keeps its DOM and paint exactly. Authority: `draft`; `approved_by` is `null`. No owner question is open. | +| Review checks | Reject a hover-revealed panel or a mouse drag that reveals one; swipe actions on a row whose role forbids interactive descendants; a model restricted by host; `commit` with several entries on a side and no warning; a panel exposed to the tree while closed; any element added to `Item` for the panels; `overflow: hidden` on the group where `clip` is meant; a row exit or enter animation introduced under this record; `hasRemoval` read as an obligation, a warning, or a timing rule; a second adaptive media query. | +| Governing rules | [`architecture:interaction-modality`](../../architecture/interaction-modality.md) INV4, INV6, INV7; [`spec:AST-002`](../AST-002/spec.md) FR1, FR4, FR15, FR16, `DEC-1`; [`architecture:public-component-api`](../../architecture/public-component-api.md) INV2, INV3, INV5. | This table is a review projection; the body below is authoritative. @@ -250,7 +250,7 @@ constants. - **AR2 — A mouse drag never swipes a row.** A mouse drag and hover MUST NOT uncover the panel, and on a pointer that can hover a closed row's `swipeActions` MUST have no effect on the tab order, the accessible tree, or - the row's paint. A pen's drag, a trackpad's horizontal scroll swipe, or a mouse's + the row's paint. A pen's drag (`spec:AST-094` FR43), a trackpad's horizontal scroll swipe, or a mouse's horizontal wheel, which sends the same events, MAY uncover the panel (DEC-7), and a row it leaves resting open follows AR1. - **AR3 — The row's primary stays one tab stop.** `swipeActions` MUST NOT change the primary element's role, name, or activation, and MUST add no @@ -301,15 +301,15 @@ constants. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | -| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | -| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL; a trackpad's horizontal scroll and a mouse's horizontal wheel; a mouse drag, which must not swipe | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | -| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | -| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | -| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer; a trackpad horizontal scroll that opens a row; a mouse drag | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, a panel a mouse drag or hover uncovers, or any effect on a closed row under a hovering pointer fails. | -| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | +| Contract | Verification | Representative states | Mutation or failure expectation | +| ------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR3 | `Item` and `ListItem` prop-surface suites plus exported-type checks | One, two, and four entries per side; every `variant` × `hasRemoval` combination; `commit` with two entries on a side | A field with a node the row renders as the control, a silent multi-entry `commit`, or `ListItem` reshaping the prop fails. | +| FR2 | `Item` DOM snapshot suite plus real-Chromium evidence | No swipe actions; one side; both sides; each render mode (`onClick`, `href`, delegation, parent role); mid-drag | A new element in any row, a changed DOM on an existing row, a panel that moves with the row, or a panel painted under the root's background or over the content fails. | +| FR4–FR6, FR8 | Real-Chromium dispatched-touch evidence plus physical-device check; RTL via the direction provider | Rest; release short, past the panel, past the commit point, fling; both models; both sides; `hasRemoval` and plain entries under each; vertical drag; mouse drag; RTL; a trackpad's horizontal scroll and a mouse's horizontal wheel; a mouse drag, which must not swipe; a pen drag | A resting sliver, an immediate fire under `reveal`, a rest under `commit`, the wrong entry fired, a plain entry that holds out, a `hasRemoval` entry that springs back, a warning on a held-out row, or a mirrored panel fails. | +| FR7 | `Item` suite with two rows under one parent | Open one, drag the other; outside tap; Escape; entry fired; unmount | Two rows resting open, or a row that stays open after any closing event, fails. | +| FR9 | `List` style assertion plus real-Chromium evidence of a row dragged past the edge | A row at the list's inline edge mid-drag; the list scrolled vertically | A horizontal scrollbar, the row painting past the list, or `hidden` where `clip` is specified fails. | +| AR1–AR3 | Accessible-tree and tab-order assertions, open and closed, both models; hovering-pointer evidence | Closed; resting open; `commit` mid-drag; touch screen-reader cursor on an entry; hover and Tab on a fine pointer; a trackpad horizontal scroll that opens a row; a mouse drag | A panel exposed while closed, a `commit` panel in the tree, an entry without a name while resting, a changed primary, a panel a mouse drag or hover uncovers, or any effect on a closed row under a hovering pointer fails. | +| Documentation | Consumer-doc check on `swipeActions` | The prop's documentation | A doc that does not state the keyboard consequence or the group clip fails. | Known verification gap: none of the suites above exist on `main`, and no component implements the contract. This record is `draft`, does not govern diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 57f812e96ab1a..2bdaf010cac3b 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -445,8 +445,10 @@ outside a menu closes it. element's existing always-on suppression stays as it is. On a label or plain text whose hold does nothing, such as a checkbox row's label, a long press selects natively, with the browser's handles; any other element follows its - own record. Keyboard and assistive-technology selection are unchanged, and a - mouse keeps its released behavior (`spec:AST-017` FR59; DEC-9). + own record. Keyboard and assistive-technology selection are unchanged. A pen's hold + acts as a finger's; a mouse makes the context request by a right click, not + a long press (FR43), and otherwise keeps its released behavior + (`spec:AST-017` FR59; DEC-9). Guidance: a caller whose menu element carries text a person may want offers a Copy action in its menu. @@ -511,7 +513,8 @@ outside a menu closes it. and the 44 px coarse-pointer touch target, which stays coarse-only and never overlaps a neighbor's target. On the web, a pan also takes wheel and trackpad scrolling; a pinch takes a ctrl-wheel; one shared - context request answers a long press, a right click, and Shift+F10; the + context request answers a finger's or a pen's long press, a right click, and + Shift+F10; the press model has a fine-pointer profile with no delay (FR11); and with a mouse, a trackpad, or a pen where the primary pointer is fine a reorder starts on a drag from its handle without a hold, while a pen where the @@ -628,7 +631,7 @@ outside a menu closes it. | FR31–FR33 | Browser tests and storyboards under a finger | a menu, a Popover and a ContextMenu lift: open, close, a pan outside, a tap outside, a pinch over the surface; a mouse | a surface that does not grow from and shrink into its trigger, a pan outside that leaves a menu open, a Popover whose dismissal changed, a pinch blocked other than by the author's `touch-action`, a tap outside that reaches the page, or a changed mouse path fails | | FR34, FR35 | Browser tests and storyboards under a finger | a sub-menu drilling in and its Back row returning; a tap and a drag during a popover's and a menu's growth; a pinch during growth; a mouse; a tap and a drag during an alert's, a sheet's, and a dialog's entrance; a key and a screen reader during an entrance; a pinch during an alert's and a sheet's entrance; a tap and a scroll on the page beside an entering non-modal sheet, which pass | a drill-in that leaves the parent's rows in place, a change between levels that does not play on its roles, a tap during growth that closes, scrolls or moves focus, a pinch blocked other than by the author's `touch-action`, or a changed mouse path, a held key or assistive-technology activation, a hidden content tree, or a tap or drag during an entrance that closes the presentation, scrolls a held page, moves focus or reaches anything beneath, fails | | FR36–FR40 | Release tests on synthetic touch streams with explicit timestamps, per use | below and past each commit point; a flick that projects past it; a slow drag past it; a release moving back; Reduce Motion; keyboard and mouse paths | a decision that changes mid-settle, a release moving back that commits, a settle that does not start at the release velocity, or an action with no keyboard or mouse path fails | -| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | +| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | | FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails | | FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | | all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect | @@ -795,7 +798,7 @@ scrolling for a pan, a ctrl-wheel for a pinch, and right click or Shift+F10 for a long press. What would fight the browser is off: a mouse drag's swipe actions, a pen's pinch, and the screen-edge pan under a mouse, a trackpad, or a pen. The 44 px target is touch-only, and none of this adds stylesheet bytes. A mouse press uses the press model with no delay instead of the -browser's `:active`; this supersedes the coarse-only press controller. +browser's `:active`. The press controller tracks every pointer. Rejected: keeping gestures and press tracking touch-only. diff --git a/packages/core/src/Button/Button.spec.md b/packages/core/src/Button/Button.spec.md index b9a82b1e92788..c3bc2b625d548 100644 --- a/packages/core/src/Button/Button.spec.md +++ b/packages/core/src/Button/Button.spec.md @@ -37,15 +37,15 @@ system_specs: [spec:AST-005, spec:AST-085, spec:AST-086] ## Contract at a glance -| Area | Contract | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. This record states the shipped Button surface: `label`, variant, size, elevation, content mode, loading and `clickAction`, `isInterruptible`, disabled, tooltip, width, form attributes, and link mode. | -| Behavior | A named native `