Skip to content

feat(core): an experimental List reorder subpath for rows that are not ListItems - #8149

Closed
vjeux wants to merge 108 commits into
meta:mainfrom
vjeux:uikit/c13-list-experimental
Closed

vjeux wants to merge 108 commits into
meta:mainfrom
vjeux:uikit/c13-list-experimental

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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

On hold for a route decision. cixzhang's ruling on #8076 rules out module-level experimental subpaths and new public surface without a spec decision. This PR waits on the open question of how Lab reaches core's touch code (#8076 (comment)): (a) Lab's touch layers move into core, and this PR closes with ListInput's reorder moving too; or (b) one spec'd public entry, which these exports fold into.

User need

Lab inputs whose rows are records of fields, like ListInput, need core List's reorder, but their rows cannot be ListItems. Without a shared session each keeps its own drag.

Problem and solution fit

@astryxdesign/core/List/experimental exports core List's reorder session for rows that hold their own content:

  • useListReorder(listRef, onReorder, hintId): the session for a list whose direct children, of any tag, are the rows;
  • ListReorderContext: hands it to the rows;
  • ListReorderHandle: the handle anywhere in each row, named after it or by aria-label; isDisabled keeps it focusable but inert;
  • LIST_REORDER_FIXED_ROW_ATTRIBUTE: marks a row that keeps its place.

It is the session List runs for onReorder: the same hold, slides, autoscroll, drop, mouse, keys and moves menu. Every exposed declaration, the session's controller and types included, is marked @experimental.

Current specification

spec:AST-017 FR21 and FR22 (current): experimental hooks use the owning component's /experimental subpath and are never re-exported, declarations carry @experimental, and docs carry stability: 'experimental'. spec:AST-094 (draft) through #8139.

Public delta and caller burden

One new experimental subpath with four exports; nothing added to the package root or the List subpath. [experimental] changeset.

Design and alternatives

  • Exporting the handle keeps its gestures and accessibility in core.

Owner decisions, proposed; pending owner review:

  1. This experimental surface, for rows that are not ListItems.
  2. Whether List.onReorder is stable is decided in feat(core): createReorderable makes List rows reorderable the way an iOS table does #8139; this subpath is unaffected.

Evidence

  • experimental.test.tsx: the four exports; ./List/experimental in the exports map and absent from the root, the List subpath, hooks and utils. A list of <li> rows with inputs reorders through the subpath: the handle is named and described, a fixed row has no handle and bounds the others, and an arrow-key move keeps focus. A list of <div> rows with the handle deep inside each moves too.

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

  • pnpm exec vitest run packages/core/src/List: 5 files, 125 tests passed.
  • pnpm exec vitest run --project node packages/cli/test/experimental-isolation.test.mjs: 34 passed. Search and the docsite route lock are unchanged: experimental hook docs stay out of both, as feat(core): the internal touch foundation: press tracking and the animator #8076 ships.
  • pnpm -F @astryxdesign/core typecheck, node scripts/sync-exports.js --check, ESLint and Prettier on the touched sources, pnpm check:changesets, pnpm check:repo.
  • Size: 8 files, 365 lines added, 16 removed.

Stack (merge in this order): … → #8138 → #8139 → #8337 → this → the ListInput reorder.

vjeux added 24 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A file that reads only defineConsts values from a .stylex.ts module lost
that import under the legacy {stylexOptions} form Storybook uses: the
babel wrapper's StyleX instance ran without treeshakeCompensation (the
unplugin applies its Vite default only to its own instance, which runs
after the wrapper), so once stylex.create was compiled the TypeScript
transform dropped the import and the defining module never reached the
build. A constant used as a media-query key failed the build with
"Invalid empty selector"; one used as a value shipped as a custom
property nothing declares. The wrapper now gets treeshakeCompensation by
default.

The layer split also substituted constants only within each layer, so a
constant defined on the library side and read on the product side stayed
a placeholder in either form; every layer now sees every constant.

A production build now warns, naming the variable and the rule that reads
it, when the StyleX stylesheet reads a generated custom property nothing
declares; undefinedVariables: 'error' fails the build instead, and this
repo's Storybook and Sandbox configs set it so CI catches the next case.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.
…tarts at the commit

play() bakes curves given as value and velocity functions of time (a spring
chasing a moving target, a fitted UIKit curve) like a spring.
createAnimator(dialog, {pseudoElement: '::backdrop'}) drives a
pseudo-element: a static filling animation holds its end value.
animatorFor(element) shares one animator per element, so callers animating
different axes compose in one translate track; atRest() waits out every
curve on its properties, and rebase() moves them by a layout shift keeping
their springs. A call from rest gets no start time, so the browser starts it
at its commit, as Core Animation does, and handle.started reports that
start. Every animation and set() promotes its channels to a compositing
layer in the same task (will-change, or a host custom property for a
pseudo-element) until 500 ms after the last motion: Mobile Safari only runs
a script-started curve through a block on an element that already has a
layer. Under Reduce Motion the substituted cross-fade can follow a
presentation's own spring, and crossfadeAs gives a slide that changes no
opacity its direction. The header lists the iOS Safari limits measured on
the simulator.
…eraction harness

useAnimator wraps the animator for React without re-rendering per frame.
Browser proofs on the interaction harness, in Chromium and WebKit on the
phone profile: a spring keeps advancing on every screencast frame through a
1 s main-thread block and every frame sits on its analytic curve; a spring
ending inside the block keeps its end state; no Layout or Paint runs while
it animates; the engine plays the curve when seeked; a finished animation
leaves within a frame or two of the curve's end; a dialog's ::backdrop
follows the curve.
@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 25, 2026 6:16am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
⏳ Queued (for maintainers only) Oct 25, 2026, 6:06 AM UTC

vjeux added 24 commits October 24, 2026 22:08
addTouchGate(gate) holds a gated target's touches away from every
participant (a presentation running in a layer); it returns the release.
A second finger landing anywhere cancels a pan limited to one finger and
gives its touch back to the browser, so the two fingers pinch (WCAG 1.4.4)
instead of the row sliding until the browser cancels it.
TouchConsumer.keepsTouch(): once a press tracker's press is delivered
(highlighted), the host cancels that finger's moves, so the browser's pan
can't take or cancel a held control (UIKit's touchesShouldCancel(in:) is
false for a tracking control). A move before delivery still scrolls. From
C04's measurement on Android's NumberInput steppers.
Only a continuous-tracking control created with keepsTouch: true (a held
stepper) gets the blocking touchmove listener; plain buttons, links, cards
and list cells keep passive listeners, so a scroll starting on them never
waits on the main thread. A test checks that a plain Button and an Item
cell register no blocking touchmove listener.
…laimed

spec:AST-094 FR10: a second finger must not cancel or redirect a drag a
recognizer has claimed, and page pinch-zoom stays the browser's whenever no
drag is claimed. The host's second-finger rule cancelled every one-finger
touch pan, claimed ones included, and released the touches' claims even
where another recognizer still held them. It now fails only a pan that is
still possible and has claimed nothing (no claim, no early claim), so the
two fingers stay the browser's pinch, and it releases no claim. A claimed
drag keeps its finger, as a UIKit pan ignores touches beyond its maximum.
The arena's releaseClaims, whose only caller this was, goes.
…touchmove

attachConsumer's detach cleared the consumer and ran cleanup, which returns
early while the element keeps recognizers, so the non-passive touchmove a
keepsTouch tracker needed stayed on the element and every later scroll
starting there waited on the main thread. The detach now refreshes the
element's listeners first, as a recognizer's detach does.
…ts it

UIKit (iOS 26.2) keeps a row tapped to push selected while the pushed screen
covers it and deselects it when the pop starts (0.35 s linear, inside the
transition); an interactive pop scrubs it and a cancel selects it again.
holdSelection(target) (core utils) holds the list row under an activation
under any finger (readTouchPresentation().isTouch: one touch model, iOS and
Android alike) and returns the handle a navigation drives on its own clock;
with a mouse it is null, and the press releases at the lift as before. The hold paints on the
press layer (data-astryx-press="held", promoted through the motion
animator's will-change variable) and hides the dividers around the row until
the deselect's first frame (data-astryx-deselecting), and again on a cancel.
The touch press controller leaves states it did not write alone. Item and
TreeList rows carry data-astryx-pressable="cell" (CELL_PRESS_PROFILE and
cellPressableProps live with the press controller in pressFeedback.ts, so a
row never imports the hold).

The hold's focus() returns focus to the row's control (in a tree, its
treeitem, the tree's roving Tab stop) through core's focusForLastInput, so
a finger's pop lands with no keyboard ring (UIKit draws none) and a
keyboard's shows it; Item and TreeList rows start the shared
input-modality tracking for it. Its timing types are self-contained
(HoldSpring, HoldCurve). The dividers come back in the deselect's first
frame rather than fading with it (borders have no compositor-only fade),
noted in the list table. The story's slider follows a slow pop, so a
finger takes it over from the value on screen. Under reduced motion every
deselect takes the animator's own Reduced Motion policy (no 'keep').

The list table registers its motion roles: the deselect's clock and curve
(list.deselect.navigationPopMs, list.deselect.navigationPopCurve) are read
under the row's theme with resolveMotion when a deselect starts. The cell
highlight's release stays out of the roles: its clock is the press model's
(timing.press.highlightOutMs) and the press layer's stylesheet reads both
values at build time. `astryx docs motion` lists the new roles.

A deselect from rest starts when the browser commits it: `startTime` pins
it to a transition's clock only when the caller passes one, so a busy first
frame delays the deselect instead of skipping into it. The list table is
plain data with an idempotent registerListTable() beside it, which the
list's touch modules (here holdSelection) call before their first motion,
so a page that never animates a list ships no list roles.
On iOS 26.2 a table row is 52 pt at the default text size (the .default,
.value1 and .value2 styles) and 62 pt with a subtitle, and the Human
Interface Guidelines put iOS controls at 44 pt; a List row under a finger
was 40 px, 60 px with a description, and 33 px in compact density. Under any
finger (@media (pointer: coarse), the resolver's touch query; one touch
model, iOS and Android alike), ListItem now has a minimum height of 52 px,
62 px with a description, 44 px in compact density, read from the UIKit
constants table (list.metrics) through a build-time mirror; a theme's
min-height on list-item still wins. Lists with markers are running text and
keep their rows. Mouse and trackpad lists keep today's heights.
…does

A UIKit outline list (iOS 26.2) slides a row's children out from under it as
one block inside a clip that grows with them, moves the rows below with
them, and turns the disclosure chevron 90°, all on one critically damped
spring (response 0.25 s), with no fade; collapse runs it back. Under any
finger (useTouchPresentation: one touch model, iOS and Android alike) a
TreeList row now does the same on the compositor: the group clips
its own box for the motion, its children translate from the group's height
above it, every row after the group translates with them, and the chevron
turns by an additive rotate over its final transform, all played by the
motion foundation's animator and retargeted from the value on screen. A
collapse keeps the children rendered, inert and skipped by the arrow keys,
until they have slid away. TreeList renders a row's children lazily for
that. Elsewhere (Android's own list motion, a mouse) the children come and
go in one frame as before, and the chevron keeps the theme's transition.

The outline spring and the table update spring are motion roles
(list.update.outlineSpring, list.update.tableSpring): the animator plays them
as the tree's theme sets them, and `astryx docs motion` lists them.
The expansion moved the rows below the group inside the tree, but page
content below the tree still moved by layout: it jumped down at an expand
and up at the end of a collapse. It now moves on the same spring as the rows
(utils/rowMotion.ts contentBelow: in-flow content after the tree, up to its
scroll container, that sits below it; positioned and sticky elements and
anything another owner already translates or animates are left to layout).
A transient individual translate, cleared at rest, under any finger like
the expansion itself (one touch model; a mouse moves nothing).
followingContent="stays" keeps it still for apps that manage that area. Row
motions go through the motion foundation's shared per-element animatorFor
(utils/rowMotion.ts plays every spring there, with no local animator), and
the chevron's turn reads its direction with isRtlElement.

Each element moves by what the group's layout moves it (layoutShifts: one
read with the group laid out, one without), so content layout leaves in
place (a footer pinned at the bottom of its column, a page-level container)
does not move. Each element releases its translate when its own spring
arrives, and the hook's unmount stops every element it moved at rest
(stopRow), so a tree removed mid-collapse leaves nothing translated; the
effect names that external system (architecture:react-component-runtime
INV3).

The expansion keys on a coarse primary pointer (useTouchPresentation),
iOS and Android alike; a mouse keeps the one-frame expand. The chevron's
theme transition is off under a finger, where the spring turns it.
…es updates

A UIKit table (iOS 26.2) animates insert, delete, move and reload and batch
updates on one critically damped spring (response 0.3 s): after a delete the
rows below start where they were and slide up into the gap; after a
swipe-to-delete the swiped row is already off screen and only the rows below
move. useListUpdateMotion (core/utils) reads each row's layout position once
per commit, puts a moved row back with an inverse translate in the same
frame and springs it home on list.update.tableSpring, and fades new rows in
on the same spring; a removed row leaves with its commit and nothing is
written on it. A layout shift never changes a running motion's curve: a row
still moving keeps its spring, so a reorder's drop settles on its own.
Page content below the list moves by the list's change in height
(followingContent="stays" keeps it still). It skips a list a reorder session
owns (data-astryx-reordering). List turns it on with hasAnimatedChanges, under
any finger (useTouchPresentation: one touch model, iOS and Android alike);
with a mouse nothing
moves. rowMotion gains animateRow and motionOf's spring, so other components' row
motions are recorded.
…orFor

utils/rowMotion.ts drops its own per-element animator registry and drives
every row through animatorFor (one animator per element for all its motions), so
a swipe's x and a FLIP's y compose on one translate channel. A layout shift
rebases a moving property (animator.rebase: same spring, velocity and
target) and springs a resting one home on the caller's spring; motions from
rest leave the start to the browser's commit. Only identity inline values
are cleared at rest.
… and a row crossing others rides above them

Rows have no background, so in an update two labels could show through
each other where a UIKit cell, opaque, hides one: a new row faded in on
top of the row still sliding out of its band, and a reordered row's label
crossed the labels of the rows it passed.

A new row now shows nothing until every row sliding across its band has
carried its label half a row past the new row's, then fades in on the
table spring's clock, arriving with the rows around it. A row sliding
across rows that move the other way (the less common way, up on a tie)
carries data-astryx-travelling for its slide: ListItem paints it above
the others with the surface color behind it (a layer under the press
layer, so its divider and its press stay visible); a selected row keeps
its own fill.

rowMotion gains playRow, which plays a recorded closed-form curve on a
row's shared animator; the fade's spring resolves under the list's theme.

The new row's fade starts from rest with the slides it is timed against, at
the browser's commit, so a busy frame cannot put it ahead of them.
…ts edge

A removed row left with its commit: in one frame it was gone (a swiped row
with its red action showing), leaving an empty slot the rows below then
slid into. UIKit (iOS 26.2, deleteRows after a destructive swipe) collapses
the deleted cell instead: its height, content, separator and action shrink
to 0 from the bottom on the same critically damped spring as the following
rows, the next row's top always on its bottom edge.

On the commit that removes a row, the detached row comes back for its exit
inside a ghost (data-astryx-leaving, inert, aria-hidden): a clipping box at
its old place spanning the list's content box, placed before the row that
followed it. The box translates up by the row's height while the row inside
translates down by as much, so the content stays put and the clip closes,
compositor only; a ghost below another removed row also rides up with it.
All of them start at the browser's commit, like the slides from rest, so the
edges meet on every frame; the ghost goes once its motion rests. The rows a
commit compares never include a ghost.
…ntent layout moved

Review notes: with hasAnimatedChanges off (or under a mouse), every
List still read each row's layout on every commit; and the content below a
list moved by the list's change in height whether or not layout moved it,
so a footer held at the bottom of its column, or a container at the page's
top level, jumped and slid back.

With motion off the hook now reads nothing and observes nothing; the first
commit after it turns on only records. Each commit records the layout tops
of the content below with the rows, and the next one moves each piece by
what layout moved it, leaving content layout did not move in place. The
docs note that the moving content's translate is the containing block of
any position: fixed element inside it for the length of the motion.
design:reduced-motion DR1/DR2: row motions no longer pin reducedMotion
'keep' (rowMotion's animateRow, springHome, springTo and playRow); every
one takes the animator's own Reduced Motion handling. Under reduced motion
with a finger nothing travels: a removed row fades out where it is (the
ghost and its row as exits), a new row fades in where it lands, and the
rows that move and the content below cross-fade into place. A browser test
holds every animation a removal, an insert and a reorder start and checks
the record's limits: opacity only, curves ending within 200 ms.
A removed row whose parts ran a Web Animation (a swipe's travel, on the
compositor) collapsed showing its resting state: React detaches the row,
which drops its animations, and the collapse re-inserts it, so a swiped row
reappeared unswiped for the length of the collapse.

A ListItem leaving a list with hasAnimatedChanges now writes what its
running animations present inline as it unmounts, while it is still in the
document (keepPresentation: commitStyles on the row and its parts), and the
ghost cancels whatever animations the row still carries. The ListItem effect
names that external system (INV3).

The copy leaves when its collapse settles (its handle's finished), and at
once, its motion stopped, when the motion stops owning the list
(hasAnimatedChanges off, a mouse, an unmount): no timer bounds it. On the
table spring it is gone within a second (architecture:component-style-authoring
INV7).
The UIKit constants table gains motion/uikit/reorder.ts: the reorder
control's claim (the content-touch delay, the pan hysteresis), the slide
spring and its shortening while autoscrolling, the drop's distance-scaled
spring, and the autoscroll's 20 ms tick and steps by edge depth. The slide
spring is a motion role a theme may retune, registered by the table's own
registerReorderTable() (the table itself is plain data; the List reorder's
touch layer registers it before its first motion).
List/reorderModel.ts applies them: the proposed slot (a row changes places
once the dragged row's centre passes its far edge less the dragged row's
height, never before its near edge, so a tall row does not start past a
short neighbour), the displaced rows' offsets, the slot's place, the
springs, the autoscroll step and the clamp (the centre on a scroll edge,
the row's edges on the list's own ends). Its tests replay the measured drag.
…der the List reorder

# Conflicts:
#	packages/core/src/utils/pressFeedback.ts
List takes onReorder(fromIndex, toIndex): each row gets a trailing reorder
handle, and ListItem's isReorderDisabled keeps a row in place (its end
content kept in line with the other rows'). A finger held still on a handle
for the scroll view's content-touch delay picks the row up (a flick on it
scrolls; two fingers are a pinch); the row follows the finger one to one,
opaque, the rows it passes slide out of its way on the table spring, the
page autoscrolls by whole points on a 20 ms clock near the edges, and the
drop springs from rest into its slot on a spring scaled by the distance
left. A dragged row stays inside its list. The motion is the same on every
touch screen (a coarse pointer); a mouse drags at once and rows take their
places without motion. The slide spring is read through its motion role
(resolveMotion), once per drag.

The handle's keys move the row, with Space and Escape to pick it up and put
it back; on a touch screen a key move slides its rows. Every move is
announced once the new order renders, and a declined one as kept. A focused
handle shows one whole ring, and the handle fits a compact row.

What runs only on a touch screen (the hold that claims a finger, the
slides' spring under the list's theme and the springs) is
ListReorderTouch.ts, imported by the reorder session alone; it registers
the reorder table's motion role before its first motion, so a mouse
registers nothing. The
gesture host owns the document's touch listeners: the hold claims only a
lone finger (fingersDown) and asks the host to lock text selection under
the held finger (preventsTextSelection), so the handle keeps no selection
or callout styles of its own and a mouse keeps the page's selection. The
autoscroll runs on the shared edge autoscroll timer (utils/edgeAutoscroll.ts)
on UIKit's 20 ms clock. The values are in the UIKit constants table
(motion/uikit/reorder.ts), measured on iOS 26.2.
Activating a reorderable List's handle without a drag (a tap, a click, a
screen reader's double tap) opens Move up, Move down, Move to top and Move
to bottom, each offered only when it moves the row, through the same moves
as the handle's keys: a row can be reordered with a single pointer and no
drag (WCAG 2.2 SC 2.5.7). A press that dragged the row opens nothing. The
menu is a DropdownMenu anchored over the handle, mounted only while it is
open (a mounted menu would lay out with every move of a dragged handle).

ListItem's new reorderLabel names the handle of a row whose label is not
plain text ("Reorder {reorderLabel}"); without it such a handle is named
after the row's place.
…t ListItems

The List experimental subpath (List/experimental in the core package's
exports) carries core List's reorder session for a list whose rows hold
their own content: useListReorder, ListReorderContext, ListReorderHandle and
LIST_REORDER_FIXED_ROW_ATTRIBUTE. A row is any direct child of the list,
whatever its tag, with its handle anywhere inside it; the handle takes a
whole accessible name of its own. It follows spec:AST-017's experimental
boundary: never re-exported from the package root or the List subpath,
every exposed declaration (the session's controller and types included)
marked experimental, and a stability: 'experimental' hook doc.
@vjeux
vjeux force-pushed the uikit/c13-list-experimental branch from eb157e1 to 8b8779e Compare October 25, 2026 06:06
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the internal-module route in #8076 (b91dd6c); ListInput imports core's List reorder modules directly.

@vjeux vjeux closed this Oct 25, 2026
@vjeux
vjeux deleted the uikit/c13-list-experimental branch October 25, 2026 06:36

This branch was successfully deployed

1 active deployment
Preview — 8b8779ea Deployed Oct 25, 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