diff --git a/docs/architecture/interaction-modality.md b/docs/architecture/interaction-modality.md index 98b663a0c33f0..8f5f96b816ab2 100644 --- a/docs/architecture/interaction-modality.md +++ b/docs/architecture/interaction-modality.md @@ -25,7 +25,7 @@ verified_by: packages/core/src/Selector/Selector.test.tsx, packages/core/src/Slider/Slider.test.tsx, ] -deciding_specs: [spec:AST-100/DEC-1] +deciding_specs: [spec:AST-100/DEC-1, spec:AST-094/DEC-17] --- # Interaction modality architecture @@ -188,6 +188,17 @@ work. path, through the animator or a CSS custom property alike. Scroll snapping and momentum stay native. It adds no module and is internal (`spec:AST-017` FR21). +- **INV13 — Press tracking across a group has one owner.** In + `@astryxdesign/core`, one internal module, `actionGroupTracker`, tracks a + held pointer across a group's actions for every `family:action-groups` + member other than menus and Selector's and MultiSelector's options, whose + tracking and public `menuPressStep` belong to + `module:DropdownMenu/useMenuPress` (OQ1; implementation gaps: MultiSelector, + and TopNav's, SideNavHeading's, TopNavHeading's, NavHeadingMenu's, + BreadcrumbItem's and TabMenu's menus, + do not mount it yet). No other member keeps its own copy of the tracking. It is not exported (`spec:AST-017` + FR21). + ## Allowed variation Component families keep their shipped interaction models: @@ -252,7 +263,8 @@ is asked to carry a new decision. - `architecture:react-component-runtime` owns shared lifecycle and resource mechanisms. This record owns only the cross-cutting modality-path, no-stale-interaction-outcome, gesture-velocity-source, hover-intent - (INV11), and scroll-progress-source (INV12) requirements. + (INV11), scroll-progress-source (INV12), and group press-tracking (INV13) + requirements. - The Core velocity tracker and its release filter (INV10) — the one source of gesture velocity for Core components; not exported. - The hover recognizer, `HoverGestureRecognizer`, and its `intent` setting @@ -260,11 +272,14 @@ is asked to carry a new decision. exported. - The native-scroll module's progress and its subscription (INV12) — the one source of scroll progress for Core components; not exported. +- The action-group tracker (INV13) — the one press tracker for action groups + outside menus and Selector's and MultiSelector's options; not exported. ## Deciding specs -`spec:AST-100/DEC-1` decides one release velocity per kind of surface (INV10). -Otherwise no separate specification changes this record. The last-input rule was approved as +`spec:AST-100/DEC-1` decides one release velocity per kind of surface (INV10), +and `spec:AST-094/DEC-17` decides one press tracking for every action group +(INV13). Otherwise no separate specification changes this record. The last-input rule was approved as a system architecture decision by `cixzhang` on 2026-08-30. Essential-action reachability, newer-intent precedence, and the evidence/scope guard were approved by `cixzhang` on 2026-09-10. @@ -284,6 +299,7 @@ reachability, newer-intent precedence, and the evidence/scope guard were approve | INV10 | Source boundary check over Core and Lab, a Lab-to-Core comparison on one recorded stream, and release-filter unit tests over recorded iOS pan and scroll-view streams | A Core component with its own sample buffer, velocity math, or release filter; a Lab component with its own sample buffer, velocity math, or release filter; a Lab component reporting a different velocity than Core for the same stream; an export of the tracker; a non-zero release after a still pause | | INV11 | Evidence gap: not implemented yet; the shared hover recognizer and its adopters' move onto it are pending. Then: unit tests of the hover recognizer's intent and browser tests of its adopters with a mouse, a pen, a finger, and the keyboard | A show without the owner's entry delay, a missed handoff within a group, a delayed keyboard show, a hover-shown surface that closes before the pointer reaches it, a finger served by it, or a second hover-timing implementation fails | | INV12 | Unit tests of native scroll's progress and real-browser proofs of its adopters | Progress routed through React state, a missing edge, travel under Reduce Motion, use where native scroll-driven animation is supported, or a public export fails | +| INV13 | Source boundary check over Core and each family member's pointer-stream tests | A member outside menus and Selector's and MultiSelector's options that tracks a held pointer across its actions with its own code, or a public export of the tracker, fails | Real-browser evidence is required when the behavior depends on `:focus-visible`, pointer capability, touch or pen synthesis, rendered focus @@ -297,3 +313,8 @@ indicator on pointer press, and keeping internal action focus owned by the action. The benchmark also verifies programmatic editor focus after both keyboard and pointer input before the behavior is treated as complete. A benchmark demonstrates the invariants; it does not carry authority for them. + +## Open questions + +- **OQ1 — When do menus and Selector's and MultiSelector's options move onto + the shared tracker?** (`engineering`) Open. diff --git a/docs/families/action-groups.md b/docs/families/action-groups.md new file mode 100644 index 0000000000000..39074b0c9ebf1 --- /dev/null +++ b/docs/families/action-groups.md @@ -0,0 +1,219 @@ +--- +schema_version: 1 +template_version: 1 +kind: family +id: family:action-groups +authority: current +archive_reason: null +superseded_by: null +approved_by: cixzhang +approved_at: 2026-10-26 +owners: [cixzhang] +review_triggers: [behavior, interaction, accessibility] +verified_by: + [ + packages/core/src/hooks/useMenuPress.test.tsx, + packages/core/src/hooks/menuPressGesture.test.ts, + ] +members: + [ + component:DropdownMenu, + component:DropdownMenuSubMenu, + component:ContextMenu, + component:Selector, + component:MultiSelector, + component:DateInput, + component:DateTimeInput, + component:TimeInput, + component:AlertDialog, + component:ButtonGroup, + component:ToggleButtonGroup, + component:Toolbar, + component:Banner, + component:Toast, + component:TopNavMenu, + component:SideNavHeading, + component:TopNavHeading, + component:TabMenu, + component:DateRangeInput, + component:NavHeadingMenu, + component:BreadcrumbItem, + ] +architecture: [architecture:interaction-modality] +contributing: [] +deciding_specs: [spec:AST-094/DEC-17] +--- + +# Action groups contract + + + +```json +{ + "scope": "global", + "triggers": { + "interaction": ["FR1", "FR2", "FR3", "FR4", "FR5"] + } +} +``` + +## Intent + +A person can press one action in a group of sibling actions, slide across the +others while the highlight follows, and act on the one they lift on, the same +way in every group, with SegmentedControl as the one named exception. Every +member gets the behavior from one shared implementation, or menus and +Selector's and MultiSelector's options from the menu press model, so no member builds its own. + +## Membership rule + +A component is a member when it renders a set of two or more sibling +momentary, toggle, or option actions that a person chooses one of. Members +today: + +- menus: DropdownMenu, ContextMenu, and their sub-menus, the navigation + menus of TopNav (TopNavMenu), SideNavHeading, TopNavHeading, and + NavHeadingMenu; and + BreadcrumbItem's `menu`, which renders DropdownMenu items; +- TabMenu's menu of tab options; +- option lists: Selector's and MultiSelector's options, and the wheels of + DateInput, DateTimeInput, and TimeInput; +- AlertDialog's actions; +- ButtonGroup and ToggleButtonGroup; +- DateRangeInput's presets, one labeled group of preset buttons; +- Toolbar's buttons; a field inside a toolbar is skipped; +- the action rows of Banner and Toast when a row has two or more sibling + actions. + +Action rows in a Dialog's footer, a Card's footer, or any `LayoutFooter` get +the behavior by composing ButtonGroup. + +A component built later that matches the rule joins by the rule, including an +action sheet, a tab bar or navigation bar, a filter-chip group, and a rating. + +SegmentedControl is a named exception: this family's rule does not bind it. + +These are not members: + +- text inputs and InputGroup as a whole, whose input keeps the caret and + selection and whose addons belong to that input rather than forming a group; +- TabList, whose scrollable rows need a drag to scroll and whose tabs switch + views; +- Pagination and Stepper, inline page controls in the page flow rather than a + menu; +- TopNavMegaMenu's panel, a browsing grid of links in a `role="group"`, not a + menu (`component:TopNavMegaMenu` FR5); +- SideNavItem's collapsed-rail flyout, a popover of the item's navigation rows, + not a menu; +- Popover itself, a surface whose content owns its role; a menu inside it + follows its own component; +- Calendar's day grid, a two-dimensional date grid with its own keyboard and + range-selection model, which a slide to commit would break; +- RadioList, CheckboxList, and SelectableCard, whose rows and cards sit in + scrolling lists; +- Token chips, which are removable and have no group control. +- the suggestion lists of Typeahead (also Tokenizer's), CommandPalette, and the + chat composer's trigger menu, whose focus stays in the text input, as a text + input's does. + +## Shared owner + +One shared, internal implementation gives every member other than menus and +Selector's and MultiSelector's options FR1–FR5 +(`architecture:interaction-modality` INV13); those get them from +`module:DropdownMenu/useMenuPress`. No member keeps another copy. + +## Canonical concepts + +- **Action group:** two or more sibling actions a person chooses one of. +- **Held pointer:** a press that has not lifted, tracked across the group. +- **Lifted action:** the enabled action under the pointer when it lifts. + +## Cross-component invariants + +- **FR1 — The lift decides.** In a member, a press on an action that slides + across its siblings acts on the action the pointer lifts on, through that + action's own activation; a press that never leaves its action acts on it. + One gesture acts at most once (`spec:AST-094` FR26). After a slide, focus does what + a click on the lifted action does. +- **FR2 — The highlight follows the pointer.** While the pointer is held, the + enabled action under it is highlighted, at most one at a time, and nothing + is highlighted over a disabled action, a gap, or outside the group + (`spec:AST-094` FR27). +- **FR3 — Sliding off cancels.** A press that lifts outside the group acts on + nothing; a cancelled pointer, a second finger, or the window losing focus + ends the gesture with nothing acting (`spec:AST-094` FR28). Whether a + member that opened for the gesture stays open stays with the member. +- **FR4 — A toggle group toggles only the lifted action.** In a toggle group, + only the action lifted on toggles; sliding across never toggles the actions + passed over. +- **FR5 — Every pointer, one target rule.** The behavior applies to every web + pointer, mouse and pen as well as a finger. Under a fine pointer a group + keeps one `pointerdown` listener on its element, cancelable where the member + needs it, adds document listeners only during a press and removes them at + the release, and may arm one per-document click swallower from the release + until that gesture's click; nothing else is idle (`spec:AST-094` FR19, + FR24). The 44 px target is each member's hit + expansion under a coarse pointer only. Keyboard and assistive-technology + behavior is unchanged. + +## Allowed component variation + +- What an action does on activation, and how a member shows its highlight, + stay with the member. +- Menus keep their own opening and handoff (`spec:AST-094` FR29) and their + scrolling rules. +- A wheel is a scroll container: a drag scrolls it, as it does today, and a + tap acts on the option under it; only a press that does not scroll tracks. + +## Representative matrix + +| Member and state | Shared invariant | Deliberate variation | +| -------------------------------------- | --------------------------------------------------- | ---------------------------------------------- | +| DropdownMenu / slide from row to row | the lifted row acts; one row lit at a time | opening and handoff stay the menu's (FR29) | +| AlertDialog / slide from Cancel | the lifted action runs; a lift outside does nothing | the actions' order and stacking stay local | +| ToggleButtonGroup / slide across three | only the lifted toggle changes | single or multiple selection stays the group's | +| Toolbar with a field / slide across | buttons track; the field is skipped | which controls a toolbar holds stays local | + +## Adoption and exceptions + +| Components or surface | Required adoption | +| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Menus and Selector's and MultiSelector's options | Follow FR1–FR5 through `module:DropdownMenu/useMenuPress`. | +| TopNav's, SideNavHeading's, TopNavHeading's, NavHeadingMenu's, BreadcrumbItem's, and TabMenu's menus | Follow FR1–FR5 through `module:DropdownMenu/useMenuPress`, as menus. | +| Date and time wheels | Follow FR1–FR5 through the shared implementation. | +| AlertDialog's actions | Track the actions only. | +| ButtonGroup, ToggleButtonGroup, Toolbar | Track sibling buttons; a field inside is skipped; toggles follow FR4. | +| DateRangeInput's presets | Track the preset buttons only. | +| Banner and Toast action rows | Track only a row with two or more sibling actions. | + +TopNav's, SideNavHeading's, TopNavHeading's, NavHeadingMenu's, BreadcrumbItem's, and TabMenu's menus act on +the browser's click today and track no held pointer across their rows; their +adoption of the menu press model is an implementation gap. + +These are required outcomes, not a claim of completed adoption. The excluded +components listed under the membership rule are not exceptions to approve; +they are outside the family. + +## Verification map + +| Contract | Verification | Representative members and states | +| -------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| FR1–FR3 | Shared-implementation tests and each member's pointer-stream tests | a slide across a menu, AlertDialog's actions, a ButtonGroup; a lift outside | +| FR4 | ToggleButtonGroup pointer-stream tests | a slide across three toggles; only the lifted one toggles | +| FR5 | Member tests with a mouse, a pen, a finger, the keyboard, and a screen reader | a 44 px target under a coarse pointer only; unchanged keyboard and screen-reader paths | + +## Decision links + +- `spec:AST-094/DEC-17` — every group of sibling actions tracks a held + pointer the same way. + +## Open questions + +None. + +## Content boundary + +This file owns action-group membership and the shared press, slide, and lift +behavior. It does not duplicate component prop tables, what each action does, +menu opening, or the shared implementation's thresholds. diff --git a/docs/families/buttons.md b/docs/families/buttons.md index e325abba8083d..6f6167afb124a 100644 --- a/docs/families/buttons.md +++ b/docs/families/buttons.md @@ -161,7 +161,9 @@ Membership follows public responsibility, not an import of Button or a rendered - **FR11 — A button-specific group has one accessible owner.** ButtonGroup and ToggleButtonGroup MUST expose an accessible group label and propagate their documented size and disabled defaults without removing a member's accessible - name. The group owns only the behavior and surface declared by its group mode. + name. The group owns only the behavior and surface declared by its group + mode, and the press tracking across members that `family:action-groups` + gives it. A ToggleButtonGroup member identified by `value` MUST derive pressed state and selection requests from the group, not its standalone `isPressed`, `onPressedChange`, or `pressedChangeAction`. Member `onPressedChange` and diff --git a/docs/families/layout-regions.md b/docs/families/layout-regions.md index 19d09fee8b72a..c1b011e41c1b6 100644 --- a/docs/families/layout-regions.md +++ b/docs/families/layout-regions.md @@ -107,7 +107,9 @@ wrapper, or a header-like visual treatment. - **FR1 — A region owns structure, not product meaning.** A member establishes a spatial boundary and renders caller content without adopting the content's - product semantics, state, or component contracts. + product semantics, state, or component contracts. Toolbar's press + tracking across its buttons (`family:action-groups` FR5) is the one + exception. - **FR2 — Region direction is logical.** Start and end follow writing direction. Divider placement and panel adjacency use the same logical region position. - **FR3 — Layout selects geometry from slot presence.** Header, Content, Footer, @@ -176,7 +178,8 @@ wrapper, or a header-like visual treatment. - **AV4 — Size model.** Layout owns fill/auto container height and content-width propagation; individual regions own applicable height, width, padding, and scroll props; Section owns its box-size props. -- **AV5 — Interaction.** Toolbar owns roving focus and keyboard hints. Other +- **AV5 — Interaction.** Toolbar owns roving focus, keyboard hints, and the + press tracking `family:action-groups` FR5 gives it across its buttons. Other members add no toolbar behavior. Resizable owns resize interaction. - **AV6 — Composition mechanism.** Members may use Stack utilities, direct CSS layout, contexts, or component composition. The observable region contract is diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 9c0995b96a26d..41244a1b58789 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -194,8 +194,9 @@ a measured, sourced difference exists. 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 - activation stays the browser's click, except that a toggle under - `spec:AST-020` FR9's slide-off exception (Switch, Checkbox, + activation stays the browser's click, except that an action a + `family:action-groups` member tracks activates at the lift (FR26), and a toggle + under `spec:AST-020` FR9's slide-off exception (Switch, Checkbox, CheckboxListItem) may also complete on a lift after the finger slid off. Under a fine pointer a control uses a third profile with no delay: its press paint starts at once on `pointerdown`, through the same press @@ -278,7 +279,12 @@ a measured, sourced difference exists. - **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 + read for the row swipe, as its record states. A `family:action-groups` member + tracks its own group as FR24 allows: under a fine pointer it keeps one + `pointerdown` listener on the group element, cancelable where the member + needs it, adds document listeners only during a press, removed at the + release, and may arm one per-document click swallower from the release until + that gesture's click (DEC-17). A recognizer that begins MUST claim its touches by cancelling `touchmove` through a non-passive listener on its element; a watcher, which never claims, is exempt (DEC-7). A `touchmove` of a touch no recognizer owns MUST NOT be cancelled, with three exceptions. While a scroll lock under a modal presentation holds the @@ -336,6 +342,12 @@ the entrances it lists; FR32 and 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–FR28 are the shared rule of `family:action-groups`: every member, not +only menus, follows them under the family's scope (DEC-17). Whether a menu +stays open after a release outside it stays with the menu: FR28 keeps it open +under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and +`component:Selector` FR7. + - **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 a tap and acts on that row. One gesture acts at most once: the browser's @@ -479,7 +491,8 @@ outside a menu closes it. 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 - page, count once and not per component, and a claiming recognizer's `touchmove` listener for a finger + page, count once and not per component, an action group's release-armed click + swallower (FR19) counts as press work, 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 @@ -557,7 +570,8 @@ outside a menu closes it. the three motion knobs join the motion configuration (FR4, DEC-15, `spec:AST-108`). - `architecture:interaction-modality`: the touch-presentation conditions - (FR13) sit beside the shared modality state; its invariants are unchanged. + (FR13) sit beside the shared modality state, and INV13 gives every action + group one press tracking (DEC-17). - `architecture:react-component-runtime`: FR24 states the caller-visible outcome of INV5 and INV7 for touch work; both are unchanged. - Under FR41: ContextMenu's trigger, DropdownMenu's built-in trigger and its menu, and @@ -588,6 +602,9 @@ outside a menu closes it. 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. +- Every `family:action-groups` member, as its membership rule lists them: + a press that slides across sibling actions acts on the action it lifts on + (FR26–FR28), under every pointer. - Every component under a coarse pointer: no box grows for touch, and a touch target reaches 44 px only through an invisible hit area clamped by its neighbors, unless a component or module record sets its own (FR42). @@ -617,32 +634,32 @@ outside a menu closes it. ## Verification -| Contract | Verification | Representative states | Mutation or failure expectation | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR1, FR2 | Value check over every measured value and its uses | every value; a guess and its measurement task; a platform-specific value; two components playing the same motion | an unsourced value, an unknown source kind, a guess without a listed measurement, an unsourced platform split, or two values for one use fails | -| FR3 | Unit tests of the spring, decay, rubber band and projection math against an independent integration and values read from UIKit | under-, critically and overdamped springs; release velocities; both rates; overscroll depths | changing a formula or a constant breaks the agreement with UIKit's values | -| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table at `tempo` 1 and `'standard'`, or a theme that changes a role other than through its motion knobs fails | -| FR5 | Browser proof: an animation keeps advancing frame by frame through a 1 s main-thread block, with no layout or paint per frame | Chromium phone emulation; WebKit | a script-driven or layout-animating implementation freezes or lays out | -| FR6 | Unit and browser tests catching an animation mid-flight and retargeting it | catch at peak velocity; retarget with a new spring | a layout read, a jump, or a lost velocity fails | -| FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), or an opacity fade over 200 ms fails | -| FR9, FR10 | Recognizer unit tests on synthetic streams and browser tests with real touch input | drag, second finger, pinch, page zoom, main-thread block | a second finger that breaks a drag, or a page pinch blocked other than by the author's `touch-action`, fails | -| FR11, FR12 | Press and long-press tests on synthetic streams with explicit timestamps | inside and outside a scroll view; a drag the browser takes; a lift inside; a menu row under a finger; a press on a row of a menu whose rows fit and of one that scrolls; a slide-off lift on a Switch and on a Radio | the wrong profile's timing, a press that survives the browser taking the touch, or a control activated other than by the browser's click, outside `spec:AST-020` FR9's slide-off exception, a row of a menu whose rows fit that waits 150 ms, or one of a scrolling menu that does not, or a slide-off lift that completes a control outside `spec:AST-020` FR9's exception, fails | -| FR13 | Resolver unit tests and a server-render test | coarse and fine pointer; narrow, wide and short; server render; hydrating render | a component that answers the conditions differently, or a touch presentation in the server or hydrating render, fails | -| FR14 | Browser tests of a sheet over a scrolled list | scrolled and at its top | a midway handoff fails | -| 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 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 | -| 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 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 | +| Contract | Verification | Representative states | Mutation or failure expectation | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1, FR2 | Value check over every measured value and its uses | every value; a guess and its measurement task; a platform-specific value; two components playing the same motion | an unsourced value, an unknown source kind, a guess without a listed measurement, an unsourced platform split, or two values for one use fails | +| FR3 | Unit tests of the spring, decay, rubber band and projection math against an independent integration and values read from UIKit | under-, critically and overdamped springs; release velocities; both rates; overscroll depths | changing a formula or a constant breaks the agreement with UIKit's values | +| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table at `tempo` 1 and `'standard'`, or a theme that changes a role other than through its motion knobs fails | +| FR5 | Browser proof: an animation keeps advancing frame by frame through a 1 s main-thread block, with no layout or paint per frame | Chromium phone emulation; WebKit | a script-driven or layout-animating implementation freezes or lays out | +| FR6 | Unit and browser tests catching an animation mid-flight and retargeting it | catch at peak velocity; retarget with a new spring | a layout read, a jump, or a lost velocity fails | +| FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), or an opacity fade over 200 ms fails | +| FR9, FR10 | Recognizer unit tests on synthetic streams and browser tests with real touch input | drag, second finger, pinch, page zoom, main-thread block | a second finger that breaks a drag, or a page pinch blocked other than by the author's `touch-action`, fails | +| FR11, FR12 | Press and long-press tests on synthetic streams with explicit timestamps | inside and outside a scroll view; a drag the browser takes; a lift inside; a menu row under a finger; a press on a row of a menu whose rows fit and of one that scrolls; a slide-off lift on a Switch and on a Radio | the wrong profile's timing, a press that survives the browser taking the touch, or a control outside an action group activated other than by the browser's click, outside `spec:AST-020` FR9's slide-off exception, a row of a menu whose rows fit that waits 150 ms, or one of a scrolling menu that does not, or a slide-off lift that completes a control outside `spec:AST-020` FR9's exception, fails | +| FR13 | Resolver unit tests and a server-render test | coarse and fine pointer; narrow, wide and short; server render; hydrating render | a component that answers the conditions differently, or a touch presentation in the server or hydrating render, fails | +| FR14 | Browser tests of a sheet over a scrolled list | scrolled and at its top | a midway handoff fails | +| 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 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 and each member's pointer-stream tests, as `family:action-groups`' verification map lists them 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 | +| 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 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 | ## Decision log @@ -795,6 +812,21 @@ iOS 26.2 simulator measures it. Rejected: the 150 ms delay on every menu row. +### DEC-17 — Every group of sibling actions tracks a held pointer the same way + +**Reference:** `spec:AST-094/DEC-17` +**Decider:** `cixzhang`, `2026-10-26` + +A person who presses one action and slides to another expects the same result +in a menu, an alert's buttons, a button group, or a toolbar. Every member +group of sibling actions behaves the same way, decided once and built in +two owners: the menu press model and the action-group tracker +(`family:action-groups`; `architecture:interaction-modality` INV13). The behavior works with a mouse +as well, so it stays on for every pointer, while the 44 px target stays +touch-only. + +Rejected: a tracking rule for menus only. + ### DEC-15 — The public motion set is three theme knobs; per-role tables stay internal **Reference:** `spec:AST-094/DEC-15` diff --git a/packages/core/src/Banner/Banner.spec.md b/packages/core/src/Banner/Banner.spec.md index 57118b806d736..f3bd93599e2d9 100644 --- a/packages/core/src/Banner/Banner.spec.md +++ b/packages/core/src/Banner/Banner.spec.md @@ -92,7 +92,8 @@ Consumer migration instructions belong in consumer docs and release notes. **Does not own / non-goals** - Button geometry, interaction layers, tooltip painting, or consumer-supplied - `endContent` — delegated to `component:Button`. + `endContent` — delegated to `component:Button`. An action row of two or more + actions follows `family:action-groups`. - A new theme property, prop, variant, or DOM element. ## Public concepts @@ -153,7 +154,8 @@ frame target contract. ### Performance and resources This contract introduces no new measurement, observer, listener, render pass, or -runtime work. +runtime work beyond the press tracking `family:action-groups` FR5 gives an +action row of two or more actions. ## Accessibility contract diff --git a/packages/core/src/ButtonGroup/ButtonGroup.spec.md b/packages/core/src/ButtonGroup/ButtonGroup.spec.md index 7e7838f91ace6..5d4023558975b 100644 --- a/packages/core/src/ButtonGroup/ButtonGroup.spec.md +++ b/packages/core/src/ButtonGroup/ButtonGroup.spec.md @@ -78,7 +78,8 @@ Consumer migration instructions belong in consumer docs and release notes. **Does not own / non-goals** - Each member's own surface, naming, activation, pending, and disabled rendering - — owned by `component:Button` and `component:IconButton`. + — owned by `component:Button` and `component:IconButton`. A press that slides across + members acts on the lifted member, as `family:action-groups` owns. - A member's menu or other layer, and keys pressed inside it — owned by that member (for example `component:DropdownMenu`). - Selection state across toggles — owned by `component:ToggleButtonGroup`. @@ -140,7 +141,8 @@ Consumer migration instructions belong in consumer docs and release notes. ### Performance and resources - **PR1 — Tab-stop repair.** The group repairs the Tab stop after each of its - own renders and when focus enters a member; it adds no global listener. + own renders and when focus enters a member; it adds no global listener + beyond the press tracking `family:action-groups` FR5 gives it. ## Accessibility contract diff --git a/packages/core/src/ToggleButton/ToggleButtonGroup.spec.md b/packages/core/src/ToggleButton/ToggleButtonGroup.spec.md index da31357296411..c67188e3c810a 100644 --- a/packages/core/src/ToggleButton/ToggleButtonGroup.spec.md +++ b/packages/core/src/ToggleButton/ToggleButtonGroup.spec.md @@ -129,8 +129,9 @@ Consumer migration instructions belong in consumer docs and release notes. ### Performance and resources -- **PR1 — No group listeners.** The group adds no event listener, observer, or - measurement. +- **PR1 — Group listeners only for press tracking.** The group adds no + observer or measurement, and no event listener beyond the press tracking + `family:action-groups` FR5 gives it. ## Accessibility contract @@ -162,17 +163,17 @@ No design record governs ToggleButtonGroup. Its root carries the ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -| FR1, AR1 | `ToggleButton.test.tsx` group role and label cases | Single and multiple | A missing name or role fails. Evidence gap: no test asserts the root target, `data-testid`, or `xstyle`. | `audit:ToggleButtonGroup/accessibility` | -| FR3–FR5, ORD1 | `ToggleButton.test.tsx` single and multiple suites | Select, switch, deselect, add, remove | A wrong pressed member or a wrong `onChange` payload fails. | `audit:ToggleButtonGroup/behavior` | -| FR6 | `ToggleButton.test.tsx` group-ownership cases | Members with standalone handlers and `onClick` | A member Action runs, or pressed state moves before `value`, fails. | `audit:ToggleButtonGroup/behavior` | -| FR7, ORD3, AR3 | `ToggleButton.test.tsx` disabled suite; `ToggleButton.a11y.test.tsx` | Member disabled; group disabled; tooltip-bearing member | A disabled member that toggles, or a re-enabled member, fails. The shared disabled states expect native `disabled`, matching the FR7 gap. | `audit:ToggleButtonGroup/accessibility` | -| FR2 | `ToggleButton.test.tsx` grouped elevation case | Member with its own elevation | A member losing its elevation inside the group fails. Evidence gap: no test asserts the spacing or the unpainted wrapper. Evidence gap: no test reads a member's painted shadow. | `audit:ToggleButtonGroup/layout` | -| FR9 (selection) | `layerScopedContext.test.tsx` "ends ancestor selection and callbacks while honoring explicit toggle props" | Member inside a fixed Layer and an inline Dialog | A member inside a layer that follows the outer group's selection or callbacks fails. | `audit:ToggleButtonGroup/behavior` | -| FR8, FR9 (size and disabled) | Evidence gap: no test of member Tab order, or of size and disabled scoping at a layer | — | — | `audit:ToggleButtonGroup/accessibility` | -| FR7 (size), ORD2, PR1 | Evidence gap: no test asserts the group size default, member size precedence, or the absence of group listeners | — | — | `audit:ToggleButtonGroup/behavior` | -| AR2 | `ToggleButton.a11y.test.tsx` and `ToggleButton.a11y.chromium.spec.ts` operable and focusable facts for the single- and multiple-group states | Group members | A group member that is not operable or that Tab does not reach fails. | `audit:ToggleButtonGroup/accessibility` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| FR1, AR1 | `ToggleButton.test.tsx` group role and label cases | Single and multiple | A missing name or role fails. Evidence gap: no test asserts the root target, `data-testid`, or `xstyle`. | `audit:ToggleButtonGroup/accessibility` | +| FR3–FR5, ORD1 | `ToggleButton.test.tsx` single and multiple suites | Select, switch, deselect, add, remove | A wrong pressed member or a wrong `onChange` payload fails. | `audit:ToggleButtonGroup/behavior` | +| FR6 | `ToggleButton.test.tsx` group-ownership cases | Members with standalone handlers and `onClick` | A member Action runs, or pressed state moves before `value`, fails. | `audit:ToggleButtonGroup/behavior` | +| FR7, ORD3, AR3 | `ToggleButton.test.tsx` disabled suite; `ToggleButton.a11y.test.tsx` | Member disabled; group disabled; tooltip-bearing member | A disabled member that toggles, or a re-enabled member, fails. The shared disabled states expect native `disabled`, matching the FR7 gap. | `audit:ToggleButtonGroup/accessibility` | +| FR2 | `ToggleButton.test.tsx` grouped elevation case | Member with its own elevation | A member losing its elevation inside the group fails. Evidence gap: no test asserts the spacing or the unpainted wrapper. Evidence gap: no test reads a member's painted shadow. | `audit:ToggleButtonGroup/layout` | +| FR9 (selection) | `layerScopedContext.test.tsx` "ends ancestor selection and callbacks while honoring explicit toggle props" | Member inside a fixed Layer and an inline Dialog | A member inside a layer that follows the outer group's selection or callbacks fails. | `audit:ToggleButtonGroup/behavior` | +| FR8, FR9 (size and disabled) | Evidence gap: no test of member Tab order, or of size and disabled scoping at a layer | — | — | `audit:ToggleButtonGroup/accessibility` | +| FR7 (size), ORD2, PR1 | Evidence gap: no test asserts the group size default, member size precedence, or that the group adds no listeners beyond the press tracking `family:action-groups` FR5 gives it | — | — | `audit:ToggleButtonGroup/behavior` | +| AR2 | `ToggleButton.a11y.test.tsx` and `ToggleButton.a11y.chromium.spec.ts` operable and focusable facts for the single- and multiple-group states | Group members | A group member that is not operable or that Tab does not reach fails. | `audit:ToggleButtonGroup/accessibility` | ## Current-state impact diff --git a/packages/core/src/Toolbar/Toolbar.spec.md b/packages/core/src/Toolbar/Toolbar.spec.md index 7f8ee78b14b53..3b59d0788689b 100644 --- a/packages/core/src/Toolbar/Toolbar.spec.md +++ b/packages/core/src/Toolbar/Toolbar.spec.md @@ -97,7 +97,8 @@ Consumer migration instructions belong in consumer docs and release notes. - The painted outer chrome, variant, block padding, and selected divider edges — owned by Section and uncontracted here. - Content supplied through the three layout slots — owned by the caller or its - rendered components. + rendered components, except the press tracking `family:action-groups` FR5 + gives Toolbar across its buttons. - A general arbitrary-child layout primitive contract. - Arrow-key handling inside composed menus or popovers — owned by those components.