Repository navigation
feat(Wheel): a trackpad scroll and a mouse wheel drive the picker wheel's pan (spec:AST-094 FR43) - #8698
feat(Wheel): a trackpad scroll and a mouse wheel drive the picker wheel's pan (spec:AST-094 FR43)#8698vjeux wants to merge 1996 commits into
Conversation
…n before the click guard internal/motion.ts (meta#8562) re-exports UIKIT_CLICK from the barrel, so a tree carrying both fails typecheck and the Storybook build since this pull request dropped it. The barrel keeps it, with the click guard's window, and the table of every gesture group still leaves it out. Fails until the barrel exports it.
…'s window This pull request dropped UIKIT_CLICK when the click guard took the stray-click window over, but importers written before it read the barrel's UIKIT_CLICK (internal/motion.ts in meta#8562 re-exports it), so a tree carrying both failed typecheck and the Storybook build. UIKIT_CLICK stays in the barrel, deprecated, with the guard's 400 ms (CLICK_GUARD_WINDOW_MS), and UIKIT_GESTURES still leaves it out.
…ter proofs
spec:AST-094 FR43 has gestures attach for every pointer, so each FR43 proof needs a pen and a wheel as real engine input, not only a finger. webInputDriver(page) adds wheel({x, y, dx, dy, ctrl}) and pen(points): in Chromium through CDP Input.dispatchMouseEvent, with a wheel step's deltas scaled back so the page receives exactly its CSS px (Chromium divides them by the device scale factor times the visual viewport's scale: a raw 120 px step reached a phone page as 40), and a pen stroke with pointerType pen, pressure, tilt and scripted timestamps; in WebKit through page.mouse.wheel in a desktop context. WebKit's phone emulation has no wheel and Playwright has no pen for WebKit: the driver's wheels and pens flags say so, with WHEEL_SKIP and PEN_SKIP. The input log also records wheel steps and pointer pressure.
…ter events) into meta#8360
…ter events) into the every-pointer host # Conflicts: # packages/core/src/gestures/__tests__/GestureHost.test.ts
…k) into the FR43 harness helper Keeps the helper's harness self-test green in WebKit too: meta#8535 fixes the backward case's flake under load.
On a page whose wheel listeners are all passive (every page with the input log), Chromium answers Input.dispatchMouseEvent before the page has the wheel step and delivers it afterwards, so a log read right after wheel() could miss it. wheel() now arms a one-shot passive capture listener before the dispatch and waits until the page has the step (2 s limit), in WebKit too. The ctrl-only note was wrong: a ctrl step reaches an all-passive page as well, and nothing zooms. The docs cite spec:AST-100 FR4, which is current.
… into the FR43 harness helper
Bundled with the size report's esbuild options, none of Core's public entries carries a byte of gestures/ until a component imports it (architecture:react-component-runtime INV13). The click guard in utils/ imports the haptic switch's attribute from gestures/, so the root and hooks entries carry gestures/hapticSwitch.ts: both cases fail until it moves.
…blic entries carry no gesture code The click guard (utils/clickGuard.ts) imported HAPTIC_SWITCH_ATTRIBUTE from gestures/hapticSwitch.ts, which put gesture code in Core's root and hooks entries. The attribute moves to utils/hapticSwitch.ts, as the coarse-pointer query did; the click guard, the gesture host and haptics read it there, and gestures/hapticSwitch.ts re-exports it, deprecated, for importers written before the move. The module-graph case follows the new path.
…der a fine pointer (spec:AST-094 FR24, FR19) FR24 as revised at c46ace4: a claiming recognizer's touchmove listener for a finger counts outside the one-listener budget. Under a fine pointer a horizontal pan's element carries its non-passive touchmove beside its one pointerdown, so a touchscreen laptop's finger claims as on a phone; a tap, which claims only at its lift, keeps its one listener. A one-way pan (a toast's swipe) writes no touch-action, leaving the browser every direction, so its element's listener, there before the finger lands, is the only way it claims. In a desktop browser context the fixture's claiming elements carry that listener and nothing else, and the page none. All fail while the blocking listener is a coarse pointer's only.
…chmove for every pointer (spec:AST-094 FR24, FR19) FR24 at c46ace4 counts a claiming recognizer's touchmove listener for a finger outside the one-listener budget, so a fine pointer's element keeps it beside its press's pointerdown: a touchscreen laptop's finger claims by cancelling its moves, as on a phone, instead of resting on touch-action alone. A kept press (a hold-repeat) keeps its listener too. The two cases that counted only the pointerdown now count both.
…claims (spec:AST-094 FR43, FR41) A mouse drag starts selecting text before a pan claims at 10 px, so a pan over text (a toast, a pager page, a sheet) selected it while it moved. Once the pan claims, the selection the drag started goes and the element refuses one until the lift; a mouse click's selection is left alone, as a tap claims only at its lift. The pan case fails until the host locks on a mouse or a pen claim.
…laims (spec:AST-094 FR43, FR41) The host locked selection only for a finger or a pen at touch-down under a coarse pointer, so a mouse drag that a pan claimed kept selecting the text under it. The arena's claim now locks a mouse or a pen contact: the selection its drag started goes and the claiming recognizers' elements refuse one with inline styles until the pointer lifts (lockDragSelection, utils/selectionLock.ts, which shares lockSelection's refcounted styles). A tap claims at its lift, so a click keeps its selection.
… the drag selection lock and the one-read finger The README's fine-pointer listener budget names a claiming recognizer's blocking touchmove beside the press listener (FR24, FR19), and a mouse or pen drag a recognizer claims takes no selection (FR41). The changeset says so, and that a finger is read once where the browser sends Touch Events without exposing ontouchstart.
…s (spec:AST-094 FR43) A ctrl-click is macOS's context click, so like a right click it starts no press, while a ctrl-click elsewhere still presses. isPrimaryClick (utils/primaryClick.ts) names the rule once, for the host and for the press controller; the test helper now takes a button and the ctrl key. Fails until the host checks it.
…k never did (spec:AST-094 FR43) isPrimaryClick (utils/primaryClick.ts, on utils/isApplePlatform.ts) is the one rule: the main button, and not a ctrl-click on macOS or iPadOS, which is that platform's context click. The host's armed and installed press paths use it, and the press controller shares it.
… public entries carry no gesture code) into the every-pointer host
…xperimental touch exports The motion README's Files table is the union: main's rows, with meta#8663's resolveSnapPoint.ts and selectPage.ts, plus this branch's, in main's order. # Conflicts: # packages/core/src/motion/README.md
…two frames In Chromium the press reaches the compositor with no page round trip, so a stroke sent right after a navigation could carry the previous document's page scale: about:blank's in the phone project put a press at 100 px at 41.02 px (9 of 10 strokes when the driver predates the navigation). pen() now waits for two presented frames first, through the harness's own frames. The README's WebKit wheel row also says the step moves the mouse there and holds Control as a key.
…o frames) into the FR43 harness helper
…nd a pinch a ctrl-wheel (spec:AST-094 FR43) A pan takes scroll input only for the scroll types it allows (UIKit's allowedScrollTypesMask, none by default, so its element gets no wheel listener), along its own axis, after the scrollers under the pointer, as decided at a wheel gesture's first event; a line- or page-mode delta or a whole pixel notch is a mouse wheel's (discrete), the rest a trackpad's. A pinch takes a ctrl-wheel (a trackpad pinch), its scale following exp(-deltaY / 100). A wheel gesture ends 100 ms after its last event, keeps the page's selection, and the listener goes when the pan is disabled. The manual clock runs its timers on runDue. Fails until the host takes wheels.
…nto uikit/c21-wheel-look
…o their own changesets In the same release meta#8179's time columns loop, so "no shipped column loops yet" would contradict it; the columns that loop say so themselves.
…heirs) into uikit/c21-wheel-rest-and-spin
…heirs) into uikit/c21-wheel-look
… scrollers) into uikit/c21-wheel-fr43
…tep a frame from the change that ends it Against the control, a frame with no new component position was a miss. On an easing tail the component moves under one cast step a frame, which the screencast cannot show on every frame: Lightbox's dismiss-cancel proof in WebKit missed one of the control's 12 to 14 frames in 7 of 10 runs, each time at its spring's last plateau (210, 210, then 208 on WebKit's 2 px grid). Such a run of unchanged frames now counts as moved when the change that ends it is a single cast step. A freeze ends in a catch-up of more than one step and stays a miss, as does a run that no change ends, and the component must still show a new position on at least minJudgedFrames of the control's frames, so a frame-counted animation that stands still through the block and resumes one step a frame fails too. The cast step is read from the tracked value's changes in the judged part rather than from its positions: the photo's position once the block ended (207) sat off WebKit's 2 px grid and made the step read as 1 px. Red first: the tail, pooled tail, off-grid commit and frame-counted freeze cases fail on the previous busy.ts; the single-miss, slow freeze and stop-for-good cases still fail. Lightbox's five busy proofs with this judgment: 30 of 30 in Chromium, 39 of 40 in WebKit (the other presented 3 frames in its judged part, under the 4 a window needs). The harness's own proofs still pass a compositor animation with no miss and fail the rAF one (0 of 39 to 47 control frames) in both engines.
…awn PM row, and the 1 s busy proof follows rows in turn - The clamped-turn proof taps the PM row where the cylinder draws it. - UIPickerView's cylinder foreshortens a row as it turns away from the band and leaves the band's row to the band's copy, so no one row stays in sight, moving a pixel a frame, for the whole 1 s block. Rows take turns: each is followed from three and a half rows below the band until it reaches the band's edge, and the harness judges each stretch, which together cover the whole block.
… of waiting for its pan to end A trackpad's scroll or a smooth mouse wheel with no momentum of its own stops sending wheel at its last step, and the gesture host ends the pan 100 ms later. The rows held still for those 100 ms and then started the coast at full speed. Now, once a continuous stream pauses for half again its own interval (20-50 ms), the wheel glides on from where it is with the stream's speed, as the release would; that glide is the release when the pan ends, and a wheel that arrives during it takes the rows back from where they are. A mouse wheel's notches keep settling by position.
… turn through the 1 s block) into uikit/c21-wheel-fr43
A stream sends a wheel every frame it moves, and the browser dispatches a frame's input before its animation frame callbacks, so the glide now starts at the first frame that follows one with no wheel. A busy page delays that frame along with the timer that ends the pan, where a timer of its own fired late under load (the browser proof held still 98 and 148 ms on a busy box). The browser proof counts frames instead of milliseconds, and drives a genuinely quick stream through CDP, without the driver's wait for each step's delivery.
…ng curve only when it is at most a frame longer than the run before it astracat found that the sub-step credit let pooledBusyProof pass a rAF loop that steps a fixed 1 or 2 px a frame: in each 70 ms block the cast still shows the loop's last steps, then it stands still and goes on one step a frame without catching up, so its run ended in a single step. With the previous busy.ts the self-test's frame-counted loop passed the pooled proof in 5 of 5 Chromium runs. A slowing curve lengthens its runs a frame at a time, so a run now counts as moved only when it is at most one frame longer than the component's run before it; a stall is longer than the runs before it. A judged part with no change reads the finest cast step, 0.5 px, so a larger jump never passes for one step. The self-test adds the frame-counted loop, at 1 and 2 px a frame, to the pooled proof's mutants: it fails every pool (Chromium 10 of 10, moved on 2 to 8 of 7 to 12 control frames; WebKit 10 of 10, 0 or 1 of 11 or 12). The timed rAF mutant can read as moved on one control frame, presented as the block starts, so its assertion takes 0 or 1 and also requires its stall to exceed the control's (Chromium showed 1 of 40 once). Red first: the frame-counted stall, the pooled frame-counted loop and the finest-step cases fail on the previous busy.ts. Lightbox's five busy proofs with this judgment: 30 of 30 in Chromium; 33 of 40 in WebKit with no miss, the other 7 presenting too few control frames to judge.
There was a problem hiding this comment.
Approval waits on an engineering owner's spec decision: the wheel and trackpad pan itself is covered by spec:AST-094 FR43 on main, but this head also writes requirements main does not have (the FR41 selection rule for a claimed mouse or pen drag, component:DateInput FR13, PR3, DD1 and DEC-1, and component:DateTimeInput FR8). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, or rejects the text so it moves to its own spec pull request, and the author may do so as an engineering owner. Whatever that decision, the branch needs these changes before it can be approved:
- The diff against
maincarries the whole unmerged stack (232 files, about 41.4k changed lines); land #8480, #8676 and the pull requests beneath them first, or retarget, so this pull request is its own four-file change. - When a wheel arrives during the glide, the rows keep the glide's travel and add the wheel's whole step (the
isGlidingre-base inPickerWheel.tsx), so a trackpad stream slower than the screen's frame rate runs the rows ahead of the fingers; treat a frame as a pause only after the stream's own interval, or drop the glide's travel on a quick resume, and prove it with a stream at half the frame rate. - Add iPadOS Safari evidence for the iPad case, a trackpad before and after against
main, and this head's base-to-head size report for DateInput, DateTimeInput and PowerSearch.
[Automated review]
| if (stream?.isGliding === true) { | ||
| // More wheel after the stream paused: the scroll takes the rows | ||
| // back from the glide where they are, and moves them by this | ||
| // wheel's step. | ||
| const shown = motion.stop(); | ||
| drag = { | ||
| start: fingerOffset(shown, currentLayout) + stream.translation, | ||
| }; | ||
| dragRef.current = drag; | ||
| } | ||
| const offset = dragOffset(drag.start - translation, currentLayout); | ||
| showRows(offset / ROW_HEIGHT, offset / ROW_HEIGHT); | ||
| motion.setOffset(offset, {ticks: true}); |
There was a problem hiding this comment.
When a wheel arrives during the glide, this re-base starts from where the glide has carried the rows (motion.stop()) and then adds the wheel's whole step, so the glide's travel is kept on top of the scroll. Inside a stream whose wheels arrive less often than frames (a 120 Hz screen with a 60 to 90 Hz trackpad stream), every empty frame starts a glide (:760-771) and the rows run ahead of the fingers, about 1.5x at 60 Hz on 120 Hz. Consider treating a frame as a pause only once the stream's own interval since its last wheel has passed, or dropping the glide's travel when a wheel resumes within that interval, and add a proof that drives the stream at half the frame rate.
…its wheels' look and exception; the band note matches the drawing - DateInput's DD1 and PR3 now cover its own month and year wheels; the TimeInput and DateTimeInput sheets record their wheels' UIPickerView look (DD1) and the INV7 exception for the labels drawn three times (PR2) in their own records, which own those sheets. TimeInput's sheet has 250 elements under a finger against 286 under a mouse; DateTimeInput's open sheet 377 against 470. - The band-magnification note in picker.ts says what UIKit draws and what the wheel draws: each band label magnified about its own centre, as wheelColumns.ts records. The value is unchanged.
… long a frame takes The proof's guard compared the longest frame gap of two openings under CPU throttling, and the load on the machine running it moved that gap more than the wheel did: at one head the 200-year opening's worst gap ran from 266 to 516 ms, against 183 to 233 ms for two years, so the guard failed now and then on a busy machine. It now counts the year wheel's elements the opening writes or adds, from the tap until the wheel rests: 1 for two years and 19 for 200, the same on every run. A cost per year shows as 200 or more. Counting needs no CPU throttling, so the proof runs in WebKit too. It waits for the wheel to rest instead of a fixed 1.5 s, and the sheet's wait no longer swallows a failure.
…il cancelled, and its compositor run is blocked only once on the compositor C23 saw the control self-test fail under load at eight workers: "660 ms judged at 16.6 ms/frame: 28 frames presented, 0/28 moved". Its animations ran once, for 1,600 ms from before the screencast started, so on a loaded runner, where starting the cast takes long, the compositor's animation had ended before the block and nothing moved in the judged part. Both animations now go back and forth until cancelled, as the pooled self-test's do, and the compositor's block uses `during`, so it starts only once that animation runs on the compositor. Red first: with the setup stretched to 1.8 s, as a loaded runner takes, the previous test fails with exactly that read (0/39 moved) and this one passes. Normal runs at eight workers: 10 of 10 in Chromium and 10 of 10 in WebKit.
… control) into uikit/c21-wheel-proofs
… hesitation drops the glide's travel A trackpad can send its wheels less often than the screen draws: at 60 Hz on a 120 Hz screen, every other frame has no wheel. Taking each such frame as the stream's pause started a glide there, and the next wheel added its whole step to where the glide had carried the rows, so they ran ahead of the fingers: six 24 px steps every other frame moved them 170 to 187 px instead of 144. The pause is now the first frame one and a half of the stream's own intervals (the longer of its last two gaps, read from when each wheel was sent) after its last wheel. A wheel that comes within three intervals, during a glide that a hesitation began, resumes the same scroll: the glide's travel is dropped, and the rows hold where it left them until the scroll passes it, so they never step back. A longer pause still keeps the glide's place, as before.
…pending owner approval [skip ci] FR13 and DEC-1 settle where a wheel rests. The turn of a shown wheel onto a value changed from outside, as when a bound clamps the time after another wheel turned, sat inside FR13 and DateTimeInput FR8, so both read it as settled by DEC-1, which doesn't decide it. It is now DateInput FR14, the shared wheel's own requirement, a verified observation pending owner approval; DateTimeInput FR8 keeps only the rest, and the two tests that prove the turn cite FR14.
…nst a compositor-only control [skip ci] The 1 s busy proof marked one row and asked that every frame the compositor presented show it moved. Two things made it fail on a busy machine. Late in the block the coast moves about a pixel a frame, under the screencast's step, so a frame delivered early shows no change; and over a whole second the coast travels further than the column shows, so the marked row was out of sight at one end, turned and faded at the other. It now follows markers that ride the strip itself, every three rows in three colours, placed before the flick so the block meets them already on screen. They move with the coast and never turn or fade, and each is judged while within reach of the band; together they cover the block. The judgment is busyWindowProof's against a compositor-only control recorded in the same frames (meta#8906): the coast moves on every frame the control moves, a run under one step that a single step ends counts as moved, and its longest stall is no longer than the control's.
…wheel-uikit-look # Conflicts: # packages/core/src/DateInput/DateInput.spec.md
… into uikit/c21-wheel-uikit-look # Conflicts: # packages/core/src/DateInput/__tests__/Wheel.interaction.browser.spec.ts # packages/core/src/DateInput/__tests__/WheelActivation.interaction.browser.spec.ts
…R14 [skip ci] meta#8131 made the turn its own requirement, DateInput FR14, pending owner approval; the wheel's park note and its test said FR13.
…) into uikit/c21-wheel-trackpad-glide
…ip's animation [skip ci] They counted the frames until the rows showed a new place after the scroll, which adds the frames a busy machine takes to show an animation's first step to the wheel's own decision. They now read the frame at which the glide's animation runs on the strip, which must be the stream's pause or the frame after it, and then that the rows moved on.
There was a problem hiding this comment.
Approval waits on an engineering owner’s spec decision: the wheel and trackpad pan itself is covered by spec:AST-094 FR43 on main, but this head also writes requirements main does not have (the FR41 selection rule for a claimed mouse or pen drag, component:DateInput FR13, FR14, PR3, DD1 and DEC-1, component:DateTimeInput FR8, PR2 and DD1, and PR2 and DD1 in component:TimeInput). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, or rejects the text so it moves to its own spec pull request, and the author may do so as an engineering owner. Whatever that decision, the branch needs these changes before it can be approved:
- The diff against
maincarries the whole unmerged stack (234 files, about 42.9k changed lines); land #8480, #8676 and the pull requests beneath them first, or retarget, so this pull request is its own five-file change. - The hesitation hold in
PickerWheel.tsxtakes its direction from the glide, so a slow scroll whose pause snaps back to the row behind, or a scroll that reverses right after a pause, stays pinned where the glide stopped; take the direction from the scroll and add tests for both cases. - Add iPadOS Safari evidence for the iPad case, a trackpad before and after against
main, and this head’s base-to-head size report for DateInput, DateTimeInput, TimeInput and PowerSearch.
[Automated review]
| ); | ||
| drag = { | ||
| start: drag.start, | ||
| hold: {offset: shown, direction: Math.sign(shown - pan)}, |
There was a problem hiding this comment.
This hold takes its direction from the glide (shown - pan), and each later wheel keeps the rows at held while (offset - held) * direction < 0 (:879-887). That only holds back a glide that ran ahead of the scroll. When the glide went against the next wheel, as a slow stream under picker.release.minimumSpeed does when it snaps back to the row behind (wheelPhysics.ts:373-387), or when the person reverses right after the pause, every resumed wheel satisfies the test and the rows stay pinned while the fingers keep moving. Consider taking the direction from the scroll (the sign of the resumed wheel’s step), holding only when the glide stopped ahead of the scroll, and adding jsdom cases for a slow stream that hesitates less than half a row past a row and for a reversal after a hesitation.
Depends on #8480 (the wheel stack's top) and #8676 (a pan takes wheel and trackpad scrolling). Review only this PR's change: vjeux/astryx@f30a46d...2d68021
User need
On a touch device with a trackpad or mouse, such as an iPad with a keyboard, a two-finger scroll over a sheet's picker wheel stopped dead where the scroll ended: the wheel moved by each scroll step and snapped onto a row 160 ms after the last one. iOS lets the wheel keep the scroll's speed, coast on and rest on a row. This makes the touch wheel do the same.
Problem and solution fit
What led to it: Astryx's UIKit-parity work, and the owner's decision that gestures work for every pointer (
spec:AST-094FR43, approved in #8646): a pan also takes wheel and trackpad scrolling. #8676 gives the pan that input; this hands the wheel's scrolling to its pan.PickerWheel's pan now allows both scroll kinds, and the wheel's ownwheellistener is gone. A scroll drives the same pan, release plan and rest rule as a finger.Current specification
spec:AST-094FR43 and FR24 (one listener per element).Public delta and caller burden
No API change. Size (INV6): DateInput −144 B gzip JavaScript (TimeInput −138, DateTimeInput −139), CSS unchanged.
Design and alternatives
Evidence
Scope
Testing
On a touch device with a trackpad (or Chromium's phone emulation with a mouse wheel), open Storybook's TimeInput › Bottom Sheet With Min Max:
Automated:
PickerWheel.test.tsxcovers each step (the quick scroll fails without this change), and the Chromium spec drives real wheel input. Not this change: CI's root-entry bundle check fails on main's test, which #8671 fixes.