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 (
-
- );
- },
-};
-
-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 (
-
- );
- },
-};
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(
-