diff --git a/.changeset/switch-every-pointer.md b/.changeset/switch-every-pointer.md deleted file mode 100644 index 1246ec4101628..0000000000000 --- a/.changeset/switch-every-pointer.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@astryxdesign/core': patch ---- - -[feat] Switch's press and drag work with a mouse and a pen as well as a finger: a drag past the flip point toggles the switch at the release, wherever that happens, unless the pointer came back past where it pressed. A mouse or pen press slid off the switch still leaves it alone, as a checkbox does. With a mouse the switch keeps its desktop look, and the value moves the thumb. On a touchscreen laptop a finger flips it by the same rule, with the desktop look. - -@vjeux diff --git a/.changeset/switch-touch-presentation.md b/.changeset/switch-touch-presentation.md deleted file mode 100644 index 109149d7a4e5a..0000000000000 --- a/.changeset/switch-touch-presentation.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@astryxdesign/core': patch ---- - -[feat] Under a finger, on every touch screen, Switch moves like the iOS switch. A press lifts the thumb a little, with a soft shadow, and a horizontal drag moves it with the finger and flips the switch once the finger has travelled most of the track; lifting the finger commits. A tap toggles through the browser's own click, and a hold or a short drag toggles at the lift. The thumb stays inside the track however far the finger drags past either end, in either writing direction. A press slid off the switch and lifted outside still toggles it, as on iOS. A vertical drag scrolls the page and the switch lets go. The motion runs on the compositor, so it stays smooth while scripts run. The thumb keeps its circle and its size on every device, and under a coarse pointer the switch's invisible hit area grows toward 44 × 44 px with nothing visible added, stopping halfway to any neighbouring control so a tap goes to the nearer one. The track keeps its theme's fill, and the colour change crossfades over it. With reduced motion on, a change cross-fades instead of moving on a touch screen and is immediate with a mouse. A press shows the pressed overlay, through a drag too, and a still hold selects no text. Keyboard and assistive technology behave as before. In Chromium, a Switch retargeted right after a tap, such as one tapped again mid-travel, turns around from where it is, with no step back to the start of its travel. Switch joins Slider on the shared press tracking and touch negotiation. - -@vjeux diff --git a/.changeset/uikit-switch-values.md b/.changeset/uikit-switch-values.md deleted file mode 100644 index 8c588dd1e4ece..0000000000000 --- a/.changeset/uikit-switch-values.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@astryxdesign/core': patch ---- - -[chore] Internal: UISwitch's values measured on iOS 26.2 (its geometry, the springs of the thumb's lift, settle, travel and drag, the track colour, its minimum lift time and its flip distance) join the UIKit constants table as internal constants, with the rule a switch's drag follows as a pure function: the finger flips the switch once it has travelled the track width minus the thumb width from where it landed, coming back past that point flips it back, and lifting the finger commits the side shown, or toggles when nothing flipped. Not exported from the package entry point, and no theme sets them. Switch's touch presentation plays them. - -@vjeux diff --git a/apps/storybook/stories/Switch.stories.tsx b/apps/storybook/stories/Switch.stories.tsx index 5f338b8c035fa..8d3ea25adf88e 100644 --- a/apps/storybook/stories/Switch.stories.tsx +++ b/apps/storybook/stories/Switch.stories.tsx @@ -648,61 +648,3 @@ export const PressedState: Story = { ); }, }; - -export const BesideALink: Story = { - name: 'Beside a link', - parameters: { - docs: { - description: { - story: - 'A small switch 8 px from a link. Under a coarse pointer the switch’s invisible hit area grows toward 44 × 44 px but stops halfway to the link, so a tap on the link opens it and a tap nearer the switch toggles it.', - }, - }, - }, - render: () => { - const [value, setValue] = useState(false); - return ( -
- - Details -
- ); - }, -}; - -export const InScrollingList: Story = { - name: 'In a scrolling list', - parameters: { - docs: { - description: { - story: - 'Switches in rows of a list taller than the screen, as in a settings page. Under a finger, a vertical drag scrolls the page and changes nothing, even after the finger rests on a switch; a horizontal drag moves the thumb, and a tap toggles.', - }, - }, - }, - render: () => { - const [values, setValues] = useState(() => - Array.from({length: 24}, (_, i) => i % 3 === 0), - ); - return ( -
- {values.map((value, i) => ( - - setValues(current => current.map((v, j) => (j === i ? next : v))) - } - /> - ))} -
- ); - }, -}; diff --git a/docs/specs/AST-094-touch-presentations/motion-roles.md b/docs/specs/AST-094-touch-presentations/motion-roles.md index e234a8db041c8..0854c35e6c0cd 100644 --- a/docs/specs/AST-094-touch-presentations/motion-roles.md +++ b/docs/specs/AST-094-touch-presentations/motion-roles.md @@ -4,38 +4,31 @@ Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. -| Role | Kind | Default | Named by | -| ------------------------------- | -------- | --------------------------------------------------------------- | ----------------------- | -| `progress.curves.fill` | curve | cubic-bezier(0, 0, 1, 1) | `component:ProgressBar` | -| `progress.fillTrackMs` | duration | 1000 ms | `component:ProgressBar` | -| `progress.reducedMotionPulseMs` | duration | 2000 ms | `component:ProgressBar` | -| `springs.contextMenuDismiss` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.contextMenuLift` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.contextMenuMorph` | spring | response 0.3 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.directChange` | spring | response 0.5 s, damping ratio 0.825 | `spec:AST-094` FR4 | -| `springs.emphasis` | spring | duration 0.5 s, bounce 0.3 | `spec:AST-094` FR4 | -| `springs.fingerTracking` | spring | response 0.15 s, damping ratio 0.86 | `spec:AST-094` FR4 | -| `springs.menuClose` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.menuOpen` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.quickChange` | spring | duration 0.5 s, bounce 0.15 | `spec:AST-094` FR4 | -| `springs.settle` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | -| `springs.sliderDrop` | spring | response 0.5 s, damping ratio 0.7 | `component:Slider` | -| `springs.sliderLift` | spring | response 0.215 s, damping ratio 0.6 | `component:Slider` | -| `springs.sliderRelease` | spring | response 0.4 s, damping ratio 1 | `component:Slider` | -| `springs.stateChange` | spring | response 0.55 s, damping ratio 1 | `spec:AST-094` FR4 | -| `springs.subMenuBack` | spring | response 0.4 s, damping ratio 1 | `spec:AST-094` FR4 | -| `springs.subMenuOpen` | spring | response 0.4 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.timedChange` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | -| `springs.viewChange` | spring | mass 3, stiffness 1000, damping 500 | `spec:AST-094` FR4 | -| `switchControl.lift` | spring | response 0.23 s, damping ratio 0.55 | `component:Switch` | -| `switchControl.minimumLiftMs` | duration | 235 ms | `component:Switch` | -| `switchControl.settle` | spring | response 0.5 s, damping ratio 0.7 | `component:Switch` | -| `switchControl.tracking` | spring | response 0.36 s, damping ratio 1 | `component:Switch` | -| `switchControl.travel` | spring | response 0.3 s, damping ratio 1 | `component:Switch` | -| `switchControl.wellOff` | spring | mass 1, stiffness 246.74011002723395, damping 31.41592653589793 | `component:Switch` | -| `switchControl.wellOn` | spring | mass 1, stiffness 85.37720070146501, damping 18.47995678582231 | `component:Switch` | -| `timing.curves.entrance` | curve | cubic-bezier(0, 0, 0.58, 1) | `spec:AST-094` FR4 | -| `timing.curves.exit` | curve | cubic-bezier(0.42, 0, 1, 1) | `spec:AST-094` FR4 | -| `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | -| `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | -| `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | +| Role | Kind | Default | Named by | +| ------------------------------- | -------- | ----------------------------------- | ----------------------- | +| `progress.curves.fill` | curve | cubic-bezier(0, 0, 1, 1) | `component:ProgressBar` | +| `progress.fillTrackMs` | duration | 1000 ms | `component:ProgressBar` | +| `progress.reducedMotionPulseMs` | duration | 2000 ms | `component:ProgressBar` | +| `springs.contextMenuDismiss` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.contextMenuLift` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.contextMenuMorph` | spring | response 0.3 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.directChange` | spring | response 0.5 s, damping ratio 0.825 | `spec:AST-094` FR4 | +| `springs.emphasis` | spring | duration 0.5 s, bounce 0.3 | `spec:AST-094` FR4 | +| `springs.fingerTracking` | spring | response 0.15 s, damping ratio 0.86 | `spec:AST-094` FR4 | +| `springs.menuClose` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.menuOpen` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.quickChange` | spring | duration 0.5 s, bounce 0.15 | `spec:AST-094` FR4 | +| `springs.settle` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | +| `springs.sliderDrop` | spring | response 0.5 s, damping ratio 0.7 | `component:Slider` | +| `springs.sliderLift` | spring | response 0.215 s, damping ratio 0.6 | `component:Slider` | +| `springs.sliderRelease` | spring | response 0.4 s, damping ratio 1 | `component:Slider` | +| `springs.stateChange` | spring | response 0.55 s, damping ratio 1 | `spec:AST-094` FR4 | +| `springs.subMenuBack` | spring | response 0.4 s, damping ratio 1 | `spec:AST-094` FR4 | +| `springs.subMenuOpen` | spring | response 0.4 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.timedChange` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | +| `springs.viewChange` | spring | mass 3, stiffness 1000, damping 500 | `spec:AST-094` FR4 | +| `timing.curves.entrance` | curve | cubic-bezier(0, 0, 0.58, 1) | `spec:AST-094` FR4 | +| `timing.curves.exit` | curve | cubic-bezier(0.42, 0, 1, 1) | `spec:AST-094` FR4 | +| `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | +| `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | +| `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 00e4957dd8ef7..5684069e40102 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -235,7 +235,7 @@ a measured, sourced difference exists. 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; and a Switch completes its own toggle at the lift of a finger hold or a flipping drag, rather than through the browser's click (`component:Switch` FR15). Where the engine hit-tests an element a compositor animation moves at the motion's start (iOS WebKit), a touch on moving content goes to what was drawn under the finger at touch-down, and a tap there (no recognizer claimed it, the browser did not take it, and it stayed within 45 pt) MUST activate it: the browser's own click for that touch is cancelled or swallowed, and that element receives one click, an editable focused first. + CheckboxListItem) may also complete on a lift after the finger slid off. Where the engine hit-tests an element a compositor animation moves at the motion's start (iOS WebKit), a touch on moving content goes to what was drawn under the finger at touch-down, and a tap there (no recognizer claimed it, the browser did not take it, and it stayed within 45 pt) MUST activate it: the browser's own click for that touch is cancelled or swallowed, and that element receives one click, an editable focused first. 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 controller and release fade as a finger's, in place of CSS `:active`; the diff --git a/docs/specs/components/Switch/Switch.spec.md b/docs/specs/components/Switch/Switch.spec.md index 1265c3bf9529d..685d1ca15169c 100644 --- a/docs/specs/components/Switch/Switch.spec.md +++ b/docs/specs/components/Switch/Switch.spec.md @@ -27,9 +27,6 @@ verified_by: [ packages/core/src/Switch/Switch.test.tsx, packages/core/src/Switch/Switch.focusRing.source-build.test.mjs, - packages/core/src/Switch/switchTouch.test.ts, - packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts, - packages/core/src/Switch/__tests__/SwitchPointer.interaction.browser.spec.ts, packages/core/src/Switch/__tests__/Switch.a11y.test.tsx, packages/core/src/Switch/__tests__/Switch.a11y.chromium.spec.ts, packages/core/src/__tests__/PressedState.a11y.chromium.spec.ts, @@ -46,42 +43,22 @@ architecture: architecture:public-component-api, ] contributing: [] -system_specs: - [ - spec:AST-020/DEC-5, - spec:AST-020/FR9, - spec:AST-021, - spec:AST-085, - spec:AST-086, - spec:AST-094, - spec:AST-094/FR4, - spec:AST-094/FR6, - spec:AST-094/FR18, - spec:AST-094/FR24, - ] +system_specs: [spec:AST-021, spec:AST-085, spec:AST-086, spec:AST-094] --- # Switch component contract - - -```json -{ - "paths": ["packages/core/src/motion/uikit/switch.ts"] -} -``` - ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Public contract | None changes. Switch takes `label`, `value`, `onChange`, `changeAction`, `isLoading`, `isDisabled`, `disabledMessage`, `isRequired`, `isOptional`, `status`, `size`, `labelPosition`, `labelSpacing`, field content, and base props. | -| Behavior | A native checkbox with `role="switch"` drawn as a track and thumb. A change calls `onChange`, then an optional `changeAction` with the optimistic value; while busy the switch shows a Spinner in the thumb and refuses changes. Its press and drag work for every pointer, and under a finger it follows the finger as the iOS switch does (FR13–FR15). | -| End-user impact | People flip a setting that takes effect at once and hear when it is saving. Keyboard and screen-reader users can reach a disabled switch and its reason. | -| Builder impact | None. | -| Compatibility/readiness | Under a finger a hold and a press slid off now toggle the switch, and under a mouse or a pen a drag past the flip distance now flips it (FR15); a coarse pointer's hit area grows toward 44 px (FR10); no API, default, or DOM change. Caller handlers other than `onClick` run while disabled, and caller ARIA lands on the field root; both are listed under Current-state impact. | -| Review checks | Reject a lost switch role or name, a change while disabled or busy, focus moving on change, a ring painted anywhere but the track, a thumb that travels the wrong way under RTL, or a disabled switch that submits. | -| Governing rules | `spec:AST-021` switch pattern; `spec:AST-085` FR1–FR3, FR5, FR6; `spec:AST-086` FR8; `design:user-states` DR1, DR2 (pressed overlay); `design:reduced-motion` DR1; WCAG 2.5.2, 3.2.2, 1.4.11; `spec:AST-094` FR4, FR11, FR13, FR24, FR42, FR43; `spec:AST-020` FR9 (DEC-5). | +| Area | Contract | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | None changes. Switch takes `label`, `value`, `onChange`, `changeAction`, `isLoading`, `isDisabled`, `disabledMessage`, `isRequired`, `isOptional`, `status`, `size`, `labelPosition`, `labelSpacing`, field content, and base props. | +| Behavior | A native checkbox with `role="switch"` drawn as a track and thumb. A change calls `onChange`, then an optional `changeAction` with the optimistic value; while busy the switch shows a Spinner in the thumb and refuses changes. | +| End-user impact | People flip a setting that takes effect at once and hear when it is saving. Keyboard and screen-reader users can reach a disabled switch and its reason. | +| Builder impact | None. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. Caller handlers other than `onClick` run while disabled, the coarse-pointer hit area stops at 24 by 24, and caller ARIA lands on the field root; all three are listed under Current-state impact. | +| Review checks | Reject a lost switch role or name, a change while disabled or busy, focus moving on change, a ring painted anywhere but the track, a thumb that travels the wrong way under RTL, or a disabled switch that submits. | +| Governing rules | `spec:AST-021` switch pattern; `spec:AST-085` FR1–FR3, FR5, FR6; `spec:AST-086` FR8; `design:user-states` DR1, DR2 (pressed overlay); `design:reduced-motion` DR1; WCAG 2.5.2, 3.2.2, 1.4.11; `spec:AST-094` FR42. | This table is a review projection; the body below is authoritative. @@ -90,23 +67,14 @@ This table is a review projection; the body below is authoritative. Switch presents a labelled boolean setting whose change takes effect immediately. It owns the switch control, its change and Action pipeline, busy and disabled behavior, label placement, and the field's theming anatomy. -Its press and drag work for a mouse, a pen and a finger (FR15). Under a touch -presentation it follows a finger the way the iOS switch does (FR13–FR15); -under a fine primary pointer it keeps its released look, and a keyboard or -assistive technology keeps its released behavior. Consumer usage lives in `Switch.doc.mjs`. +Consumer usage lives in `Switch.doc.mjs`. ## Compatibility and migration -- Released default preserved: `no` under a finger, where a hold and a press - slid off now toggle the switch (FR15), and under a mouse or a pen, where a - drag past the flip distance now flips it (FR15); `yes` for a keyboard and - assistive technology; `size="md"`, `labelPosition="end"`, +- Released default preserved: `yes`; `size="md"`, `labelPosition="end"`, `labelSpacing="hug"` -- Compatibility class: a touch presentation adds the finger behavior - (FR13–FR15), a mouse or a pen drags the switch by the same flip rule - (FR15), and a coarse pointer's hit area grows toward 44 × 44 px (FR10); the - look under a fine pointer and every public prop, default, export, and DOM - element stay as they were +- Compatibility class: records shipped behavior; no public prop, default, + export, or DOM change - Controlled/uncontrolled behavior: controlled only; `value` is required, and the switch shows an optimistic value only while a `changeAction` is pending - Migration decision: none @@ -146,40 +114,20 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| FR1 | The current render places a thumb inside the switch track and arranges that control with its label: a visually hidden native `` sits over a decorative track, with the label after it by default or before it with `labelPosition="start"`. | `spec:AST-021`; current source, docs, and focused tests | -| FR2 | The field, track, thumb, and label carry their existing local targets. The field reflects `data-label-position` and `data-label-spacing` only for non-default values; the track reflects `data-size`, `data-checked`, and `data-disabled`; the thumb reflects `data-size` and `data-checked`. | `architecture:component-theming-surface`; current source and tests | -| FR3 | A busy switch renders Spinner inside the thumb; a status message renders through detached FieldStatus. | Current source, docs, and focused tests | -| FR4 | The input is named by its label (or a caller `aria-labelledby`) and described by any caller description, then the description, status message, and disabled reason that exist; the description is not part of the name. A click on the label or description toggles the switch. | `spec:AST-021`; current tests | -| FR5 | A change calls `onChange(checked, event)`; unless that call prevents default, `changeAction(checked, event)` runs in a transition and the switch shows the proposed state while the Action is pending, and `value` once it settles. | Current tests | -| FR6 | While `isLoading`, or while the shown state differs from `value`, the input sets `aria-busy`, ignores pointer input, refuses changes, keeps focus, and the switch announces its busy start once per busy period through the shared channel. | `spec:AST-086` FR8; current tests | -| FR7 | Disabled: `aria-disabled="true"` on a focusable input, no `name`, detached from its form (so it neither submits nor blocks submission when required), no change, no caller `onClick` on the field, no hover or press paint, and a half-opacity track. With `disabledMessage`, a reason tooltip opens on row hover and keyboard focus and describes the input. Implementation gap: caller DOM handlers other than `onClick`, such as `onKeyDown`, still run on the field root while disabled (`spec:AST-085` FR2). | `spec:AST-085` FR1–FR3, FR5; current tests | -| FR8 | Enabled, the input submits `on` under `htmlName` when on and nothing when off; `isRequired` sets native `required`, while `aria-required` follows the effective required state; `aria-invalid` follows `status` (`false` on a required switch without an error), so the browser's own invalid state never shows before an error. | Current tests | -| FR9 | Keyboard focus paints the shared focus outline on the track. Hovering the enabled row tints the track; pressing paints the pressed overlay on both, applying `design:user-states` DR1 and DR2; a touch press fades out from the row. Track color transitions are instant under reduced motion; under a finger the colour fades with the thumb's cross-fade (FR15). | `architecture:interaction-modality` INV1; `design:user-states` DR1, DR2 for the pressed overlay; `design:reduced-motion`; current tests | -| FR10 | The thumb travels toward the inline end when on, mirrored under RTL, and moves without a transition under reduced motion (`design:reduced-motion` DR1); under a finger it cross-fades to its rest instead (FR15). Where the primary pointer is coarse, the switch's hit area grows around the track toward 44 by 44 CSS pixels, each side stopping at half the gap to a neighbouring target so a tap goes to the nearer one (`spec:AST-094` FR42, `utils/hitArea.ts`), with nothing visible added. | WCAG 2.5.8; current tests; `design:reduced-motion` DR1; `spec:AST-094` FR42 | -| FR11 | Under forced colors, on and off stay distinguishable (Highlight track when on), and the hover tint does not hide the thumb. | WCAG 1.4.11; current tests | -| FR12 | `ref`, `onFocus`, and `onBlur` reach the input, and a caller `aria-labelledby` and `aria-describedby` name and describe it. Other base props (`id`, `data-*`, other ARIA, `onClick`, and other handlers), `className`, `style`, `xstyle`, and `width` reach the field root. Caller ARIA attributes name and describe the switch, so they MUST reach the input (`architecture:public-component-api` INV5). Implementation gap: ARIA other than `aria-labelledby` and `aria-describedby`, such as `aria-label`, lands on the field root. | Current tests; `architecture:public-component-api` INV5 | -| FR13 | The thumb is one circle under any pointer, at each state's size: a touch presentation moves and lifts it and never reshapes it, and its lift and rubber band derive from the box it renders. | Owner direction, 2026-10-26 | -| FR14 | Under a finger the thumb, lifted or banded past either end, paints inside the track, in either writing direction. | Owner direction, 2026-10-25 | -| FR15 | For every pointer (`spec:AST-094` FR43) a tap, a hold of any length or a short drag toggles the switch at the release (a tap through the browser's click; where the browser sends no click for a finger's release, as for a hold, the switch clicks its input itself), and a drag commits the side it flipped the switch to, which it reaches once the pointer is the track width less the thumb width from where it pressed, toward the other end, and leaves again once the pointer comes back past where it pressed, wherever the pointer is released (measured in `motion/uikit/switch.ts`: `travel`, `flipDistance`). Under a finger a press slid off and lifted outside without a flip toggles it too, as `spec:AST-094` FR11 allows for a toggle under `spec:AST-020` FR9's slide-off exception (DEC-5); a mouse or a pen slid off and released outside without a flip leaves it alone, as the native checkbox does. Under a finger the thumb follows the drag as UISwitch's does (iOS 26.2); under a fine primary pointer the switch keeps its own look, and the value moves its thumb (`spec:AST-094` FR13). The switch's own click, for a hold and for a flipping drag, is the exception `spec:AST-094` FR11 states for Switch. Under reduced motion nothing lifts or springs: a tap, a release or a change cross-fades the thumb to its rest and the track colour with it, while a dragging finger still moves the thumb. | `spec:AST-094` FR11, FR13, FR43; `spec:AST-020` FR9/DEC-5; `design:reduced-motion` | - -### Motion roles - -Under a finger the switch plays these motion roles, which this record names -and the generated motion role table carries (`spec:AST-094` FR4, DEC-5). They -are internal, so no theme overrides them yet (DEC-11); each value is -measured on UISwitch, iOS 26.2 (`motion/uikit/switch.ts`). - -| Role | Default | Use | -| ----------------------------- | --------------------------------------- | ------------------------------------------------------------ | -| `switchControl.lift` | response 0.23 s, damping ratio 0.55 | The thumb lifting while a finger presses | -| `switchControl.settle` | response 0.5 s, damping ratio 0.7 | The lifted thumb settling after the lift | -| `switchControl.minimumLiftMs` | 235 ms | The shortest lift before the settle, so a quick tap shows it | -| `switchControl.travel` | response 0.3 s, damping ratio 1 | The thumb travelling to the committed end | -| `switchControl.tracking` | response 0.36 s, damping ratio 1 | The thumb chasing a dragging finger | -| `switchControl.wellOn` | mass 1, stiffness 85.38, damping 18.48 | The track colour turning on | -| `switchControl.wellOff` | mass 1, stiffness 246.74, damping 31.42 | The track colour turning off | +| ID | Invariant | Basis | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | The current render places a thumb inside the switch track and arranges that control with its label: a visually hidden native `` sits over a decorative track, with the label after it by default or before it with `labelPosition="start"`. | `spec:AST-021`; current source, docs, and focused tests | +| FR2 | The field, track, thumb, and label carry their existing local targets. The field reflects `data-label-position` and `data-label-spacing` only for non-default values; the track reflects `data-size`, `data-checked`, and `data-disabled`; the thumb reflects `data-size` and `data-checked`. | `architecture:component-theming-surface`; current source and tests | +| FR3 | A busy switch renders Spinner inside the thumb; a status message renders through detached FieldStatus. | Current source, docs, and focused tests | +| FR4 | The input is named by its label (or a caller `aria-labelledby`) and described by any caller description, then the description, status message, and disabled reason that exist; the description is not part of the name. A click on the label or description toggles the switch. | `spec:AST-021`; current tests | +| FR5 | A change calls `onChange(checked, event)`; unless that call prevents default, `changeAction(checked, event)` runs in a transition and the switch shows the proposed state while the Action is pending, and `value` once it settles. | Current tests | +| FR6 | While `isLoading`, or while the shown state differs from `value`, the input sets `aria-busy`, ignores pointer input, refuses changes, keeps focus, and the switch announces its busy start once per busy period through the shared channel. | `spec:AST-086` FR8; current tests | +| FR7 | Disabled: `aria-disabled="true"` on a focusable input, no `name`, detached from its form (so it neither submits nor blocks submission when required), no change, no caller `onClick` on the field, no hover or press paint, and a half-opacity track. With `disabledMessage`, a reason tooltip opens on row hover and keyboard focus and describes the input. Implementation gap: caller DOM handlers other than `onClick`, such as `onKeyDown`, still run on the field root while disabled (`spec:AST-085` FR2). | `spec:AST-085` FR1–FR3, FR5; current tests | +| FR8 | Enabled, the input submits `on` under `htmlName` when on and nothing when off; `isRequired` sets native `required`, while `aria-required` follows the effective required state; `aria-invalid` follows `status` (`false` on a required switch without an error), so the browser's own invalid state never shows before an error. | Current tests | +| FR9 | Keyboard focus paints the shared focus outline on the track. Hovering the enabled row tints the track; pressing paints the pressed overlay on both, applying `design:user-states` DR1 and DR2; a touch press fades out from the row. Track color transitions are instant under reduced motion. | `architecture:interaction-modality` INV1; `design:user-states` DR1, DR2 for the pressed overlay; `design:reduced-motion`; current tests | +| FR10 | The thumb travels toward the inline end when on, mirrored under RTL, and moves without a transition under reduced motion (`design:reduced-motion` DR1). On coarse pointers the input's hit area is at least 24 by 24 CSS pixels (WCAG 2.5.8) and follows the inline direction. Under `spec:AST-094` FR42 a coarse-pointer target MUST reach 44 CSS pixels through an invisible hit area where neighbors allow. Implementation gap: the input's hit area stops at 24 by 24. | WCAG 2.5.8; current tests; `design:reduced-motion` DR1; `spec:AST-094` FR42 | +| FR11 | Under forced colors, on and off stay distinguishable (Highlight track when on), and the hover tint does not hide the thumb. | WCAG 1.4.11; current tests | +| FR12 | `ref`, `onFocus`, and `onBlur` reach the input, and a caller `aria-labelledby` and `aria-describedby` name and describe it. Other base props (`id`, `data-*`, other ARIA, `onClick`, and other handlers), `className`, `style`, `xstyle`, and `width` reach the field root. Caller ARIA attributes name and describe the switch, so they MUST reach the input (`architecture:public-component-api` INV5). Implementation gap: ARIA other than `aria-labelledby` and `aria-describedby`, such as `aria-label`, lands on the field root. | Current tests; `architecture:public-component-api` INV5 | ### Allowed variation @@ -212,28 +160,16 @@ measured on UISwitch, iOS 26.2 (`motion/uikit/switch.ts`). disabled, and shares the press controller's window and document listeners while any pressable is mounted, and, through the tooltip's layer, the shared gesture counter's document listeners, installed once per page; it owns no - observer, and the busy announcement fires once per busy period. An enabled - switch also adds one consumer to the shared gesture host, whose listeners - are installed once per page, and a `click` listener to the element around - its track: at once under a coarse primary - pointer, and only during a press under a fine one, where the host arms one - passive `pointerdown` listener on that element (`spec:AST-094` FR24). + observer, and the busy announcement fires once per busy period. ## Accessibility contract -A touch presentation leaves Switch's accessible name, switch role, checked -state, busy state, description and status association, and disabled behavior -as they are under any other pointer. - Switch binds to the shared switch-pattern accessibility contract under `spec:AST-021`. The binding owns the standards-derived outcomes — role, accessible name, exposed on/off state, description, disabled exposure and inoperability, pointer and keyboard activation, pointer cancellation, and focus staying put on change — across the representative states listed in -`__tests__/Switch.a11y.states.ts`. The contract checks pointer cancellation -with a mouse, where a press slid off leaves the switch alone; under a finger a -press slid off toggles it, under `spec:AST-020` FR9's reversible-toggle -exception (DEC-5, FR15). Callback payloads, form participation, +`__tests__/Switch.a11y.states.ts`. Callback payloads, form participation, disabled-reason tooltip composition, and styling remain Switch-owned and stay in `Switch.test.tsx`. @@ -244,12 +180,12 @@ disabled-reason tooltip composition, and styling remain Switch-owned and stay in ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| ---------------- | -------------------------------------------------------- | ------------------------------- | -------------- | ----------------------------- | -| Field | Arranges the switch control with its label and feedback. | Current source and public docs | Supporting | FR1, FR2 | -| Track and thumb | Present the current on/off control. | Current source and public docs | Prominent | FR1, FR2, FR9–FR11, FR13–FR15 | -| Label | Identifies the controlled setting. | Current source and public docs | Prominent | FR1, FR2, FR4 | -| Spinner/status | Present conditional progress and validation feedback. | Current shared-component source | Supporting | FR3, FR6 | +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | -------------------------------------------------------- | ------------------------------- | -------------- | ------------------ | +| Field | Arranges the switch control with its label and feedback. | Current source and public docs | Supporting | FR1, FR2 | +| Track and thumb | Present the current on/off control. | Current source and public docs | Prominent | FR1, FR2, FR9–FR11 | +| Label | Identifies the controlled setting. | Current source and public docs | Prominent | FR1, FR2, FR4 | +| Spinner/status | Present conditional progress and validation feedback. | Current shared-component source | Supporting | FR3, FR6 | ### Theming anatomy @@ -289,9 +225,6 @@ making the absence intentional. - `spec:AST-085` owns disabled focusability; Switch meets it. - `spec:AST-086` FR8 owns the busy announcement. - `design:user-states` owns the pressed treatment. -- `spec:AST-094` owns touch presentations and FR43's gestures for every - pointer: Switch follows its FR11 press profiles, its FR42 hit area and FR43 - (FR10, FR13–FR15). - `architecture:component-theming-surface` owns anatomy qualification, factual `none` dispositions, and composition-preserving target ownership. - FieldStatus and Spinner retain their existing public target contracts when @@ -299,21 +232,20 @@ making the absence intentional. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -| FR1, FR4 | `Switch.test.tsx` structure, label, description, and interaction suites | On/off, size, label position, hidden label | Removing stable parts, or folding the description into the name, fails; the caller description first is asserted. Evidence gap: no test asserts the label order (the position cases count children only) or the disabled reason last. | `audit:Switch/anatomy` | -| FR2 | `Switch.test.tsx` label-target, labelSpacing, and pressed-state cases; `themingTargets.test.ts` | Current local targets | A missing label, field, track, or thumb target, or a missing `data-label-spacing`, fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts `data-label-position`, or the track and thumb `data-size`, `data-checked`, and `data-disabled`. | `audit:Switch/theming` | -| FR3, FR5, FR6 | `Switch.test.tsx` loading, announcement, and status suites | Busy, pending Action, StrictMode, re-busy, detached status | Removing composed feedback, or announcing more than once per busy period, fails. Evidence gap: no test asserts the Spinner inside the thumb, `changeAction` after `onChange` with `preventDefault`, the optimistic state, or that pointer input is ignored while busy. | `audit:Switch/anatomy` | -| FR7, FR8 | `Switch.test.tsx` disabled, disabledMessage, and form cases; `PressedState.a11y.chromium.spec.ts` disabled press case | Disabled with and without reason; required; on and off | A change while disabled, a submitted disabled value, a blocked submit, or press paint on a disabled switch fails. Evidence gap: no test asserts the absent hover tint, the half-opacity track, or that caller `onKeyDown` still runs while disabled (the FR7 gap). No test asserts `aria-required` following a form-level required default. | `audit:Switch/behavior` | -| FR9–FR11 | `Switch.test.tsx` pressed-state, RTL, coarse-pointer, and forced-colors cases; `Switch.focusRing.source-build.test.mjs` | Mouse and touch press; RTL; coarse pointer; forced colors | Missing press paint, unmirrored travel, or lost forced-colors state fails. Evidence gap: no test asserts the hover tint, instant transitions under reduced motion with a mouse, or the track focus outline; the source-build check covers only how the outline finds the input. | `audit:Switch/visual` | -| FR12 | `Switch.test.tsx` rest-forwarding and ref cases | Root attributes; caller labelledby and describedby | A dropped ref, or a caller `aria-labelledby` or `aria-describedby` on the root, fails; the rest-forwarding case asserts `aria-label` on the root, which is the FR12 gap, so fixing the gap changes that test. Evidence gap: no test distinguishes `onFocus` and `onBlur` on the input from the root, or asserts a caller `onClick`, other handlers, `className`, `style`, `xstyle`, or `width` on the root. | `audit:Switch/api` | -| FR10 (coarse hit area), FR13–FR15 | `__tests__/Switch.interaction.browser.spec.ts` (Chromium and WebKit, real touch input), `__tests__/SwitchPointer.interaction.browser.spec.ts` (Chromium and WebKit, a real mouse; Chromium, a real pen), `Switch.test.tsx` hit-area cases and listener counts, and `switchTouch.test.ts` | The circle at each state's size; the 44 × 44 px hit area, and its stop halfway to a link beside it; a tap, a hold, a short drag, a flipping drag and a press slid off and lifted outside; right to left; Reduce Motion; a mouse or a pen drag past the flip, past it and back, and slid off; a click and a right click; a fine pointer's listeners at rest | A reshaped thumb, a hit area under 44 × 44 px with nothing near, a hit area past the midpoint to a neighbour, a thumb painted outside the track, a slid-off press that leaves the switch alone under a finger, a flip at any other distance, a mouse or pen drag that does not flip at the flip distance, a mouse or pen slid off that toggles, a lift or animation under a fine pointer, or more than one listener at rest under a fine pointer fails. | `audit:Switch/behavior` | -| Switch pattern — semantics | `__tests__/Switch.a11y.test.tsx` (DOM layer, higher layers reported unrun) and `__tests__/Switch.a11y.chromium.spec.ts` (accessibility tree) | Off, on, on after a controlled update, described, hidden label, disabled, focusable-disabled, required, invalid, loading | Losing the switch role, the accessible name, the visible label's words within that name, the exposed on/off state, an attached description, or disabled, required, and invalid exposure fails the shared contract. | `audit:Switch/anatomy` | -| Switch pattern — interaction | `__tests__/Switch.a11y.chromium.spec.ts` (real browser) | Every state, each checked for what applies to it: round trips, pointer cancellation, and focus-on-change cover the operable states (off, on, on after a controlled update, described, hidden label, required, invalid); Tab reach-and-escape covers every focusable state, adding focusable-disabled and loading; inoperability covers the states the user cannot change (disabled, focusable-disabled, loading). | Losing either direction of a pointer or Space round trip, letting a mouse press slid off the control still change it (WCAG 2.5.2; under a finger FR15's reversible-toggle exception applies), moving focus when the state changes (WCAG 3.2.2), failing to reach or leave the control by Tab, or changing a state the user is not meant to be able to change fails the shared contract. | `audit:Switch/anatomy` | -| Switch pattern — label stability | `__tests__/Switch.a11y.chromium.spec.ts`, reported not gated | Every operable state: off, on, on after a controlled update, described, hidden label, required, invalid | The APG requires the label not to change with the state. No current record adopts it, so the expectation is advisory: a name that flips across a round trip is reported, and does not fail the build. | `audit:Switch/anatomy` | -| Switch pattern — state inventory | `__tests__/Switch.a11y.chromium.spec.ts` inventory assertion | Every bound state | The labels `__tests__/Switch.a11y.states.ts` claims each state renders are compared against the rendered page, so a stale inventory is reported as one rather than as a standards failure. | `audit:Switch/anatomy` | -| ORD1, ORD2, PR1 | ORD1 is covered by the FR5 and FR7 rows, and the caller-description-first part of ORD2 by the FR1 row. Evidence gap: no test asserts the rest of the ORD2 order or listener bounds | — | — | `audit:Switch/behavior` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and all current targets | Canonical-key drift, invalid dispositions or target spelling, or an unclaimed current local target fails repository validation. | `audit:Switch/theming` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| FR1, FR4 | `Switch.test.tsx` structure, label, description, and interaction suites | On/off, size, label position, hidden label | Removing stable parts, or folding the description into the name, fails; the caller description first is asserted. Evidence gap: no test asserts the label order (the position cases count children only) or the disabled reason last. | `audit:Switch/anatomy` | +| FR2 | `Switch.test.tsx` label-target, labelSpacing, and pressed-state cases; `themingTargets.test.ts` | Current local targets | A missing label, field, track, or thumb target, or a missing `data-label-spacing`, fails; a rendered class absent from the documented targets fails. Evidence gap: no test asserts `data-label-position`, or the track and thumb `data-size`, `data-checked`, and `data-disabled`. | `audit:Switch/theming` | +| FR3, FR5, FR6 | `Switch.test.tsx` loading, announcement, and status suites | Busy, pending Action, StrictMode, re-busy, detached status | Removing composed feedback, or announcing more than once per busy period, fails. Evidence gap: no test asserts the Spinner inside the thumb, `changeAction` after `onChange` with `preventDefault`, the optimistic state, or that pointer input is ignored while busy. | `audit:Switch/anatomy` | +| FR7, FR8 | `Switch.test.tsx` disabled, disabledMessage, and form cases; `PressedState.a11y.chromium.spec.ts` disabled press case | Disabled with and without reason; required; on and off | A change while disabled, a submitted disabled value, a blocked submit, or press paint on a disabled switch fails. Evidence gap: no test asserts the absent hover tint, the half-opacity track, or that caller `onKeyDown` still runs while disabled (the FR7 gap). No test asserts `aria-required` following a form-level required default. | `audit:Switch/behavior` | +| FR9–FR11 | `Switch.test.tsx` pressed-state, RTL, coarse-pointer, and forced-colors cases; `Switch.focusRing.source-build.test.mjs` | Mouse and touch press; RTL; coarse pointer; forced colors | Missing press paint, unmirrored travel, or lost forced-colors state fails. Evidence gap: no test asserts the hover tint, the 24 by 24 coarse minimum (the coarse cases check only the centering class), instant transitions under reduced motion, or the track focus outline; the source-build check covers only how the outline finds the input. | `audit:Switch/visual` | +| FR12 | `Switch.test.tsx` rest-forwarding and ref cases | Root attributes; caller labelledby and describedby | A dropped ref, or a caller `aria-labelledby` or `aria-describedby` on the root, fails; the rest-forwarding case asserts `aria-label` on the root, which is the FR12 gap, so fixing the gap changes that test. Evidence gap: no test distinguishes `onFocus` and `onBlur` on the input from the root, or asserts a caller `onClick`, other handlers, `className`, `style`, `xstyle`, or `width` on the root. | `audit:Switch/api` | +| Switch pattern — semantics | `__tests__/Switch.a11y.test.tsx` (DOM layer, higher layers reported unrun) and `__tests__/Switch.a11y.chromium.spec.ts` (accessibility tree) | Off, on, on after a controlled update, described, hidden label, disabled, focusable-disabled, required, invalid, loading | Losing the switch role, the accessible name, the visible label's words within that name, the exposed on/off state, an attached description, or disabled, required, and invalid exposure fails the shared contract. | `audit:Switch/anatomy` | +| Switch pattern — interaction | `__tests__/Switch.a11y.chromium.spec.ts` (real browser) | Every state, each checked for what applies to it: round trips, pointer cancellation, and focus-on-change cover the operable states (off, on, on after a controlled update, described, hidden label, required, invalid); Tab reach-and-escape covers every focusable state, adding focusable-disabled and loading; inoperability covers the states the user cannot change (disabled, focusable-disabled, loading). | Losing either direction of a pointer or Space round trip, letting a press slid off the control still change it (WCAG 2.5.2), moving focus when the state changes (WCAG 3.2.2), failing to reach or leave the control by Tab, or changing a state the user is not meant to be able to change fails the shared contract. | `audit:Switch/anatomy` | +| Switch pattern — label stability | `__tests__/Switch.a11y.chromium.spec.ts`, reported not gated | Every operable state: off, on, on after a controlled update, described, hidden label, required, invalid | The APG requires the label not to change with the state. No current record adopts it, so the expectation is advisory: a name that flips across a round trip is reported, and does not fail the build. | `audit:Switch/anatomy` | +| Switch pattern — state inventory | `__tests__/Switch.a11y.chromium.spec.ts` inventory assertion | Every bound state | The labels `__tests__/Switch.a11y.states.ts` claims each state renders are compared against the rendered page, so a stale inventory is reported as one rather than as a standards failure. | `audit:Switch/anatomy` | +| ORD1, ORD2, PR1 | ORD1 is covered by the FR5 and FR7 rows, and the caller-description-first part of ORD2 by the FR1 row. Evidence gap: no test asserts the rest of the ORD2 order or listener bounds | — | — | `audit:Switch/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and all current targets | Canonical-key drift, invalid dispositions or target spelling, or an unclaimed current local target fails repository validation. | `audit:Switch/theming` | No repository check resolves `delegatesTo` owner and target pairs; the pairs above match the current Spinner and FieldStatus targets. @@ -323,6 +255,9 @@ above match the current Spinner and FieldStatus targets. - **Caller handlers while disabled (FR7).** Caller DOM handlers other than `onClick`, such as `onKeyDown`, still run while disabled, against `spec:AST-085` FR2. +- **Coarse target (FR10).** The coarse-pointer hit area stops at 24 by 24 CSS + pixels, short of the 44 px target `spec:AST-094` FR42 sets where neighbors + allow. - **Caller ARIA on the root (FR12).** A caller's `aria-label` and other ARIA attributes, except `aria-labelledby` and `aria-describedby`, land on the field root instead of the switch input, against `architecture:public-component-api` @@ -330,14 +265,7 @@ above match the current Spinner and FieldStatus targets. ## Decision log -FR13 rests on owner direction of 2026-10-26: the thumb keeps its circle and its -size on every device, and a coarse pointer gets a hit area that grows toward -44 × 44 px and stops halfway to its neighbours, with nothing visible added -(FR10). FR14 rests on owner direction of 2026-10-25. Under a mouse or a pen a -press slid off keeps the native checkbox's cancel: `spec:AST-094` FR11 lets a -toggle under `spec:AST-020` FR9's slide-off exception complete only on a lift -after the finger slid off. Switch otherwise applies `spec:AST-021` and -`spec:AST-085`. +No component-local decision. Switch applies `spec:AST-021` and `spec:AST-085`. ## Open questions diff --git a/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json b/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json index 399f2de0a678c..1aab6be4c1afc 100644 --- a/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json +++ b/internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json @@ -87,6 +87,8 @@ "data-astryx-press" ], "packages/core/src/Switch/Switch.tsx": [ + "data-astryx-press", + "data-astryx-press", "data-astryx-press", "data-astryx-press" ], diff --git a/packages/core/src/Switch/Switch.doc.mjs b/packages/core/src/Switch/Switch.doc.mjs index e8d0c5bc36f01..136244e4e2f31 100644 --- a/packages/core/src/Switch/Switch.doc.mjs +++ b/packages/core/src/Switch/Switch.doc.mjs @@ -223,7 +223,7 @@ export const docs = { ], anatomy, description: - 'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead. Under a finger it moves like the iOS switch on every touch screen: a press lifts the thumb, a drag across flips it, lifting the finger commits, and a tap toggles through the browser’s click. The thumb keeps its circle and size on every device, and under a coarse pointer the invisible hit area grows toward 44 × 44 px, stopping halfway to any neighbouring control so a tap goes to the nearer one. With reduced motion on, a change cross-fades on a touch screen and is immediate with a mouse.', + 'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead.', bestPractices: [ { guidance: true, @@ -395,7 +395,7 @@ export const docsZh = { }, usage: { description: - 'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead. Under a finger it moves like the iOS switch on every touch screen: a press lifts the thumb, a drag across flips it, lifting the finger commits, and a tap toggles through the browser’s click. The thumb keeps its circle and size on every device, and under a coarse pointer the invisible hit area grows toward 44 × 44 px, stopping halfway to any neighbouring control so a tap goes to the nearer one. With reduced motion on, a change cross-fades on a touch screen and is immediate with a mouse.', + 'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead.', bestPractices: [ { guidance: true, @@ -431,7 +431,7 @@ export const docsDense = { description: 'Toggle switch for boolean values w/ integrated label support.', usage: { description: - 'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead. Under a finger it moves like the iOS switch on every touch screen: a press lifts the thumb, a drag across flips it, lifting the finger commits, and a tap toggles through the browser’s click. The thumb keeps its circle and size on every device, and under a coarse pointer the invisible hit area grows toward 44 × 44 px, stopping halfway to any neighbouring control so a tap goes to the nearer one. With reduced motion on, a change cross-fades on a touch screen and is immediate with a mouse.', + 'A toggle control for on/off states that take effect immediately. Supports labels, descriptions, loading states, and validation. Use it for settings or preferences that apply instantly. For changes requiring a form submission, use a checkbox instead.', bestPractices: [ { guidance: true, diff --git a/packages/core/src/Switch/Switch.test.tsx b/packages/core/src/Switch/Switch.test.tsx index 69d24d0dbe94f..fb95600716af1 100644 --- a/packages/core/src/Switch/Switch.test.tsx +++ b/packages/core/src/Switch/Switch.test.tsx @@ -23,7 +23,6 @@ */ import {StrictMode} from 'react'; -import * as stylex from '@stylexjs/stylex'; import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import {render, screen, fireEvent, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -34,16 +33,11 @@ import { } from '../__tests__/forcedColors'; import { hasPressedArm, + hasReleaseFade, readsPressStrength, - releasesOnLayer, - rulesDeclaredFor, - rulesWithSelector, } from '../__tests__/pressState'; -import {press as uikitPress} from '../motion/uikit/timing'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; import {InternationalizationProvider} from '../i18n'; -import {installPressFeedback} from '../utils/pressFeedback'; -import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; afterEach(() => { __resetLiveRegionsForTest(); @@ -318,27 +312,6 @@ describe('Switch', () => { expect(children.length).toBe(2); }); - it('keeps its input when labelPosition changes, so what is attached to it stays attached', () => { - const view = render( - {}} - labelPosition="start" - />, - ); - const input = screen.getByRole('switch'); - view.rerender( - {}} - labelPosition="end" - />, - ); - expect(screen.getByRole('switch')).toBe(input); - }); - // Retained on purpose: the shared switch contract encodes no busy expectation // — no current record adopts one — so this is the only thing gating it. it('sets aria-busy on input when loading', () => { @@ -1068,7 +1041,7 @@ describe('Switch', () => { expect(hasPressedArm(thumb)).toBe(true); }); - it("paints the touch press on the track's ::before and releases it on the compositor", () => { + it('fades the touch press out from the row, with the track and thumb reading its strength', () => { const {container} = render( {}} />, ); @@ -1078,273 +1051,15 @@ describe('Switch', () => { if (track == null || thumb == null || row == null) { throw new Error('the switch has no pressable row, track or thumb'); } - const coarse = { - insideMedia: /\(pointer: coarse\)/, - outsideMedia: /prefers-reduced-motion/, - }; - const selector = (rule: string) => rule.slice(0, rule.indexOf('{')); - const body = (rule: string) => rule.slice(rule.indexOf('{')); - // The controller writes the row. Under a finger the press is a layer on - // the track, under the thumb. It exists only while the row is pressed - // or releasing, with no element and no box at rest. It paints the - // pressed token, shows at once, and releases on the compositor. Track - // and thumb both paint it. + // The controller writes the row; the row owns the strength and its + // release, and the two parts paint the pressed token at that strength + // on both touch arms, so they fade together. + expect(hasReleaseFade(row)).toBe(true); for (const part of [track, thumb]) { - const layer = rulesDeclaredFor(part).filter(rule => - selector(rule).includes('::before'), - ); - const creates = layer.filter(rule => - /(^|[{;\s])content:/.test(body(rule)), - ); - expect(creates.length).toBeGreaterThan(0); - for (const rule of creates) { - expect(selector(rule)).toMatch(/data-astryx-press="(on|fading)"/); - } - expect( - layer.some(rule => - /background-color:[^;}]*--color-overlay-pressed/.test(rule), - ), - ).toBe(true); - } - const on = rulesWithSelector(track, '[data-astryx-press="on"]', coarse); - expect(on.some(rule => /opacity:\s*1\b/.test(rule))).toBe(true); - expect(on.some(rule => /animation-name:\s*(?!none)/.test(rule))).toBe( - false, - ); - for (const part of [track, thumb]) { - expect( - releasesOnLayer(part, '::before', uikitPress.highlightOutMs), - ).toBe(true); - } - // Nothing reads the strength under a finger, so its main-thread - // release stops there; any other pointer keeps it (the desktop rule). - expect( - rulesWithSelector(row, '[data-astryx-press="fading"]', coarse).some( - rule => /animation-name:\s*none/.test(rule), - ), - ).toBe(true); - const fine = {outsideMedia: /prefers-reduced-motion|\(pointer: coarse\)/}; - expect( - rulesWithSelector(row, '[data-astryx-press="fading"]', fine).some( - rule => /animation-name:\s*(?!none)/.test(rule), - ), - ).toBe(true); - for (const part of [track, thumb]) { - expect(readsPressStrength(part, '[data-astryx-press="on"]')).toBe( - false, - ); - expect(readsPressStrength(part)).toBe(false); + expect(readsPressStrength(part, '[data-astryx-press="on"]')).toBe(true); + expect(readsPressStrength(part)).toBe(true); } }); - - it('releases the touch press on the shared release’s clock and curve', () => { - render( - <> - {}} /> -
- , - ); - const track = document.querySelector('.astryx-switch'); - if (track == null) { - throw new Error('the switch has no track'); - } - const shared = screen.getByTestId('shared-release'); - const fading = '[data-astryx-press="fading"]'; - const reduced = /prefers-reduced-motion/; - const valuesOf = (rules: string[], property: string) => [ - ...new Set( - rules.flatMap(rule => { - const match = rule.match(new RegExp(`${property}:\\s*([^;}]+)`)); - // StyleX may write a time in s or ms: compare it in ms. - return match == null - ? [] - : [ - match[1] - .trim() - .replace( - /(\d*\.?\d+)(ms|s)\b/g, - (_, n: string, unit: string) => - `${Number(n) * (unit === 's' ? 1000 : 1)}ms`, - ), - ]; - }), - ), - ]; - const layer = (filter: {insideMedia?: RegExp; outsideMedia?: RegExp}) => - rulesWithSelector(track, fading, filter).filter(rule => - rule.slice(0, rule.indexOf('{')).includes('::before'), - ); - const touch = layer({ - insideMedia: /\(pointer: coarse\)/, - outsideMedia: reduced, - }); - const own = rulesWithSelector(shared, fading, {outsideMedia: reduced}); - for (const property of [ - 'animation-duration', - 'animation-timing-function', - ]) { - expect(valuesOf(own, property)).toHaveLength(1); - expect(valuesOf(touch, property)).toEqual(valuesOf(own, property)); - } - // Reduce Motion's arm nests one `@media` rule in another, which the - // test DOM drops: the browser proofs check it. - }); - }); - - describe('the row that holds the touch press', () => { - const FADING = '[data-astryx-press="fading"]'; - /** Rules under a coarse primary pointer, Reduce Motion's arms left out. */ - const UNDER_COARSE = { - insideMedia: /\(pointer: coarse\)/, - outsideMedia: /prefers-reduced-motion/, - }; - /** Rules for any other pointer, Reduce Motion's arms left out. */ - const UNDER_FINE = { - outsideMedia: /prefers-reduced-motion|\(pointer: coarse\)/, - }; - const selectorOf = (rule: string) => rule.slice(0, rule.indexOf('{')); - const fadingOnHost = (rules: string[]) => - rules.filter( - rule => - selectorOf(rule).includes(FADING) && !selectorOf(rule).includes('::'), - ); - const valuesOf = (rules: string[], property: string) => - rules - .flatMap(rule => { - const match = rule.match(new RegExp(`${property}:\\s*([^;}]+)`)); - return match == null ? [] : [match[1].trim()]; - }) - .sort(); - /** The switch's row, and an element with the shared press styles alone. */ - function renderOwners(): {alpha: HTMLElement; row: Element} { - render( - <> - {}} /> -
- , - ); - const row = document - .querySelector('.astryx-switch') - ?.closest('[data-astryx-pressable]'); - if (row == null) { - throw new Error('the switch has no pressable row'); - } - return {alpha: screen.getByTestId('alpha'), row}; - } - - it('declares the shared strength and paint for the track’s and thumb’s layers', () => { - const {alpha, row} = renderOwners(); - const strengthAndPaint = (el: Element) => - rulesDeclaredFor(el) - .filter(rule => /--astryx-press-alpha|--_press-paint/.test(rule)) - .sort(); - expect(strengthAndPaint(alpha).length).toBeGreaterThan(0); - expect(strengthAndPaint(row)).toEqual(strengthAndPaint(alpha)); - }); - - it('with any pointer but a coarse primary one, keeps the shared release on the row', () => { - const {alpha, row} = renderOwners(); - const release = (el: Element) => - fadingOnHost(rulesDeclaredFor(el, UNDER_FINE)); - expect(valuesOf(release(alpha), 'animation-name')).not.toEqual([]); - for (const property of [ - 'animation-name', - 'animation-duration', - 'animation-timing-function', - ]) { - expect(valuesOf(release(row), property)).toEqual( - valuesOf(release(alpha), property), - ); - } - }); - - it('under a coarse primary pointer, drops the strength’s own release on the row', () => { - const {alpha, row} = renderOwners(); - const names = (el: Element) => - valuesOf( - fadingOnHost(rulesDeclaredFor(el, UNDER_COARSE)), - 'animation-name', - ); - // The shared styles keep the strength's release there; the row holds - // the strength, so the layers' fade is the one fade. - expect(names(alpha)).not.toContain('none'); - expect(names(row)).toEqual(['none']); - }); - }); - - describe('mouse look', () => { - // jsdom applies no `@media (pointer: coarse)` rule, so these are the - // styles a mouse gets. The touch presentation must override none of - // them: a later style that names a property replaces all of it. - const look = (container: HTMLElement) => { - const track = container.querySelector('.astryx-switch'); - const thumb = container.querySelector( - '.astryx-switch-thumb', - ); - if (track == null || thumb == null) { - throw new Error('the switch has no track or thumb'); - } - const t = getComputedStyle(track); - const h = getComputedStyle(thumb); - return { - fill: t.backgroundColor, - padding: t.paddingTop, - fade: [t.transitionProperty, t.transitionDuration], - size: [h.width, h.height], - slide: [h.transitionProperty.replace(/\s/g, ''), h.transitionDuration], - travel: h.transform, - }; - }; - - it('keeps the track fill, padding and colour fade, and the thumb size and slide', () => { - // A fine pointer: the desktop switch. - expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); - const {container, rerender} = render( - {}} />, - ); - const off = look(container); - expect(off.fill).toContain('--color-background-gray'); - expect(off.padding).toBe('4px'); - expect(off.fade[0]).toBe('background-color'); - expect(off.fade[1]).not.toMatch(/^(0s)?$/); - expect(off.size).toEqual(['16px', '16px']); - expect(off.slide[0]).toBe('transform,width,height'); - expect(off.slide[1]).not.toMatch(/^(0s)?$/); - - rerender( {}} />); - const on = look(container); - expect(on.fill).toContain('--color-accent'); - expect(on.size).toEqual(['20px', '20px']); - expect(on.travel).toBe('translateX(14px)'); - }); - - it('adds no element: the thumb is the track’s only child, under a finger too', () => { - const {container} = render( - {}} />, - ); - const track = container.querySelector('.astryx-switch'); - const thumb = container.querySelector('.astryx-switch-thumb'); - // architecture:component-style-authoring INV7: the touch presentation - // styles the existing thumb (the fade layer is the track's ::before). - expect(track?.children.length).toBe(1); - expect(thumb?.parentElement).toBe(track); - }); - - it('keeps the small size’s padding and thumb', () => { - expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); - const {container} = render( - {}} />, - ); - const off = look(container); - expect(off.padding).toBe('2px'); - expect(off.size).toEqual(['14px', '14px']); - }); }); describe('coarse pointer and RTL hit-target positioning', () => { @@ -1380,304 +1095,6 @@ describe('Switch', () => { // jsdom cannot emulate forced-colors rendering, so these assert that the // compiled output includes the forced-colors rules; visual behavior needs // manual verification under Windows High Contrast. -describe('touch listeners (spec:AST-094 FR24)', () => { - const coarseMedia = (query: string) => - ({ - matches: - query.includes('pointer: coarse') || - query.includes('any-pointer: coarse'), - media: query, - onchange: null, - addEventListener: () => {}, - removeEventListener: () => {}, - addListener: () => {}, - removeListener: () => {}, - dispatchEvent: () => false, - }) as unknown as MediaQueryList; - - // React's root delegates touch events too: count against a plain element. - const listenersWhileRendering = ( - isDisabled: boolean | 'none' | 'busy', - ): {touch: number; pointer: number; contextMenu: number} => { - vi.spyOn(window, 'matchMedia').mockImplementation(coarseMedia); - const types: string[] = []; - const record = function (this: unknown, type: string) { - types.push(type); - }; - const onDocument = vi - .spyOn(document, 'addEventListener') - .mockImplementation(record); - const onElements = vi - .spyOn(HTMLElement.prototype, 'addEventListener') - .mockImplementation(record); - const {unmount} = render( - isDisabled === 'none' ? ( -
- ) : ( - {}} - isDisabled={isDisabled === true} - isLoading={isDisabled === 'busy'} - /> - ), - ); - onDocument.mockRestore(); - onElements.mockRestore(); - unmount(); - vi.restoreAllMocks(); - return { - touch: types.filter(type => type.startsWith('touch')).length, - pointer: types.filter(type => type.startsWith('pointer')).length, - contextMenu: types.filter(type => type === 'contextmenu').length, - }; - }; - - it('a switch under a coarse pointer attaches no touch listener, and a disabled one arms no press listener', () => { - const baseline = listenersWhileRendering('none'); - const enabled = listenersWhileRendering(false); - const disabled = listenersWhileRendering(true); - const busy = listenersWhileRendering('busy'); - // No state adds a touch listener, so a scroll never waits on the switch. - // Nor a context-menu one: the gesture host keeps a held switch's menu - // closed. - for (const counts of [enabled, disabled, busy]) { - expect(counts.touch).toBe(baseline.touch); - expect(counts.contextMenu).toBe(baseline.contextMenu); - } - // The gesture host arms a press listener for an enabled switch only. - expect(enabled.pointer).toBeGreaterThan(disabled.pointer); - // Busy stays attached, so the change that made it busy keeps its motion. - expect(busy.pointer).toBeGreaterThan(disabled.pointer); - }); - - it('under a fine pointer an enabled switch arms one passive pointerdown listener on its own element and adds no page listener until a press; a disabled one arms none (spec:AST-094 FR24, FR43)', () => { - const fineMedia = (query: string) => - ({ - matches: - query.includes('pointer: fine') || query.includes('hover: hover'), - media: query, - onchange: null, - addEventListener: () => {}, - removeEventListener: () => {}, - addListener: () => {}, - removeListener: () => {}, - dispatchEvent: () => false, - }) as unknown as MediaQueryList; - const listenersWhileRendering = (isDisabled: boolean) => { - vi.spyOn(window, 'matchMedia').mockImplementation(fineMedia); - // The one shared press controller is the allowed idle page listener. It - // is installed before the render is measured, so a count is the - // switch's own. - const releasePressController = installPressFeedback(); - const onElements: { - target: unknown; - type: string; - options: unknown; - }[] = []; - const onPage: string[] = []; - const elementSpy = vi - .spyOn(HTMLElement.prototype, 'addEventListener') - .mockImplementation(function ( - this: unknown, - type: string, - _listener: unknown, - options?: unknown, - ) { - onElements.push({target: this, type, options}); - }); - const recordPage = function (this: unknown, type: string) { - onPage.push(type); - }; - const documentSpy = vi - .spyOn(document, 'addEventListener') - .mockImplementation(recordPage); - const windowSpy = vi - .spyOn(window, 'addEventListener') - .mockImplementation(recordPage); - const {container, unmount} = render( - {}} - isDisabled={isDisabled} - />, - ); - const surface = container.querySelector('[role="switch"]')?.parentElement; - elementSpy.mockRestore(); - documentSpy.mockRestore(); - windowSpy.mockRestore(); - unmount(); - releasePressController(); - vi.restoreAllMocks(); - const armed = onElements.filter(({target, type, options}) => { - const o = options as AddEventListenerOptions | undefined; - return ( - target === surface && - type === 'pointerdown' && - o?.capture === true && - o?.passive === true - ); - }).length; - // The host's page listeners for a press: none at rest (FR24). - const pressListeners = onPage.filter(type => - /^(pointermove|pointerup|pointercancel|touch)/.test(type), - ).length; - return {armed, pressListeners}; - }; - expect(listenersWhileRendering(false)).toEqual({ - armed: 1, - pressListeners: 0, - }); - expect(listenersWhileRendering(true)).toEqual({ - armed: 0, - pressListeners: 0, - }); - }); - - it('runs no hit-area observer or window listener under a fine pointer, or while disabled under a coarse one', () => { - const observersWhileRendering = ( - isCoarse: boolean, - isDisabled: boolean, - ): {observers: number; resize: number} => { - vi.spyOn(window, 'matchMedia').mockImplementation(query => ({ - ...coarseMedia(query), - matches: isCoarse && query.includes('pointer: coarse'), - })); - let observers = 0; - vi.stubGlobal( - 'IntersectionObserver', - class { - constructor() { - observers += 1; - } - observe() {} - unobserve() {} - disconnect() {} - }, - ); - const onWindow = vi.spyOn(window, 'addEventListener'); - const {unmount} = render( - {}} - isDisabled={isDisabled} - />, - ); - const resize = onWindow.mock.calls.filter( - ([type]) => type === 'resize', - ).length; - unmount(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - return {observers, resize}; - }; - expect(observersWhileRendering(false, false)).toEqual({ - observers: 0, - resize: 0, - }); - expect(observersWhileRendering(true, true)).toEqual({ - observers: 0, - resize: 0, - }); - // Enabled under a coarse pointer it measures. - expect(observersWhileRendering(true, false).observers).toBe(1); - }); -}); - -describe('hit area under a coarse pointer (Switch FR10)', () => { - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('grows toward 44 × 44 px and stops halfway to a link beside it, so a tap nearer the link is the link’s', () => { - vi.stubGlobal('matchMedia', (query: string) => ({ - matches: query === '(pointer: coarse)', - media: query, - addEventListener: () => {}, - removeEventListener: () => {}, - })); - let reveal: ((entries: IntersectionObserverEntry[]) => void) | null = null; - vi.stubGlobal( - 'IntersectionObserver', - class { - constructor(callback: (entries: IntersectionObserverEntry[]) => void) { - reveal = callback; - } - observe() {} - unobserve() {} - disconnect() {} - }, - ); - const frames: (() => void)[] = []; - vi.stubGlobal('requestAnimationFrame', (run: () => void) => - frames.push(run), - ); - vi.stubGlobal('cancelAnimationFrame', () => {}); - const {container} = render( -
- {}} - /> - Details -
, - ); - const input = screen.getByRole('switch'); - const surface = input.parentElement as HTMLElement; - const link = container.querySelector('a') as HTMLElement; - const box = (left: number, top: number, width: number, height: number) => - ({ - left, - top, - right: left + width, - bottom: top + height, - width, - height, - x: left, - y: top, - toJSON: () => ({}), - }) as DOMRect; - // A 32 × 20 (small) switch with the link 8 px to its right. - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( - function (this: HTMLElement) { - if (this === surface) { - return box(100, 100, 32, 20); - } - if (this === link) { - return box(140, 100, 60, 20); - } - return box(0, 0, 0, 0); - }, - ); - expect(surface.style.getPropertyValue('--astryx-hit-inset-right')).toBe(''); - reveal!([ - { - target: surface, - isIntersecting: true, - } as unknown as IntersectionObserverEntry, - ]); - frames.splice(0).forEach(run => run()); - // Up and down 12 px (20 → 44); left 6 px (32 → 44); right stops at - // 4 px, half the 8 px gap. - expect(surface.style.getPropertyValue('--astryx-hit-inset-top')).toBe( - '12px', - ); - expect(surface.style.getPropertyValue('--astryx-hit-inset-left')).toBe( - '6px', - ); - expect(surface.style.getPropertyValue('--astryx-hit-inset-right')).toBe( - '4px', - ); - }); -}); - describe('forced colors (WCAG 1.4.11)', () => { it('compiles forced-colors overrides so on/off state survives Windows High Contrast', () => { render( {}} />); diff --git a/packages/core/src/Switch/Switch.tsx b/packages/core/src/Switch/Switch.tsx index 35e99cf95bc18..98d7fd2034913 100644 --- a/packages/core/src/Switch/Switch.tsx +++ b/packages/core/src/Switch/Switch.tsx @@ -17,9 +17,7 @@ */ import { - Fragment, useId, - useMemo, useOptimistic, useTransition, useEffect, @@ -38,7 +36,6 @@ import { typographyVars, typeScaleVars, focusVars, - shadowVars, } from '../theme/tokens.stylex'; import {FieldLabel} from '../Field/FieldLabel'; import {FieldStatus} from '../FieldStatus/FieldStatus'; @@ -48,6 +45,7 @@ import {Spinner} from '../Spinner'; import {useTooltip} from '../Tooltip'; import {mergeProps, mergeRefs, rtlStyles} from '../utils'; import {switchFocusInput, switchScope} from './switch.markers.stylex'; +import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import type {BaseProps} from '../BaseProps'; import type {SizeValue} from '../utils/types'; import {themeProps} from '../utils/themeProps'; @@ -63,52 +61,6 @@ import { import english from '../i18n/generated-english/Switch/Switch.generated'; registerEnglishFallback(english); -import { - interactionOverlayStyles, - pressConsts, - pressVars, -} from '../utils/interactionOverlay.stylex'; -import {pressHoldsDragProps} from '../privateAttributes/pressHoldsDrag.stylex'; -import {useSwitchTouch, type SwitchTouchParts} from './useSwitchTouch'; -import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; -import {watchHitArea, writeHitInsets} from '../utils/hitArea'; - -/** - * The track box of each size, px (width × height), for the touch - * presentation's thumb geometry. - * SYNC: wrapperSizeStyles and trackSizeStyles below. - */ -const TRACK_SIZE = {sm: [32, 20], md: [40, 24]} as const; - -/** - * A finger: utils/touchPresentation.ts's TOUCH_QUERY. The touch presentation - * is one model on every touch device: the lift, tracking, flip rule and - * springs sit under it alone. - * SYNC: utils/touchPresentation.ts TOUCH_QUERY. - */ -const TOUCH = '@media (pointer: coarse)'; - -/** The press controller's two states on the row it writes to. */ -const PRESS_ON = '[data-astryx-press="on"]'; -const PRESS_FADING = '[data-astryx-press="fading"]'; - -/** - * The shared press release, for the press layers the switch fades on the - * compositor. The clock scales by the theme's tempo, and the curve is the - * controller's own. Switch.test.tsx compares the two. - * SYNC: utils/interactionOverlay.stylex.ts PRESS_RELEASE_DURATION, - * PRESS_RELEASE_DURATION_REDUCED and RELEASE_EASE. - */ -const PRESS_RELEASE_DURATION = `calc(${pressConsts.releaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5))`; -const PRESS_RELEASE_DURATION_REDUCED = `min(${pressConsts.releaseMs}ms, calc(${pressConsts.releaseMs}ms * var(--_astryx-motion-tempo, 1)))`; -const PRESS_RELEASE_EASE = 'cubic-bezier(0, 0, 0.2, 1)'; - -// The measured hit insets (utils/hitArea), 0 until measured. -const HIT_TOP = 'var(--astryx-hit-inset-top, 0px)'; -const HIT_RIGHT = 'var(--astryx-hit-inset-right, 0px)'; -const HIT_BOTTOM = 'var(--astryx-hit-inset-bottom, 0px)'; -const HIT_LEFT = 'var(--astryx-hit-inset-left, 0px)'; - const wrapperSizeStyles = stylex.create({ sm: { width: 32, @@ -120,9 +72,17 @@ const wrapperSizeStyles = stylex.create({ }, }); -// Every touch value sits in the same property as the mouse value, as a -// nested condition, never in a later style. A later style that names a -// property replaces all of it, mouse value included. +const inputSizeStyles = stylex.create({ + sm: { + width: 32, + height: 20, + }, + md: { + width: 40, + height: 24, + }, +}); + const trackSizeStyles = stylex.create({ sm: { width: 32, @@ -136,28 +96,16 @@ const trackSizeStyles = stylex.create({ }, }); -// The thumb is one circle under any pointer, at each state's size. Under a -// finger it rests by insets instead of a transform, centered in the track's -// height at each end. So the compositor moves and lifts it with translate and -// scale alone; useSwitchTouch measures the box it renders. const thumbOffSizeStyles = stylex.create({ sm: { width: 14, height: 14, - insetInlineStart: {default: null, [TOUCH]: 3}, - transform: { - default: 'translateX(0)', - [TOUCH]: 'none', - }, + transform: 'translateX(0)', }, md: { width: 16, height: 16, - insetInlineStart: {default: null, [TOUCH]: 4}, - transform: { - default: 'translateX(0)', - [TOUCH]: 'none', - }, + transform: 'translateX(0)', }, }); @@ -165,8 +113,6 @@ const thumbOnSizeStyles = stylex.create({ sm: { width: 16, height: 16, - insetInlineStart: {default: null, [TOUCH]: 'auto'}, - insetInlineEnd: {default: null, [TOUCH]: 2}, // The thumb rests at the inline-start edge (flex-start, which flexbox // already mirrors under RTL). The on-state travel toward the inline-end // edge is a physical translateX, so it must flip sign under RTL — right @@ -174,25 +120,15 @@ const thumbOnSizeStyles = stylex.create({ // iOS): off-thumb on the reading-start side, on-thumb on the reading-end. transform: { default: 'translateX(12px)', - ':is([dir="rtl"] *)': { - default: 'translateX(-12px)', - [TOUCH]: 'none', - }, - [TOUCH]: 'none', + ':is([dir="rtl"] *)': 'translateX(-12px)', }, }, md: { width: 20, height: 20, - insetInlineStart: {default: null, [TOUCH]: 'auto'}, - insetInlineEnd: {default: null, [TOUCH]: 2}, transform: { default: 'translateX(14px)', - ':is([dir="rtl"] *)': { - default: 'translateX(-14px)', - [TOUCH]: 'none', - }, - [TOUCH]: 'none', + ':is([dir="rtl"] *)': 'translateX(-14px)', }, }, }); @@ -205,43 +141,11 @@ const thumbOnSizeStyles = stylex.create({ // hover tint already is: pressing the input, the track or the label all // activate the row. const pressedImage = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`; - -/** The touch press's release on the press layer: opacity 1 → 0. */ -const pressLayerRelease = stylex.keyframes({ - from: {opacity: 1}, - to: {opacity: 0}, -}); - -const PRESS_ALPHA = pressVars['--astryx-press-alpha']; - -/** - * The shared strength's release, restated so a fine pointer keeps it: the - * same keyframes, so the same animation. - */ -const pressStrengthRelease = stylex.keyframes({ - from: {[PRESS_ALPHA]: 1}, - to: {[PRESS_ALPHA]: 0}, -}); - -const pressOwnerStyles = stylex.create({ - /** - * Composed after `interactionOverlayStyles.pressedAlpha` on the row. Under - * a coarse primary pointer the track's and thumb's layers fade the press. - * So the strength holds through the release, not animated on the main - * thread. With any other pointer the row keeps `pressedAlpha`'s release. - */ - pressedAlphaLayered: { - animationName: { - default: null, - [PRESS_FADING]: { - default: pressStrengthRelease, - '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': - 'none', - '@media (pointer: coarse)': 'none', - }, - }, - }, -}); +// The touch press's paint, declared by the shared overlay styles on the +// element the controller writes to (`pressedAlpha`) at the press's strength, +// 1 while on and 1 → 0 over the release, and inherited resolved by the layer +// that paints it. See interactionOverlay.stylex.ts. +const pressedOverlayImage = 'var(--_press-paint-image)'; const labelWrapperSizeStyles = stylex.create({ sm: { @@ -278,13 +182,6 @@ const styles = stylex.create({ flexShrink: 0, isolation: 'isolate', }, - // Horizontal touch moves are the switch's drag and vertical ones the page's - // scroll, under any primary pointer. A touchscreen laptop's finger counts - // too. It is set in CSS so it holds before hydration. A disabled or loading - // switch takes no drag, so it leaves every move to the page. - switchWrapperInteractive: { - touchAction: 'pan-y pinch-zoom', - }, input: { position: 'absolute', top: '50%', @@ -297,24 +194,10 @@ const styles = stylex.create({ }, zIndex: 1, }, - // The invisible input covers the track. Under a coarse pointer it is the - // hit area a finger needs. It grows toward 44 × 44 px by the insets - // utils/hitArea measures, each side stopping at half the gap to a - // neighbouring target. Its center moves with any uneven side, and nothing - // visible grows. The insets are 0 until measured. Physical left and - // translate, as centerInline: the insets are physical sides. inputCoarse: { - width: { - default: '100%', - '@media (pointer: coarse)': `calc(100% + ${HIT_LEFT} + ${HIT_RIGHT})`, - }, - height: { - default: '100%', - '@media (pointer: coarse)': `calc(100% + ${HIT_TOP} + ${HIT_BOTTOM})`, - }, - transform: { - default: 'translate(-50%, -50%)', - '@media (pointer: coarse)': `translate(calc(-50% + (${HIT_RIGHT} - ${HIT_LEFT}) / 2), calc(-50% + (${HIT_BOTTOM} - ${HIT_TOP}) / 2))`, + '@media (pointer: coarse)': { + minInlineSize: 24, + minBlockSize: 24, }, }, inputDisabled: { @@ -326,22 +209,11 @@ const styles = stylex.create({ track: { display: 'flex', alignItems: 'center', - // Under a finger the press layer (::before), the colour fade (::after) - // and the thumb sit in the track, in a stacking context of its own. - position: {default: null, [TOUCH]: 'relative'}, - isolation: {default: null, [TOUCH]: 'isolate'}, borderRadius: radiusVars['--radius-full'], transitionProperty: 'background-color', - // Under a finger the track's fill changes at once and the fade layer - // crossfades it on the compositor. Reduced motion comes last so it holds - // everywhere: under a finger the fade layer - // carries the colour, and with a mouse the change is immediate. transitionDuration: { default: durationVars['--duration-fast'], - [TOUCH]: '0s', - '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', - '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': - '0s', + '@media (prefers-reduced-motion: reduce)': '0s', }, transitionTimingFunction: easeVars['--ease-standard'], boxSizing: 'border-box', @@ -368,102 +240,28 @@ const styles = stylex.create({ default: null, '@media (forced-colors: none)': { default: pressedImage, - // Under a coarse pointer the touch press controller writes - // `data-astryx-press` on the row instead, and the press layer - // (::before) paints it. + // Under a coarse pointer the touch press model writes `data-astryx-press` + // on the row instead; see interactionOverlay.stylex.ts. '@media (pointer: coarse)': 'none', }, }, - }, - // The press, under a finger: a layer over the track's fill and colour - // fade and under the thumb. It exists only while the row is pressed or - // releasing. Its opacity is the press: on at once, then the shared - // release's clock and curve. The release runs on the compositor, so the - // press never sticks while the main thread is busy. - '::before': { - content: { - default: null, - [stylex.when.ancestor(PRESS_ON, switchScope)]: { - default: null, - [TOUCH]: '""', - }, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: '""', - }, - }, - position: 'absolute', - inset: 0, - zIndex: 1, - borderRadius: 'inherit', - pointerEvents: 'none', - backgroundColor: { - default: null, - '@media (forced-colors: none)': colorVars['--color-overlay-pressed'], - }, - opacity: { - default: null, - [stylex.when.ancestor(PRESS_ON, switchScope)]: { - default: null, - [TOUCH]: 1, - }, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: 0, - }, - }, - animationName: { - default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: pressLayerRelease, - }, - }, - animationDuration: { - default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: { - default: PRESS_RELEASE_DURATION, - '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': - PRESS_RELEASE_DURATION_REDUCED, - }, - }, - }, - animationTimingFunction: { + // Nested in the same media as the arm above so it outranks the drop: an + // ancestor-scoped attribute selector gets no priority of its own. + [stylex.when.ancestor('[data-astryx-press="on"]', switchScope)]: { default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { + '@media (forced-colors: none)': { default: null, - [TOUCH]: PRESS_RELEASE_EASE, + '@media (pointer: coarse)': pressedOverlayImage, }, }, - animationFillMode: { + [stylex.when.ancestor('[data-astryx-press="fading"]', switchScope)]: { default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { + '@media (forced-colors: none)': { default: null, - [TOUCH]: 'both', + '@media (pointer: coarse)': pressedOverlayImage, }, }, }, - // The colour fade, under a finger: a layer over the track's own fill. It - // shows the fill the track is leaving, or mid-drag the one it heads to, - // and fades it on the compositor. Its colour is the theme's, read from - // the track (switchFill.ts). The animator owns its opacity, and at rest it - // is clear. - '::after': { - content: '""', - display: {default: 'none', [TOUCH]: 'block'}, - position: 'absolute', - inset: 0, - zIndex: 0, - borderRadius: 'inherit', - pointerEvents: 'none', - opacity: 0, - backgroundColor: 'var(--_switch-fade-fill, transparent)', - // Its own compositing layer from the start, so its fade runs off the - // main thread (iOS Safari otherwise animates it on the main thread). - willChange: {default: null, [TOUCH]: 'opacity'}, - }, }, // The one ring in the system not drawn by focusOutlineStyles. The focusable // input is the track's preceding sibling, so the condition reads that input @@ -531,117 +329,38 @@ const styles = stylex.create({ alignItems: 'center', justifyContent: 'center', borderRadius: radiusVars['--radius-full'], - // Under a finger it sits in the track, centered in its height, above the - // colour fade (::after) and the press layer (::before). useSwitchTouch - // moves and lifts it on the compositor: nothing of its own transitions. - position: {default: null, [TOUCH]: 'absolute'}, - insetBlock: {default: null, [TOUCH]: 0}, - marginBlock: {default: null, [TOUCH]: 'auto'}, - zIndex: {default: null, [TOUCH]: 2}, - pointerEvents: {default: null, [TOUCH]: 'none'}, - transitionProperty: { - default: 'transform, width, height', - [TOUCH]: 'none', - }, - // Under reduced motion nothing slides: under a finger the thumb - // cross-fades through the animator, and with a mouse it moves at once. + transitionProperty: 'transform, width, height', transitionDuration: { default: durationVars['--duration-fast'], - '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '0s', - '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': - '0s', + '@media (prefers-reduced-motion: reduce)': '0s', }, transitionTimingFunction: easeVars['--ease-standard'], - // The lifted shadow, under a finger: the theme's elevation on a layer the - // thumb's size. It fades in with the lift on the compositor, and at rest - // it is clear. The press on the thumb too, under a finger: the same layer - // as the track's, over the thumb's fill. - '::before': { - content: { - default: null, - [stylex.when.ancestor(PRESS_ON, switchScope)]: { - default: null, - [TOUCH]: '""', - }, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: '""', - }, - }, - position: 'absolute', - inset: 0, - borderRadius: 'inherit', - pointerEvents: 'none', - backgroundColor: { - default: null, - '@media (forced-colors: none)': colorVars['--color-overlay-pressed'], - }, - opacity: { - default: null, - [stylex.when.ancestor(PRESS_ON, switchScope)]: { - default: null, - [TOUCH]: 1, - }, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: 0, - }, - }, - animationName: { - default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: pressLayerRelease, - }, - }, - animationDuration: { - default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: { - default: PRESS_RELEASE_DURATION, - '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': - PRESS_RELEASE_DURATION_REDUCED, - }, - }, - }, - animationTimingFunction: { + // Pressed: the same overlay as the track, so the whole control darkens + // under the finger rather than the thumb standing out against a darker + // track. + backgroundImage: { + default: null, + [stylex.when.ancestor(':active', switchScope)]: { default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { - default: null, - [TOUCH]: PRESS_RELEASE_EASE, + '@media (forced-colors: none)': { + default: pressedImage, + '@media (pointer: coarse)': 'none', }, }, - animationFillMode: { + // Nested in the same media as the arm above so it outranks the drop: an + // ancestor-scoped attribute selector gets no priority of its own. + [stylex.when.ancestor('[data-astryx-press="on"]', switchScope)]: { default: null, - [stylex.when.ancestor(PRESS_FADING, switchScope)]: { + '@media (forced-colors: none)': { default: null, - [TOUCH]: 'both', + '@media (pointer: coarse)': pressedOverlayImage, }, }, - }, - '::after': { - content: '""', - display: {default: 'none', [TOUCH]: 'block'}, - position: 'absolute', - inset: 0, - borderRadius: 'inherit', - pointerEvents: 'none', - opacity: 0, - boxShadow: shadowVars['--shadow-low'], - willChange: {default: null, [TOUCH]: 'opacity'}, - }, - // Pressed with a mouse: the same overlay as the track, so the whole - // control darkens rather than the thumb standing out against a darker - // track. Under a finger the track's press layer paints under the thumb, - // which lifts. - backgroundImage: { - default: null, - [stylex.when.ancestor(':active', switchScope)]: { + [stylex.when.ancestor('[data-astryx-press="fading"]', switchScope)]: { default: null, '@media (forced-colors: none)': { - default: pressedImage, - '@media (pointer: coarse)': 'none', + default: null, + '@media (pointer: coarse)': pressedOverlayImage, }, }, }, @@ -800,7 +519,7 @@ export interface SwitchProps extends Omit { status?: InputStatus; /** * Size variant controlling track and thumb dimensions. - * - 'sm': 32x20px (matches sm checkbox/radio vertical rhythm) + * - 'sm': 34x20px (matches sm checkbox/radio vertical rhythm) * - 'md': 40x24px (default, matches md checkbox/radio vertical rhythm) * @default 'md' */ @@ -930,68 +649,10 @@ export function Switch({ const ariaDescribedBy = describedByParts.length > 0 ? describedByParts.join(' ') : undefined; - // The touch presentation (coarse pointer): its parts are always rendered; - // CSS shows them under a finger and the hook animates and listens there. - const surfaceRef = useRef(null); - const inputRef = useRef(null); - const thumbRef = useRef(null); - const trackRef = useRef(null); - const touchParts = useMemo( - () => ({ - surface: surfaceRef, - input: inputRef, - thumb: thumbRef, - track: trackRef, - }), - [], - ); - // The track in a state: its theme target and fill. The other state's, for - // the fade layer's colour (switchFill.ts). - const trackPropsFor = (on: boolean) => - mergeProps( - themeProps('switch', { - checked: on ? 'checked' : null, - disabled: isDisabled ? 'disabled' : null, - size, - }), - stylex.props( - styles.track, - trackSizeStyles[size], - on ? styles.trackOn : styles.trackOff, - styles.trackFocus, - isDisabled && styles.trackDisabled, - isDisabled && !on && styles.trackDisabledOff, - ), - ); - // Syncs the hit area's insets with the layout around the switch, through - // utils/hitArea's observers. Under a coarse pointer they grow toward 44 px, - // stopping at half the gap to each neighbouring target. A disabled switch - // watches nothing. - useIsomorphicLayoutEffect(() => { - const surface = surfaceRef.current; - if (surface == null || isDisabled) { - return; - } - return watchHitArea(surface, insets => writeHitInsets(surface, insets)); - }, [isDisabled]); - useSwitchTouch(touchParts, { - isOn, - isInteractive: !isDisabled && !isBusy, - isDisabled, - trackSize: TRACK_SIZE[size], - otherTrackProps: trackPropsFor(!isOn), - }); - const switchElement = ( -
+
-