Skip to content

feat(List): reorder mode's hint, its moves and Done as buttons - #8337

Closed
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:uikit/c13-list-reorder-moves
Closed

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:uikit/c13-list-reorder-moves

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8106 → #8139 → #8337 → #8143 → #8163

This merges together with #8139: it completes #8139's reorder mode with the hint and its buttons. Review only this PR's change: vjeux/astryx@5e893e4...1f67838

Why

Reorder mode moves a row with the arrow keys. Someone using a mouse or a touch screen who can't drag has no arrow keys, and WCAG 2.2 requires a way to move it without dragging (SC 2.5.7).

What led to it

This is part of the UIKit-parity program, which makes Astryx's components behave like their iOS counterparts on touch screens. spec:AST-103 FR4 and DEC-5 call for on-screen moves in reorder mode. A small hint now appears below the handle, with Move up, Move down and Done:

  • Each button does what its key does, with the same announcement, and focus stays on the handle.
  • Done ends the mode as Escape does, keeping the moves.
  • Escape inside the hint never closes a dialog underneath.
  • The moves and Done wear the keyboard hint's own text role.

Options and tradeoffs

  • A "Move before / Move after" menu, or a hint that shows the moves next to the row.
  • Bigger buttons on touch, or same-size buttons whose tap area grows invisibly.

Why this choice

DEC-5 chose the hint, because it shows the moves where the row is, without opening a menu. The hint is the keyboard hint's own layer: hooks/anchoredHint.tsx now holds the anchored hint both render, and the keyboard hint renders through it unchanged. On touch, each button's tap area grows toward 44 px through the shared touch hit area (spec:AST-094 FR42), so the hint looks the same with a mouse and with a finger. It adds three strings.

How to check it

In Storybook Core/List → Reorderable:

  1. Click a grip, or tap it on a phone. The hint appears with ↑, ↓ and Done.
  2. Click ↓ twice. The row moves down two places, and focus stays on the grip.
  3. Click Done. The hint goes away and the moves stay.
  4. Start the mode again and press Escape. It ends.

Gates at e856639, whose own change this head carries unchanged on #8139's new head:

  • Core typecheck 0, ESLint 0, INV29 0. check:knowledge, check:changesets and check:repo: 0. review:pre: only the size note, counted from main.
  • Units: List, Item and the keyboard hint, 302 of 302.
  • Red-first: the theme-role case, which now compares Done with the keyboard hint's text, fails on the previous head's Done style and passes here.
  • Browser: the reorder mode, pointer and touch specs pass in Chromium (21 of 21). WebKit passes 13, and skips the 8 pen and Chromium-only cases by their reasons. They cover clicks, keys, taps and start(key), plus a check that tap areas never overlap.
  • The accessibility audit over the List stories: no new violations.

Evidence

Tap, Move down, Done on iOS 26.2. It was captured before Done took the hint's text role, so Done there is darker and bolder than it is now.

Writing rubric results

Over family:overlay-dismissal (List's reorder-mode hint joins the members) and component:List (the readiness line and verified_by):

R1 narration: none; the new entries and the readiness line state what the system does now.
R2 research: none.
R3 evidence: the verification row and verified_by name tests, not changes.
R4 rejected alternatives: none added.
R5 change references: none.
R6 open questions: none touched.

Pull request size

1,159 authored lines in 14 files: 312 of source, 813 of tests, and the rest the records, strings, docs and a changeset.

@vercel

vercel Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 5, 2026 10:30am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 10:53 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs an owner decision before it can be approved: no current spec covers a reorder handle opening its row's moves, or the new ListItem.reorderLabel prop. An engineering owner has to approve or reject both and identify the record that will govern them, recording that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Whatever that decision, these need fixing:

  • A real mouse click on a handle opens nothing: the handle's own mouse long-press recognizer makes the gesture host swallow the click, so the documented click path fails for mouse users, and a real-browser mouse-click test would catch it.
  • The menu opens from an Effect one frame after the click and reads the DOM during render; opening it from the click handler, with the moves and the name computed there, would follow the React runtime rules.
  • The diff carries the whole stack, about 44,600 authored lines, so landing #8139 and its predecessors first would leave only this pull request's own commit.
  • The disabled-row test still passes without the disabled guard, because that row has no moves anyway.

[Automated review]

aria-disabled={isDisabled || undefined}
aria-haspopup="menu"
aria-expanded={menu === 'open'}
onClick={() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A real mouse click never reaches this handler. The mouse LongPressGestureRecognizer above (minimumPressDuration: 0) recognizes every press, so GestureHost arms its stray-click swallower on pointerup and drops the trusted click. In Chromium a press and release on a handle opens no menu; only touch taps and clicks with detail === 0 get through. Opening the moves when the mouse recognizer ends without a changed, plus a page.mouse.click browser proof, would make the documented "a click" path work.

// the handle, and a dragged handle would then lay out on every move.
// Mounted closed, then opened, so the menu moves focus into its rows.
const [menu, setMenu] = useState<'closed' | 'mounting' | 'open'>('closed');
useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Effect only sequences React state (mount closed, open a frame later) and names no external system (architecture:react-component-runtime INV3); it also defers the click's open (INV2). Could the click handler compute the moves and the name, store them in the menu state, mount closed under flushSync and open in the same handler? That would also remove the render-time DOM reads at lines 318 and 331-333 (INV1, INV4).

it('offers no moves from a disabled row, nor from a row that cannot move', () => {
render(
<List onReorder={() => {}}>
<ListItem label="Inbox" isDisabled />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Inbox sits alone above the fixed Sent, so movesFor already offers it no moves, and this test passes even with the !isDisabled guard removed. A disabled row inside a movable group (no fixed row) would prove spec:AST-085 FR2 for the new menu.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs an owner decision before it can be approved: no current spec covers a reorder handle opening its row's moves, or the new ListItem.reorderLabel prop. An engineering owner has to approve or reject both and identify the record that will govern them, recording that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Whatever that decision, these need fixing:

  • A real mouse click on a handle opens nothing: the handle's own mouse recognizer makes the gesture host swallow the click, so the documented click path fails for mouse users, and a real-browser mouse-click test would catch it.
  • After a drag, the next assistive-technology activation of the handle is ignored, because the drag flag is cleared only by the next pointer press.
  • The menu opens from an Effect one frame after the click and reads the DOM during render; opening it from the click handler, with the moves and the name computed there, would follow the React runtime rules.
  • The diff carries the whole stack, about 44,500 authored lines including a press change that fails a RadioList test in CI, so landing #8139 and its predecessors first would leave only this pull request's own commit.

[Automated review]

aria-label={name}
aria-describedby={context.hintId}
aria-disabled={isDisabled || undefined}
aria-haspopup="menu"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

aria-haspopup="menu" is set on every handle, but activation opens a menu only when the row has moves (line 275). On a row alone in its group, such as Drafts below a fixed Sent in the new test, a screen reader announces a menu button that does nothing. Could the popup state be exposed only on a row that can move, derived from the list's children rather than the DOM?

// The row's own pointer handling (its swipe) never sees the handle's
// touches; the gesture system listens on the document, before this.
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
draggedRef.current = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Apart from the click itself, this is the only place draggedRef is cleared, and a drag's own click never arrives because the gesture host suppresses it. After any drag the flag stays set, so the next detail 0 activation, which brings no pointerdown, is swallowed: in Chromium, after a mouse drag, the first click() on the handle opened nothing and the second opened the moves. Clearing the flag when the drag ends or is cancelled would fix it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs an engineering owner's decision before it can be approved: no current spec covers a reorder handle that opens its row's moves when activated without a drag, or the new ListItem.reorderLabel prop. An engineering owner has to approve or reject both and identify the record that will govern them, recording that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Whatever that decision, these need fixing:

  • A real mouse click on a handle opens nothing, because the handle's own mouse recognizer makes the gesture host swallow the click, so mouse users who cannot drag still have no path, and a real-browser page.mouse.click test would catch it.
  • After a drag, the next activation of that handle with no pointer press, which is how the tests model a screen reader, is ignored, because the drag flag is cleared only by the next pointer press.
  • The menu opens from an Effect a frame after the click and reads the DOM during render, and computing the moves and the name in the click handler would follow the React runtime rules.
  • The diff carries the whole stack, about 44,600 authored lines, so landing #8139 and its predecessors first would leave only this pull request's own commit.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's rule for sizes on phones and touch screens: the default look doesn't grow controls to match iOS. Under a touch pointer only, touch targets reach 44 px through an invisible hit area, and that hit area never overlaps a neighboring control's target. Where neighbors are closer than 44 px, it's clamped to half the gap, or skipped. Shrinking something to fit a phone is fine. Mobile size increases will come from theme adaptations.

For this PR: please keep the visible size unchanged and give the target its 44 px through an invisible, coarse-pointer-only hit area, clamped against its neighbors. A shared internal helper for that clamp is on its way. If this stays idle, we'll make the change after about 30 minutes, keeping your authorship.

@vjeux

vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, please go ahead; this stack will fold your change and conform its proofs at the next push.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, but the diff still carries the whole stack under #8139 and the hint's own browser proofs fail at this head. Please:

  • Merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only its own 964 changed lines instead of 40,690.
  • Fix ListReorderMode.interaction.browser.spec.ts: labels() counts the hidden insertion-line li that reorder mode now adds, and the target case still requires 44 px button boxes, which the clamped hit layer no longer provides (6 of its 9 cases fail in Chromium).
  • Add a test that fails if the reorder hint leaves the keyboard hint's anchoring or theme roles, as the FR4 hint verification row in spec:AST-103 asks.

Because this pull request edits a current family record, the spec-owner-approval check also waits for an engineering owner's approval on the final head.

[Automated review]

Comment on lines +47 to +50
const labels = async (page: Page) =>
page
.locator('#storybook-root li')
.evaluateAll(rows => rows.map(element => element.textContent ?? ''));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In reorder mode the list now also holds #8139's insertion line, an aria-hidden li (listReorderEngine.tsx:236, :273-276), and this helper reads it as a row: the Space, Enter, both start(key) and taps-alone cases time out in Chromium on an extra "" entry. Reading #storybook-root li:not([aria-hidden]), or rows by the listitem role, would fix all five.

boxes.push({name, box, glyph});
}
for (const {name, box, glyph} of boxes) {
expect(box.width, `${name} is 44 px wide`).toBeGreaterThanOrEqual(44);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

At this head the hint's buttons keep their size and a finger's target is the clamped ::after layer, so this fails in Chromium: “Move up is 44 px wide … Expected: >= 44, Received: 20”. The centring check below now compares each button with its own glyph, the same box. Could this case measure the hit layer instead: each glyph's points hit its own button, a tap 1 px inside each layer edge reaches it, the target holds a centred minimum square, and no two layers overlap?

expect(hintButton('Move up')).toBeInTheDocument();
expect(hintButton('Done')).toBeInTheDocument();
// In the anchored hint layer, outside the list; never a tab stop.
expect(down.closest('[popover]')).not.toBeNull();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

spec:AST-103's FR4 hint verification row expects “a hint off the keyboard hint's layer, anchoring, or theme roles” to fail, but this only checks for some [popover] ancestor. Asserting that the hint's popover carries the shared anchoredHint__styles.hint class, as useKeyboardHint.test.tsx:70 does for the keyboard hint, would pin it.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's decision on gestures on the web: gestures attach for every pointer, not only touch.

  • Stays off: mouse swipe actions (they'd take over text selection and native drag), pen pinch, edge pan on web inputs, and the 44 px touch target, which stays touch-only and never overlaps a neighbor.
  • Web adaptations: Pan takes wheel and trackpad scroll; Pinch takes ctrl+wheel. A shared "context request" input covers long-press, right-click and Shift+F10. The press tracker gets a fine-pointer profile with no delay, and reorder starts on a handle drag without a hold.
  • The gesture host drops its touch-only query, and pointer queries move into the environment queries. Desktop listeners attach only where a component uses a gesture, and no new code ships for that.

This supersedes the earlier exclusion of mouse press paint. The spec:AST-094 amendment follows in its own spec PR, and the gesture-host PRs conform to it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, but the diff still carries the whole stack under #8139 and one of the hint's own browser proofs fails at this head. Please:

  • Merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only its own 1,128 changed lines instead of 38,806.
  • Fix the finger-target case in ListReorderMode.interaction.browser.spec.ts: it requires every hint button to be narrower than 44 px, but under the touch type scale Done's text makes it 44.03 px wide in Chromium, so the case fails before its hit-layer checks run; asserting that a coarse pointer adds no padding or minimum size would test what the case means.

Because this pull request edits a current family record, the spec-owner-approval check also waits for an engineering owner's approval on the final head.

[Automated review]

);
for (const {name, box, hit} of targets) {
// Its own size: no wider for a finger.
expect(box.right - box.left, `${name} keeps its size`).toBeLessThan(44);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This fails in Chromium at this head: “Done keeps its size … Expected: < 44, Received: 44.03125”. Under the phone profile the coarse-pointer type scale raises Done's text from 12 to 14 px, so its box widens from 38.89 px (mouse) to 44.03 px while its padding stays 4 px and min-width stays auto; the width depends on the platform's fallback font, since no web font loads. The non-overlap, hit-test and edge-tap checks below never run. Could this assert that the coarse pointer adds no padding or minimum size instead, or keep the < 44 ceiling only for the 20 px glyph buttons?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, but the diff still carries the whole stack under #8139, and the hint's finger-target browser proof checks the wrong thing. Please:

  • Merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only its own 1,171 changed lines instead of 40,505.
  • In the finger-target case of ListReorderMode.interaction.browser.spec.ts, assert that a coarse pointer adds no padding or minimum size instead of capping every button, Done included, below 44 px, a width that depends on the platform's font.

Because this pull request edits a current family record, the spec-owner-approval check also waits for an engineering owner's approval on the final head.

[Automated review]

);
for (const {name, box, hit} of targets) {
// Its own size: no wider for a finger.
expect(box.right - box.left, `${name} keeps its size`).toBeLessThan(44);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This caps every hint button, Done included, below 44 px. Done's width is its label in the platform's font under the mobile type scale, so the identical case failed in Chromium at the previous head (Done 44.03 px) and the non-overlap and hit-test checks below never ran, while a Done that did grow for a finger, up to 44 px, would still pass. Comparing each button's box under the coarse pointer with its box under a fine pointer, or asserting that no padding or minimum size is added, would test what the case means; the < 44 ceiling can stay for the 20 px glyph buttons.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, but the diff still carries the whole stack under #8139, and the hint's finger-target browser proof checks the wrong thing. Please:

  • Merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only its own 1,171 changed lines instead of 39,305.
  • In the finger-target case of ListReorderMode.interaction.browser.spec.ts, compare each hint button's box under the coarse pointer with its box under a fine pointer instead of capping every button, Done included, below 44 px, since spec:AST-094 FR42 leaves a box that grows only with its text outside its rule.

Because this pull request edits a current family record, the spec-owner-approval check also waits for an engineering owner's approval on the final head.

[Automated review]

);
for (const {name, box, hit} of targets) {
// Its own size: no wider for a finger.
expect(box.right - box.left, `${name} keeps its size`).toBeLessThan(44);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This ceiling makes the case measure Done's label in the platform's font: in this phone context the mobile type scale applies (design:mobile-typography DR3), and a label of 36 px or more, plus --spacing-1 on each side, fails it, although spec:AST-094 FR42 puts "a box that grows only with its text" outside its rule; the non-overlap check and hit-tests below then never run. A button that did grow for a finger, up to 44 px, would still pass. FR42's verification asks for geometry "under a coarse and a fine pointer": could this compare each button's box with its box in a fine-pointer context (or assert that the coarse pointer adds no padding or minimum size), keeping the 44 px ceiling only on the hit layer, as lines 474-475 already do?

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, but the diff still carries the whole stack under #8139, and the hint's finger-target browser proof checks the wrong thing. Please:

  • Merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only its own 1,171 changed lines instead of 39,359.
  • In the finger-target case of ListReorderMode.interaction.browser.spec.ts, compare each hint button's box under the coarse pointer with its box under a fine pointer instead of capping every button, Done included, below 44 px, since spec:AST-094 FR42 leaves a box that grows only with its text outside its rule.

Because this pull request edits a current family record, the spec-owner-approval check also waits for an engineering owner's approval on the final head.

[Automated review]

);
for (const {name, box, hit} of targets) {
// Its own size: no wider for a finger.
expect(box.right - box.left, `${name} keeps its size`).toBeLessThan(44);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This caps every hint button below 44 px, Done included, so Done's label width under the mobile type scale decides the case (spec:AST-094 FR42 puts a box that grows only with its text outside its rule), and a button that grew for a finger up to 43 px would still pass. FR42's verification asks for geometry under a coarse and a fine pointer: compare each button's box here with its box under a fine pointer (or assert the coarse pointer adds no padding or minimum size), and keep the 44 px ceiling on the hit layer only, as the lines below already do.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, and its finger-target browser proof now compares each button with its fine-pointer box as spec:AST-094 FR42 asks, but the diff still carries the whole stack under #8139. Please merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only its own 1,252 changed lines instead of 39,688. Because this pull request edits a current family record, the spec-owner-approval check also waits for an engineering owner's approval on the final head.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge: the reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, but the diff still carries the whole stack under #8139 (37,508 changed lines against this pull request's own 1,252), and GitHub reports it as conflicting with main. Please merge #8139 and its predecessors first, or rebase after they land, so this pull request holds only the hint; the carried List.hasAnimatedChanges also disagrees with component:List FR12, which now specifies an animatedLayout handle. One small fix in the hint itself: the Effect at listReorderEngine.tsx:2024-2028 names no external system, which architecture:react-component-runtime INV3 requires, so fold it into the show and hide Effect or read the layer through an Effect Event.

[Automated review]

Comment on lines +2024 to +2028
// No external system: keeps the latest layer handle for the effect below,
// which re-runs only when the mode's row changes. Idempotent.
useIsomorphicLayoutEffect(() => {
layerRef.current = layer;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Effect says it synchronizes no external system, but architecture:react-component-runtime INV3 says a new Effect MUST name the external system it synchronizes. Fold the layer handle into the show and hide Effect below, or read it through an Effect Event, and delete this one.

@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

An offer for the review's Effect point: offer/list-reorder-hint-effect fast-forwards this PR's head with one commit.

The hint's show-and-hide Effect now reads the layer's anchor, show and hide directly. Those keep their identity while the layer opens and closes, so the Effect still runs only when the mode's row changes. That removes the separate Effect that kept the latest layer in a ref, which named no external system (architecture:react-component-runtime INV3). The hint's unit and browser tests pass in Chromium and WebKit.

If you want it here, merge the branch. The rest of the review is about the stack under this PR.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This needs an engineering owner's decision before it can be approved, and it needs changes either way.

Owner decision: spec:AST-103 FR4 and DEC-5 define the reorder hint, but no record states the wording of its two move buttons, "Move up" and "Move down" (FR1 names only the moveUp and moveDown label keys). An engineering owner needs to settle that wording, by stating it in spec:AST-103 or by commenting /approve-spec <full head SHA> with that decision; the author may do so if they are an engineering owner.

Blocking findings:

  • The diff carries the whole open stack under #8139 (37,500 authored lines, 1,252 of them this pull request's) and no longer merges with main, where 85 files conflict, including hooks/useKeyboardHint.tsx and docs/families/overlay-dismissal.md; land #8139 and its predecessors or rebase so only this pull request's commits remain.
  • The layout Effect at listReorderEngine.tsx:2024-2028 names no external system, which architecture:react-component-runtime INV3 requires; fold it into the show-and-hide Effect as the offered branch does.
  • HintButton (listReorderEngine.tsx:1944-1982) and the reorder handle start their own watchHitArea and ::after layer instead of the shared useTouchHitArea and touchHitAreaStyles.layer, so the hint ignores a page's larger touch target; use the shared hook and delete the copies.
  • Done is drawn in the primary text role at semibold (listReorderEngine.tsx:1951-1955), while spec:AST-103 FR4 asks for the keyboard hint's theme roles.
  • Stacked: List.hasAnimatedChanges (List.tsx:88) contradicts component:List FR12, which names an animatedLayout handle.
  • Stacked: a mouse click in a link row's padding now follows the link (Item.tsx:749-767), a desktop behavior change that needs an owner decision or a touch-only gate.
  • Stacked: createReorderable exposes only start (createReorderable.ts:88), while spec:AST-103 FR4b requires attach.

The hint's own behavior matches FR4 in the head Storybook and its unit tests pass; the stacked findings belong to the pull requests that own those changes.

[Automated review]

Comment on lines +2024 to +2026
// No external system: keeps the latest layer handle for the effect below,
// which re-runs only when the mode's row changes. Idempotent.
useIsomorphicLayoutEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This Effect says it synchronizes no external system, and architecture:react-component-runtime INV3 says a new Effect must name the one it synchronizes. The layer's ref, show and hide keep their identity, so the show-and-hide Effect below can read them from layer directly and this Effect can go (the offered branch does that).

Comment on lines +1976 to +1982
useIsomorphicLayoutEffect(() => {
const button = ref.current;
if (button == null) {
return undefined;
}
return watchHitArea(button, insets => writeHitInsets(button, insets));
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This repeats what useTouchHitArea (utils/touchHitArea.ts) and touchHitAreaStyles.layer already do for List rows, minus the page's --size-touch-target, the wait for animations to rest and the clipped layer, so a page that sets --size-touch-target above 44 px grows rows but not these buttons. Calling useTouchHitArea(ref, true) with touchHitAreaStyles.layer, here and in ListReorderHandle, removes this mount Effect and the ::after copy above.

Comment on lines +1953 to +1954
color: colorVars['--color-text-primary'],
fontWeight: fontWeightVars['--font-weight-semibold'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done renders black at 600 beside the moves' secondary grey at 400 (measured in the head Storybook). spec:AST-103 FR4 asks for the keyboard hint's theme roles, and its verification row fails a hint off them; dropping color and fontWeight here keeps Done in the hint's supporting text.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec b637049

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge. An engineering owner's /approve-spec covers this head's spec content, including the hint's Move up and Move down wording, and the hint itself does what spec:AST-103 FR4 and DEC-5 specify, but four things need fixing:

  • The diff carries #8106 and #8139 (8,679 of its 9,912 authored lines), and the description's stack line is not one identical Stack: #a → #b → #c declaration across those pull requests; land them first, or rebase after they land, so only this pull request's commit remains.
  • Done is drawn in the primary text colour at semibold (listReorderEngine.tsx:1954-1958), while spec:AST-103 FR4 asks for the keyboard hint's theme roles (its secondary text at regular weight); drop those two declarations and have the browser theme-role test compare Done.
  • docs/specs/components/List/List.spec.md:60 still says reorderable is built "but for its mode's hint", which this pull request builds; drop "its mode's hint and".
  • Regression: textOf in ListReorderHandle.tsx:225-237 copies the shared getNodeText (utils/getNodeText.ts) that main now has, and the two already join adjacent text differently; call the shared utility, extending it if List needs another join, and delete the copy.

Non-blocking:

  • Miss: endMode's and blur's comments still say a blur ends reorder mode (listReorderEngine.tsx:765-766, :1794), which the code no longer does, and isMovingByKey is never read.

[Automated review]

Comment on lines +1954 to +1958
done: {
paddingInline: spacingVars['--spacing-1'],
color: colorVars['--color-text-primary'],
fontWeight: fontWeightVars['--font-weight-semibold'],
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done measures rgb(0, 0, 0) at 600 here while the moves and the keyboard hint's label are rgb(71, 71, 71) at 400 (the hint's --color-text-secondary). spec:AST-103 FR4 asks for "the same layer, anchoring, and theme roles as Core's keyboard hint". Keep paddingInline and drop color and fontWeight, so Done takes the hint's text role.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e856639: Done keeps only its padding and takes the hint's text role. The theme-role case now compares Done, and it fails on the previous head.

Comment thread docs/specs/components/List/List.spec.md Outdated
| End-user impact | Row text can share the line of the list header and a heading above, with row paint stopping at the text, reaching into the host padding, or reaching the host edges. Touch users get a 44 px row target (FR10) and the cell press (FR11); with `animatedLayout`, row updates animate for every pointer (FR12). A row with a menu opens it by a long press, a right click, or Shift+F10, or through its More actions button (FR13). |
| Builder impact | One `edge` choice per list. `flush` works anywhere; `outset` and `bleed` act only inside a host that publishes container padding. Passing an `animatedLayout` handle is one new choice, and a row menu is the other: the caller passes a ListItem's ref to `useContextMenu` (FR13), and who draws a plain row's More actions button is open (OQ2); and rows animate only with an `itemKey`; the touch target and press need none. |
| Compatibility/readiness | Additive; omission keeps current geometry; `edgeCompensation` keeps working with a codemod to `edge`. `reorderable` and `itemKey` are additive and stable, specified and not implemented yet; without them nothing changes. `animatedLayout` is additive and specified, not implemented yet. The touch target (FR10) and the press's compositor release (FR11) are implemented under coarse primary pointers. Neither changes anything under a fine pointer. Row menus (FR13) are additive and specified, not implemented yet. |
| Compatibility/readiness | Additive; omission keeps current geometry; `edgeCompensation` keeps working with a codemod to `edge`. `reorderable` is stable and built, but for its mode's hint and `labels`. `itemKey` and `animatedLayout`'s row updates (FR12) are implemented; its start and end content motion is not. The touch target (FR10) and the press's compositor release (FR11) are implemented under coarse primary pointers. Neither changes anything under a fine pointer. Row menus (FR13) are additive and specified, not implemented yet. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request builds the hint, so "but for its mode's hint and labels" is no longer true. Suggested: "reorderable is stable and built, but for labels."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e856639: "reorderable is stable and built, but for its labels and positions." Neither option is built yet.

Comment on lines +225 to +237
function textOf(node: ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (Array.isArray(node)) {
return node.map(textOf).join('');
}
if (isValidElement<{children?: ReactNode}>(node)) {
return textOf(node.props.children);
}
return '';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regression: main now has getNodeText (utils/getNodeText.ts), the shared owner for a node's spoken text (Toast announcements, Table column-resize handle names). This copy joins siblings with no space where getNodeText joins with one, so ['Inbox', <b>3</b>] is spoken "Inbox3" here and "Inbox 3" there. Call getNodeText (extend it if List needs another join) and delete textOf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #8139 (9a1afa9), where the handle lives: it calls getNodeText, and the copy is gone.

Comment on lines +765 to +766
* into the hint goes back to the handle. A blur, a drag or the row leaving
* ends it without a word.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): blur() keeps the mode (:1817-1819), as spec:AST-103 FR4a wants, so this sentence and blur's JSDoc (:1794) describe a rule the code does not follow. Suggested: "A drag or the row leaving ends it without a word." isMovingByKey (:611-615) is also written and never read.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #8139 (9a1afa9): endMode's and blur's comments now match the code, and isMovingByKey is gone.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec e856639

A List that takes the handle useAnimatedLayout returns animates its row
changes on one shared motion, for every pointer. An inserted row enters,
a removed row collapses in place, and a row that crosses rows moving the
other way rides above them. The rows after a change, and the content
after the list, glide to their new places. Rows match across renders by
their itemKey; a row without one changes at once.

The row marks the update module writes (item key, leaving, travelling)
live in their own private-attribute owner modules. A list under the
handle isolates for every pointer, so a sliding row stays below a
page-level sticky header.

The List and ListItem records now say the row updates and itemKey are
implemented, and their verified_by lists the files that verify them.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge. The reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, the three fixes from the last review (Done's text role, the List record's readiness line, the shared getNodeText) are in, and an engineering owner's /approve-spec covers this head's spec content. One thing remains:

  • The diff carries #8106 and #8139 beside this pull request's own commit (10,179 authored lines, 1,246 of them its own), and the description's Stack: line is not one identical declaration across #8106, #8139, #8143 and #8163, so land #8106 and #8139 first or rebase after they land, or give every listed pull request the same Stack: line.

Non-blocking:

  • The open hint covers the next row's handle, so a click or tap aimed at its centre lands on Done and ends reorder mode instead of moving it to that row.
  • Miss: List's per-commit Effect (List.tsx:325-328) could name the reorder session it synchronizes and why it runs after the commit.

[Automated review]

vjeux added 2 commits November 5, 2026 02:10
createReorderable({onReorder, isReorderable}) makes a collection's
reorder behavior, and List takes it as `reorderable`. Each ListItem
with an itemKey gets a reorder handle at its end, and each committed
move calls onReorder({key, to}) once.

Under a finger, a 150 ms hold on a handle picks its row up, and a
flick on it still scrolls. The rows stay still while a dimmed source,
a preview and an insertion line show the move. On release they glide
on the shared settle role. A pen holds the same way where the primary
pointer is coarse. A mouse, or a pen where the primary pointer is
fine, drags a handle at once. A click, Space or Enter turns on reorder
mode: the insertion line marks the row's place, the arrow keys move
the row and announce its place, and Escape ends the mode.

The reorder's machinery ships with the object, so a List without
`reorderable` loads none of it. The UIKit table's reorder values and
the drop model come in here with their first user. With an
animatedLayout handle, a drag's commits stay the reorder's own. The
CLI lists createReorderable with the hooks.
While a reorderable List row is in reorder mode, a hint anchored below
its handle shows Move up, Move down and Done until the mode ends. Each
move does what its key does, with the same announcement, and leaves the
focus on the handle. Done ends the mode as Escape does, keeping the
moves. So a pointer or a finger can reorder by taps alone, as WCAG
2.5.7 asks. A screen reader that moves its focus into the hint keeps
the mode, and the focus goes back to the handle.

The hint shares the keyboard hint's layer: hooks/anchoredHint.tsx holds
the anchored hint both render, and useKeyboardHint renders its hint
through it unchanged. The hint joins the shared dismissal stack, so an
Escape in it ends the mode and leaves a Dialog below it open.
@vjeux

vjeux commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

All five pull requests in the stack now open with the same line, Stack: #8106 → #8139 → #8337 → #8143 → #8163. The hint sits below the handle, where spec:AST-103 FR4 and DEC-5 anchor it, as Core's keyboard hint does. List's per-commit Effect now names the reorder session (#8139 5e893e4).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes are needed before this can merge. The reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, and its record edits and strings carry an engineering owner's approval. One thing remains:

  • The diff still carries #8106 and #8139 beside this pull request's own commit (10,233 authored lines, 1,246 of them its own), and the Stack: declaration does not hold yet: #8163's body lacks the identical Stack: #8106 → #8139 → #8337 → #8143 → #8163 line and its head is not built on #8143's current head, so update #8163 or land #8106 and #8139 first and rebase.

Non-blocking:

  • Miss: endMode's comment says a drag ends reorder mode "without a word" (listReorderEngine.tsx:778-779), but a drag ends it through endMode() and announces "Done, …".
  • The open hint covers the next row's handle, so a click or tap aimed at that handle's centre lands on Done and ends reorder mode.

[Automated review]

Comment on lines +778 to +779
* into the hint goes back to the handle. A drag or the row leaving ends it
* without a word.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: a drag does end the mode with a word. begin calls endMode() (:1077-1080, "A drag ends reorder mode, keeping its moves, and says so"), which announces "Done, …". Suggested: "The row leaving ends it without a word."

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1f67838

ghost left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI's test jobs skipped this draft head, so the hint's browser specs have not run in CI yet. The reorder-mode hint does what spec:AST-103 FR4 and DEC-5 specify, its record edits and strings are owner-approved, and the Stack: declaration now holds, so #8106 and #8139 are reviewed in their own pull requests. This pull request merges only after #8106 and #8139 are approved and land.

Non-blocking:

  • Miss: endMode's comment says a drag ends reorder mode "without a word" (listReorderEngine.tsx:778-779), but a drag ends it through endMode() and announces "Done, …".
  • The open hint covers the next row's handle, so a click or tap aimed at that handle's centre lands on Done and ends reorder mode.

[Automated review]

Comment on lines +778 to +779
* into the hint goes back to the handle. A drag or the row leaving ends it
* without a word.

ghost Nov 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: a drag does end the mode with a word. begin calls endMode() (:1077-1080, "A drag ends reorder mode, keeping its moves, and says so"), which announces "Done, …". Suggested: "The row leaving ends it without a word."

@vjeux

ghost commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #8139 (ee13b42), as the stack review asked.

This branch was successfully deployed

1 active deployment
Preview — 1f67838d Deployed Nov 5, 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