Skip to content

feat(Slider): the thumbs track as a pan for every pointer - #8669

Draft
vjeux wants to merge 1928 commits into
meta:mainfrom
vjeux:uikit/c03-slider-every-pointer
Draft

vjeux wants to merge 1928 commits into
meta:mainfrom
vjeux:uikit/c03-slider-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8190 (which carries #8671) and #8676 (which carries #8665 and #8666). Review only this PR's change: vjeux/astryx@93e4ce4...ce50d06 (it also shows the parents' newer commits: #8676's last two, #8671 and #8360's animator fix)

User need

Part of Astryx's UIKit-parity work (spec:AST-094): #8190 made a Slider under a finger track like iOS's UISlider, but on a desktop a mouse drag still jumped the value to the pointer. FR43 asks gestures to work for every pointer.

Problem and solution fit

  • Mouse drag: a mouse gets a pan of its own: it waits 10 px in any direction (FR19), then follows the pointer's travel along the track.
  • Primary click only: a right click or a macOS ctrl-click on the track no longer moves the value.
  • No wheel: a scroll over the slider stays the page's, in any direction.
  • Touch targets stay touch-only (FR43); a fine pointer adds one passive listener per part (FR24); a finger behaves as before.

Current specification

spec:AST-094 FR43, FR19, FR24 and DEC-21; #8905 adds FR43's exception for a control's thumb. component:Slider FR6 (from #8190) now covers every pointer.

Public delta and caller burden

No API change. Size (component-size.js, base to head): the Slider import's JavaScript 85.1 → 85.2 KB gzip, CSS unchanged.

Design and alternatives

  • Keep a mouse on the old handlers: the value jumps to the pointer, as in a range input, but a mouse and a finger would track differently, against FR43.
  • A mouse through a pan (chosen): one tracking model. The cost: a mouse drag waits 10 px. A finger's pan stays limited to the track's direction, so a swipe across scrolls the page; a mouse has no page scroll to yield to, so its pan claims in any direction.
  • Wheel: pans take scroll input by default, but a slider is a control: like a range input, it opts out rather than take the page's scroll (spec(AST-094): a thumb that sets a value takes no wheel or trackpad scroll (FR43) #8905).

Left to the owner (component:Slider, per FR13; today's behavior stays): a lift on a mouse press, a coast after its release, a track press that sets the value on release.

Evidence

The test plan below, run in Chromium and WebKit.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

In Storybook (Slider › Default), with a mouse:

  1. Drag a thumb: it holds for 10 px, then follows the pointer without jumping, even when the drag starts straight down.
  2. Click the track: the value moves there, as before.
  3. Right-click the track (ctrl-click on a Mac): the value stays.
  4. Scroll a wheel or trackpad over a thumb: the value stays; the scroll goes to the page.

Run on this head, locally (fork pull requests start no CI): steps 1, 3 and 4 are browser tests in Chromium and WebKit (the ctrl-click a unit test), each failing before its change and passing now; the full core suite, typecheck, lint, the builds and pnpm check:knowledge pass. #8671 per-component bundle check, carried through #8190, passes here.

Writing rubric results

  • R1 narration: none; FR6's added sentence and the Compatibility line state behavior, as those sections do.
  • R2 research: none.
  • R3 evidence: FR6's verification row names the fine-pointer browser test.
  • R4 rejected alternatives: none added.
  • R5 change references: none.
  • R6 open questions: none touched.

vjeux added 30 commits October 26, 2026 23:31
… (spec:AST-094 FR43)

A drag from the edge band begins a screen-edge pan for a finger only,
unless the caller allows another pointer. The mouse and pen case fails
until the recognizer defaults to touches.
… FR19, FR24, DEC-21)

Recognizers and press consumers attach for a mouse, a pen and a finger
alike. Where the primary pointer is fine, each element with an enabled
recognizer or consumer keeps one passive pointerdown listener and the page
has none: a press on it (a mouse's primary button, a pen, or a finger)
installs the host's page listeners until that press's contacts end. The
action group's own fine-pointer path folds into this one, so a group is
served like every attachment.

- touch-action is written for every pointer, a kept press's manipulation
  too (FR18); the element's blocking touchmove stays a coarse pointer's,
  so under a fine one the element keeps its one listener (FR24).
- A press consumer takes a mouse press as a press (DEC-21): no
  feedback-only mode; PressTracker brakes only a finger's press (FR11).
- The screen-edge pan allows touches only unless its caller allows another
  pointer (FR43).
- perEventPointer is accepted and does nothing; the fine-pointer warning
  goes.

Tests that encoded the coarse-only design now state FR43's; a finger's
pointerdown comes before its touchstart in them, as in a browser.
…th real mouse drags (spec:AST-094 FR43, FR24)

Under a desktop's mouse the host still writes touch-action, leaves the
page no listener while idle and warns for nothing; a real mouse drag pans
both the bare page's pan and the fixture's horizontal pan, and the page's
touch listeners are gone again after the release.
…(spec:AST-094 FR18, DEC-13)

A screen-edge pan attached to a stack container, as wide as the screen,
with a touch 4 px from its edge: inside the 40.2 px band the pan decides at
UIKit's 15 px whatever the element's width, and then tracks the travel past
it. From several edges too. Both fail while a wider element decides at
FR19's 10 px.
…'s width (spec:AST-094 FR18, DEC-13)

FR18: inside the 40.2 px band a screen-edge pan decides at UIKit's 15 px
screen-edge hysteresis; outside it the author's scrolling is untouched and
FR19's 10 px governs every other claim. The pan decided at 10 px on any
element wider than the band, so NavigationStack's edge pan, attached to the
stack container, began 5 px early. The edge zone still decides only the
touch-action; a touch outside every band still fails at once. The two
cases that pinned the 10 px decision go.
… 15 px whatever the element's width) into the experimental touch exports
…er's move keeps that finger until it lifts) into the experimental touch exports
…than the band (spec:AST-094 FR18, DEC-22)

FR18 as amended in meta#8631 (DEC-22): the band's pan-x claim and 15 px start
hold only where the screen-edge pan is attached to an element within the
band; on an element that reaches beyond it, whose touch-action cannot
claim per touch, a touch in the band decides at FR19's 10 px like every
other claim. This restores the 10 px decision 3d85c30 removed and keeps
15 px on an element within the band. The cases pin DEC-22 both ways (a wide
element with a touch at x=4 begins at 10, an in-band strip at 15, and from
several edges at 10 on a wide element); the 10 px cases fail on 3d85c30.
…an the edge band decides at 10 px again, DEC-22) into the experimental touch exports
…ouch decides at 15 px on an element within the band and at 10 px beyond it, DEC-22; meta#8641 bec2283) into meta#8270
…8641's moving-touch claim kept on this line's internals delivery) into meta#8315

# Conflicts:
#	packages/core/src/gestures/GestureArena.ts
The case walked ContextMenu, DropdownMenu, useLongPress and useMenuPress's
imports and failed on the UIKit gestures table: it kept that table out of
the menus' bundles, the lazy design the no-lazy-load ruling and direct
imports retire. The long-press numbers keep one source, still pinned by the
table, recognizer and hooks case and by the hooks reaching longPress.ts.
…8589's watcher test reads its end; the gestures-table gate retired) into meta#8360
…8589's watcher test reads its end; the gestures-table gate retired) into the every-pointer host
# Conflicts:
#	.github/workflows/ci.yml
…xposes no ontouchstart is taken once

Chromium fixes ontouchstart when the page is created, so turning touch
emulation on later sends Touch Events without it: the host reads Pointer
Events for the finger and must not read its Touch Events too. A one-finger
long press fails here, the finger tracked twice.
…from their pointer events

install() reads a finger from its pointer events when the window has no
ontouchstart, but the four touch handlers still took the Touch Events a
browser sends anyway (Chromium after touch emulation turns on), tracking
the finger twice, so a one-finger long press never recognized. Each touch
handler now returns while usesTouchEvents is false, after noting the
fingers down, as install()'s comment promised.
vjeux added 4 commits October 29, 2026 12:30
# Conflicts:
#	.github/workflows/ci.yml
…age's selection

The drag selection hold cleared the page's selection when any mouse or
pen contact was claimed, but an action group's press claims at its
pointerdown, before it has moved or selected anything, so a plain click
on a group's button left its Copy or Cut with nothing to act on. A claim
before any movement now keeps the page's selection: the hold still
refuses a new one, and puts the page's back over a selection that
appears while it runs. A claim after movement clears what its drag
selected, as before (spec:AST-094 FR41).

The case before the new one now waits for its hold to end a task after
the lift, so no hold outlives that test.
…t every pointer does

- GestureHost's header, the gestures README and coarsePointer.ts no
  longer describe the coarse-only host: a finger or a pen takes the
  touch-down lock under any primary pointer, a kept press's finger is
  kept during a fine pointer's press too, and recognizers attach for
  every pointer while the page listeners last a press (spec:AST-094 FR19,
  FR24, FR41, FR43).
- The fine-pointer selection case asserted the replaced rule and passed
  only because it read iOS WebKit's inline lock without iOS WebKit: it
  now asserts the lock a fine pointer's finger and pen take, and that a
  mouse takes none.
- FR41's sentence and its verification row say that a press claimed at
  its own pointerdown, before it moves, keeps the page's selection, while
  a drag that moved before its claim loses what it selected.
meta#8320), meta#8189 at ca43cee, meta#8168 at 1cb400e

# Conflicts:
#	packages/cli/test/__golden__/no-icon-set/search-icon-set.json
#	packages/cli/test/__golden__/no-icon-set/search-icon-set.txt
@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

This needs an owner's call on wheel input. FR43 says a pan takes wheel and trackpad scrolling, with no exception. This slider takes none, so a scroll over it stays the page's, as over a native range input; taking it would let a page scroll change a value by accident. The options: amend FR43 to exclude a control's thumb, or give the slider wheel input. My pick is the FR43 amendment.

vjeux added 3 commits October 29, 2026 13:05
…ion, in the present tense

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.
meta#8665's review fixes: an armed press under a fine pointer brings the
claiming listeners, keeps a kept press's finger and goes when the page
hides; a press claimed at its own pointerdown keeps the page's selection;
FR41 says what a claimed drag does with the selection; meta#8360's
input-stream notes. The merge is clean.
…r path

Each case fails on this head:

- Under Reduce Motion a release's momentum keeps moving and a track tap
  sets its value at once, both at full opacity (design:reduced-motion DR2:
  a throw's momentum is direct movement; an in-place value change applies
  at once). The thumb cross-fades both today.
- Range thumbs closer than their touch targets split the gap, so a finger
  on the lower thumb's exposed side takes the lower thumb (spec:AST-094
  FR42). Today the upper thumb's 48 px target covers it.
- A valueDisplay change that remounts the thumbs keeps their gestures.
  Today the new touch targets have none.
- A finger's drag moves the thumb even when the owner renders nothing, its
  position written straight to the thumb (spec:AST-094 FR9). Today the
  thumb moves only through the owner's render.

The pen case now advances the frames before it asserts, so it checks what a
pen's tap on the track does: it sets the value on its lift, as a finger's
does.
vjeux added 12 commits October 29, 2026 13:24
…s gap, remounts, and a drag off the render path

- Under Reduce Motion a release's coast plays as momentum (the animator's
  decay), so it keeps moving, and a tap on the track sets its value at once:
  design:reduced-motion DR2 calls a throw's momentum direct movement and
  an in-place value change immediate. Neither cross-fades now.
- Each thumb's touch target grows to UISlider's 48 pt, but on the side
  facing a range's other thumb only to half the gap between them
  (spec:AST-094 FR42), computed in container units so it needs no layout
  read. A finger on the lower thumb no longer drags the upper one.
- The thumbs' gestures re-attach when a valueDisplay change wraps or
  unwraps the thumbs in their value tooltips, which mounts new targets.
- Each move of a drag writes the thumb's and the fill's positions straight
  to their translates (spec:AST-094 FR9), with onChange still reported per
  value; the parts go back to the stylesheet when the interaction ends, and
  a finished interaction no longer hands back the parts of one still being
  dragged.
- The Effects that hand the controller its model and parts getter, and the
  one that disposes it, name what they synchronize
  (architecture:react-component-runtime INV3).
…EC-4)

AST-094 FR13 leaves each component's touch presentation to its own record.
FR6 records Slider's: under a coarse primary pointer a thumb takes a finger
or a pen through UISlider's 48 pt target, clamped to half the gap facing a
range's other thumb; a drag moves the value by the finger's travel past
the 10 px threshold with no render per move; the release coasts on
UISlider's measured spring; a tap on the track sets the value on lift; and
Reduce Motion follows DR2. FR5's interaction ends, under a finger, when the
coast settles. DEC-4 records the decision, with no prop to restore the
released behavior.
… thumb

A mouse's first 12 px straight across a horizontal slider, then 60 px
along it: the thumb should move by the 60 px, since a mouse has no page
scroll to yield to. Today the thumb's pan, limited to the travel's
direction, fails on the first movement, and the thumb never moves.
…thumb

The thumb's pan was limited to the travel's direction for every pointer,
so a mouse drag whose first 10 px ran across the travel failed it and the
thumb never moved. A mouse now has a pan of its own that claims in any
direction after the same 10 px, and the value follows the drag's travel
along the axis, counted from the threshold's projection on it. A finger
and a pen keep the pan limited to the travel, so a swipe across the track
still scrolls the page.
…se drag

Slider.doc.mjs said a mouse keeps the pointer behavior, the changeset
counted listeners, and the primary-click comment named a pen rule the
gesture host does not apply. They now describe the mouse drag, past the
same 10 px in any direction, and the primary click. component:Slider FR6
covers every pointer (spec:AST-094 FR43): a mouse drag tracks past the
same threshold in any direction, a press on the track sets the value where
a primary click lands, and a scroll over the slider stays the page's.
Inside a scrolling sheet a wheel or trackpad scroll over a slider goes to
the sheet, not the page, so the record, the docs, the changeset and the
controller's comments say it goes to the scroll under the slider, as
spec:AST-094 FR43's exception for a control's thumb does.
…ame window

Both 'a dip held before its first frame lands once the hold is …' proofs
waited for no running animation, then one frame and a timeout, assuming
the next step starts within a frame. The dip lands on a timer due on the
call's own clock, though, and the proofs passed only because the evaluate
round trips covered the gap: in WebKit on a loaded box the window closed
first and the proof read the element mid-dip (opacity 0, translate none;
reproduced 1 in 4 with the round trips removed). They now wait on the
call's own finished, then on the browser's own animations still running
or waiting for their first frame, and read nothing from the animator.
…ment at once and settles then

A Reduce Motion dip whose landing runs more than its whole fade back in
late (a hold released long after the dip was due) played a 1 ms opacity
animation, which WebKit keeps pending until its next frame, and settled
the call on a 1 ms timer: the call's finished could resolve while the
element still showed opacity 0. The animator's notes say the element
appears at once there. Fails now: the landing plays one opacity animation
and the call has not settled.
…element at once and settles

A Reduce Motion landing that runs at least the whole fade back in late
had nothing left of the cross-fade, but still played a 1 ms opacity
animation and settled the call 1 ms later. WebKit keeps that animation
pending until its next frame, so the call's finished could resolve with
the element still at opacity 0. Such a landing now writes the end opacity
at once (handing a borrowed one back to the stylesheet), plays nothing,
and settles the call then, as the animator's notes say.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed ce50d06, answering the Oct 28 review point by point:

@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: the slider's thumb pans take no wheel or trackpad scroll, which AST-094 FR43 on main still requires of a pan until the amendment in #8905 lands, and the new every-pointer clause in component:Slider FR6 needs owner approval. An engineering owner records the decision by landing that FR43 amendment and commenting /approve-spec ce50d067e2324902a7f6c0b5c3db9669531a3ded here, or by rejecting the exception so the thumb's pan takes wheel input; the author may do so if they are an engineering owner. Whatever that decision, these changes are needed:

  • Rebase once #8190, #8665, #8676, #8666, #8671 and #8360 land, so the diff against main holds only this change (36,403 authored lines today; its own ten commits touch about 715).
  • A mouse drag that starts diagonally counts up to 2.9 px of the 10 px threshold when the pan begins (sliderTouchController.ts:531-534); subtract the travel the recognizer swallowed and add a diagonal test.
  • A pen under a fine pointer now lifts, coasts and sets the value on a track tap's lift, which FR6, the docs and the description do not describe.
  • Add ## Writing rubric results for the two edited records, as AGENTS.md asks of a record pull request.

[Automated review]

Comment on lines +531 to +534
interaction.beganOffset = interaction.isMouse
? distance > 0
? (pan.hysteresis * along) / distance
: 0

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 mouse pan begins once either axis reaches 10 px (a square in PanGestureRecognizer.tryBegin), but this subtracts the projection of a 10 px circle, so a 45° start counts about 2.9 px of threshold travel at once, against AST-094 FR17 (“its translation excludes that travel”). Subtracting what the recognizer swallowed, Math.sign(along) * Math.min(Math.abs(along), pan.hysteresis), keeps straight starts as they are; a diagonal test would pin it.

| FR3 | Filled range, tick marks, mark labels, and adjacent text value display are stable rendered parts without their own current Slider target. | Current source and public docs | Verified current asymmetry; not ratified as policy |
| FR4 | Label and status presentation continue to use Field and FieldStatus; value tooltips continue to use Tooltip. | Current source and focused tests | Verified composition boundary |
| FR5 | `onChangeEnd` fires once when an interaction ends — a pointer drag or track press is released or cancelled (under a finger, once the release's coast settles; FR6), or a keyboard step (arrow, Page, Home, End) completes — and carries the value the owner renders after its synchronous response to that interaction's `onChange` calls, including any clamp, snap, or rejection; it never carries a value Slider emitted that the owner did not render. A range reports both values the same way. | Maintainer decision (DEC-3) | Approved |
| FR6 | Under a coarse primary pointer, after hydration (`spec:AST-094/FR13`), each thumb takes a finger or a pen through an invisible touch target of UISlider's measured 48 pt; on the side facing a range's other thumb it grows only to half the gap between them (`spec:AST-094/FR42`). A drag on a thumb moves the value by the finger's travel past `spec:AST-094/FR19`'s 10 px threshold over the track's length, writing each move to the thumb with no component render (`spec:AST-094/FR9`). The release coasts on UISlider's measured spring toward 0.4 of the momentum projection. A tap on the track sets the value when the finger lifts; a swipe across the track scrolls the page. Under reduced motion the thumb does not grow, a tap's value applies at once, and the coast continues (`design:reduced-motion/DR2`). Under every other pointer (`spec:AST-094/FR43`) a mouse drag on a thumb moves the value the same way, past the same threshold in any direction; a press on the track sets the value where a primary click lands; and a wheel or trackpad scroll over the slider goes to the scroll under it. | UIKit measurements (`motion/uikit/slider`) and maintainer decision (DEC-4) | Approved |

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 clause covers a mouse, but under a fine primary pointer a pen now takes the finger path: Slider.tsx leaves every pen press to the gesture host, so a pen on a thumb lifts and coasts and a pen press on the track sets the value only when its tap ends. AST-017 FR59 asks for the owner decision on a new fine-pointer feel in this record, so the pen needs a sentence here (or the mouse path).

This branch was successfully deployed

1 active deployment
Preview — ce50d067 Deployed Oct 30, 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