Skip to content

fix(Wheel): the touch wheel draws only the rows around its band - #8289

Closed
vjeux wants to merge 548 commits into
meta:mainfrom
vjeux:uikit/c21-wheel-window
Closed

vjeux wants to merge 548 commits into
meta:mainfrom
vjeux:uikit/c21-wheel-window

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8162 (and its predecessors); review the last commit.

User impact

Under a finger, opening a DateInput or DateTimeInput sheet, or its month and year wheels, freezes the page longer for each year in the range; the sheet's motion skips. #8209 surfaced it.

Evidence

iOS 26.2 simulator, BottomSheetWideRange (200 years), worst frame gap after each tap, 2 runs:

Open the sheet Open month and year Back to the calendar
#8162 242–753 ms 278–754 ms 169–280 ms
This PR 47–102 ms 54–65 ms 51–59 ms

On #8209's story: 175–238 ms with 101 years, 58–59 ms with one. Cutting the looping copies changed nothing; the cost is rows.

Across the window's edge, each row is drawn before it shows (a blue dot marks the finger, drawn for the recording):

Fling Slow drag
Fling Drag

Android 15 emulator, Chromium 158, 200 years:

Fling to the end Slow drag
Fling Drag

Problem and solution fit

  • Why: the touch wheel draws every option twice, each cylinder row its own 3D layer.
  • Harm: showing a wheel pays per row; date ranges span decades.
  • Why it matters: the cost lands as a sheet animates.

UIPickerView lays out only the cells it shows. PickerWheel now draws the cylinder's rows within 90° of the band and the band copy's three rows, each with 2 more on each side: at most 15 and 7, for any list. Motions mount their rows before playing (a reduced-motion cross-fade, at its midpoint); drags move the window near its edge; it settles back at rest. The band copy's rows sit at their positions, so looping needs no strip copies. The listbox keeps every option, stacked transparent over the band, for assistive technology. Today's column, under a fine pointer, is unchanged.

Also: WheelGroup keeps its element in a ref and flags its mount, so each column places its vanishing point once the group mounts.

Expected behavior and authority

spec:AST-094 (#8378) FR5; UIPickerView's cell reuse.

Smallest restoration

The window lives in PickerWheel.tsx, row attachment in wheelMotion.ts, the strip's position in wheelDrum.ts; no API change. Stories with 200 and 2 years back a browser proof. #8162's busy proof now marks the drawn row.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • PickerWheel.test.tsx: the window (15 cylinder and 7 band rows of 200, every option listed), the ends, a fling drawing its path then settling, a drag moving the window, a looping window wrapping, keys past the window, the vanishing point once the group mounts, and the selection lock under a held finger (fails without preventsTextSelection). The window cases fail on test(Wheel): the picker wheel's browser proofs, storyboard and activation #8162.
  • WheelActivation.interaction.browser.spec.ts: under a finger, rows on the cylinder are 2 of 2 and at most 15 of 200 (200 on test(Wheel): the picker wheel's browser proofs, storyboard and activation #8162: red), and at 4× CPU throttle the 200-year opening stays within the 2-year one; under a mouse the year wheel is today's column with every year. On the stack top's build, Chromium 2 passed; WebKit 1 passed, 1 skipped (mouse).
  • On the stack top's build: Wheel.interaction.browser.spec.ts, Chromium 7 passed and WebKit 4 passed, 3 skipped; the rest and pinch-zoom specs, 11 passed.
  • pnpm exec vitest run packages/core/src/DateInput packages/core/src/TimeInput packages/core/src/DateTimeInput packages/core/src/motion packages/core/src/gestures packages/core/src/utils packages/core/src/BottomSheet: 95 files, 2320 tests.
  • pnpm -F @astryxdesign/core typecheck, ESLint and Prettier on touched files, pnpm check:changesets.
  • iOS 26.2 simulator: the rAF logs above, and the two clips.

Stack (merge in this order): … → #8156 → #8159 → #8161 → #8162 → this PR (the full order is in #8156).

@vercel

vercel Bot commented Oct 24, 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 Oct 26, 2026 3:01am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
⏳ Queued (for maintainers only) Oct 26, 2026, 2:52 AM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This pull request can't be approved yet: the row window and the UIKit wheel it builds on rest only on spec:AST-094, a draft record added in this stack, so an engineering owner has to approve or reject AST-094 (FR5 and FR9 in particular) as the picker wheel's governing spec, by commenting /approve-spec <full head SHA> on this pull request; as an engineering owner, the author can record that decision too. Three changes are needed whatever that decision is:

  • Land #8162 and its predecessors first (or rebase onto a base that has them), so this pull request holds only its own 1,052-line commit instead of the 30,375-line stack.
  • WheelGroup keeps its DOM element in React state (WheelGroup.tsx:88), which architecture:react-component-runtime INV1 rules out; keep the element in a ref and put only a mount flag in state.
  • The mouse-drag cases deleted from DateInputTouch.test.tsx point to Wheel.test.tsx, which has no mouse case; a primary-button drag that turns the wheel and a secondary button that does not need tests there.

The -webkit-touch-callout tweak belongs in its own change, proven by a computed-style check rather than the current source-text test.

[Automated review]

* ```
*/
export function WheelGroup({children}: WheelGroupProps) {
const [element, setElement] = useState<HTMLDivElement | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This keeps the group's DOM node in React state, which architecture:react-component-runtime INV1 rules out ("DOM nodes and whole DOM collections MUST NOT be stored in React state; store only the minimal rendering key or own the node through a ref"). Keeping groupRef and putting only a mount key in state would conform: for example a callback ref that sets groupRef.current and flips an isMounted flag that the Wheel's placement effect depends on.

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.

Fixed in 8f8a358: the element stays in a ref; the group's context carries groupRef and an isMounted flag that a callback ref flips, and the Wheel's placement effect depends on it. New case "hands its element to the wheels without keeping it in React state" fails before the change; the vanishing-point-without-ResizeObserver case still passes.

});
});
});
// A finger or a mouse dragging a wheel goes through the gesture system: see

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The six mouse-drag cases removed here are replaced by this pointer to Wheel.test.tsx, but that file has no mouse, pointer or button case. architecture:component-test-sufficiency INV3 keeps existing regression evidence until equivalent evidence subsumes it; a Wheel.test.tsx case for a primary-button mouse drag that turns the wheel, and one for a secondary button that does not, would cover 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.

At this head the six mouse-drag cases are present (a mouse gets today's column), and PickerWheel.test.tsx has primary- and secondary-button mouse cases for the touch column.

});

it('neither selects text nor opens the callout under a held finger', () => {
const source = readFileSync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This reads Wheel.tsx as text, so it passes whatever the compiled column does; architecture:component-test-sufficiency INV6 does not count an assertion that repeats a source declaration. A browser check of the column's computed -webkit-touch-callout would prove the behavior, and the callout change itself fits better in its own pull request.

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.

Agreed, and the source check is gone at 8f8a358. The wheel's pan takes the gesture system's selection lock (preventsTextSelection, #8270, used from #8159) and the column's own user-select and callout styles are dropped. A jsdom case checks that the lock lands on the column under a held finger and lifts after (it fails without preventsTextSelection). On the iOS 26.2 simulator the column computes -webkit-user-select and -webkit-touch-callout none from touchstart to touchend (trace).

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

ContextMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 1.1KB 1.1KB 0B
Lines of Code 639 639 -
Complexity Very High Very High (84) -
DateInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 266B 266B 0B
Lines of Code 3525 5506 -
Complexity Very High Very High (515) -
DateTimeInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 286B 286B 0B
Lines of Code 3609 3614 -
Complexity Very High Very High (559) -
MoreMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 219B 219B 0B
Lines of Code 87 87 -
Complexity Low Low (4) -
MultiSelector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 273B 273B 0B
Lines of Code 2626 2626 -
Complexity Very High Very High (338) -
TimeInput (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 411B 411B 0B
Lines of Code 1236 1241 -
Complexity Very High Very High (191) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 51.3KB 51.9KB +651B 12.2KB 12.2KB 0B
core/AppShell 52.5KB 53.1KB +627B 11.4KB 11.4KB 0B
core/AvatarGroupOverflow 37.0KB 37.6KB +646B 6.2KB 6.2KB 0B
core/Banner 44.6KB 45.2KB +649B 9.5KB 9.5KB 0B
core/BaseTypeahead 50.7KB 51.3KB +643B 9.7KB 9.7KB 0B
core/BottomSheet 25.8KB 25.8KB 0B 7.5KB 7.5KB 0B
core/BottomSheetSwitcher 9.1KB 9.1KB 0B 3.2KB 3.2KB 0B
core/BreadcrumbItem 62.7KB 63.3KB +596B 10.8KB 10.8KB 0B
core/Button 40.1KB 40.8KB +649B 9.0KB 9.0KB 0B
core/Calendar 51.8KB 52.5KB +668B 9.8KB 9.8KB 0B
core/Carousel 45.8KB 46.4KB +622B 10.0KB 10.0KB 0B
core/ChatComposer 57.2KB 57.8KB +603B 10.5KB 10.5KB 0B
core/ChatComposerInput 52.3KB 53.0KB +618B 9.8KB 9.8KB 0B
core/ChatComposerTokenElement 52.3KB 53.0KB +618B 9.8KB 9.8KB 0B
core/ChatDictationButton 43.4KB 44.0KB +623B 9.2KB 9.2KB 0B
core/ChatLayout 45.8KB 46.4KB +606B 9.7KB 9.7KB 0B
core/ChatLayoutScrollButton 43.3KB 43.9KB +625B 9.3KB 9.3KB 0B
core/ChatSendButton 42.8KB 43.4KB +615B 9.0KB 9.0KB 0B
core/CheckboxInput 46.5KB 47.1KB +580B 9.2KB 9.2KB 0B
core/CheckboxListItem 50.6KB 51.2KB +641B 10.3KB 10.3KB 0B
core/ClickableCard 12.7KB 13.3KB +639B 6.8KB 6.8KB 0B
core/CodeBlock 55.5KB 56.1KB +626B 10.4KB 10.4KB 0B
core/Collapsible 11.6KB 12.2KB +652B 4.1KB 4.1KB 0B
core/CommandPalette 49.0KB 49.7KB +658B 11.6KB 11.6KB 0B
core/CommandPaletteItem 8.3KB 8.9KB +644B 3.3KB 3.3KB 0B
core/ComplexSelector 52.1KB 52.7KB +638B 10.3KB 10.3KB 0B
core/ContextMenu 73.9KB 74.8KB +922B 14.2KB 14.2KB 0B
core/ContextMenuItem 16.4KB 17.1KB +653B 5.0KB 5.0KB 0B
core/DateInput 89.6KB 117.3KB +27.7KB ⚠️ 14.3KB 14.6KB +341B
core/DateRangeInput 61.3KB 61.9KB +596B 10.9KB 10.9KB 0B
core/DateTimeInput 98.9KB 126.7KB +27.9KB ⚠️ 14.7KB 15.0KB +340B
core/DialogHeader 45.7KB 46.3KB +622B 9.5KB 9.5KB 0B
core/DrawerHeader 45.7KB 46.3KB +661B 10.9KB 10.9KB 0B
core/DropdownMenu 69.0KB 69.9KB +888B 14.4KB 14.4KB 0B
core/DropdownMenuCheckboxItem 16.0KB 16.6KB +659B 5.4KB 5.4KB 0B
core/DropdownMenuDivider 6.5KB 6.5KB 0B 3.4KB 3.4KB 0B
core/DropdownMenuGroup 5.9KB 5.9KB 0B 3.1KB 3.1KB 0B
core/DropdownMenuItem 15.3KB 15.9KB +679B 4.9KB 4.9KB 0B
core/DropdownMenuRadioGroup 5.9KB 5.9KB 0B 3.1KB 3.1KB 0B
core/DropdownMenuRadioItem 16.0KB 16.7KB +660B 5.4KB 5.4KB 0B
core/DropdownMenuSubMenu 54.5KB 55.0KB +558B 9.3KB 9.3KB 0B
core/FileInput 50.6KB 51.2KB +588B 10.1KB 10.1KB 0B
core/IconButton 40.2KB 40.8KB +632B 9.0KB 9.0KB 0B
core/Item 11.2KB 11.9KB +658B 4.5KB 4.5KB 0B
core/LayerProvider 54.4KB 55.0KB +628B 9.9KB 9.9KB 0B
core/Lightbox 46.4KB 47.0KB +621B 9.6KB 9.6KB 0B
core/Link 40.6KB 41.2KB +645B 7.4KB 7.4KB 0B
core/ListItem 50.1KB 50.7KB +643B 10.3KB 10.3KB 0B
core/Markdown 133.2KB 134.2KB +991B 16.9KB 16.9KB 0B
core/MobileNav 45.7KB 46.3KB +618B 9.8KB 9.8KB 0B
core/MobileNavToggle 43.2KB 43.8KB +628B 9.2KB 9.2KB 0B
core/MoreMenu 69.2KB 70.1KB +900B 14.4KB 14.4KB 0B
core/MultiSelector 71.9KB 72.8KB +969B 14.1KB 14.1KB 0B
core/NumberInput 52.5KB 53.0KB +577B 10.4KB 10.4KB 0B
core/Outline 28.5KB 29.1KB +588B 3.9KB 3.9KB 0B
core/Overlay 14.2KB 14.2KB 0B 3.7KB 3.7KB 0B
core/Pagination 83.6KB 84.6KB +975B 14.8KB 14.8KB 0B
core/Popover 44.1KB 44.7KB +614B 9.2KB 9.2KB 0B
core/PowerSearch 143.9KB 172.7KB +28.8KB ⚠️ 18.1KB 18.4KB +327B
core/RadioListItem 49.2KB 49.8KB +659B 9.4KB 9.4KB 0B
core/SegmentedControlItem 8.5KB 9.1KB +639B 3.7KB 3.7KB 0B
core/SelectableCard 12.6KB 13.3KB +643B 7.0KB 7.0KB 0B
core/Selector 75.1KB 76.0KB +903B 14.5KB 14.5KB 0B
core/SelectorOption 14.4KB 15.0KB +651B 4.8KB 4.8KB 0B
core/SideNav 52.8KB 53.4KB +618B 10.3KB 10.3KB 0B
core/SideNavCollapseButton 43.5KB 44.1KB +615B 9.2KB 9.2KB 0B
core/SideNavHeading 51.6KB 52.2KB +594B 9.9KB 9.9KB 0B
core/SideNavItem 51.3KB 51.8KB +577B 9.9KB 9.9KB 0B
core/Step 32.2KB 32.9KB +632B 5.1KB 5.1KB 0B
core/Stepper 44.3KB 45.0KB +621B 9.3KB 9.3KB 0B
core/Switch 45.4KB 46.0KB +603B 9.1KB 9.1KB 0B
core/Tab 9.6KB 10.2KB +645B 4.0KB 4.0KB 0B
core/TabMenu 48.5KB 49.1KB +611B 9.8KB 9.8KB 0B
core/Table 80.1KB 81.0KB +916B 14.6KB 14.6KB 0B
core/TableCell 74.9KB 75.9KB +936B 14.4KB 14.4KB 0B
core/TableHeaderCell 74.7KB 75.6KB +915B 14.3KB 14.3KB 0B
core/TableSelectionToolbar 49.1KB 49.7KB +617B 11.0KB 11.0KB 0B
core/TextInput 50.0KB 50.5KB +593B 10.2KB 10.2KB 0B
core/Thumbnail 47.0KB 47.6KB +625B 10.8KB 10.8KB 0B
core/TimeInput 76.1KB 103.5KB +27.5KB ⚠️ 13.2KB 13.6KB +392B
core/Timestamp 27.4KB 27.4KB -2B 9.3KB 9.3KB 0B
core/Toast 51.2KB 51.8KB +642B 9.5KB 9.5KB 0B
core/ToggleButton 41.0KB 41.6KB +626B 9.1KB 9.1KB 0B
core/Token 29.5KB 30.1KB +643B 4.3KB 4.3KB 0B
core/Tokenizer 61.2KB 61.8KB +640B 11.4KB 11.4KB 0B
core/TopNav 46.6KB 47.2KB +594B 10.1KB 10.1KB 0B
core/TopNavHeading 51.0KB 51.6KB +599B 9.7KB 9.7KB 0B
core/TopNavItem 8.9KB 9.5KB +638B 3.7KB 3.7KB 0B
core/TopNavMegaMenu 51.0KB 51.6KB +599B 10.5KB 10.5KB 0B
core/TopNavMegaMenuItem 9.0KB 9.6KB +645B 3.9KB 3.9KB 0B
core/TopNavMenu 50.5KB 51.0KB +605B 9.9KB 9.9KB 0B
core/TreeList 32.4KB 33.0KB +612B 4.5KB 4.5KB 0B
core/Typeahead 57.7KB 58.4KB +641B 11.0KB 11.0KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/DateInput +27.7KB ⚠️ +341B
core/DateTimeInput +27.9KB ⚠️ +340B
core/PowerSearch +28.8KB ⚠️ +327B
core/TimeInput +27.5KB ⚠️ +392B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 33.7KB 34.0KB +291B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

48 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 556,900
RichTextEditor Markdown parity: side by side probe light 538,816
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 28 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@vjeux
vjeux force-pushed the uikit/c21-wheel-window branch from db48788 to 93a47dd Compare October 24, 2026 20:59
@vjeux vjeux changed the title fix(Wheel): a wheel draws only the rows around its band fix(Wheel): the iOS wheel draws only the rows around its band Oct 24, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner decides whether draft spec:AST-094 (in particular FR5 and FR9, which the wheel's row window rests on) should govern the touch picker wheel, and provides or identifies the governing spec for the new role-keyed theme motion API and the package-level experimental subpaths. An engineering owner records the decision on the draft by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these fixes are needed:

  • The 744 px compact edge in packages/core/src/utils/touchPresentation.ts and the new platform adaptation condition contradict spec:AST-012 FR9 and FR2 on main.
  • The DateTimeInput time sheet loses its 8 px column gutter on Android and with a mouse, although its changesets say Android and mouse sheets and wheels are unchanged.
  • WheelGroup stores its DOM node in React state, and the callout test in PickerWheel.test.tsx only reads source text.
  • At about 34,800 authored lines, the stack should land as separate pull requests along its seams.

[Automated review]

export const TOUCH_QUERY = '(pointer: coarse)';

/** The viewport is compact: layout behaviours. */
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This private 744 px edge contradicts spec:AST-012 FR9 on main: compact is a width below the theme’s effective md point (768 by default) or a height below 500, and no resolver keeps size numbers of its own. As written, an iPad mini in portrait (744 × 1133) gets the popover where FR9 requires the sheet.

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.

Not in this pull request's commits: utils/touchPresentation.ts is #8076's. At #8076 1203525, which this branch now sits on, the resolver reads the theme's compact definition (DEFAULT_COMPACT_DEFINITION, AST-012 FR9) and keeps no numbers of its own.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-012 FR2 closes the when vocabulary to width, height, pointer, contrast and motion, and says future fields need an explicit addition to it. platform needs that amendment first, or should come out of this stack.

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.

Not in this pull request's commits: themeAdaptations.ts and its platform field come from #8076's stack. The FR2 amendment or the removal belongs to that change.

},
touchTimeWheels: {
display: 'flex',
alignItems: 'center',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping gap: spacingVars['--spacing-2'] here also changes the non-iOS sheet, where WheelGroup renders a bare fragment: in Chromium with a coarse pointer the hour, minute and AM/PM columns lose their 8 px gutter and the band capsules touch. wheel-group-inputs says Android and mouse sheets are unchanged, and wheel-uikit-picker says the same of the wheels.

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.

Right: #8161 had dropped the gutter, and under a fine pointer WheelGroup renders its columns as a fragment, so today's columns touched. Fixed in ee5538b (re-picked into this branch). The time row's style is main's again, and a new Chromium check under a mouse requires every gap between adjacent columns to be at least 8 px. It read 0 px on the previous head.

* ```
*/
export function WheelGroup({children}: WheelGroupProps) {
const [element, setElement] = useState<HTMLDivElement | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV1: DOM nodes must not be stored in React state. Owning the element through a ref and publishing the ref (or a stable getter) through context avoids the extra render that publishing the node causes.

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.

Fixed at 8f8a358: the element stays in a ref. The group's context carries the ref and a mount flag, so each column places its vanishing point once the group mounts. The case "hands its element to the wheels without keeping it in React state" fails before the change.

@vjeux
vjeux force-pushed the uikit/c21-wheel-window branch from 93a47dd to 8f8a358 Compare October 25, 2026 00:39
@vjeux vjeux changed the title fix(Wheel): the iOS wheel draws only the rows around its band fix(Wheel): the touch wheel draws only the rows around its band Oct 25, 2026
@vjeux
vjeux force-pushed the uikit/c21-wheel-window branch from 8f8a358 to 2d5b2a0 Compare October 25, 2026 01:00

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval is on hold until an engineering owner decides whether draft spec:AST-094 should govern the touch picker wheel (in particular FR5 and FR9, which the row window rests on, and FR4, which forbids the themable touch motion this stack adds), and provides or identifies the governing spec for the theme motion roles, the package-level experimental subpaths, the nearest-valid rest, the press-feedback selection lock and the new astryxStylex option. An engineering owner records the decision on the draft by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these fixes are needed:

  • The platform adaptation condition contradicts spec:AST-012 FR2.
  • The compact edge reads the default md point rather than the active theme's, against spec:AST-012 FR9 and the touch-presentation changeset.
  • defineTheme silently accepts motion role ids that no table registers, including the timing.reducedMotionCrossFadeMs example in the changeset.
  • At about 36,200 authored lines, the stack should land as separate pull requests along its seams.

[Automated review]

Comment on lines +68 to +72
export const COMPACT_QUERY = DEFAULT_COMPACT_DEFINITION.query;

/** Both: presentations such as a sheet in place of a popover. */
export const COMPACT_TOUCH_QUERY =
DEFAULT_COMPACT_DEFINITION.coarsePointerQuery;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-012 FR9 puts the edge at the effective md point and lists an overridden md among its verification states, but DEFAULT_COMPACT_DEFINITION keeps 768 px under a theme that sets adaptations.widthBreakpoints.md, while the touch-presentation changeset promises the theme's point. Resolving compactDefinition() from the active theme's breakpoints, as AppShell reads them through useThemeDefinition(), would conform.

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.

This file comes from #8076, which this pull request carries because it is stacked on it; the change belongs there and reaches this pull request when its base does.

Comment on lines +140 to +145
const registered = motionRole(id);
if (registered !== undefined && registered.kind !== kind) {
throw new Error(
`${location}: "${id}" is a ${registered.kind}; set it under the matching group.`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An id that no table registers falls through this check and writes a --motion-* variable nothing reads: timing.reducedMotionCrossFadeMs (excluded at motion/uikit/timing.ts:158, yet the example in touch-motion-theme-roles) and a typo such as springs.sheetPresnt both pass without an error. Rejecting ids outside the generated role list would surface both.

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.

This file comes from #8257, which this pull request carries because it is stacked on it; the change belongs there and reaches this pull request when its base does.

vjeux and others added 23 commits October 25, 2026 18:18
Her arena work (the touch depth, a view's delivery only to involved
recognizers, the reset count, touchBeganElsewhere's delivery, the
simultaneity rule) reaches the recognizer's plumbing through the internal
view, as this PR keeps it out of the public type; the new
deliverBeganElsewhere joins that view.
…lved, main) into the gesture hook fixes

# Conflicts:
#	packages/core/src/gestures/GestureArena.ts
#	packages/core/src/gestures/GestureRecognizer.ts
Her foundation fixes for the review items this PR also answered take her
form: the swipe's segment reading with directions ruled out for good, the
tap's lift centroid, the claim release that counts a recognized discrete
gesture, Hover's restart and the idle recognizer's comment. Both sides'
tests stay: her spacing, 40-degree, re-basing and settling cases beside
this PR's 20-degree, re-based release and mid-hover cases.
…ePressTracker's restored option-change test) into meta#8315
…ades

On a touch screen, or after a touch drag, the wheel's own scrolls cross-fade
with the rows landing while they are faded out (design:reduced-motion DR2);
otherwise they land at once (DR3). "With a mouse or keyboard" read as if a
key step on a touch screen landed at once.
…uikit/c21-wheel-nearest-valid

# Conflicts:
#	.github/workflows/ci.yml
… they add

picker.tapSpin.durationMs is a role defineTheme accepts and astryx docs motion
lists, a compatible capability, so the entry is a [feat] that names it first
(spec:AST-017 FR7) instead of an internal [chore].
…uikit/c21-wheel-math

# Conflicts:
#	packages/core/src/motion/README.md
…urves.move

spec:AST-094 FR4 names the table's roles by what they do: the base now
calls Core Animation's ease-in-ease-out timing.curves.move (the same
control points, 0.42, 0, 0.58, 1). The tap spin's default curve and the
picker table's notes read that role. With the old name the tap spin baked
an undefined curve: at the merge, wheelPhysics.test.ts's tap spin and three
of wheelDrum.test.ts's looping cases failed.
…e base

The base's motion docs topic and this branch's picker role (the tap spin's
duration, picker.tapSpin.durationMs) each moved the topic's skeleton; the
merge kept the base's golden. Regenerated with
`vitest run packages/cli/test/experimental-isolation.test.mjs --update`:
only the motion topic's skeleton hash changes, and no experimental surface
is named.
…gnizer and compositor curves

The picker wheel is a native scroll-snap column. WebKit gives every
scroll-snapping scroller the fast deceleration rate (0.99/ms) while
UIPickerView decelerates at the normal rate (0.998) onto a row it picks from
its own projection, and a native scroll's momentum cannot be caught or held
to a curve. Under a finger, on every touch device (one touch model, iOS and
Android alike), the Wheel is now UIPickerView's, PickerWheel.tsx, on the
gesture system and the picker's values in the UIKit constants table:

- A ScrollViewPanGestureRecognizer, the pan a scroll view uses, drags the
  rows, on UIKit's rubber band past an end. The lift plays UIKit's release
  plan (wheelPhysics.ts) from a scroll view's release velocity (the pan's
  last two, blended as UIPickerView's); a finger landing on a moving wheel
  stops it where it is; a tap turns a row onto the band. Hidden mid-drag, the wheel
  drops the drag uncommitted and parks the committed row when shown again.
- Each motion plays through wheelMotion.ts: per-row Web Animations on the
  compositor, each row turned onto the cylinder, with a selection tick per row
  reached where the browser can vibrate.
- The tap spin's duration and curve and the bounce at an end are the theme's
  motion roles (picker.tapSpin.durationMs, timing.curves.easeInOut,
  scroll.bounce), resolved against the column as each motion starts.
- The value commits when the wheel comes to rest; keys and clicks commit at
  once and turn the wheel. Home and End go to the first and last enabled
  row wherever the wheel is heading. The listbox's active descendant is the
  committed row, derived during render.
- Under reduced motion (design:reduced-motion) every transition the wheel
  starts lands without travel: the rows cross-fade onto their row on the
  animator's Reduce Motion fade (reducedMotionFade, WheelMotion.crossFade),
  whatever curve it was asked for; the drag itself follows the finger.
- isLooping, resolveRest and a spinTo(index) handle for the date and time
  wheels.
- WheelGroup: one band and one vanishing point across picker columns. A Wheel
  on its own draws its own band. The band is decoration, so the frame's (or
  the lone column's) ::before draws it, marked data-wheel-band (INV7). The vanishing point follows the shared
  resize observer.
- A held finger never starts text selection or the callout: the pan asks the
  gesture host for it (preventsTextSelection). The column keeps the pointer
  of a finger that lands on it, as it keeps the touch: a BottomSheet would
  otherwise arm its pull-down on the pointer and follow the first 8 px of a
  downward drag.

The switch reads useTouchPresentation. A wheel shows only on a sheet, so a
caller who pins the fine surface gets no sheet and no wheel
(architecture:public-component-api INV16). Under a mouse or other fine
pointer Wheel keeps today's scrolling column, now named ScrollWheel and
otherwise unchanged (the released DateInput tokens stay as they are; the
touch geometry is in the unexported wheelGeometry.stylex.ts); there spinTo
commits at once, isLooping and resolveRest do nothing, and WheelGroup
renders its children alone. PickerWheel.tsx is the touch layer, imported by
Wheel.tsx alone. The shared types and nearestEnabledIndex move to
wheelOptions.ts.
…s coarse

The gesture host takes input only where the primary pointer is coarse
(spec:AST-094 FR19, the gesture line's coarse-pointer gate), and the touch
column draws only there. PickerWheel.test.tsx ran under jsdom's fine pointer,
so on that base every touch case saw no input: 11 of 30 failed. Each case now
runs under a coarse primary pointer, as on a phone or a tablet; the mouse
cases are a mouse on a touch device, a tablet's trackpad.
The base renamed Core Animation's ease-in-ease-out to timing.curves.move
(spec:AST-094 FR4, the same control points). The tap spin resolves that
role under the column's theme, and the docs and the release note name it.
With the old name the spin resolved an undefined curve, and 13 of
PickerWheel.test.tsx's cases that turn the wheel failed at the merge.
…nLock

The base replaced a recognizer's preventsTextSelection with the selectionLock
option (spec:AST-094 FR41): a recognizer whose hold does something passes
selectionLock: lockSelection from gestures/selectionLock.ts. The wheel's pan
is such a recognizer (a held finger turns the wheel), so it keeps the lock.
… one picker under a finger

The month and year wheels of the DateInput sheet and the time wheels of the
TimeInput and DateTimeInput sheets sit in a WheelGroup. Under a finger, on
every touch device, one selection band spans every column, and each
column's dimmed rows lean toward the picker's centre, as UIPickerView draws
them. Under a mouse or other fine pointer the group renders the columns
alone, so the sheets keep today's layout; the sheets' own styles are
unchanged (the DateTimeInput time row keeps its gutter between today's
columns: a Chromium check under a mouse holds every gap to 8 px or more).
Wheel.interaction.browser.spec.ts drives the TimeInput sheet's minute wheel
with real touch input; a finger gets the touch column on every engine. In
Chromium and WebKit: a flick coasts on UIKit's curve (the release plan of
wheelPhysics.ts, fitted to the engine's own release velocity) onto the row
it projects and commits on arrival; a finger landing on the coasting wheel
stops it where it is, then it eases onto the nearest row; and a storyboard
films a flick and its coast, its trace asserted. In Chromium, which replays
the script's timestamps: the coast keeps advancing every frame through a
main-thread block, and does no Layout or Paint per frame. WebKit's
real-time input gives the release its own velocity, so the iOS simulator
capture proves the block there.

Under a mouse (Chromium, a desktop profile) a drag scrolls today's column
onto a row and commits it.

Under reduced motion (design:reduced-motion) a flick cross-fades onto its
row: every animation under the column, sampled each frame, animates opacity
alone for 200 ms or less (Chromium, WebKit).

PickerWheel.test.tsx adds a jsdom case for a path the audit named: each
column leans toward the picker centre from where it is laid out, so an RTL
row mirrors the lean.
Under a finger, opening a DateInput or DateTimeInput bottom sheet, and its
month and year wheels, held the main thread longer for each year in the
range: every wheel drew every row twice (on the cylinder, each its own 3D
layer, and on the band's copy). In Mobile Safari (iOS 26.2 simulator),
opening the year wheel of a 200-year range held the page for 278-754 ms; it
now takes 54-65 ms.

UIPickerView lays out only the cells it shows. PickerWheel now draws a
window of rows: on the cylinder its front half (rows within 90 degrees of
the band), and on the band's copy the row under the band and one each side,
each with a buffer of 2: 15 and 7 rows at most at rest, for any list.
Every motion mounts the rows it will show before it plays (flushSync), so
its cost follows the distance it travels; a reduced-motion cross-fade
mounts its target's rows at the fade's midpoint; a drag moves the window
when the finger nears its edge; the window settles back around the rest.
The band copy's rows sit at their positions along the wheel, so a looping
wheel needs no copies of its strip. WheelMotion attaches rows by index and
carries a running motion through a re-attach: rows that mount join it in
step, rows that unmount leave it, and rows that mount during a cross-fade
show where it is.

The listbox's options are separate, plain and complete, stacked
transparent over the band: assistive technology meets every option, and
both drawings carry their text as generated content.

Also: WheelGroup flags when its element is attached (the element stays in
a ref), so a Wheel places its vanishing point at the group's centre once
the group mounts; and a test holds the gesture host's selection lock on
the column under a held finger.

Stories BottomSheetWideRange (200 years) and BottomSheetNarrowRange
(2 years) back the browser proof. Under a mouse, its year wheel stays
today's column with every year listed.

Wheel.interaction.browser.spec.ts marks the row drawn on the cylinder for
its busy-main-thread proof (the option it marked is transparent now).
@vjeux
vjeux force-pushed the uikit/c21-wheel-window branch from e068810 to 9a52dc9 Compare October 26, 2026 02:52
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #8162, so this pull request closes. Today's look (#8159) draws each option once and leaves the rows out of sight upright and transparent, so the row window has nothing left to do. Its activation proof and the two stories moved to #8162, where opening the year wheel at a phone's CPU (Chromium, 4× throttling) measures no slower than today's column: 167 ms worst frame for 200 years against 200 ms under a mouse, and 133 ms for both at two years.

@vjeux vjeux closed this Oct 26, 2026
@vjeux
vjeux deleted the uikit/c21-wheel-window branch October 26, 2026 02:59
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8289 is folded into meta#8162. Under today's look there is no WheelGroup:
the month and year wheels render their columns directly, and the browser
spec drags by `WHEEL_ROW_PITCH`.

This branch was successfully deployed

1 active deployment
Preview — 9a52dc9d Deployed Oct 26, 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants