Skip to content

feat(Switch): the touch presentation, for every pointer - #9636

Merged
cixzhang merged 3 commits into
meta:mainfrom
vjeux:switch/touch-and-pointers
Nov 3, 2026
Merged

cixzhang merged 3 commits into
meta:mainfrom
vjeux:switch/touch-and-pointers

Conversation

@vjeux

@vjeux vjeux commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Batch: C-controls · Switch and SegmentedControl under a finger.

User need

On a phone, Switch acts like a desktop checkbox: a finger cannot drag its thumb, and it freezes while scripts run.

Problem and solution fit

  • Under a finger: a press lifts the thumb; a horizontal drag moves it with a rubber band and flips it at UISwitch's distance; the lift commits. A vertical drag scrolls.
  • Activation: a tap is the browser's click; a hold, a short drag or a press slid off toggles at the lift.
  • Motion runs on the compositor, through busy pages.
  • Mouse and pen drag by the same rule, with the desktop look. Slid off, they leave the switch alone.
  • Hit area: under a coarse pointer it grows toward 44 × 44 px, stopping halfway to a neighbour.

Current specification

  • component:Switch FR6; FR9 and FR10, changed here; FR13–FR15, added here.
  • spec:AST-094 FR11, changed here; FR4, FR6, FR13, FR18, FR24, FR43 and DEC-5; spec:AST-020 FR9 and DEC-5.
  • Owner-gated records change: Switch.spec.md, AST-094 spec.md and its generated motion-roles.md. They need an engineering owner's /approve-spec <head sha>.

Public delta and caller burden

No prop, type or theme change, and no new element: under a coarse pointer the track paints its press and fade on its pseudo-elements.

Design and alternatives

Chosen: passive press tracking, compositor animations, the gesture host's click guard and menu cancel. Rejected: CSS only (no drag); per-frame restyling (stutters); claiming the touch at the press (blocks scrolls).

Size

The Switch import grows from 44.8 to 80.8 KB gzip; four fifths is core's shared gesture host and animator, loaded once per page. The cold first change takes 14–16 ms, or 54–62 ms at 4× CPU throttling; later ones take 1–2 ms. Authored lines: 6,630 added and 230 removed, under the ~8,000 batch cap; 4,330 are tests.

Supersedes #8123, #8124 and #8681, with co-author credit.

Evidence

iOS 26.2 simulator and Android 15 emulator: main, this change, then iOS's switch; the red ring is the finger. P11 and M1–M3 use a mouse, M4 a pen. Recorded on the superseded heads.

P1
P2
P3
P4
P7
P8
P9
P10
P11
P12
P13
P14
P15
P16
P17
P18
P19
A1
A2
A3
A8
A9
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

Switch.spec.md: R1, R2, R4, R5 and R6: none. R3: FR13 and FR14 cite the owner's direction. AST-094 spec.md: R1–R6: none (FR11's clause is the owners' wording).

Testing

In Storybook's Switch › Default on a phone: tap, drag the thumb across, and slide off and lift. Each toggles once. In In scrolling list, a vertical swipe scrolls and toggles nothing.

No late mousedown, mouseup or trusted click follows a lift the switch commits itself, in Chromium or WebKit (a local probe, not part of the diff), so no gap is recorded.

Local gates at e6c1eda (the head adds the PR1 and changeset text and a rebase that regenerates the motion-roles table) Result
Build, typecheck, strict lint, i18n runtime, knowledge, motion roles; private-attribute and truthy-JSX baselines Pass; strict lint 0 errors
Provenance and barrel baselines, API report --check Differ only in entries from main
Unit tests, ui and node projects ui: 18,490 pass. node: 16,045 pass
Chromium on the lane's 2 pinned cores, 1 worker, frame proofs ×5 40/40
Chromium on the lane's 2 pinned cores, 4 workers, ×10 530/560; every red is a frame proof (next row)
WebKit --cpus=4, CI, 1 worker, ×10 460 pass, 0 red; the cases that skip are Chromium-only (screencast frame proofs and the engine pen)
WebKit --cpus=3, 1 and 16 workers (report only) 46 pass at each, 0 red
Classified load reds: frame proofs at 4 workers (report only) P10 toggle 9, colour 7, press 6; P14 mid-travel 5, late lift 2; late first frame 1. With #9637 on main the first travel no longer replays. Cause: the animator's beginGroup treats a call inside a curve's settle tail as continuing motion, so the new animation starts at the call and steps when a starved compositor commits late. The settle-tail cases go to a follow-up animator change (#9733); the mid-travel catch is an open spec:AST-094 FR6 question for the spec owners

@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 3, 2026 1:39am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 2, 2026
@astracat-bot

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 1:54 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 2, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · e3a07de

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Switch · Storybook 🔴 86.6 kB (+39.5 kB) · 10.5 kB CSS (+855 B) 1,777 (+1,199) ✓ · ✓ · ✓
richtext/RichTextEditor 277.6 kB (+54 B) · 2.6 kB CSS (0) — — · — · —
NumberInput 55.0 kB (+48 B) · 11.2 kB CSS (0) — — · — · —
103 more components
Component Size LoC Visual · A11y · RTL
Pagination 85.6 kB (+48 B) · 16.0 kB CSS (0) — — · — · —
SideNav 65.7 kB (+48 B) · 10.9 kB CSS (0) — — · — · —
Stepper 46.9 kB (+48 B) · 9.9 kB CSS (0) — — · — · —
DateTimeInput 114.4 kB (+47 B) · 15.8 kB CSS (0) — — · — · —
Typeahead 62.5 kB (+47 B) · 11.8 kB CSS (0) — — · — · —
richtext/RichTextView 258.2 kB (+46 B) · 2.3 kB CSS (0) — — · — · —
AvatarGroupOverflow 38.9 kB (+45 B) · 6.5 kB CSS (0) — — · — · —
lab/Comments 231.9 kB (+45 B) · 2.5 kB CSS (0) — — · — · —
Toast 59.4 kB (+44 B) · 10.1 kB CSS (0) — — · — · —
ChatDictationButton 46.0 kB (+43 B) · 9.8 kB CSS (0) — — · — · —
RadioListItem 51.3 kB (+43 B) · 10.1 kB CSS (0) — — · — · —
lab/TransferList 74.3 kB (+43 B) · 1.7 kB CSS (0) — — · — · —
TextInput 52.8 kB (+41 B) · 10.9 kB CSS (0) — — · — · —
TreeList 37.9 kB (+41 B) · 5.0 kB CSS (0) — — · — · —
ChatComposer 60.8 kB (+40 B) · 11.2 kB CSS (0) — — · — · —
ContextMenu 75.0 kB (+40 B) · 15.3 kB CSS (0) — — · — · —
SelectorOption 16.5 kB (+40 B) · 5.4 kB CSS (0) — — · — · —
SideNavItem 54.5 kB (+40 B) · 10.4 kB CSS (0) — — · — · —
ToggleButton 43.2 kB (+40 B) · 9.6 kB CSS (0) — — · — · —
TopNavMegaMenu 54.2 kB (+40 B) · 11.2 kB CSS (0) — — · — · —
richtext/RichTextEditorToolbar 221.4 kB (+40 B) · 454 B CSS (0) — — · — · —
ChatComposerTokenElement 55.9 kB (+39 B) · 10.4 kB CSS (0) — — · — · —
SelectableCard 14.6 kB (+39 B) · 7.3 kB CSS (0) — — · — · —
lab/CommentThread 226.5 kB (+39 B) · 2.4 kB CSS (0) — — · — · —
Item 13.2 kB (+38 B) · 4.9 kB CSS (0) — — · — · —
ListItem 52.7 kB (+38 B) · 11.0 kB CSS (0) — — · — · —
PowerSearch 183.0 kB (+38 B) · 20.6 kB CSS (0) — — · — · —
TableSelectionToolbar 52.2 kB (+38 B) · 11.5 kB CSS (0) — — · — · —
TopNavMegaMenuItem 10.7 kB (+38 B) · 4.2 kB CSS (0) — — · — · —
TopNavMenu 58.1 kB (+38 B) · 10.5 kB CSS (0) — — · — · —
lab/ChatEmojiPicker 64.9 kB (+38 B) · 773 B CSS (0) — — · — · —
lab/Schedule 61.3 kB (+38 B) · 3.9 kB CSS (0) — — · — · —
ChatComposerInput 55.9 kB (+37 B) · 10.4 kB CSS (0) — — · — · —
ClickableCard 14.5 kB (+37 B) · 7.1 kB CSS (0) — — · — · —
Popover 49.8 kB (+37 B) · 12.4 kB CSS (0) — — · — · —
Step 27.8 kB (+37 B) · 5.6 kB CSS (0) — — · — · —
Tab 11.2 kB (+37 B) · 4.2 kB CSS (0) — — · — · —
TabMenu 55.2 kB (+37 B) · 10.4 kB CSS (0) — — · — · —
Token 25.1 kB (+37 B) · 4.8 kB CSS (0) — — · — · —
Button 42.3 kB (+36 B) · 9.5 kB CSS (0) — — · — · —
ChatLayoutScrollButton 45.8 kB (+36 B) · 9.9 kB CSS (0) — — · — · —
CommandPaletteItem 10.0 kB (+36 B) · 3.6 kB CSS (0) — — · — · —
Lightbox 50.4 kB (+36 B) · 10.1 kB CSS (0) — — · — · —
lab/MobilePowerSearch 168.0 kB (+36 B) · 1.2 kB CSS (0) — — · — · —
lab/MobileTokenizer 100.7 kB (+36 B) · 880 B CSS (0) — — · — · —
BaseTypeahead 54.9 kB (+35 B) · 10.2 kB CSS (0) — — · — · —
ChatSendButton 45.1 kB (+35 B) · 9.5 kB CSS (0) — — · — · —
MoreMenu 73.1 kB (+35 B) · 15.4 kB CSS (0) — — · — · —
SegmentedControlItem 10.2 kB (+35 B) · 4.0 kB CSS (0) — — · — · —
lab/DiffViewer 70.7 kB (+35 B) · 2.1 kB CSS (0) — — · — · —
lab/ListInput 81.5 kB (+35 B) · 1.6 kB CSS (0) — — · — · —
Banner 47.3 kB (+34 B) · 10.3 kB CSS (0) — — · — · —
DropdownMenuRadioItem 18.2 kB (+34 B) · 5.9 kB CSS (0) — — · — · —
IconButton 42.3 kB (+34 B) · 9.5 kB CSS (0) — — · — · —
Link 42.9 kB (+34 B) · 7.9 kB CSS (0) — — · — · —
TableCell 76.2 kB (+34 B) · 15.8 kB CSS (0) — — · — · —
Carousel 50.7 kB (+33 B) · 10.5 kB CSS (0) — — · — · —
DateRangeInput 69.5 kB (+33 B) · 11.7 kB CSS (0) — — · — · —
DialogHeader 48.4 kB (+33 B) · 10.2 kB CSS (0) — — · — · —
DrawerHeader 48.5 kB (+33 B) · 11.6 kB CSS (0) — — · — · —
DropdownMenuSubMenu 55.9 kB (+33 B) · 9.9 kB CSS (0) — — · — · —
FileInput 53.6 kB (+33 B) · 10.7 kB CSS (0) — — · — · —
MobileNav 50.2 kB (+33 B) · 10.4 kB CSS (0) — — · — · —
TopNav 51.8 kB (+33 B) · 10.7 kB CSS (0) — — · — · —
TopNavItem 10.6 kB (+33 B) · 4.0 kB CSS (0) — — · — · —
lab/EmojiPicker 70.2 kB (+33 B) · 722 B CSS (0) — — · — · —
CommandPalette 48.5 kB (+32 B) · 12.2 kB CSS (0) — — · — · —
DropdownMenu 72.8 kB (+32 B) · 15.4 kB CSS (0) — — · — · —
DropdownMenuCheckboxItem 18.2 kB (+32 B) · 5.9 kB CSS (0) — — · — · —
SideNavCollapseButton 46.1 kB (+32 B) · 9.8 kB CSS (0) — — · — · —
TableHeaderCell 75.8 kB (+32 B) · 15.5 kB CSS (0) — — · — · —
lab/TourStep 55.2 kB (+32 B) · 590 B CSS (0) — — · — · —
AlertDialog 56.1 kB (+31 B) · 12.7 kB CSS (0) — — · — · —
DropdownMenuItem 17.4 kB (+31 B) · 5.5 kB CSS (0) — — · — · —
TopNavHeading 58.0 kB (+31 B) · 10.4 kB CSS (0) — — · — · —
Thumbnail 49.5 kB (+30 B) · 11.3 kB CSS (0) — — · — · —
Tokenizer 69.9 kB (+30 B) · 17.1 kB CSS (0) — — · — · —
lab/CommentPin 47.1 kB (+30 B) · 1.2 kB CSS (0) — — · — · —
BreadcrumbItem 66.9 kB (+29 B) · 11.4 kB CSS (0) — — · — · —
ChatLayout 48.4 kB (+29 B) · 10.3 kB CSS (0) — — · — · —
CheckboxListItem 53.3 kB (+29 B) · 11.0 kB CSS (0) — — · — · —
CheckboxInput 48.7 kB (+28 B) · 9.7 kB CSS (0) — — · — · —
DateInput 102.5 kB (+27 B) · 15.3 kB CSS (0) — — · — · —
lab/CommentComposer 102.1 kB (+27 B) · 1.9 kB CSS (0) — — · — · —
CodeBlock 59.1 kB (+26 B) · 11.1 kB CSS (0) — — · — · —
MultiSelector 72.5 kB (+26 B) · 15.2 kB CSS (0) — — · — · —
Slider 88.4 kB (+26 B) · 9.4 kB CSS (0) — — · — · —
AppShell 68.5 kB (+25 B) · 12.2 kB CSS (0) — — · — · —
SideNavHeading 58.6 kB (+25 B) · 10.6 kB CSS (0) — — · — · —
TimeInput 88.6 kB (+25 B) · 14.3 kB CSS (0) — — · — · —
lab/ChatReactionBar 65.4 kB (+25 B) · 1.1 kB CSS (0) — — · — · —
Selector 76.7 kB (+24 B) · 15.7 kB CSS (0) — — · — · —
LayerProvider 62.7 kB (+23 B) · 10.5 kB CSS (0) — — · — · —
Markdown 145.0 kB (+22 B) · 18.3 kB CSS (0) — — · — · —
MobileNavToggle 45.7 kB (+22 B) · 9.7 kB CSS (0) — — · — · —
Table 84.3 kB (+22 B) · 16.0 kB CSS (0) — — · — · —
lab/TransferListSelector 80.6 kB (+22 B) · 1.9 kB CSS (0) — — · — · —
Calendar 54.2 kB (+21 B) · 10.3 kB CSS (0) — — · — · —
ComplexSelector 55.4 kB (+21 B) · 11.1 kB CSS (0) — — · — · —
Outline 24.2 kB (+20 B) · 4.2 kB CSS (0) — — · — · —
Collapsible 22.5 kB (+19 B) · 4.5 kB CSS (0) — — · — · —
ContextMenuItem 52.6 kB (+14 B) · 9.9 kB CSS (0) — — · — · —
Timestamp 21.3 kB (+1 B) · 9.9 kB CSS (0) — — · — · —

Package size: @astryxdesign/core 5.99 MB packed (+42.7 kB); no new dependencies.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec ea58f3c

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@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.

Changes requested: the Switch touch presentation and its record changes are consistent with the touch-presentation spec, but two fixes are needed before approval.

  • The switch adds its own click guard and touchend cancel (useSwitchTouch.ts, CLICK_GUARD_MS in switchTouch.ts) beside the shared guard in utils/clickGuard.ts that the gesture host already arms; route the switch's release through the shared guard, extending the host's consumer interface if needed, and remove the copy.
  • .changeset/switch-touch-presentation.md says "Mouse, keyboard and assistive technology behave as before", but this pull request changes mouse drags (see switch-every-pointer.md), so drop "Mouse," from that sentence.

Three small comment fixes are noted inline.

[Automated review]

Comment on lines +339 to +361
const onTouchEnd = (event: TouchEvent) => {
if (this.cancelsTouchEnd && event.cancelable) {
event.preventDefault();
}
this.cancelsTouchEnd = false;
};
// Where a `touchend` cannot be cancelled, the browser's trusted click is
// swallowed instead. Keyboard and assistive-technology clicks (`detail`
// 0) and the switch's own (untrusted) click always pass.
const onClick = (event: MouseEvent) => {
if (event.isTrusted && this.browserClickTimer != null) {
// The lift's own click: it activates the input natively.
clearTimeout(this.browserClickTimer);
this.browserClickTimer = null;
this.scheduleReconcile();
return;
}
if (event.isTrusted && event.detail > 0 && now() < this.clickGuardUntil) {
this.clickGuardUntil = 0;
event.preventDefault();
event.stopPropagation();
}
};

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 touchend cancel and click swallower duplicate what utils/clickGuard.ts and the gesture host already own (the host cancels the touchend of a lift whose click must not happen and arms the shared guard). The copy uses a 1000 ms window instead of 400, disarms only on this switch's next press, and skips the shared exemptions. Could the press consumer ask the host to cancel its lift's click, with the switch's own input.click() inside withoutClickGuard, so CLICK_GUARD_MS and these listeners can go?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c7108c2, per the spec owners' ruling (architecture:react-component-runtime INV13: one stray-click swallower). The 1000 ms guard, CLICK_GUARD_MS and the touchend cancel are gone. When the switch decides a lift itself, or clicks its input after the browser's click did not come, it arms the existing utils/clickGuard.ts guard as it is and clicks inside withoutClickGuard. No Core change: a press consumer asking the host to cancel its lift stays with #9623.

Comment thread .changeset/switch-touch-presentation.md Outdated
'@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. Mouse, 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 is the first component built on the shared press tracking and touch negotiation.

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.

"Mouse, keyboard and assistive technology behave as before" conflicts with switch-every-pointer.md: a mouse drag past the flip distance now flips the switch, and a drag past the flip and back no longer toggles. Suggest: "Keyboard and assistive technology behave as before."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c7108c2: "Keyboard and assistive technology behave as before."

Comment thread packages/core/src/Switch/Switch.tsx Outdated
Comment on lines +662 to +674
// ---------------------------------------------------------------------
// Under a finger the track keeps its own fill, the theme's. A layer over it
// fades the outgoing fill out on the compositor. The thumb is a capsule of
// constant size, in a box that useSwitchTouch moves and lifts on the
// compositor, as UISwitch's (iOS 26.2). The thumb is the circle a mouse
// sees, at each state's size, resting at its inset from its own end. Where
// a touch value shares a property with the mouse, it is a nested condition
// of that property above. The parts below exist only under a finger.
// ---------------------------------------------------------------------
// The fade layer shows the fill the track is leaving, or mid-drag the one
// it heads to. Its colour is the theme's, read from the track
// (switchFill.ts) and set inline. The animator owns its opacity, so it has
// no transition of its own.

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.

Non-blocking: this block calls the thumb "a capsule of constant size" and then "the circle a mouse sees, at each state's size", and "the parts below" are not below (labelWrapper follows). The comments on the track and thumb styles already give the reasons; suggest deleting this block.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c7108c2: deleted.

Comment thread packages/core/src/Switch/Switch.tsx Outdated
/**
* The track box of each size, px (width × height), for the touch
* presentation's thumb geometry.
* SYNC: wrapperSizeStyles, inputSizeStyles and trackSizeStyles below.

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.

Non-blocking: inputSizeStyles no longer exists. Suggest SYNC: wrapperSizeStyles and trackSizeStyles below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c7108c2.

Comment thread packages/core/src/Switch/switchFill.ts Outdated
readonly attributes: Readonly<Record<string, string>>;
}

/** The track state of rendered props (`className` and `data-*` props). */

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.

Non-blocking: this JSDoc describes trackStateOf but sits above sameTrackState's own JSDoc. Suggest moving it to trackStateOf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c7108c2: moved onto trackStateOf.

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 2, 2026
@cixzhang
cixzhang marked this pull request as draft November 2, 2026 21:59
@cixzhang
cixzhang marked this pull request as ready for review November 2, 2026 22:00
@vjeux
vjeux force-pushed the switch/touch-and-pointers branch from ea58f3c to c7108c2 Compare November 2, 2026 23:42
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec c7108c2

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 2, 2026

@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.

Changes requested. Thanks for routing the switch's clicks through the shared click guard and fixing the changeset; three fixes remain, plus the size note that depends on the first.

  • Switch FR15 says the switch's own click for a finger hold and a flipping drag departs from spec:AST-094 FR11 ("activation stays the browser's click"), and this pull request leaves FR11 unchanged, so either amend FR11 here with an engineering owner's approval or drop the own click.
  • useSwitchTouch.ts adds its own contextmenu listener to cancel the menu during a press, beside the gesture host's context-menu cancelling in GestureHost.ts, so let a press consumer ask the host to cancel the menu and remove the switch's listener.
  • touch-action: pan-y pinch-zoom in Switch.tsx also applies to disabled and loading switches, which attach no gesture, so a horizontal swipe that starts on one no longer scrolls a horizontal scroller; apply it only while the switch is interactive.
  • At 6,804 authored lines the change relies on the owner-directed batch exemption, which applies once the FR11 point is resolved.

Three small comment cleanups are noted inline; they do not block.

[Automated review]

| 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 a stated departure from FR11's activation by the browser's click. 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` |

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 row states the switch's own click for a hold and a flipping drag "is a stated departure from FR11's activation by the browser's click", but spec:AST-094 FR11 itself is not amended, so the two current records disagree. Could FR11 gain this Switch exception in this pull request (with an engineering owner's approval), or the own click go?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda, with the spec owners' text: AST-094 FR11 now reads "…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)". FR15's last sentence points at that exception.

}
};
// A held switch opens no menu (UISwitch shows none).
const onContextMenu = (event: Event) => {

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.

GestureHost already owns cancelling the browser's context menu during a held gesture (onContextMenu, installed by syncClaimingListeners, which counts this press consumer as a claimer but cancels only claimed touches). Could a press consumer ask the host to cancel the menu while its press is held, so this listener can go?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda: a press consumer can now hold the menu closed through an opt-in holdsContextMenu (touch consumer and press tracker), which the host's existing contextmenu cancel reads. No other consumer changes. Switch uses it, and its own listener is gone.

// Horizontal touch moves are the switch's drag and vertical ones the
// page's scroll, under any primary pointer. A touchscreen laptop's finger
// counts too. It is set in CSS so it holds before hydration.
touchAction: 'pan-y pinch-zoom',

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 applies to disabled and loading switches too, which attach no gesture, so a horizontal swipe that starts on one stops scrolling a horizontal container. Could the pan-y style apply only while the switch is interactive (still in CSS, keyed on isDisabled and busy)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda: pan-y pinch-zoom is its own style, applied only while the switch is neither disabled nor loading.

* The browser sends no click for a finger's hold, so the switch clicks its
* input once the click window passes. A mouse or a pen gets a click when
* it is released on the input. A drag that flipped the switch gets no
* click, so the switch cancels its `touchend` and clicks its input. A

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.

Non-blocking: the touchend cancel is gone. Suggest "so the switch clicks its input, and the shared click guard swallows the browser's late click."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda.

* tracking, flip rule and release decide the value, with no animator, and
* the thumb moves when the value does. Only its press listens at rest. The
* click guard and the context-menu block come with a press.
*/

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.

Non-blocking: the switch no longer has a click guard of its own. Suggest "Its click and context-menu listeners come with a press."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda: "Its click listener comes with a press." (the context menu is now the host's).

this.detachers = this.attachPassive(host, elements);
return;
}
// The switch's springs play as its motion roles.

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.

Non-blocking: this restates registerSwitchTable(); suggest deleting it (and the similar comment above const lift = switchControl.lift).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda: both deleted.

Comment thread packages/core/src/Switch/Switch.tsx Outdated
disabledMessageTooltip.interactionRef(el);
}}
{...(isDisabled ? undefined : pressable)}
// The thumb's drag keeps the row's press painted.

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.

Non-blocking: this restates pressHoldsDragProps; suggest deleting it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e6c1eda: deleted.

@vjeux
vjeux force-pushed the switch/touch-and-pointers branch from c7108c2 to e6c1eda Compare November 3, 2026 00:43
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec e6c1eda

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@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.

Changes requested. Thanks for amending spec:AST-094 FR11, moving the context-menu cancel into the gesture host, and limiting touch-action to interactive switches; those three points are resolved, and an engineering owner has approved the record changes at this head. Two small fixes remain:

  • docs/specs/components/Switch/Switch.spec.md PR1 still says an enabled switch adds "a touchend, a click and a contextmenu listener", but it now adds only the click listener (and Switch.test.tsx asserts no touch or context-menu listener), so trim that clause to the click listener; the record change then needs a fresh /approve-spec for the new head.
  • .changeset/switch-touch-presentation.md ends "Switch is the first component built on the shared press tracking and touch negotiation", but Slider's touch controller already creates a press tracker and attaches it to the gesture host, so drop that sentence or say Switch joins Slider.

The size is within the owner-directed batch allowance and does not block.

[Automated review]

Comment on lines +216 to +218
switch also adds one consumer to the shared gesture host, whose listeners
are installed once per page, and a `touchend`, a `click` and a `contextmenu`
listener to the element around its track: at once under a coarse primary

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.

The switch now adds only its click listener here (useSwitchTouch.ts listen): the touchend cancel and the contextmenu listener are gone, and Switch.test.tsx asserts no touch or context-menu listener in any state. Suggest: "…and a click listener to the element around its track…".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 27c6a6e: "…and a click listener to the element around its track…".

Comment thread .changeset/switch-touch-presentation.md Outdated
'@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 is the first component built on the shared press tracking and touch negotiation.

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.

"Switch is the first component built on the shared press tracking and touch negotiation" is not accurate: Slider/sliderTouchController.ts already creates a press tracker (createPressTracker) and attaches it to the gesture host (host.attachConsumer). Suggest dropping the sentence, or "Switch joins Slider on the shared press tracking and touch negotiation."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 27c6a6e: "Switch joins Slider on the shared press tracking and touch negotiation."

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 27c6a6e

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@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.

Recommend approval. An engineering owner has approved the Switch record changes and the spec:AST-094 FR11 amendment at this head, and both points from the last round are fixed: PR1 now names only the click listener, and the changeset says Switch joins Slider on the shared press tracking.

Two optional comment cleanups are noted inline; they do not block.

[Automated review]

Comment on lines +240 to +243
/**
* The press and the pan both report every touch event; the first report
* takes the step and this skips the second (cleared after the event).
*/

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.

Non-blocking: no pan recognizer is attached here; only the press tracker calls move(). Could this comment name the case the guard still covers, or could the guard go if none remains?

Comment on lines +229 to +231
* a coarse primary pointer the track's and thumb's layers fade the press.
* So the strength holds through the release, not animated on the main
* thread. With any other pointer the row keeps `pressedAlpha`'s release.

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.

Non-blocking: under Reduce Motion with a fine pointer, line 238 sets animation-name: none, so the row does not keep pressedAlpha's release there (the shared style keeps a shortened one). Suggest: "Under a coarse pointer, or with Reduce Motion, the strength holds through the release."

vjeux and others added 3 commits November 2, 2026 17:28
The C-controls Switch batch, on current main, as one commit. It supersedes
the three Switch drafts.

- UISwitch's measured values join the UIKit constants table
  (motion/uikit/switch.ts), with the flip rule as a pure model
  (Switch/switchTouch.ts).
- Under a finger, Switch moves like UISwitch: a press lifts the thumb, a
  horizontal drag moves it with a rubber band and flips at the measured
  distance, the lift commits, and a vertical drag scrolls. Motion runs on
  the compositor, through a busy main thread.
- A mouse and a pen drag by the same flip rule, with the desktop look
  (FR15). A press slid off and released outside leaves the switch alone.
- The switch's own clicks arm Core's shared click guard, which swallows the
  browser's late click, so a lift toggles once. It keeps no click guard of
  its own (architecture:react-component-runtime INV13).
- Under a coarse pointer the hit area grows toward 44 x 44 px, stopping
  halfway to a neighbour.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…tch's menu closed, and touch-action follows interactivity

From review, with the spec owners' text and rulings:

- spec:AST-094 FR11 states the Switch exception: 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). Switch FR15's last
  sentence now points at that exception.
- A press consumer can hold the browser's context menu closed while its
  press is down: an opt-in `holdsContextMenu` on the touch consumer and on
  the press tracker, which the gesture host's existing `contextmenu` cancel
  reads. No other consumer changes. Switch uses it and drops its own
  `contextmenu` listener.
- `touch-action: pan-y pinch-zoom` applies only while the switch takes a
  drag; a disabled or loading switch leaves every move to the page.
- Four comments that restated code or named the removed guard are fixed.

Red first: the host case (an opted-in consumer's held press cancels the
menu) and the coarse-pointer listener case (no context-menu listener of the
switch's own) fail on the old code; the disabled and loading touch-action
case fails on the old build. A held switch still opens no menu, in Chromium
and WebKit.
…ngeset says Switch joins Slider

From review: an enabled switch now adds only its `click` listener to the
element around its track; the `touchend` cancel and the `contextmenu`
listener are gone. Slider already builds on the shared press tracking and
touch negotiation, so Switch joins it rather than coming first.
@vjeux
vjeux force-pushed the switch/touch-and-pointers branch from 27c6a6e to e3a07de Compare November 3, 2026 01:30
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec e3a07de

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@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.

Recommend approval. An engineering owner approved the Switch record changes and the spec:AST-094 FR11 amendment at this head, and the code, tests and CI agree with them: under a finger a hold, a drag past the flip distance and a press slid off now toggle the switch, a mouse or pen drag flips it by the same rule, and the coarse hit area reaches 44 × 44 px.

Two optional comment fixes, noted inline:

  • useSwitchTouch.ts:240-243 mentions a pan recognizer that is not attached.
  • Switch.tsx:229-231 says a fine pointer keeps the shared press release, but Reduce Motion with a fine pointer turns it off.

[Automated review]

/** The finger's last travel along x, for a lift that reports none. */
private lastTravel = 0;
/**
* The press and the pan both report every touch event; the first report

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.

Optional (INV29): no pan recognizer is attached here; only the press tracker calls move(). State the case this guard still covers, or drop the guard if none remains.

* Composed after `interactionOverlayStyles.pressedAlpha` on the row. Under
* a coarse primary pointer the track's and thumb's layers fade the press.
* So the strength holds through the release, not animated on the main
* thread. With any other pointer the row keeps `pressedAlpha`'s release.

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.

Optional (INV29): line 238 sets animation-name: none under Reduce Motion with a fine pointer, so the row does not keep pressedAlpha's release there. For example: "Under a coarse pointer, or with Reduce Motion, the strength holds through the release."

This branch was successfully deployed

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

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants