diff --git a/.changeset/switch-every-pointer.md b/.changeset/switch-every-pointer.md index 1246ec4101628..7829688522ba5 100644 --- a/.changeset/switch-every-pointer.md +++ b/.changeset/switch-every-pointer.md @@ -2,6 +2,6 @@ '@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. +[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, unless the pointer came back past where it pressed. A mouse or pen press released away from the switch leaves it alone, flipped or not, 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/docs/specs/components/Switch/Switch.spec.md b/docs/specs/components/Switch/Switch.spec.md index 1265c3bf9529d..1e6f2570d4747 100644 --- a/docs/specs/components/Switch/Switch.spec.md +++ b/docs/specs/components/Switch/Switch.spec.md @@ -146,23 +146,23 @@ 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` | +| 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 (measured in `motion/uikit/switch.ts`: `travel`, `flipDistance`). A finger's drag commits wherever the finger lifts; a mouse's or a pen's commits when it is released inside the switch's tracking bounds, its box grown by UIControl's 70 px tracking expansion. 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 released outside those bounds leaves it alone, flipped or not, 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. A finger's slide-off toggle (`spec:AST-020` FR9, DEC-5; `spec:AST-094` FR11) meets WCAG 2.5.2 Pointer Cancellation through its Abort or Undo option: sliding back, or toggling again, undoes it. A mouse or pen released outside the tracking bounds cancels the press. | `spec:AST-094` FR11, FR13, FR43; `spec:AST-020` FR9/DEC-5; `design:reduced-motion`; WCAG 2.5.2 | ### Motion roles diff --git a/packages/core/src/Switch/__tests__/SwitchPointer.interaction.browser.spec.ts b/packages/core/src/Switch/__tests__/SwitchPointer.interaction.browser.spec.ts index b865277a5c439..4f1fd2df0b362 100644 --- a/packages/core/src/Switch/__tests__/SwitchPointer.interaction.browser.spec.ts +++ b/packages/core/src/Switch/__tests__/SwitchPointer.interaction.browser.spec.ts @@ -6,9 +6,9 @@ * primary pointer), driven by a real mouse. * @output Browser proofs that Switch's gestures work for a mouse and a pen * (`spec:AST-094` FR43): a drag flips it by the flip rule (Switch FR15), a - * press slid off and released outside leaves it alone (the - * reversible-toggle exception is a finger's), a click toggles once, a right - * click does nothing, and the switch keeps its desktop presentation (FR13). + * press released outside the tracking bounds leaves it alone, flipped or + * not (WCAG 2.5.2), a click toggles once, a right click does nothing, and + * the switch keeps its desktop presentation (FR13). * The pen is the harness's real engine pen (Chromium only). * @position Beside Switch.interaction.browser.spec.ts, which proves the * touch presentation under a finger. @@ -121,6 +121,14 @@ async function afterTwoFrames(page: Page): Promise { }); } +/** + * How far to go vertically for a release outside the switch's tracking + * bounds. It goes toward the side of the 320 px viewport with more room. + */ +function awayY(y: number): number { + return y < 160 ? 120 : -120; +} + /** A mouse drag through `points`, pressed at the first and released at the last. */ async function mouseDrag( page: Page, @@ -137,14 +145,15 @@ async function mouseDrag( } test.describe('Switch under a mouse (spec:AST-094 FR43)', () => { - test('a drag past the flip distance flips the switch, and the release commits it off the switch too (Switch FR15)', async ({ + test('a drag past the flip distance flips the switch, and a release just off it, inside its tracking bounds, commits it (Switch FR15)', async ({ browser, }) => { const sw = await openMouseSwitch(browser); try { const {x, y} = sw.center; - // Past the flip, then on past the switch onto its label: the browser - // sends the input no click for a release that far. + // Past the flip, then past the switch onto its label, still inside its + // box grown by the 70 px tracking expansion. The browser sends the + // input no click for a release that far. await mouseDrag(sw.page, [ {x, y}, {x: x + FLIP_DISTANCE + 6, y}, @@ -175,6 +184,26 @@ test.describe('Switch under a mouse (spec:AST-094 FR43)', () => { } }); + test('a drag past the flip distance that slides off and is released away from the switch commits nothing: the press can be taken back (Switch FR15, WCAG 2.5.2)', async ({ + browser, + }) => { + const sw = await openMouseSwitch(browser); + try { + const {x, y} = sw.center; + // Past the flip, then 200 px away, outside the tracking bounds, as the + // accessibility contract's aborted press does. + await mouseDrag(sw.page, [ + {x, y}, + {x: x + FLIP_DISTANCE + 6, y}, + {x: x + 200, y: y + awayY(y)}, + ]); + expect(await sw.isChecked()).toBe(false); + expect(await sw.changes()).toBe(0); + } finally { + await sw.close(); + } + }); + test('a press slid off and released outside without a flip leaves the switch alone: the reversible-toggle exception is a finger’s (Switch FR15)', async ({ browser, }) => { @@ -325,7 +354,7 @@ test.describe('Switch under a mouse (spec:AST-094 FR43)', () => { } }); - test('a pen drag past the flip distance flips the switch, and the release commits it off the switch too (Switch FR15)', async ({ + test('a pen drag past the flip distance flips the switch, and a release just off it, inside its tracking bounds, commits it (Switch FR15)', async ({ browser, }) => { const sw = await openMouseSwitch(browser); @@ -349,6 +378,29 @@ test.describe('Switch under a mouse (spec:AST-094 FR43)', () => { } }); + test('a pen drag past the flip distance that slides off and is released away from the switch commits nothing, as a mouse’s (Switch FR15, WCAG 2.5.2)', async ({ + browser, + }) => { + const sw = await openMouseSwitch(browser); + try { + const driver = await webInputDriver(sw.page); + test.skip(!driver.pens, PEN_SKIP); + const {x, y} = sw.center; + await driver.pen([ + {x, y, t: 0}, + {x: x + 10, y, t: 60}, + {x: x + FLIP_DISTANCE + 6, y, t: 120}, + {x: x + 100, y, t: 180}, + {x: x + 200, y: y + awayY(y), t: 240}, + ]); + await afterClickWait(sw.page); + expect(await sw.isChecked()).toBe(false); + expect(await sw.changes()).toBe(0); + } finally { + await sw.close(); + } + }); + test('a pen slid off and released outside without a flip leaves the switch alone, as a mouse does (Switch FR15)', async ({ browser, }) => { diff --git a/packages/core/src/Switch/useSwitchTouch.ts b/packages/core/src/Switch/useSwitchTouch.ts index 42e92420c3294..ed284e3f2ceb3 100644 --- a/packages/core/src/Switch/useSwitchTouch.ts +++ b/packages/core/src/Switch/useSwitchTouch.ts @@ -615,10 +615,14 @@ export class SwitchTouchController { this.endPressListeners(BROWSER_CLICK_WAIT_MS); return; } - if (this.drag?.flip.didFlip !== true && touch.pointerType !== 'touch') { - // A mouse or a pen slid off and released outside, with no flip: - // the switch is left alone, as the native checkbox is. Only a - // finger's press slid off toggles. + if ( + touch.pointerType !== 'touch' && + (event === 'touchUpOutside' || this.drag?.flip.didFlip !== true) + ) { + // A mouse or a pen released outside the tracking bounds, flipped or + // not, leaves the switch alone, as the native checkbox does. So the + // press can be taken back (WCAG 2.5.2). Only a finger's press slid + // off toggles. this.released = true; this.abandon(time); this.endPressListeners(0);