Repository navigation
feat(Wheel): UIPickerView's motion under a finger, with flat rows - #8159
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report🔎 Preview ·
|
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| DateInput · Storybook | 🔴 147.8 kB (+41.9 kB) · 15.4 kB CSS (+51 B) | 5,007 (+1,219) | ✓ · ✓ · ✓ |
| PowerSearch | 🔴 235.3 kB (+40.9 kB) · 21.7 kB CSS (+45 B) | — | — · — · — |
| TimeInput · Storybook | 🔴 132.7 kB (+39.9 kB) · 14.4 kB CSS (+70 B) | 1,251 (+1) | ✓ · ✓ · — |
8 more components
| Component | Size | LoC | Visual · A11y · RTL |
|---|---|---|---|
| lab/MobilePowerSearch | 🔴 224.9 kB (+6.2 kB) · 1.2 kB CSS (0) | — | — · — · — |
| DateTimeInput · Storybook | 🔴 168.5 kB (+5.9 kB) · 17.1 kB CSS (+50 B) | 3,644 (+1) | ✓ · ✓ · ✓ |
| AlertDialog | 84.4 kB (0) · 13.1 kB CSS (0) | — | — · — · — |
| SegmentedControl | 82.5 kB (0) · 7.8 kB CSS (0) | — | — · — · — |
| Slider | 89.4 kB (0) · 9.4 kB CSS (0) | — | — · — · — |
| Switch | 87.6 kB (0) · 10.6 kB CSS (0) | — | — · — · — |
| lab/Comments | 259.2 kB (0) · 2.5 kB CSS (0) | — | — · — · — |
| lab/CommentThread | 254.1 kB (0) · 2.4 kB CSS (0) | — | — · — · — |
Package size: @astryxdesign/core 6.27 MB packed (+36.8 kB); no new dependencies.
API changes: none in this pull request
Committed API report behind this build: core 2 rows; run pnpm api:report.
Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.
Generated by PR Enrichment workflow | View full report
There was a problem hiding this comment.
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
optionsarray rerunsmotion.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]
ece20fb to
ee9446d
Compare
There was a problem hiding this comment.
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]
ee9446d to
2def93c
Compare
There was a problem hiding this comment.
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.stylexdrops the releasedwheelItemSizeandwheelEdgePaddingkeys in a patch; keep them and movewheelGeometryout 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]
|
|
||
| // 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}); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| case 'Home': | ||
| event.preventDefault(); | ||
| moveBy(-activeIndex); | ||
| moveBy(-restIndex); | ||
| break; | ||
| case 'End': | ||
| event.preventDefault(); | ||
| moveBy(options.length - 1 - activeIndex); | ||
| moveBy(count - 1 - restIndex); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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 /> |
There was a problem hiding this comment.
The prop is isLooping; looping is not a Wheel prop.
There was a problem hiding this comment.
Fixed: the example uses isLooping.
| '@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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
2def93c to
1c1e238
Compare
There was a problem hiding this comment.
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"contradictsspec:AST-012FR9, which defines compact as width below the theme'smdpoint or height below 500 px. - Home and End pressed while a wheel is still moving commit the wrong row, and
wheelGeometrybecomes 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]
| case 'Home': | ||
| event.preventDefault(); | ||
| moveBy(-restIndex); | ||
| break; | ||
| case 'End': | ||
| event.preventDefault(); | ||
| moveBy(count - 1 - restIndex); | ||
| break; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)'; |
There was a problem hiding this comment.
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.
| * holds them to the table. Outside the iOS idiom the wheels keep the sizes | ||
| * above. | ||
| */ | ||
| export const wheelGeometry = stylex.defineConsts({ |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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.
| '@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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Resolved: this stack no longer contains #8298. The wheel reads useTouchPresentation(), so presentation-resolver.md is not in this pull request.
|
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. |
There was a problem hiding this comment.
This looks ready to approve. Every behavior change is covered by the current component:DateInput and spec:AST-094 records, and the record changes carry an engineering owner's /approve-spec on this head. The wheel's jsdom suites and its Chromium interaction and accessibility proofs, the coast through a 1 s main-thread block included, pass at this head; CI's lanes, WebKit's included, still need to pass once the pull request leaves draft.
Optional tidy-ups, none blocking:
- Scope FR18 to a coarse primary pointer: the scrolling column under a mouse still marks rows while it scrolls.
- Update DEC-2's decider date, the
PickerWheel.test.tsxheader (it still lists a programmatic spin and a custom rest rule), and the changeset line that calls the tap spin an internal constant (it is the internal motion rolepicker.tapSpin.durationMs).
[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 | |
There was a problem hiding this comment.
Non-blocking: FR18 reads as every sheet wheel, but under a mouse the scrolling column still sets the marked row on each scroll frame (Wheel.tsx:455-474) and marks a key's target before its smooth scroll. Scoping it to a coarse primary pointer ("Under a coarse primary pointer, while a wheel coasts…") matches the shipped behavior and its evidence row.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: UIPickerView's values measured on iOS 26.2 join the touch motion tables as internal constants: the row pitch, the picker's height and its rubber band, the coast's stop speed and velocity floor, the snap at rest, the tap spin and the selection tick. The pure math a wheel moves with comes with them. It projects the row a release rests on as UIScrollView sums its deceleration, coasts with the rate re-solved to stop on that row, snaps, bounces at an end, rubber-bands, and spins to a tapped row. It is checked against UIKit's recorded frames. Nothing public changes: none of it is exported from the package entry point, and themes do not set these values. |
There was a problem hiding this comment.
Non-blocking: the tap spin is no longer only an internal constant: registerPickerTable makes its duration the internal motion role picker.tapSpin.durationMs (FR14). Consider "…and the selection tick, with the tap spin's duration as an internal motion role".
| * dispatched through the gesture system with a fake clock | ||
| * @output Proves what a wheel commits and when: a drag, a release onto a row, | ||
| * past the bounds, a fling, a tap, a finger catching a moving wheel, the | ||
| * keyboard, a click, a programmatic spin, a custom rest rule |
There was a problem hiding this comment.
Non-blocking: this file no longer tests a programmatic spin or a custom rest rule (they moved with spinTo and resolveRest), so the @output can end at "the keyboard, a click".
|
/approve-spec 98cd5be |
There was a problem hiding this comment.
Changes requested for one fix. The record changes carry an engineering owner's /approve-spec on this head, every other behavior change is covered by the current component:DateInput, spec:AST-094 and design:reduced-motion records, and the jsdom suites, typecheck, lint and record checks pass.
- The rebase puts back the fixed 45-frame window in the Reduce Motion step proof of
WheelRest.a11y.chromium.spec.ts(lines 213–248), which #9734 replaced onmainwith a recorder that reads frames until the column is at rest; keepmain's polled recorder and change only what it samples (the selected row and the strip that fades).
Optional tidy-ups: scope FR18 to a coarse primary pointer (the mouse column still marks rows while it scrolls), and update DEC-2's decider date, the PickerWheel.test.tsx header and the changeset line that calls the tap spin an internal constant. CI's product lanes, WebKit's included, still need to pass once the pull request leaves draft.
[Automated review]
| ), | ||
| opacity: Number(getComputedStyle(faded).opacity), | ||
| }); | ||
| if (seen.length < 45) { |
There was a problem hiding this comment.
This brings back the fixed 45-frame window that #9734 replaced on main with a recorder polled until the column is at rest (a drawn frame with the rows moved at opacity 1). On a loaded runner the window can close before the dip ends, and expect(seen.at(-1)?.opacity).toBe(1) below then fails. Keep main's polled recorder and only change what it samples: the selected row and its parent, the strip that fades under the touch column.
|
/approve-spec 745f99c |
There was a problem hiding this comment.
Changes requested for one fix. The record changes carry an engineering owner's /approve-spec on this head, every other behavior change is covered by the current component:DateInput, spec:AST-094 and design:reduced-motion records, and the Reduce Motion step proof again records frames until the column rests.
packages/core/src/DateInput/wheelStrip.ts:19importscreateAnimatorfrommotion/animator, whichmainhas since renamedcreateMotionAnimator(#8653), so the branch merged withmainfails core's typecheck even though GitHub shows no conflict; rebase and take the rename at lines 19 and 135.
After the rebase, rerun the wheel suites, since main also changed when the animator starts a retarget on a settle tail (#9733), and drop the second PowerSearch and Table entries the merge leaves in scripts/touch-support-bundle.test.mjs. The other notes (FR18's scope, DEC-2's date, a test header, one changeset word, a few comments) are optional.
[Automated review]
| */ | ||
|
|
||
| import {canVibrate, playHaptic} from '../gestures/haptics'; | ||
| import {createAnimator, type MotionCurve} from '../motion/animator'; |
There was a problem hiding this comment.
main renamed this factory to createMotionAnimator (#8653), so once merged this import fails core's typecheck (TS2305: Module '"../motion/animator"' has no exported member 'createAnimator'), though GitHub shows no conflict. Rebase and import createMotionAnimator here and at line 135.
There was a problem hiding this comment.
Approval waits on one engineering-owner decision; no blocking code defect remains, and the earlier merge break with main (the animator rename) is fixed on this head.
This head changes requirement text in the current component:DateInput record (DEC-2's flat touch rows, FR14's tap-spin role sentence, the new FR17 and FR18) and adds the snap-rate implementation gap to spec:AST-094 FR4. The same lines carried an owner approval on the previous head, but the rebase commits end it. One behavior also has no governing record yet: under a finger, a tap on a row now commits when its 300 ms turn ends instead of at the tap, while a keyboard or assistive-technology click still commits at once. An engineering owner needs to approve both (or say which should change, for example a sentence in FR13 or FR14 naming when a tap commits) by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner.
Optional notes, none blocking:
- FR18 reads as every wheel, while the scrolling column under a mouse still marks rows as it scrolls; scoping it to a coarse primary pointer would match its evidence.
- DEC-2's decider date, the
PickerWheel.test.tsxheader, the tap-spin wording inwheel-uikit-picker-values.md, and a few comments that restate the code could be tidied.
[Automated review]
| | 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 | | ||
| | FR16 | Enter in the pointer field's text input commits typed text that has not been committed and is then held back, so a form never submits the value from before the commit. With no typed text pending, Enter is not prevented, so a form submits as it does from any text field, except on a disabled or busy field, where Enter does nothing (`spec:AST-085` FR2). | | ||
| | 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 | |
There was a problem hiding this comment.
Non-blocking: as written FR18 covers every wheel, but the scrolling column under a mouse still marks the row under the band on each scroll frame (Wheel.tsx:453-483) and marks a key’s target before its smooth scroll (Wheel.tsx:499). Scoping FR18 to a coarse primary pointer would match its only evidence row.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: UIPickerView's values measured on iOS 26.2 join the touch motion tables as internal constants: the row pitch, the picker's height and its rubber band, the coast's stop speed and velocity floor, the snap at rest, the tap spin and the selection tick. The pure math a wheel moves with comes with them. It projects the row a release rests on as UIScrollView sums its deceleration, coasts with the rate re-solved to stop on that row, snaps, bounces at an end, rubber-bands, and spins to a tapped row. It is checked against UIKit's recorded frames. Nothing public changes: none of it is exported from the package entry point, and themes do not set these values. |
There was a problem hiding this comment.
Non-blocking: the tap spin is registered as the internal motion role picker.tapSpin.durationMs (picker.ts:114-116), so listing it among the internal constants reads inaccurately.
| * dispatched through the gesture system with a fake clock | ||
| * @output Proves what a wheel commits and when: a drag, a release onto a row, | ||
| * past the bounds, a fling, a tap, a finger catching a moving wheel, the | ||
| * keyboard, a click, a programmatic spin, a custom rest rule |
There was a problem hiding this comment.
Non-blocking: this file no longer tests a programmatic spin or a custom rest rule; dropping them from the header keeps it accurate.
|
/approve-spec 90f8d8a |
|
@astracat-bot review |
There was a problem hiding this comment.
Ready to approve: the record changes in component:DateInput and spec:AST-094, and a finger's tap committing when its turn ends, carry an engineering owner's approval for this head, and nothing blocks.
Optional follow-ups:
- FR18 reads as covering every wheel, while the scrolling column under a mouse still marks rows as it scrolls; scoping it to a coarse primary pointer would match its evidence.
- In
PickerWheel.test.tsx, "ignores fingers while hidden" compares a row'sstyle.transform, which the wheel never writes, so its movement check cannot fail; comparingdistanceOf(option('10'))would. - The
PickerWheel.test.tsxheader, the tap-spin wording inwheel-uikit-picker-values.md, two orphaned test doc comments, and a few comments that restate the code could be tidied.
[Automated review]
| const before = option('10').style.transform; | ||
| drag(column, 150, 60, {steps: 10, interval: 16}); | ||
| advance(3000); | ||
| expect(onChange).not.toHaveBeenCalled(); | ||
| expect(option('10').style.transform).toBe(before); |
There was a problem hiding this comment.
Non-blocking: the wheel never writes a row's style.transform (it moves the strip's translate), so this half of the test cannot fail if a hidden wheel moves. expect(distanceOf(option('10'))).toBeCloseTo(0, 4) would check it.
| /** A slow one-finger drag: no fling, so the wheel rests where it is let go. */ | ||
| /** A mouse case runs under a fine primary pointer: the page agrees. */ |
There was a problem hiding this comment.
Non-blocking: the first doc comment belongs to drag below, but now sits above expectFinePointer; move it down to drag.
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.
…draws, and a starved page still clamps to the maximum The frame-by-frame case records the strip's drawn top with each frame's time until a frame shows the wheel at rest, then checks the turn with expectAnimatedTurn: no frame count and no fixed wait. A new case taps PM from 10 AM under 8x CPU throttling and waits until the wheels are at rest; the field must read 5:00 PM with the hour wheel on 5. On main's scrolling column a late settle can commit a stale AM and end on 9:00 AM or 12:00 PM.
…de's own timeline The scrolling column's dip now lands its rows when the fade-out ends, and plays the fade-in after that. Its unit cases finish the fake fade-out instead of advancing a timer, read the two halves, and add the late-frame and unmount cases. The sheet's browser proofs keep this branch's touch-column versions.
…e column rests The rebase took back the fixed 45-frame window. The proof now keeps main's polled recorder, which reads frames until a frame shows the rows moved and the column fully visible, and samples this branch's committed row and the strip that fades.
main renamed motion/animator's createAnimator to createMotionAnimator. The wheel strip creates its animator through the new name; nothing else changes.
main lists PowerSearch and Table through AlertDialog's Tokenizer, and this branch listed them again for TimeInput's wheels. The list keeps one entry each, and its comment names both reasons.
|
/approve-spec 2c6622c |
There was a problem hiding this comment.
Ready to approve. The record changes in component:DateInput and spec:AST-094 carry an engineering owner's /approve-spec on this head, every other behavior change is covered by current records, and the wheel's Chromium browser proofs pass in CI.
Optional follow-ups, none blocking:
- Scope FR18 to the touch column, since the scrolling column under a mouse still marks the row under its band as it scrolls.
- Add a sentence to FR14 saying a finger's tap commits when its turn ends, while a click commits at once.
- Make "ignores fingers while hidden" compare where the strip draws a row, since the wheel never writes a row's
transform.
[Automated review]
Stack: #8159 → #8178 → #8179 → #8209 → #8200 → #8206
Batch: B4 · Wheel / picker (DateInput, TimeInput, DateTimeInput)
The change: vjeux/astryx@9136e28...2c6622c
Supersedes #8156, #8526, #8162, #8361, #8527, #8480 and #8698.
#8156, #8162, #8361 and #8698 combine 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 travel. This moves them like UIPickerView; a mouse keeps the scrolling column. Keys step in time order: 12 PM is reachable.
Current specification
spec:AST-094FR4 and its implementation gaps, FR5, FR6, FR23, FR43.component:DateInputDEC-1, DEC-2, FR13 to FR15, FR17, FR18.design:reduced-motionDR2, DR3;architecture:react-component-runtimeINV6, INV13, INV16;architecture:interaction-modalityINV1.Owner decisions
DEC-2, FR14's tap-spin role, FR17, FR18 and FR4's snap-rate gap carry the owners' rulings verbatim; FR13 no longer repeats the gap.
Design and alternatives
decayand turning withplay, faded by a static column mask.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, paid once per page.
Writing rubric results
Over DEC-2, FR13 to FR18, FR4's gap line, the Current-state lines and front matter:
Evidence
Testing
Range-diff from the head the feel check passed (5c8919c) to this one, on main 9136e28: its 11 commits, 6 of them differing only in test, record or context lines, plus one identifier-only commit (
createAnimator→createMotionAnimatorinwheelStrip.ts, main's rename) and four test-only commits. The PR's own product diff is byte-identical except those two lines. The last rebase met one conflict, inscripts/touch-support-bundle.test.mjs: main's import list is kept as it is, and the wheels' reason joins its comment. The rest is test changes only: the meridiem turn proof judges the drawn frames, a new 8× starvation case, the scrolling column's Reduce Motion unit cases follow the dip's own timeline, the Reduce Motion step proof keeps main's polled recorder, and the touch bundle lists PowerSearch and Table once. Gates at 2c6622c: build, typecheck, lint (0 errors), check:knowledge, check:changesets and the Storybook build pass; test:ui 19,683 of 19,683; the date, time and motion units 2,356; WheelRest ×10 100 of 100 at 1 worker and at 4 workers on two threads; Chromium a11y contracts 134 and interaction 22 passed; WebKit at CI's profile ×10 180 passed, 60 skipped. test:node: under full load, CLI tests time out on main too. Run beside main's build at the same time, five times (three at 58c5b8a, two at 9136e28 with each launched first), main failed 13, 4, 10, 4 and 1 files and this branch 16, 4, 11, 4 and 2, nearly all timeouts. The other errors were the same, and no file failed every time here and never on main. That load issue on main is tracked separately. The docsite's one CI failure (a copied heading link under the sticky header, right-to-left and dark) passed 66 of 66 runs on both builds here, with CI's settings and without; in one full run both builds failed the same other docsite case.On a phone or the iOS simulator, open Storybook's TimeInput › Bottom Sheet With Min Max:
Automated:
PickerWheel.test.tsx,Wheel.test.tsx,wheelStrip.test.ts,wheelPhysics.test.tsand TimeInput'sPresentation.test.tsxin jsdom; the browser specs in Chromium and WebKit.Merge order: this pull request, then #8178, #8179 and #8209.