Skip to content

feat(core): Switch's press and drag work for every pointer (FR43) - #8681

Closed
vjeux wants to merge 1962 commits into
meta:mainfrom
vjeux:uikit/c02-switch-every-pointer
Closed

vjeux wants to merge 1962 commits into
meta:mainfrom
vjeux:uikit/c02-switch-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8124 and #8665 (merged in, with the shared press controller from #8118 and the pen input driver from #8666). Review only this PR's change: vjeux/astryx@c02-fr43-base...uikit/c02-switch-every-pointer

Why

With a mouse or a pen, Switch ignores a drag: dragging the thumb across and letting go off the switch leaves it off (M1), and a drag past the middle and back still toggles through the browser's click (M2).

What led to it

spec:AST-094 FR43, current on main since #8646, makes gestures work for every pointer, not only a finger. #8124 gave Switch its drag under a finger only. component:Switch FR6 splits here: the tap, hold and flip rule for every pointer; the slide-off toggle and the moving thumb stay a finger's.

Options and tradeoffs

  1. Keep a mouse on the checkbox's click: nothing to build, but FR43 requires the drag.
  2. Give a mouse the touch look (the thumb lifts and follows the pointer): one code path, but FR13 keeps the desktop look under a fine pointer.
  3. One set of rules for every pointer, with the desktop look under a mouse: the flip rule and the release decide the value for a mouse, a pen and a finger alike; under a fine pointer the thumb just moves to the new value.

One behaviour picked a side: a mouse or pen slid off and released outside leaves the switch alone, as a checkbox does (M3), because spec:AST-094 FR11 allows the slide-off toggle only after a finger slides off.

Why this choice

Option 3 is what FR43 and FR13 ask for together. It adds no stylesheet bytes and no API, and at rest under a mouse the switch keeps one passive listener; the rest arrive only with a press (FR24).

How to check it

In a desktop browser with a mouse, open Storybook's Switch › Default:

  1. Press the thumb, drag right past the middle and let go off the switch: it turns on (M1).
  2. Drag past the middle and back to where you pressed, then let go: nothing changes (M2).
  3. Press, slide down off the switch and let go: nothing changes (M3).
  4. Click: it toggles once. Right-click: nothing happens.
  5. With a pen in Chromium, drag past the middle: it turns on (M4).
  6. On a touchscreen laptop, a finger flips it by the same rule, with the desktop look; a finger tap right after a mouse flip still toggles at once.

SwitchPointer.interaction.browser.spec.ts runs these with a real mouse in Chromium and WebKit and a real pen in Chromium (Playwright has no pen for WebKit). The one red check, "is absent from the root entry", is the old bundle test that #8671 replaces.

Evidence

Desktop Chromium and WebKit with no touch screen: #8124 on the left, this PR on the right; the red ring is the pointer.

M1 Chromium
M1 WebKit
M2 Chromium
M2 WebKit
M3 Chromium
M3 WebKit
M4 pen, Chromium

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

packages/core/src/Switch/Switch.spec.md:

  • R1 narration: none; the new FR6, Intent, compatibility line and decision-log sentence are present tense.
  • R2 research in the record: none.
  • R3 evidence naming a change: none; FR6 cites spec:AST-094 FR11, FR13 and FR43 and spec:AST-020 FR9.
  • R4 rejected alternatives over one line or outside a decision: none.
  • R5 pull request or issue references: none.
  • R6 ruled open questions still listing options: none.

cixzhang and others added 30 commits October 26, 2026 19:43
…, and under inputMode 'pointer-events' a claimed finger's touchmoves are cancelled

- spec:AST-094 FR20 and DEC-8: a native scroll above an unclaimed finger
  counts as the browser taking it only once that finger has travelled the
  take distance (10 px) from where it landed. A scroll under a still finger
  (scroll anchoring, a carousel advancing, a transcript pinned to its end)
  is the page's own, and the touch and its click stay. (The same rule
  meta#8309 carried, moved down to the host that introduces it.)
- spec:AST-094 FR19 and FR21 under inputMode 'pointer-events': a finger
  comes from its pointer events, so its touches carried no id of ours and a
  claim never cancelled them. The blocking touchmove listener now matches a
  touch to the nearest live pointer-fed finger on its target, so a claimed
  pan's moves are cancelled and the browser cannot scroll under it.

Tests (failing first): 9 px and (7, 7) are not taken, 10 px and (8, 8) are,
a still finger is not; the FR30 own-scroll case moves its finger first; under
'pointer-events' a pan's touchmoves pass before its claim and are cancelled
after it.
…n: pan-y only on a strip within its band, left as is on a wider view
…r-events claims cancel) as a dependency

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
…8072 9800eb5, the FR18-FR22 browser states) as a dependency
…eta#8061 9adbe2d, meta#8072 9800eb5) into the gesture watcher

The README's touch-action table takes meta#8061's edge-pan rows beside this
PR's watcher row.
…es its recognizers, and the group keeps answering afterwards

Under a fine pointer the host is not installed, so usesTouchEvents keeps
its initial true, and a group press's own touch pointermove and pointerup
went down the touch-pairing path and returned: the finger's moves and lift
never reached the group, its contact stayed in the arena, and the group
answered no press after it, a mouse's included. The group press now feeds
its own events through the pointer path for every pointer type.

Test (failing first): under a fine pointer a finger tap on a group
recognizes, and a mouse tap afterwards does too.
… are all passive

spec:AST-094 FR20: a watcher registers only passive listeners, but a
watcher-only page still got the host's cancelable document touchend and
its contextmenu and window click listeners. The document touchend is now
passive until an enabled claimer (a recognizer that claims touches, or a
consumer) is attached, and the contextmenu and click listeners are on only
then, swapped as attachments change. The new test (a lone watcher leaves
no cancelable document or window listener; a claiming pan brings the
three) fails on the previous head.
…r watchers) into the host line

The host keeps this PR's isRtlElement and its fingers held before a
recognizer begins (a kept press, a scroll-edge pan), beside meta#8061's view
size and meta#8309's pointer-events finger lookup. meta#8589's claimer check
counts only participants active on this pointer (isActive,
isConsumerActive), and a group press's click listener now stays while a
claimer keeps it installed. The browser spec keeps the fine-pointer
describe beside the FR18-FR22 real-touch states.
…'s passive-only watcher listeners) into the arena fixes

The host-owner changeset keeps this PR's own listeners for the scroll
lock and finger-scroll watch, and adds meta#8336's scroll-edge hold.
…inter reaches its recognizers) as a dependency
… under a fine pointer) as a dependency
…tion group's finger under a fine pointer) into the gesture watcher
Main's meta#8521 is its head 25b468c squashed, which this pull request already
carries: every file it touches is byte-identical to 25b468c. In the two
files that conflicted (the motion README and animator.ts), the resolution is
this side's version, main's merged meta#8521 with this pull request's later work
on top. Nothing of main is dropped.
…s the diagonal pocket and lets keyboard clicks through

- The live watch read neighbours within 22 px only, but two small targets
  reach each other from up to 44 px apart: two 8 px dots 24 px apart kept
  18 px of growth toward each other. It reads within the minimum size now.
- A wide target and a tall one, diagonally, could each stay short of the
  other's half of the gap on one axis yet meet at full growth (32 x 32
  beside 40 x 24: 4 x 4 px). A neighbour now also counts when the two full
  areas meet.
- The one-shot click swallower lets a keyboard or assistive-technology
  activation (detail 0) through, and a cancelled touch (pointercancel)
  disarms it, so a touch the browser took for a scroll leaves nothing
  armed.
- A second watch of an element starts as visible as the first, so it hears
  its insets.

Each fix has a test that fails on the previous head.
… control's label included

The guard ended on its click, a cancelled touch, or the next press on the
control itself; a tap on the control's own label lands outside it, so a
touch that ended with neither a click nor a cancel could leave the label's
click to the input swallowed. While armed, the guard now also listens for
the next pointerdown anywhere in the document. The test's label-tap case
fails on the previous head.
793c8db) into the Switch touch presentation

Brings meta#8076's current head, which removes Lab's per-module core aliases
and the docsite and Sandbox resolver (react-component-runtime INV13), as
CodeBunny asks on this PR. GestureHost.test keeps both new imports.
…ta#8521) into meta#8270

# Conflicts:
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
…g) into meta#8293

# Conflicts:
#	packages/core/src/motion/animator.ts
The note's reword joined its next sentence onto one long line; it now
wraps like the rest of the file. No code change.
vjeux added 19 commits October 27, 2026 14:18
…, so a frame read late is not a jump

The no-jump bound stays 4 px per 60 Hz frame, but each sampled step is
divided by the frames its timestamps span (at least one), so a late or
missed frame's two vsyncs of compositor travel no longer read as a jump,
while a jump inside one frame still fails. P14 and its frames-read-late
case pass 5 of 5 in Chromium and WebKit; per frame the thumb peaks near
2-3.4 px in Chromium and 2 px in WebKit.
…0718, meta#8124's P14 check) into uikit/c02-switch-every-pointer (main's meta#8613 Button.spec.md conflict)
… in WebKit too

endAfterRelease reads the document timeline when the release is written.
WebKit starts an animation created after such a read at the time read, so
after 150 ms of script at the lift the release starts before the block
and its first frame shows it 150 ms in: the busy first frame skips into
the release. These fail on that code:

- ButtonPressRelease.interaction.browser.spec.ts (both engines): the
  first frame after the block shows the release at most two frames in;
- pressFeedback.test.ts: a finished animation on the surface is not
  waited for (the release is any of the surface's own CSS animations
  still running, with no timeline time to compare).
… reads the timeline at the write

endAfterRelease no longer reads the document timeline when the release is
written to tell the release's animation from an earlier one: WebKit
starts an animation created after such a read at the time read, which
skipped a busy first frame into the release. The clock now waits for
any of the surface's own CSS animations still running (on it or its
::before or ::after, finite and unfinished), and ends the release on the
clock when there is none, as before.
Two quick taps on a button, the second while the first one's release
still runs, leave data-astryx-press="on" on the button until the next
touch (Chromium and WebKit; on a Switch row too). These fail on that
code: pressFeedback.test.ts (a control tapped twice 70 ms apart has no
press once its releases end) and ButtonPressRelease.interaction
.browser.spec.ts (two touchscreen taps 70 ms apart, both engines: the
press goes).
When a touch lands while the last tap's release still runs, the press
controller finishes that release first. It was the only press live and
the new finger was not recorded yet, so finishing it took the tracking
listeners down just before the new touch's press was painted: its lift
went unheard and data-astryx-press="on" stayed until the next touch.
The listeners are armed again after that finish.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request is on hold for a spec decision: spec:AST-094 FR43 is on main, but the Switch's own every-pointer rule is the draft component:Switch FR4–FR6 that this pull request writes, including FR6's stated departure from AST-094 FR11's browser-click activation and a flip rule that does not say how it relates to FR36–FR37, and a bundled change also rewrites the current component:Button FR15. An engineering owner can approve this head's spec changes by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; an approval of an earlier head does not carry over. Whatever that decision, these still need changes:

  • Rebase onto the landed dependencies so the diff shows only this change's seven files; today it carries about 44,700 lines from at least eighteen other open pull requests.
  • Resolve the scripts/touch-support-bundle.test.mjs failure ("is absent from the root entry"), for example by landing #8671 first.
  • Reword FR6 to match the code: the drag returns only once the pointer comes back past where it pressed, and the no-click fallback is a finger's.

[Automated review]

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #9636, which merged this work.

@vjeux vjeux closed this Nov 3, 2026
@vjeux
vjeux deleted the uikit/c02-switch-every-pointer branch November 3, 2026 02:09
cixzhang added a commit that referenced this pull request Nov 3, 2026
Under spec:AST-094 FR43 the press controller paints a mouse's or a pen's
press too (data-astryx-press), not only a finger's. Tab painted its
pressed image only inside @media (pointer: coarse), so a mouse press
showed nothing but :active's colour, with no release fade.

- Its image arms paint the controller's press for every pointer.
- Its hover and pressed colours yield while the press is on, so a press
  never paints twice, and paint again over the release, so a hovering
  pointer's release lands on the tab's hover (as the shared overlay does).

Checkbox and Radio are #8696's; Switch is #8681's.

Test: Tab's compiled CSS paints both arms outside the coarse media.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
A mouse or a pen presses, drags and flips Switch by the same rule as a
finger, with the desktop look; a mouse or pen slid off and released
outside cancels (spec:AST-094 FR11 scopes the slide-off toggle to a
finger), and only a finger's lift without a browser click falls back to
the switch's own click. The thumb's touch-action applies to every pointer,
and the record's FR15 says so.

Absorbs meta#8681.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
A mouse or a pen presses, drags and flips Switch by the same rule as a
finger, with the desktop look; a mouse or pen slid off and released
outside cancels (spec:AST-094 FR11 scopes the slide-off toggle to a
finger), and only a finger's lift without a browser click falls back to
the switch's own click. The thumb's touch-action applies to every pointer,
and the record's FR15 says so.

Absorbs meta#8681.
@vjeux
vjeux restored the uikit/c02-switch-every-pointer branch November 6, 2026 10:45

This branch was successfully deployed

1 active deployment
Preview — 6d4f8d23 Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants