Skip to content

feat(Wheel): UIPickerView's motion under a finger, with flat rows - #8159

Draft
vjeux wants to merge 11 commits into
meta:mainfrom
vjeux:uikit/c21-wheel
Draft

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

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Batch: B4 · Wheel / picker (DateInput, TimeInput, DateTimeInput)

On main after #8131. The change: vjeux/astryx@cae1f66...94d18e1

Supersedes #8156, #8526, #8162, #8361, #8527, #8480 and #8698.

#8156, #8162, #8361 and #8698 are combined here; the shared animator replaces #8526's player. #8527's turns from script move to #8179. #8480's cylinder look (DEC-2) and #8366's looping stay out.

User need

Under a finger, the sheet wheels are scroll-snap columns; in WebKit a flick stops at a fifth of the iOS picker's distance. This makes them move like UIPickerView; a mouse keeps the scrolling column. Keys step in time order, so 12 PM is reachable.

Current specification

  • spec:AST-094 FR4 and its implementation gaps, FR5, FR6, FR23, FR43.
  • component:DateInput DEC-1, DEC-2, FR13 to FR15, FR17, FR18.
  • design:reduced-motion DR2, DR3; architecture:react-component-runtime INV6, INV13, INV16; architecture:interaction-modality INV1.

Owner decisions

DEC-2, FR14's tap-spin role, FR17, FR18 and FR4's snap-rate gap carry the spec owners' rulings verbatim; FR13 no longer repeats the gap.

Design and alternatives

  • Keep the scroll-snap column: it cannot decelerate like UIKit or be caught.
  • A player of its own with tipping rows: a second engine (INV13), and DEC-2 rejects the look.
  • Chosen: flat rows on one strip the shared animator moves, coasting with decay and turning with play, faded by a static mask inside the column.

Turns play the role picker.tapSpin.durationMs; the snap rate is FR4's recorded gap. A trackpad takes the pan's continuous path; a mouse notch turns one row.

Public delta and caller burden

No API change; the mouse column's keys change as a defect fix (FR17). Gzip import, main 0f25a0f to this head: DateInput 103.0 → 144.0 KB, TimeInput 90.0 → 129.3, DateTimeInput 114.8 → 154.9, PowerSearch 183.5 → 223.5, MobilePowerSearch 168.5 → 208.5; CSS unchanged. The growth is the shared touch runtime (gestures and animator), paid once per page, as for Slider.

Writing rubric results

Over DEC-2, the FR13 to FR18 rows, FR4's new gap line, the Current-state lines and the front matter:

  • R1 narration, R2 research, R3 pull requests as evidence: none.
  • R4 rejected alternatives, R5 change references, R6 open questions: none.

Evidence

  • A 1 s main-thread block never stops the coast. A flick's coast follows UIKit's plan within 0.75 px for the velocity the page measured.
  • Red-first: ArrowDown from 5 PM at a 12:00–17:00 bound committed 12 PM; a key with nowhere to go mid-turn stopped the wheel 21.5 px off its row.
  • A focused wheel under a finger carries no mask, so its whole focus ring shows (Chromium).

Testing

Gates at 94d18e1 so far: strict lint, typecheck, check:knowledge, check:motion-roles, check:changesets and check:i18n-runtime; the wheel, gesture and Switch units (99 files, 2,824 tests); the touch-bundle and motion-role node tests. The full test:ui and test:node, the Storybook build, Chromium (a11y, interaction, busy proofs) and WebKit at CI's profile ×10 are running.

On a phone or the iOS simulator, open Storybook's TimeInput › Bottom Sheet With Min Max:

  1. Flick the minutes: the wheel coasts and commits where it rests.
  2. Touch a coasting wheel: it stops; lift, and it eases onto the nearest row.
  3. Tap a row below the band: the wheel turns onto it and commits it.
  4. At 10:00 AM, tap PM: the time clamps to 5:00 PM and the hour wheel turns onto 5.
  5. With a keyboard at 5 PM, press ArrowDown: nothing moves. At 12 PM it moves to 1 PM.

Automated: PickerWheel.test.tsx, Wheel.test.tsx, wheelStrip.test.ts, wheelPhysics.test.ts and TimeInput's Presentation.test.tsx in jsdom; the browser specs in Chromium and WebKit.

Merge order: this pull request, then #8178, #8179 and #8209.

@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 Nov 3, 2026 2:36am 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
🟢 Reviewed (for maintainers only) Nov 3, 2026, 2:55 AM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@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 waits on an engineering owner's decision on draft spec:AST-094 (FR1–FR23 and the amendments it proposes to current records) and on the wheel's new look (32 px pitch, magnified band, plain selected row); an engineering owner records it by commenting /approve-spec ece20fbd147eca75500fb8542994e3257c42ef7c, and the author may do so as an engineering owner. Whatever that decision, please fix the issues below and split the stack so this pull request carries only the Wheel commit, since it currently changes 28,785 authored lines.

  • A wheel still coasting when another wheel commits stops between rows and never commits, because a new options array reruns motion.attach(), which stops the motion (Wheel.tsx:413-423, wheelMotion.ts:117-123).
  • With presentation="bottom-sheet" and a mouse, dragging a wheel down now dismisses the sheet; the base guarded against this and its tests were removed.
  • Home and End pressed while a wheel is moving land on the wrong row (Wheel.tsx:785-791).

[Automated review]

@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 waits on an engineering owner's decision on draft spec:AST-094 (FR1–FR23 with DEC-1 and DEC-2, including the amendments it proposes to current records) and on the wheel's new look (the cylinder with a magnified band, the 32 px row pitch and the selected row without accent or semibold); any engineering owner, the author included when they are one, records it by commenting /approve-spec ee9446ddeeb92e94419d9276cf620e19605718dc on this pull request. Whatever that decision, these still need changes:

  • A mouse drag on a wheel in the fine-pointer bottom sheet now dismisses the sheet instead of turning the wheel, and the tests that guarded it were deleted.
  • Land the stacked predecessors separately so this pull request carries only the wheel commit, then fix Home and End pressed while the wheel coasts, the active row repaired in a layout effect, and the changeset and documentation references the review lists.

[Automated review]

@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 waits on an engineering owner's decision on draft spec:AST-094 (FR1–FR23 with DEC-1 and DEC-2, including the amendments it proposes to current records) and on three Wheel choices no spec states yet: the new look (cylinder with a magnified band, 32 px pitch, plain selected row), turns that ignore Reduce Motion, and the nearest-enabled rest for bounded wheels. Any engineering owner records that decision by commenting /approve-spec 2def93ccb659fd3f9d6d484b1256c633124e790f on this pull request, and the author may do so as an engineering owner. Whatever the decision, these need changes:

  • A mouse drag on a wheel in the forced bottom sheet now dismisses the sheet instead of turning the wheel, and the tests that guarded it were deleted.
  • @astryxdesign/core/DateInput/tokens.stylex drops the released wheelItemSize and wheelEdgePadding keys in a patch; keep them and move wheelGeometry out of the public module.
  • Land the stacked predecessors separately so this pull request carries only the Wheel commit, then fix Home and End pressed during a coast, the active row repaired in a layout effect, and the changeset and documentation references noted inline.

[Automated review]

Comment thread packages/core/src/DateInput/Wheel.tsx Outdated

// Keep the finger: inside a BottomSheet the sheet would otherwise read a
// downward drag here as swipe-to-dismiss (see useOwnScrollGesture).
useOwnScrollGesture(columnRef, 'all', {isEnabled: isActive});

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 covers touch only. The base also called usePointerDragScroll, which stopped a mouse pointerdown before BottomSheet's body drag could arm and capture the pointer; without it, a downward mouse drag on a wheel in a forced presentation="bottom-sheet" dismisses the sheet instead of turning the wheel. Please keep a press that starts on a wheel away from the sheet's drag again, and restore the deleted mouse-drag tests (DateInputTouch.test.tsx) against the gesture path.

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.

Resolved at adb185f. A mouse, a fine pointer, gets today's scrolling column, which keeps usePointerDragScroll; the six mouse-drag tests in DateInputTouch.test.tsx run unchanged under a fine pointer with the sheet asked for. Under a finger the column stops pointerdown from reaching the sheet (jsdom case "keeps a finger that lands on it from an enclosing sheet"; Chromium case "a downward drag on a wheel moves its rows and leaves the sheet where it is", where the sheet moved 9.3 px before). PickerWheel.test.tsx also has mouse cases for a mouse on a touch device: a primary-button drag turns the wheel and commits where it rests, a secondary-button drag does nothing.

Comment thread packages/core/src/DateInput/Wheel.tsx Outdated
Comment on lines +785 to +791
case 'Home':
event.preventDefault();
moveBy(-activeIndex);
moveBy(-restIndex);
break;
case 'End':
event.preventDefault();
moveBy(options.length - 1 - activeIndex);
moveBy(count - 1 - restIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

moveBy steps from motion.target() (the row the wheel is heading to), but these deltas are measured from restIndex, the last committed row. While a coast, a tap spin or a scroll-wheel snap is running, Home or End lands between the motion's target and the end: Home pressed while a coast from minute 10 heads for 40 commits 30, not 0. Making Home and End absolute (the first and last enabled rows) fixes it; a test that presses them mid-coast would guard 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.

Fixed in adb185f: Home and End go to the first and last enabled row wherever the wheel is heading. New case "takes Home and End to the first and last enabled rows, even mid-coast" fails before the change (Home committed 30 from a coast heading past 40) and passes now.

Comment thread packages/core/src/DateInput/Wheel.tsx Outdated
Comment on lines +430 to +439
useIsomorphicLayoutEffect(() => {
if (!isActive || dragRef.current != null || motion.isMoving()) {
return;
}
const position = nearestPosition(selectedIndex, motion.offset(), layout);
if (Math.abs(motion.offset() - position * ROW_HEIGHT) > 0.01) {
motion.setOffset(position * ROW_HEIGHT);
}
// eslint-disable-next-line @eslint-react/set-state-in-effect -- the active row follows the wheel's resting offset, which only exists in the motion engine
setRestIndex(selectedIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A value set from outside first renders the old restIndex into aria-activedescendant, and this layout effect then repairs it, which docs/architecture/react-component-runtime.md INV1 rules out. Adjusting restIndex during render when selectedIndex changes at rest avoids the follow-up update; the effect can keep syncing the motion engine's offset.

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 adb185f: the restIndex state is gone. The active descendant derives from the committed value during render, and the park effect only syncs the motion engine's offset. New case "points the active row at a value set from outside in the same commit" (React Profiler) saw two commits, the first with the old row, before the change; now one commit with the new row.

* inlines only literal constants, so the values are repeated here; a test
* holds them to the table.
*/
export const wheelGeometry = stylex.defineConsts({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@astryxdesign/core/DateInput/tokens.stylex is a published subpath, so removing dateInputTouchSizes.wheelItemSize and dateInputTouchGeometry.wheelEdgePadding breaks code that reads them in what the changesets declare a patch, and wheelGeometry becomes public API. Consider keeping the two keys and moving wheelGeometry into a .stylex.ts module outside the exports map; only Wheel.tsx and WheelGroup.tsx read 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.

Fixed in adb185f: tokens.stylex.ts is unchanged from main (both released keys stay; today's column still uses them), and wheelGeometry moved to the unexported DateInput/wheelGeometry.stylex.ts. A new test keeps the published module to its released names (WEEKS_PER_PANE, dateInputTouchGeometry, dateInputTouchSizes).

* ```
* <WheelGroup>
* <Wheel label="Hour" options={hours} value={hour} onChange={setHour} />
* <Wheel label="Minute" options={minutes} value={minute} onChange={setMinute} looping />

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 prop is isLooping; looping is not a Wheel prop.

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: the example uses isLooping.

Comment thread .changeset/wheel-uikit-picker.md Outdated
'@astryxdesign/core': patch
---

[fix] The picker wheels in the TimeInput, DateTimeInput and DateInput bottom sheets now look and move like iOS's own picker. The rows sit on a cylinder seen in perspective, with one capsule band across every column and the row inside it magnified. A flick coasts at UIKit's deceleration onto the row UIKit's projection picks. A finger landing on a moving wheel stops it. A tap on a row turns it onto the band. Past the first or last row, a drag rubber-bands and the wheel bounces back. As on iOS, the value commits when the wheel comes to rest, and the coast keeps running while the page's main thread is busy. Arrow keys and clicks on a row still commit at once.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing renders WheelGroup at this head, so each column draws its own band; "one capsule band across every column" describes the follow-up that adopts the group, not this change.

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: the changeset no longer mentions a band across every column (that is #8161's), and it is now tagged [feat] as a new visual direction under AST-017 FR3.

@vjeux vjeux changed the title feat(Wheel): UIPickerView's wheel on the gesture recognizer and compositor curves feat(Wheel): UIPickerView's wheel in the iOS idiom, on the gesture recognizer and compositor curves 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 waits on an engineering owner's decision on draft spec:AST-094 (FR1–FR23 with DEC-1 and DEC-2), on three Wheel choices no spec states yet (the new look, turns that ignore Reduce Motion, and the nearest-enabled rest), on the theme motion roles and platform adaptation condition (which also need spec:AST-012 FR2 amended), and on the motion/experimental and gestures/experimental subpaths, which sit outside the experimental boundary spec:AST-017 FR21 names. Any engineering owner, the author included, records that decision by commenting /approve-spec 1c1e2386fe3a81037daa6fa879c4c82bd6349161 on this pull request. Whatever the decision, these need changes:

  • The hard-coded 744 px compact width for presentation="adaptive" contradicts spec:AST-012 FR9, which defines compact as width below the theme's md point or height below 500 px.
  • Home and End pressed while a wheel is still moving commit the wrong row, and wheelGeometry becomes a public export of @astryxdesign/core/DateInput/tokens.stylex.
  • Land the stacked predecessors separately and bring the Wheel commit under 2,000 lines, then fix the layout-effect repair of the active row and the changesets noted inline.

[Automated review]

Comment on lines +735 to +742
case 'Home':
event.preventDefault();
moveBy(-restIndex);
break;
case 'End':
event.preventDefault();
moveBy(count - 1 - restIndex);
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

moveBy steps from motion.target() (the row the wheel is heading to, line 646), but these deltas are measured from restIndex, the last rested row. While a coast, a tap spin or a rest turn runs, the two differ: on the 60-row minute wheel, Home pressed 200 ms into a flick up from 10 commits 23, and End 200 ms into a flick down from 50 commits 36. Make Home and End absolute (the first and last enabled rows) and add a test that presses them mid-motion.

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 adb185f: Home and End go to the first and last enabled rows wherever the wheel is heading. The case "takes Home and End to the first and last enabled rows, even mid-coast" fails before the change.

Comment on lines +362 to +372
useIsomorphicLayoutEffect(() => {
if (!isActive || dragRef.current != null || motion.isMoving()) {
return;
}
const position = nearestPosition(selectedIndex, motion.offset(), layout);
if (Math.abs(motion.offset() - position * ROW_HEIGHT) > 0.01) {
motion.setOffset(position * ROW_HEIGHT);
}
// eslint-disable-next-line @eslint-react/set-state-in-effect -- the active row follows the wheel's resting offset, which only exists in the motion engine
setRestIndex(selectedIndex);
}, [isActive, selectedIndex, layout, motion]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A value set from outside first renders the old restIndex into aria-activedescendant (line 757), then this layout effect repairs it, which docs/architecture/react-component-runtime.md INV1 rules out ("MUST NOT render a known-wrong initial state and repair it in a follow-up Effect"). The lint reason names the component's own motion engine rather than an external system. Adjust restIndex during render when selectedIndex changes at rest, and keep the effect for the engine's offset.

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 adb185f: there is no restIndex state. The active descendant derives from the committed value during render, and the effect only syncs the motion engine's offset. The case "points the active row at a value set from outside in the same commit" uses a React Profiler to check for one commit.

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.

spec:AST-012 FR9 on main makes compact "viewport width … below the effective md point (FR1) or its viewport height … below 500", with no size numbers of a resolver's own, and architecture:public-component-api INV17 says the same. A hard-coded 744 px edge sends a coarse 744–767 px screen (a 744 px iPad mini in portrait) to the popover where FR9 makes it compact, and ignores a theme that moves md. Build the query from the theme's effective md point (768 by default) and the 500 px height, and have the changeset name presentation as the way to keep a surface.

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.

* holds them to the table. Outside the iOS idiom the wheels keep the sizes
* above.
*/
export const wheelGeometry = stylex.defineConsts({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@astryxdesign/core/DateInput/tokens.stylex is a published subpath, so this adds a stable public export (spec:AST-017 FR3) although the PR body says there is no API change, and only PickerWheel.tsx and WheelGroup.tsx read it. Moving it to a .stylex.ts module outside the exports map keeps UIKit's picker numbers free to change.

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 adb185f: tokens.stylex.ts is unchanged from main, and the picker geometry lives in the unexported wheelGeometry.stylex.ts. A test keeps the published module to its released names.

Comment thread .changeset/presentation-resolver.md Outdated
'@astryxdesign/core': patch
---

[chore] An internal `usePresentation` / `readPresentation` resolves a component's `presentation` prop into the surface to render and whether its touch input modes apply, keeping an omitted value distinct from an explicit `'popover'` so a caller's choice always wins over a touch default. No component reads it yet; behavior is unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head Wheel and WheelGroup read usePresentation, so "No component reads it yet; behavior is unchanged" is no longer true; the same goes for the "no component uses it yet" lines in the gesture, UIKit table and wheel-values changesets.

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.

Resolved: this stack no longer contains #8298. The wheel reads useTouchPresentation(), so presentation-resolver.md is not in this pull request.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 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 waits on an engineering owner's decision on what this pull request writes into the draft component:DateInput and component:DateTimeInput records (FR13 with its second sentence, which keeps an out-of-bounds committed value after a drag back onto it, and its new third sentence, which turns a wheel onto a clamped value; DEC-1; DateInput's ownership of the shared wheel; DateTimeInput FR8), and on whether the 300 ms tap spin and the snap rate may stay constants under spec:AST-094 FR4; an engineering owner records that decision by commenting /approve-spec with the full head SHA, and the author may do so as an engineering owner. Whatever that decision, these still need changes:

  • Land the unmerged stack first, or merge main again as each part lands: the diff is 33,990 authored lines, 2,983 of them this pull request's own.
  • A second clamp during the hour wheel's 300 ms turn leaves it resting on the old target (PickerWheel.tsx:469-475); re-check the committed row when a turn ends and test two clamps inside one turn.
  • Play the wheel's motion through the shared animator, or have architecture:react-component-runtime INV13 amended to admit a component-owned player.
  • Add browser proofs of a flick's coast through a main-thread block and of a mid-coast catch (Chromium phone emulation and WebKit), plus real-device recordings, as spec:AST-094 FR5, FR6 and Platform support ask.
  • Correct the Changeset's "keep today's look" and "With a mouse the wheels keep today's column" (spec:AST-017 FR3).

[Automated review]

Comment on lines +469 to +475
if (dragRef.current != null || motion.isMoving()) {
return;
}
const target = selectedIndex * ROW_HEIGHT;
if (Math.abs(motion.offset() - target) > 0.01) {
if (parkedRef.current) {
move(turnCurve(columnRef.current, motion.offset(), target));

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 bails while any motion runs and only runs again when selectedIndex changes, and the turn it starts has no onEnd. In TimeInput's "Bottom Sheet With Min Max" story (9 AM to 5 PM, at 10 AM), PM clamps the time to 5 PM and starts this 300 ms turn from 10 to 5; AM within the turn clamps it to 9 AM, this returns, and the wheel rests on 5, painted under the band, while the field holds 9:00 AM. Re-checking the committed row when a turn ends (or retargeting a running park turn) would fix it; a test with two clamps inside one turn would catch 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.

Fixed at e897a73: a second change during a turn turns on from where the wheel is drawn. PickerWheel.test.tsx "turns on from where the wheel is drawn when the value changes again during that turn" covers it.

Comment thread .changeset/wheel-uikit-picker.md Outdated
'@astryxdesign/core': patch
---

[feat] Under a finger, on every touch device, the picker wheels in the TimeInput, DateTimeInput and DateInput bottom sheets now move like UIPickerView, and keep today's look: 28 px rows that tip and fade toward the edges of the column, with the row under the band in semibold accent text on its plate. A flick coasts at UIKit's deceleration onto the row UIKit's projection picks. A finger landing on a moving wheel stops it. A tap on a row turns it onto the band. Past the first or last row, a drag rubber-bands and the wheel bounces back. As on iOS, the value commits when the wheel comes to rest, and the coast keeps running while the page's main thread is busy. Arrow keys and clicks on a row still commit at once. Under reduced motion a flick still coasts, as scrolling does, with the rows fading rather than tipping; a tap, a key or a slow release cross-fades onto its row. `presentation="popover"` keeps the previous surface; no value keeps the native-scroll column under a finger. With a mouse the wheels keep today's column.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Keep today's look" holds only where animation-timeline: view() is supported: elsewhere today's column draws a plain, fully legible list (Wheel.tsx:186-196), while the touch column tips and fades on every engine (wheelLook.ts:81-114). No row is marked while the touch column coasts, snaps or turns, where today's highlight follows the scroll, and Wheel picks the column by the primary pointer, so a mouse on a phone or tablet gets the touch column. spec:AST-017 FR3 asks the Changeset to name the old and new appearance.

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 e897a73: the changeset names both looks. Under a coarse primary pointer the rows lie flat and fade toward the column's edges in every browser; today's column tilts and fades its rows only where scroll-driven animations exist, and shows a plain list elsewhere. No row is marked while the wheel coasts, snaps or turns.

}

/** Play `curve` from the current offset; the rows end at curve.to. */
play(curve: WheelCurve, {ticks = false, onEnd}: PlayOptions = {}): void {

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 INV13 has a component's own touch logic be "small glue" over the shared modules, the animator among them, and its verification row fails "a component bundling its own copy of a module's primitive". This player bakes, starts, follows, stops and cross-fades curves itself and imports nothing from motion/animator.ts; either a per-row channel in the animator or an INV13 amendment would settle it.

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

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Mapped to 161c506:

  • The band's mark: a motion that ends on the row it began on (a nudge that eases back, a drag off a row and back) gives the mark back to that row, because the player forgets its last report when a motion starts. A test in PickerWheel.test.tsx covers it.
  • Citations: FR43 is on main now. The ctrl-wheel note cites FR43 (a ctrl-wheel is a pinch's input) and FR10 (with no pinch of its own, the wheel leaves it to the page's zoom).
  • "keep today's look" is true at this head; feat(Wheel): the touch column looks like UIPickerView #8480, which changes the look, removes it from this changeset.
  • The carried gesture text (the unused UIKIT_GESTURES imports and the "no component uses them yet" lines) is fixed at fix(core): one click guard for the menu press model and the gesture host #8360's current head, which this pull request now carries.
  • The busy-main-thread proof runs in Chromium and blocks for 1 s (test(Wheel): the picker wheel's browser proofs, storyboard and activation #8162). WebKit replays touch input in real time, so the scripted coast is skipped there; Mobile Safari has an iOS simulator capture through a 530 ms block. There is no real-device iOS Safari or Android Chrome evidence: what exists is the iOS simulator and an Android emulator.

Open:

  • The tap spin's 300 ms and the snap rate are measured constants, not roles: roles.test.ts holds the registered roles to exactly FR4's list, so a picker role needs FR4, or DEC-5's generated table, to name it.
  • The wheel plays its motion on its own compositor player, where INV13 names the shared animator.
  • Approval of the draft FR13 (both sentences), DEC-1, the ownership row and DateTimeInput FR8 stays with an engineering owner.
  • The compare carries the stack below until it lands.

cixzhang pushed a commit that referenced this pull request Oct 30, 2026
…nothing else does (#8671)

## Why

Every pull request that gives a component a gesture fails `test-node` once merged with main, and the failure names no component: `scripts/touch-support-bundle.test.mjs` requires Core's root and hooks entries to carry no gesture code. At least six open pull requests (#8142, #8190, #8337, #8159, #8098, #8658) fail it this way.

## What led to it

This comes from the work to make Astryx's touch components behave as native iOS ones do. Since #8465, a component that uses a gesture imports the touch modules directly (`architecture:react-component-runtime` INV13), and the root entry re-exports every component, so it carries gesture code as soon as one component does.

## Options and tradeoffs

- Exempt the root entry: unblocks everyone, but stops catching a component that pulls gesture code it does not use.
- Check each public import on its own: catches exactly that, at the cost of a list of the imports allowed to carry gesture code.

## Why this choice

The per-import check keeps INV13's promise, a component without gestures ships none of their code, while letting gesture users through by name. It was offered on #8562 and aimed at main's test.

## What changes

- Each public import of Core is built the way the size report builds it, and may carry gesture code only if `GESTURE_USERS` names it. A failure lists the import, its gesture modules with their sizes, and the chain of imports that brings them.
- The root entry may carry only what a named import carries; the utils entry stays gesture-free.
- A strict case keeps Button and TextInput free of gesture code whatever the list says.
- `GESTURE_USERS` starts empty, so every import stays as strict as on main. The pull request that gives a component a gesture adds that component's import, and each import that reaches it, with a comment naming the gesture, so the new gesture user shows up in review.

## How to check it

1. Run `pnpm exec vitest run --project node scripts/touch-support-bundle.test.mjs` on main with this branch merged: it passes.
2. On a tree whose component uses a gesture, run it without naming that import in `GESTURE_USERS`: the test fails and names that import and the chain that brings the gesture code. Name it, and the test passes.
3. Import a gesture module from Button: the strict case fails.

## Evidence

- With the list empty, the test passes on main: no public import carries gesture code today.
- On open trees that give a component a gesture (AppShell's scroll-edge bar, Slider, the List reorder), the per-import case fails naming exactly those imports and their chains, and passes once each is named.
- No product code changes.

## Scope

- [x] No intended public API, product behavior, visual direction, or policy change.
- [x] Product changes discovered during the work were removed or split.
- [x] Public text and artifacts contain no internal Meta context.
cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…wins over touch defaults (#8298)

<!-- Template: maintenance.md (an internal resolver; no behavior change until a component reads it). -->

Small batch of the merge plan: one commit on main holding only this change. Its paragraph for the gestures README waits for that README, which comes with #8076; the resolver's file header carries the same contract.

## Maintainer impact

A component that behaves differently under a finger, such as a menu that tracks the finger or becomes a sheet, must still let a caller pin the desktop behavior. Today components read `presentation ?? 'popover'`, which cannot tell an omitted prop from an explicit `'popover'`, so a component that adopts a touch default would override a caller who asked for the popover. The menu, context menu and picker work that adopts touch modes builds on this resolver (#8104, #8627, #8080, #8125, #8201, #8159, #8162).

## Intended invariant

A caller's explicit `presentation` always wins over the touch default (`architecture:public-component-api` INV16), and the surface equals `useAdaptivePresentation`'s for every value on every device, so no caller sees a change:

| `presentation`                  | surface                                       | touch input modes |
| ------------------------------- | --------------------------------------------- | ----------------- |
| `'popover'`                     | popover                                       | never             |
| `'bottom-sheet'`                | sheet                                         | under a finger    |
| `'adaptive'`                    | sheet on a compact touch screen, else popover | under a finger    |
| omitted, `whenOmitted: 'touch'` | popover                                       | under a finger    |

The modes follow the pointer, never the platform. The server and hydrating render never present touch modes, and no hint changes that (`spec:AST-094` FR13). The hook listens only to the queries a request reads (`architecture:react-component-runtime` INV5): none for a pinned `'popover'`, the pointer for `'bottom-sheet'` and the omitted `'touch'` default, and the pointer and compact size for `'adaptive'`. A touch-mode style is applied through `touchModes`, never nested under the iOS-only `@supports` gate.

## Change

- `utils/presentation.ts`: `usePresentation` and `readPresentation` (and the pure `resolvePresentation`) return the surface to render and `touchModes`, whether the component's touch input modes apply; `usePresentation` subscribes to each media query only while the request reads it. Internal: no component reads it yet.
- `hooks/useAdaptivePresentation.ts`: its header points a component that also switches input modes to `usePresentation`, whose surface is its own.

## Evidence

- Failure before the change: an omitted `presentation` and an explicit `'popover'` resolve the same, so a touch default cannot tell them apart; a server render given a touch hint must still answer the popover with no touch modes, a case that failed first. On an iPhone, a resolver that seeded its server snapshot from the live window fails both the server-render case and the `hydrateRoot` case; listening to both queries for every value fails the subscription case. A resolver that gives `'adaptive'` the sheet without the coarse pointer, or reads compact by width only, fails the surface matrix; one that ignores the theme's `md` fails the themed case.
- Success after the change: the unit matrix (iPhone, iPad, Android, desktop, Mac Safari, a fine-pointer narrow window, a phone in landscape, and the 767/768 and 499/500 compact edges, plus `'adaptive'` under a theme whose `md` is 1000) covers each row above, a live pointer change and the one-moment read; on an iPhone, the server render and the hydrating render answer the popover with no touch modes, and after hydration the phone's sheet with touch modes; each request holds only the subscriptions it reads.
- Product/runtime behavior verified unchanged: every value renders `useAdaptivePresentation`'s surface on every device, and no component reads the resolver yet.

## Scope

- [x] No intended public API, product behavior, visual direction, or policy change.
- [x] Product changes discovered during the work were removed or split.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

Local gates at 9f0595f, one commit on main d73908f holding only this change (4 files, +571/−1):

- `pnpm test:ui` (core UI): 654 files, 16,459 tests passed.
- `pnpm exec vitest run --project node`: 804 files passed; the only failures are main's own story-tree and release-cache reds, tracked in #8509 and #8520.
- `tsc --project packages/core/tsconfig.json --noEmit`, strict ESLint and Prettier on every changed file: clean.
- `pnpm check:repo`: clean.
@github-actions github-actions Bot added 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

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on three changes this pull request writes into the current component:DateInput record:

  • DEC-2's new sentence that rows lie flat under a finger. Current DEC-2 says a finger and a mouse see the same column, with rows tipping back.
  • FR14's new sentence naming the 300 ms picker.tapSpin.durationMs motion role.
  • FR13's new gap, which keeps the snap at rest as a literal rate against spec:AST-094 FR4.

An engineering owner records the decision by commenting /approve-spec with the full head SHA on this pull request; the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • The touch wheel's own wheel listener (PickerWheel.tsx:587-628) bypasses the shared trackpad pan path that main now has (spec:AST-094 FR43). Merge main, pass allowedScrollTypes: ['continuous'] to the wheel's pan, and keep only a one-row-per-notch handler (component:DateInput FR15).
  • The title ("in today's look") and two Changesets claim more than the change does: wheel-rest-and-spin.md says time and date-time columns use the new rest rule, and wheel-uikit-picker.md says "With a mouse the wheels keep today's column", which is not true on a tablet.
  • resolveRest, spinTo and WheelHandle have no caller. Remove them, with their tests and Changeset, or land them with the column that uses them.
  • component:DateInput must record that the per-row selection tick has no haptic in iOS Safari, as spec:AST-094 FR23 requires.
  • Attach the strip to the animator from a callback ref instead of the layout effect at PickerWheel.tsx:353-356 (architecture:react-component-runtime INV6).
  • At 6,585 authored lines the change is over the size limit. Once the record changes are approved and the unused plumbing is removed, it qualifies as an owner-directed batch; otherwise split it.

Non-blocking: the BottomSheetWideRange story comment says rows tip under a finger, and wheelOptions.ts:36 has a stray doc comment.

[Automated review]

Comment on lines +589 to +628
useEffect(() => {
const column = columnRef.current;
if (column == null || !isActive) {
return;
}
let idle: number | undefined;
const onWheel = (event: WheelEvent) => {
// A ctrl-wheel is a pinch: a trackpad pinch arrives as one. The wheel
// has no pinch, so the ctrl-wheel stays the page's zoom and never turns
// the wheel.
if (event.ctrlKey) {
return;
}
event.preventDefault();
const currentLayout = latestRef.current.layout;
const delta =
event.deltaMode === 1
? event.deltaY * ROW_HEIGHT
: event.deltaMode === 2
? event.deltaY * PAGE_HEIGHT
: event.deltaY;
const {min, max} = offsetBounds(currentLayout);
const shown = motion.isMoving() ? motion.stop() : motion.offset();
motion.setOffset(Math.min(max, Math.max(min, shown + delta)), {
ticks: true,
});
window.clearTimeout(idle);
idle = window.setTimeout(() => {
const offset = motion.offset();
const target =
nearestIndex(offset, latestRef.current.layout) * ROW_HEIGHT;
move(snapCurve(offset, target, 1), {onEnd});
}, WHEEL_IDLE_MS);
};
column.addEventListener('wheel', onWheel, {passive: false});
return () => {
column.removeEventListener('wheel', onWheel);
window.clearTimeout(idle);
};
}, [isActive, motion, move, onEnd]);

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 listener runs its own trackpad and mouse-wheel path beside the shared one main now has (allowedScrollTypes on PanGestureRecognizer, with the wheel segmenter behind the gesture host), which spec:AST-094 FR43 requires every followed trackpad pan to use. After merging main, pass allowedScrollTypes: ['continuous'] to the wheel-pan recognizer and keep only a discrete-notch handler that calls moveBy(±1) (component:DateInput FR15).

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 at 69695d1, on main 36f8ea1: the pan takes allowedScrollTypes: ['continuous'], so a trackpad follows the shared path, and only a discrete-notch handler remains, which moves one row (component:DateInput FR15).

Comment on lines +353 to +356
useIsomorphicLayoutEffect(() => {
motion.setLayout(layout);
motion.attach(rowsRef.current);
}, [motion, layout]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Attaching the strip is work caused by the element attaching (architecture:react-component-runtime INV6). A callback ref on the strip <div> that sets rowsRef and calls motion.attach(node) handles attach, detach and replacement; setLayout can stay here.

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 at 69695d1: a callback ref on the strip sets rowsRef and calls motion.attach(strip), and its cleanup calls motion.attach(null), so attach, detach and replacement are handled. setLayout stays in a layout Effect.

Comment thread .changeset/wheel-rest-and-spin.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: the picker wheel takes an owner's rest rule and turns from script, for the time and date-time columns that use them. Under a finger, `resolveRest` names the row a wheel moves on to after resting on another (by default the nearest enabled row), and `spinTo` on the wheel's ref turns it onto a row on the tap spin, then commits it as a rest would; under a fine pointer `spinTo` commits the row at once. Either way it reports whether that row was committed, refusing a disabled row, an index outside the rows and a row the owner's rest moves off, and a turn the wheel's unmounting cuts short reports that it did not arrive. A rest on the committed row still commits nothing. Nothing public changes: the wheel is not exported from the package entry point.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No time or date-time column passes resolveRest or a ref at this head (TouchTimeField.tsx, TouchDateTimeField.tsx, MonthYearWheels.tsx), so this note describes code with no caller. Drop it with the plumbing, or land both with the column that uses them.

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.

Removed at 69695d1 with the plumbing. resolveRest goes; spinTo and WheelHandle move to #8179, with the time wheels that call them, their tests and their Changeset.

Comment thread .changeset/wheel-uikit-picker.md Outdated
'@astryxdesign/core': patch
---

[feat] Under a coarse primary pointer, as on a phone or a tablet, the picker wheels in the TimeInput, DateTimeInput and DateInput bottom sheets now move like UIPickerView. Their rows keep today's 28 px pitch and lie flat, fading toward the edges of the column in every browser; today's column tilts and fades its rows only where the browser supports scroll-driven animations, and shows a plain list elsewhere. The row under the band is in semibold accent text on its plate while the wheel rests and while a finger drags it; no row is marked while the wheel coasts, snaps or turns. A flick coasts at UIKit's deceleration onto the row UIKit's projection picks. A finger landing on a moving wheel stops it. A tap on a row turns it onto the band, and a value changed from outside while the sheet is open, as when a bound clamps the time, turns the wheel onto its row instead of jumping there. Past the first or last row, a drag rubber-bands and the wheel bounces back. As on iOS, the value commits when the wheel comes to rest, and the coast keeps running while the page's main thread is busy. Arrow keys and clicks on a row still commit at once. Under reduced motion a flick still coasts, as scrolling does; a tap, a key or a slow release cross-fades onto its row. `presentation="popover"` keeps the previous surface; no value keeps the native-scroll column under a finger. With a mouse the wheels keep today's column.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"With a mouse the wheels keep today's column" is true only where the primary pointer is fine: Wheel picks by the primary pointer, so a mouse on a tablet gets the touch wheel. Suggest "Under a fine primary pointer, as with a mouse on a desktop, the wheels keep today's column."

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.

Taken at 69695d1: "Under a fine primary pointer, as with a mouse on a desktop, the wheels keep the scrolling column."

Comment on lines +390 to +391
away from the committed row. Under a finger the rows fade without tipping or
shrinking; under a mouse they keep main's scroll-timeline tilt.

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 sentence contradicts the unchanged first sentence of DEC-2 ("looks the same under a finger as under a mouse … rows tip back") and the Ownership non-goal, so it needs an engineering owner's decision. A record also should not name a branch ("main's scroll-timeline tilt").

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 69695d1. DEC-2 now says one thing: under a finger, each sheet wheel lays its rows flat at the scrolling column's pitch and fades them toward its edges, without tipping or shrinking; under a mouse, the scrolling column tips and fades its rows on a scroll timeline. Its title and the cylinder non-goal match, and it names no branch. The owners approve the record lines at this head.

@vjeux vjeux changed the title feat(Wheel): UIPickerView's motion under a finger, in today's look feat(Wheel): UIPickerView's motion under a finger, with flat rows Nov 2, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 69695d1

@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 is ready to approve once its browser checks pass. Every behavior change is covered by the current component:DateInput and spec:AST-094 records, and the record changes here carry an engineering owner's /approve-spec for this head. The earlier findings are all fixed: the trackpad path, the unused turn plumbing, the FR23 haptics gap, the strip's attach Effect and the overclaiming notes.

Before merging:

  • Let the browser lanes run (they were skipped while the pull request is a draft), and merge after #8131.

Optional tidy-ups:

  • Scope component:DateInput FR18 to a coarse primary pointer, and set DEC-2's decider date to the approval date.
  • Fill in the @BROWSER@ line in the description, and drop "a programmatic spin, a custom rest rule" from PickerWheel.test.tsx's header.

[Automated review]

| FR14 | A shown bottom-sheet picker wheel whose value changes from outside, as when a bound clamps the time after another wheel turned, MUST turn onto the new row rather than jump to it. The turn plays the internal motion role `picker.tapSpin.durationMs`, UIKit's measured 300 ms, on `timing.curves.move`, as a tap on a row does. | UIDatePicker on the iOS 26.2 simulator; current source |
| FR15 | Under a coarse primary pointer, a sheet wheel's pan takes a trackpad's two-finger scroll, as `spec:AST-094` FR43 allows a record to grant: the rows follow the fingers, coast on at the lift with the fingers' last speed, and rest on a row by FR13. A mouse-wheel notch turns the wheel one row, as an arrow key does, with no coast. A ctrl-scroll stays the page's zoom (`spec:AST-094` FR43). | `spec:AST-094` FR43; current source |
| FR17 | In a sheet wheel, Arrow, Page, Home and End move through the wheel's enabled values in time order, skipping disabled ones, and stop at the first or last enabled value; a key with no enabled value in its direction commits nothing, under every presentation. On a 12-hour hour wheel, time order follows the full time (12 PM comes before 1 PM), not the column's row order. | Owner decision (cixzhang, 2026-11-02); current source |
| FR18 | While a wheel coasts, snaps or turns, no row carries the selection band's accent mark; the mark returns on the row that comes to rest, as UIPickerView does (`spec:AST-094` FR3). | Owner decision (cixzhang, 2026-11-02); UIDatePicker on the iOS 26.2 simulator; current source |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: FR18 names no presentation, but the scrolling column's highlight follows its scroll (Wheel.tsx's scroll listener), so under a mouse a row is marked while the column coasts or scrolls to a key's row. Scoping FR18 to a coarse primary pointer, as FR15 is, keeps the record from reading as a gap in the mouse column.

### DEC-2 — The sheet wheels' rows lie flat under a finger

**Reference:** `component:DateInput/DEC-2`
**Decider:** cixzhang, 2026-11-01

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: DEC-2's decision text changed at this head and was approved on 2026-11-02, the date FR17 and FR18 cite, but this line still dates it 2026-11-01.

insetBlockStart: `calc(50% - (${dateInputTouchSizes.wheelItemSize} / 2))`,
pointerEvents: 'none',
},
/** One row: an option at its place along the strip, flat. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): this JSDoc and the one on rowAt only restate the style names. Delete both; the same holds for the "Home and End: the first and the last enabled row." comments, which restate moveToEnd/toEnd and endIndex's JSDoc.

vjeux added 11 commits November 2, 2026 18:20
Under a coarse primary pointer, the TimeInput, DateTimeInput and
DateInput sheet wheels move like UIPickerView: a flick coasts at UIKit's
deceleration onto the row UIKit's projection picks, a finger landing on a
moving wheel stops it where it is drawn, a tap on a row turns it onto the
band, and past the first or last row a drag rubber-bands and bounces
back. The value commits when the wheel comes to rest, by the rest rule
(component:DateInput FR13), and the coast keeps running while the main
thread is busy (spec:AST-094 FR5): one strip moves the rows on the shared
animator, with no Layout or Paint per frame.

A value changed from outside while the sheet is open turns the wheel onto
its row instead of jumping there (component:DateInput FR14); a second
change during that turn turns on from where the wheel is drawn.

The rows lie flat (component:DateInput DEC-2): today's 28 px pitch,
fading toward the column's edges without tipping or shrinking. Under
a mouse the scrolling column and its tilt are unchanged. Under Reduce
Motion a flick still coasts, as scrolling does; a tap, a key or a slow
release cross-fades onto its row (design:reduced-motion DR2).

useOwnScrollGesture now also keeps the pointer a touch lands with, for
a scroller that pans through the gesture system, so the wheel adds no
listener of its own for the sheet's pull-down
(architecture:react-component-runtime INV16).

The touch-support bundle check lists DateInput, TimeInput and
DateTimeInput as gesture users, with PowerSearch and Table, whose value
editors reach TimeInput's wheels (architecture:react-component-runtime
INV13). The wheel's proof stories sit under Core/Wheel.

The tap spin's 300 ms is an internal motion role,
`picker.tapSpin.durationMs` (spec:AST-094 FR4), which every turn reads
under its column; the snap at rest keeps UIKit's measured rate as a
constant, because a motion role is a spring, a duration or a curve.

The record: DEC-2 states that under a finger the rows fade without
tipping or shrinking; FR14 names the tap spin's role and FR13 records the
snap rate's FR4 gap; the FR13 and FR14 verification rows cite the touch
wheel's cases; FR15's gap sentence holds for both pointers. The generated
motion role table lists the new role.

Combines the wheel's values and math, its browser proofs, one listbox
hook for both presentations, and the owner's rest rule and spin; looping
stays out until a record covers it.
The flick proof fitted the coast against UIKit's plan for velocities
around the scripted 900 px/s. In WebKit the page reads the release
velocity from the timestamps of the inputs it receives, and WebKit stamps
each input as it dispatches it, in whole milliseconds. Under load the
intervals of the 120 Hz script jitter from 3 to 17 ms while each step
stays 7 or 8 px, so one interval reads anywhere from 580 to 1,750 px/s,
and a short last interval lifts the release past twice the script. The
coast then followed UIKit's plan for that faster release, but no fit in
the scripted range matched it.

The proof now records the inputs a finger's pan reads (its touchstart,
one sample per touch pointermove, and its touchend) on the host's clock,
holds each time at the latest as the gesture arena does, and runs the
pan's own velocity tracker over them. The whole coast must follow UIKit's
plan for that velocity, within the same 0.75 px. In Chromium, which
replays the script's timestamps, the measured velocity must also be
within 5% of the script.
…ch turns it one row

component:DateInput FR15: under a coarse primary pointer, the sheet
wheels' pan takes a trackpad's two-finger scroll, as spec:AST-094 FR43
allows a record to grant (the pan allows `continuous` scroll input): the
rows follow the fingers, coast on at the lift with their last speed, and
rest on a row by the rest rule (FR13). The platform's momentum after the
lift never moves the wheel.

A mouse wheel's notch turns the wheel one row, as an arrow key does, with
no coast. The gesture host leaves a mouse wheel's notches to what is
under the pointer; the wheel steps on a wheel the host reads as a notch
(`scrollTypeOf`, now exported, so the reading has one owner) and leaves
a wheel the pan took, a ctrl-wheel (the page's zoom) and a sideways
scroll alone. The wheel's own pixel scroll handling goes.

The record's FR15 loses its implementation and evidence gaps and its
current-state line; its verification row cites the unit cases and the
browser proofs (notches in Chromium; the trackpad flick and slow stopped
drag in WebKit, whose driver sends a trackpad's precise deltas, on the
touch column opened alone in a desktop context).

Red first: the notch, three-notch, line-mode and trackpad flick cases
fail on the wheel's previous pixel scrolling; the flick rests 80 px past
the lift there, 426 px with this change.
…, which nothing here calls

`spinTo` on a Wheel's ref (`WheelHandle`) and the `resolveRest` prop have
no caller in this change: the time wheels that turn a column from script
land with TimeInput's sheet, and no column passes a rest rule. Both go,
with their tests and Changeset; the touch wheel always rests on the
nearest enabled row (`component:DateInput` FR13), and today's column is
Wheel's fine-pointer branch again, with no handle around it.
…eet wheels look like and tick

The strip reaches the animator from a callback ref: attaching is work the
element's attaching causes, and the ref lets the animator go as the strip
detaches or is replaced (architecture:react-component-runtime INV6). The
motion's layout still follows the rows in an Effect.

component:DateInput DEC-2 now says one thing: under a finger the rows lie
flat at the scrolling column's pitch and fade toward its edges without
tipping or shrinking, and under a mouse the scrolling column tips and
fades its rows on a scroll timeline; its title and the cylinder non-goal
match. Current-state impact records that the selection tick vibrates
where the browser has the Vibration API, as on Android, and is silent in
iOS Safari (spec:AST-094 FR23). The Changeset names the vibration and
says the wheels keep today's column under a fine primary pointer; the
wide-range story no longer says rows tip.
… one, under a finger and a mouse

The spec owners' rulings, recorded in component:DateInput:

- FR17: in a sheet wheel, Arrow, Page, Home and End move to the next
  enabled row in the key's direction and stop at the last enabled row; a
  key with no enabled row in that direction commits nothing, under every
  presentation. The touch wheel already did; the scrolling column under a
  mouse stopped at a disabled row instead. Both now share one rule
  (`steppedIndex` and `endIndex` in wheelOptions.ts). The new cases at a
  12:00–17:00 bound and across a disabled row fail for the mouse column
  before this change.
- FR18: while a wheel coasts, snaps or turns, no row carries the
  selection band's accent mark; the mark returns on the row that comes to
  rest, as UIPickerView does (spec:AST-094 FR3). A new case covers the
  snap and a key's turn beside the fling's coast.
…M comes before 1 PM

The spec owners' FR17 for component:DateInput, verbatim: in a sheet
wheel, Arrow, Page, Home and End move through the wheel's enabled values
in time order, skipping disabled ones, and stop at the first or last
enabled value; a key with no enabled value in its direction commits
nothing, under every presentation. On a 12-hour hour wheel, time order
follows the full time (12 PM comes before 1 PM), not the column's row
order.

A wheel option may now say where it falls when a key steps the wheel
(`order`); without it, rows step in list order. The 12-hour hour columns
of TimeInput's and DateTimeInput's sheets set the full hour, so at a
12:00–17:00 bound ArrowDown and End from 5 PM commit nothing, and
ArrowDown from 12 PM commits 1 PM, under a finger and a mouse. Before
this change both walked row order: ArrowDown from 5 PM skipped 6 to 11
and committed 12 PM. The field-level cases and the Wheel cases on a
12-hour column fail without it. The mouse column's change is a defect
fix: a key could not reach 12 PM there, against WCAG 2.1.1.
Comments, the story and two Changesets said "today's column" for the snap-scrolling column under a mouse, which names nothing once this lands. They now say the scrolling column. The listbox hook's Changeset no longer claims that no behavior changes: this pull request changes how keys step.
…h on its row

Under a finger, a key stopped the wheel's motion before it asked where to
go. With no allowed row in its direction, as a second ArrowDown during the
turn onto the last allowed row finds, the key then did nothing more: the
wheel stayed between rows while the field showed the committed value.
The key now finds its row first and leaves the turn alone when there is
none. The new case fails before this change, 21.5 px off its row.
… shows

The column faded its rows with a mask on itself, the focusable listbox.
A mask clips everything the element paints, its outline included, so a
wheel focused from the keyboard under a finger matched :focus-visible and
showed no ring (architecture:interaction-modality INV1). The fade moves to
an inner layer around the band and the strip; the rows fade as before.
A jsdom case checks that layer, and a Chromium case checks the focused
column's computed mask and outline and the fade layer's gradient.

Two comments still said "Today's column" and "Today's selection band";
they now name the scrolling column and the selection band.
…ementation gap

The spec owners' ruling on the sheet picker wheel's snap rate: it is an
implementation gap of spec:AST-094 FR4, in the record's own words. "The
sheet picker wheel's snap rate (UIKit's measured picker deceleration)
plays from an internal constant and is not yet a named role; a role kind
for rates comes with a later change." The gap now lives only in FR4, so
component:DateInput FR13 and its Current-state line no longer state it.
@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 94d18e1

@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 looks ready to approve. Every behavior change is covered by the current component:DateInput and spec:AST-094 records, and the record changes here carry an engineering owner's /approve-spec for this head. No blocking issue was found.

Before merging:

  • Let the browser lanes run, the busy-main-thread proof among them; they are skipped while the pull request is a draft.

Optional tidy-ups:

  • Scope component:DateInput FR18 to a coarse primary pointer, since under a mouse the scrolling column still marks the row under its band while it scrolls.
  • Update DEC-2's decider date, the @output line of PickerWheel.test.tsx, and the line in wheel-uikit-picker-values.md that calls the tap spin an internal constant.
  • Drop the comments that only restate the code (inline notes).

[Automated review]

| FR14 | A shown bottom-sheet picker wheel whose value changes from outside, as when a bound clamps the time after another wheel turned, MUST turn onto the new row rather than jump to it. The turn plays the internal motion role `picker.tapSpin.durationMs`, UIKit's measured 300 ms, on `timing.curves.move`, as a tap on a row does. | UIDatePicker on the iOS 26.2 simulator; current source |
| FR15 | Under a coarse primary pointer, a sheet wheel's pan takes a trackpad's two-finger scroll, as `spec:AST-094` FR43 allows a record to grant: the rows follow the fingers, coast on at the lift with the fingers' last speed, and rest on a row by FR13. A mouse-wheel notch turns the wheel one row, as an arrow key does, with no coast. A ctrl-scroll stays the page's zoom (`spec:AST-094` FR43). | `spec:AST-094` FR43; current source |
| FR17 | In a sheet wheel, Arrow, Page, Home and End move through the wheel's enabled values in time order, skipping disabled ones, and stop at the first or last enabled value; a key with no enabled value in its direction commits nothing, under every presentation. On a 12-hour hour wheel, time order follows the full time (12 PM comes before 1 PM), not the column's row order. | Owner decision (cixzhang, 2026-11-02); current source |
| FR18 | While a wheel coasts, snaps or turns, no row carries the selection band's accent mark; the mark returns on the row that comes to rest, as UIPickerView does (`spec:AST-094` FR3). | Owner decision (cixzhang, 2026-11-02); UIDatePicker on the iOS 26.2 simulator; current source |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: FR18 names no presentation, while FR17 says "under every presentation". Under a mouse, the scrolling column's scroll listener (Wheel.tsx:452-476) moves the accent mark with the scroll, so the rule holds only under a coarse primary pointer. Consider scoping it that way.

### DEC-2 — The sheet wheels' rows lie flat under a finger

**Reference:** `component:DateInput/DEC-2`
**Decider:** cixzhang, 2026-11-01

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: DEC-2's text changed in this pull request, after the 2026-11-02 decision that FR17 and FR18 cite, but the decider line still dates it 2026-11-01.

insetBlockStart: `calc(50% - (${dateInputTouchSizes.wheelItemSize} / 2))`,
pointerEvents: 'none',
},
/** One row: an option at its place along the strip, flat. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): this JSDoc and the one on rowAt (line 144) restate the style names. Delete both.

return () => column.removeEventListener('wheel', onWheel);
}, [isActive, moveBy]);

// Home and End: the first and the last enabled row.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): restates moveToEnd and endIndex. Delete it, and the same line in Wheel.tsx (515).

);
const option = options[next];
if (option == null || option.isDisabled) {
// A key commits the row it moves to and scrolls the column there.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (INV29): this narrates moveTo. Delete it. activate below (line 521) has the same body as moveTo, so it can be moveTo itself.

This branch was successfully deployed

1 active deployment
Preview — 94d18e19 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

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