Skip to content

feat(core): Table rows glide when a row's panel opens or closes, and their dividers move with them - #8656

Merged
cixzhang merged 98 commits into
mainfrom
feat/table-row-expansion-glide
Oct 28, 2026
Merged

cixzhang merged 98 commits into
mainfrom
feat/table-row-expansion-glide

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What changes for people using Table

Opening or closing a row's detail panel made every row after it jump. With an animatedLayout handle (useAnimatedLayout), useTableRowExpansion now glides those rows (and the content after the table) to their new places, plays an opening panel in on the same motion, and turns the chevron on it. Without a handle rows jump, as before. Under Reduce Motion the rows change at once (under a finger, a short fade in place). Tables with dividers="grid" do not glide yet (Table FR15): their column lines are painted at the cells' static places too, so their rows change at once; the vertical stand-ins come in a follow-up.

Each row's divider moves with its row. With collapsed borders the table paints a border at its cell's static place, so a moving row used to leave its divider behind:

  • While a row moves, its own cells are marked: their borders go transparent (the width stays, so nothing reflows), and one of them (the first that scrolls with the row) draws the same divider across the whole row in its ::after, positioned against the row. The cell is not positioned, so its overflow does not clip the line; and it is a box inside a cell, not a box in the row, so the table's column widths stay put (checked in a table its columns do not fill: 54/298/298/298/298 px before, during and after a glide). A table nested in a moving panel keeps its own dividers, since only the moving row's own cells are marked.
  • The row's own border, an app's or a theme's included, is read once when the glide starts and carried onto that line through the same private plumbing fix(core): a pinned table column draws its own row dividers #8654 uses. Pinned cells keep drawing their own (fix(core): a pinned table column draws its own row dividers #8654), so the two compose.
  • The moving cells' colour goes transparent inline, so a theme's table-cell colour or an app's stylesheet on td cannot keep the line at its static place. Each moving row is its own layer (the table's last row, which can glide a pixel farther, under the others), so no row covers the divider above it.
  • The detail panel's own divider carry is in fix(core): a pinned table column draws its own row dividers #8654; while its row glides the panel keeps what it carries.
  • Only the rows that move are visited: the walk goes out from the changed row and stops at still rows. It reads nothing after the glide starts: the size-change module's animate() takes a beforeMove callback, run once the new places are read and before any glide starts, where the rows' borders are read and the panel's entrance starts (playLayoutChange now returns it); glideOf tells which rows glide without a DOM read.

A closing panel shrinks the table's scroll wrapper at once, which would cut off the rows gliding up into its place. For the glide the wrapper keeps its old height as bottom padding, given back with an equal negative margin, so nothing outside moves differently; both go when the rows stop.

Based on stack/table-row-glide-base, which is #8639 (the animated-layout stack) merged with #8654, so this compare shows only the glide; it retargets to main once both land.

Evidence (real Chromium, desktop and 390 px)

Base (no handle: rows jump) and head (rows glide) at the same instant, 90 ms into opening and closing a row on the 2,000-row story:

Table row glide base and head frames

Frames at 0.05× speed, opening and closing a row with the Customer column pinned and the table scrolled 60 px: the rows glide and every divider (pinned and unpinned) stays on its row; the closing rows are not cut off; the settled state matches the start.

Table row glide frames

  • Reduce Motion: no animation; the rows are at their new places on the first frame.
  • A 2,000-row table (not virtualized): a glide moves 10 rows (the ones near the visible part). Click to painted frame, median of five: 1,710 ms without a handle (the table re-renders every row on an expansion change, as today) and 1,791 ms with one, with one whole-table layout more (the glide reads the new places once). The first version took about 2,300 ms with four extra layouts; reading the rows' borders and starting the panel's entrance inside the glide's own reading (animate(beforeMove)), finding the moving rows from the module instead of the DOM, and unmarking them with writes only removed the rest. A 3-row table: 24 ms.

Tests

  • TableRowGlide.a11y.browser.spec.ts (Table FR14, FR15, real Chromium and WebKit): no animation without a handle; mid-glide, a divider under the moving row's pinned and unpinned cells; column widths unchanged through a glide; an open panel's divider.
  • useTableRowExpansion.glide.test.tsx: with a handle the rows after an opening panel glide and the chevron turns on the glide's motion; without one nothing animates; a moving row's cells are marked (the first that scrolls draws the line; a pinned cell and a nested table's cells are not) and carry its divider only while it moves; an open panel carries its row's app-set border.
  • animatedLayout.boundary.test.ts: the row-expansion plugin joins the module's adopters.
  • Stories: "Gliding rows" (pinned column, horizontal scroll) and "Gliding rows long table".
  • pnpm -F @astryxdesign/core typecheck; Table and motion suites pass.

…t around a size change

When something inside a registered area changes size, the content after it
glides from its old place to its new one with a compositor-only translate
instead of jumping (architecture:react-component-runtime INV12, proposed
in #8463).

- motion/animatedLayout.ts: one batched measurement before and after the
  change; a bounded parentElement/nextElementSibling walk up to the area
  (no TreeWalker), descending only through display: contents; absolute and
  fixed elements skipped; only elements whose rectangle changed and that
  are visible move; scale-corrected; Web Animations translate with
  composite: 'add', feature-tested per call with fallbacks; offscreen
  elements land silently; Reduce Motion cross-fades with no travel; every
  pointer.
- useAnimatedLayout(ref) (public) registers an area; reportLayoutChange
  stays internal. ScrollableArea registers its viewport itself.
- Collapsible and TreeList adopt it: the shown content plays its own
  entrance, and the chevron turns, on the same placeholder layout-change
  role as the glide.
- Storybook: Core/Animated layout, with a nested tree, a Collapsible, a
  field error, load more, wrapping tags, plain divs, a display: contents
  wrapper, a sticky header and an absolute badge.

Draft for trying it out; tests, docs and a changeset follow.
…ollapsible and TreeList take as animatedLayout

The API shape the owner set: the hook takes {containerRef} and returns an
opaque handle; Collapsible and TreeList accept an animatedLayout prop and
report their size changes through it, with no context registration.
Without the prop, a component keeps today's behavior, its chevron
included. ScrollableArea no longer registers itself: an app passes the
ScrollableArea's ref as containerRef. The story uses exactly this shape.
Adds the changeset.
…subpixel rounding

A real-Chromium check showed unscaled followers moving by 79.92 px for an
80 px shift: subpixel rounding made rect.width / offsetWidth read a hair
off 1. A scale within 1% of 1 is now treated as none.
…tion by pointer, visible-part rule for the changed element, top-layer and hidden skipped, chevron on the spring)
- One shared motion role: the glide, the changed element's entrance and the
  disclosure chevron all play springs.settle, resolved through the internal
  motion-role registry (a theme's --motion-springs-settle reaches all three);
  the glide's keyframes are sampled from the spring and played linearly.
- Reduce Motion by pointer (design:reduced-motion DR1-DR3): a cross-fade
  under a coarse primary pointer, an immediate change otherwise.
- The changed element outside the area's visible part lands without
  animation; hidden and top-layer elements are skipped.
- Collapsible and TreeList take the chevron from their Icon's icon target
  and set its transition before the change commits; no handle leaves every
  inline value untouched.
- Tests: the walk, motion and handle in jsdom; Collapsible and TreeList with
  and without a handle; one real-browser case per AST-105 Coverage row, plus
  FR2-FR5.
- Docs: the useAnimatedLayout hook doc (FR7 text), the animatedLayout prop in
  the Collapsible and TreeList docs and records, and a Before and after story.
… registry only

Motion roles are internal (spec:AST-094 DEC-11): the glide, the changed
element and the chevron share the registry's one value, and a scope's
--motion-springs-settle changes nothing. The tests assert that instead of
an override.
… and compare scrolling with and without a handle

Run in real Chromium (all 20 cases, three repeats): the page helper's
microtask wait is plain JavaScript; the out-of-view and nested-area cases
wait for the first change's glide to finish before the second; and the FR3
scroll case compares the area's scroll frame by frame with and without a
handle, since the browser's own scroll anchoring moves it either way.
…ed ones and retargets on the compositor

- A single-mode CollapsibleGroup measures every item a toggle changes (the
  one that closes as well as the one that opens), so the items between
  glide with the content after the group. Items holding a handle register
  their measurement with the group.
- A measurement no commit claims by the next frame (a controlled owner
  refused the toggle) is dropped, so it never moves unchanged rows later.
  A change nothing measured (an owner set isOpen directly) lands at once,
  entrance included.
- The follower glide is one replace-composited translate, which the
  compositor runs (architecture:react-component-runtime INV12). An element
  already gliding is retargeted: its glide stops and the new one starts from
  where it is on screen, so it keeps moving without a jump.
- INV12's source boundary check (no layout property animated, the walk only
  through parentElement / nextElementSibling, nothing public, only the hook
  and the adopters use it) and a real-browser case on a throttled phone
  over a 10-level by 50-sibling tree with two changes in one frame.
spec:AST-105's Coverage row: an element already moving keeps its running
motion and adds the new move. The new glide now samples the sum of the
running glide's remaining curve and the new move into one replace-composited
translate, so the element keeps its speed through the change instead of
restarting from rest. Every animation of a change starts on the document
timeline's current time, so a replaced glide moves in the very frame it is
replaced and the changed element and its followers share one start. Tests:
the carried speed (jsdom) and, in a real browser, no jump and no lost speed
across the change.
…e morph once Core has it, its own compositor translate until then
…st adopt it in their own pull request

The module, the hook, their tests and docs, and the INV12 record line stay
here. Collapsible's and TreeList's adoption (their animatedLayout prop,
docs and records), the CollapsibleGroup measurement, the adopter tests and
the Storybook story move to a stacked pull request, so each lands and
reverts on its own. The changeset now describes the hook alone.
Restores the adoption from the module pull request onto it: the
animatedLayout prop on Collapsible and TreeList (docs and records), the
CollapsibleGroup measuring every item a toggle changes, the adopter tests,
and the Storybook story. The story keeps only adopting components and plain
following content; its demos that drove the internal reporting functions
directly are gone.
…motion

spec:AST-105 FR4: the changed element plays its own animation when it exits
too. holdLayoutExit keeps a closing element on screen but out of flow
(absolutely positioned where it is), so the content after it glides up at
once, and conceals it upward from its bottom edge on springs.settle (a fade
under Reduce Motion with a coarse pointer, nothing with a fine one); the
component hides it when the exit finishes, and releaseLayoutExit puts it
back, including when it opens again mid-exit. The throttled-tree browser
case now reads its animations in the same task, so a slow round trip can't
outlast the glide.
…'s exit

spec:AST-105 FR4: the changed element animates when it exits too. A measured
close keeps the Collapsible's content (or the TreeList row's child group)
rendered, inert and hidden from assistive technology, while holdLayoutExit
holds it out of flow and conceals it on springs.settle; the rows after it
glide up at once, and the content hides (or the group unmounts) when the
exit ends. Opening again mid-exit ends it. Rows of an exiting group leave
the tree's keyboard order at once. An unmeasured close, no handle, or an
exit that lands at once hides it in the same commit.
…es with its row; the exit stays in flow

- This pull request is now the internal module only: the public hook, its
  docs and the chevron's transition move to the adoption pull request, so
  nothing public appears before a component can take the handle.
- A changed element that an earlier change in the same frame displaced moves
  with its row, by its total change (AST-105 FR3), while it plays its own
  entrance or exit; an element not rendered before the change has no old
  place and does not move.
- The exit keeps the element in flow where it is and gives up its space
  with a negative end margin as large as its box, so it scrolls and clips
  with its container and keeps its width (no offset-parent positioning).
- Browser proofs: the sticky row uses a stuck header after the change, the
  flex row a wrapped item and a row-direction neighbor whose rectangle does
  not change; new cases for the displaced changed element and the exit in a
  scrolled area.
… chevron move here

The module pull request is now internal only, so this one carries the
public face with the components that take it: useAnimatedLayout, its doc
page and export (and its docsite route), applyLayoutChangeTransition and
its tests, and the boundary test's list of users (the hook, Collapsible and
TreeList). The adopters follow the module's in-flow exit: their tests check
the exit's end margin instead of absolute positioning. The changeset
describes the hook and the two adopters together.
…e app moves are left alone

- holdLayoutExit measures the layout after the element with it hidden and
  sets the end margin that puts that layout there, so a gap or collapsing
  margins come out exact and nothing jumps when the element hides; where no
  margin can free the space (a grid track and its gap) the exit lands at
  once instead.
- The held element is inert while it conceals (no focus, no pointer,
  nothing read); the module sets and releases it.
- A follower with its own transform, translate, rotate or scale, or with a
  running transform animation the module did not start, takes its final
  place at once, as AST-105's Coverage lists for elements the app moves.
- Tests: the exact space with a gap (jsdom), the grid fallback, app-moved
  followers; real-browser cases for a flex gap and collapsing margins, and
  for app-moved followers.
… left alone) into the adoption

The module now makes a held element inert, so Collapsible's content and
TreeList's child group drop their own inert and aria-hidden while exiting
(TreeList keeps the marker that takes the group's rows out of the keyboard
order).
…red close, and restores what it replaced

- giveUpSpace measures the first element after the closing one in the area
  (its next sibling, or an ancestor's below the area), not the parent's
  edge, which a collapsed negative margin never moves: a Collapsible-shaped
  close (the last child of a plain wrapper) now plays its exit.
- holdLayoutExit holds only a close a change in the area measured, and its
  documentation asks for the call after animate() reports it measured.
- releaseLayoutExit puts back the element's own inert and inline values.
- AST-105's out-of-coverage list names a closing grid item whose track or
  gap changes when it hides (owner-approved): it hides at once, without its
  exit.
- Tests: an unmeasured close holds nothing; prior values restored; a real-
  browser case for the plain-wrapper shape.
…d holds only a measured close) into the adoption
…al next box and checks what follows its container

- The flush stops every running glide and reads every new place before
  starting any glide, and an element inside another moving element moves
  only by its own change: nested changes compose whichever is reported
  first (AST-105 FR3).
- The exit's search for the next box enters a display: contents wrapper,
  as the follower walk does.
- The exit checks the content after its container on both axes, so a
  flex-row item that sets its row's height is refused instead of leaving
  the content below to jump.
- A refused exit puts back the element's own end margin and drops its
  change, so the content after it takes its final place at once.
- AST-105's out-of-coverage line names both layouts no margin frees (owner-
  approved).
- Tests: a refused hold keeps its own margin and glides nothing; real-
  browser cases for nested changes reported inner-first, a display:
  contents next box, and a tall flex-row item.
…ext box and container checks) into the adoption
@cixzhang
cixzhang changed the base branch from stack/table-row-glide-base to main October 28, 2026 05:57
# Conflicts:
#	docs/architecture/react-component-runtime.md
#	docs/specs/AST-105-animated-layout/spec.md
#	packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts
#	packages/core/src/motion/animatedLayout.boundary.test.ts
#	packages/core/src/motion/animatedLayout.test.ts
#	packages/core/src/motion/animatedLayout.ts
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 202f3da

@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.

The glide, the column-lines exception and the Reduce Motion behaviour match component:Table FR15 and spec:AST-105 as they stand on main, but one case FR14 names still breaks, so this needs changes before it can be approved. When an app styles a row's own border (tr), each glide draws that divider twice, the carried copy moving with the row and the table's own copy left at the row's final place, and with a pinned column the moving copy stops short of the pinned cells. Please conform that case (for example, make the row's own bottom border transparent while the row is marked, and let a gliding pinned cell read the row's carried divider instead of its own carry), and add FR14's row-border case, mid-glide and on a pinned cell, to the story and the browser spec, which today exercise only a td override.

[Automated review]

// line at the row's static place.
if (cell instanceof HTMLElement) {
inlineColorOf.set(cell, cell.style.borderBottomColor);
cell.style.borderBottomColor = 'transparent';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the cells' own bottom colours go transparent here. When the row's own border wins the collapse (an app rule such as tr { border-bottom: 3px solid … }, which Table FR14 names), the table keeps painting it at the row's static place while the line cell's ::after draws the carried copy: 90 ms into opening Ada Lovelace in Gliding rows with that rule on Grace Hopper's row, the green line shows both under the moving row and at its final place. Writing the row's own border-bottom-color (and a winning row group's) transparent with the marks, and restoring it with them, removes the static copy.

borderBottomStyle: {
default: 'inherit',
[CARRYING]: `var(${PINNED_DIVIDER_STYLE}, solid)`,
[CELL_GLIDING]: `var(${PINNED_DIVIDER_STYLE}, solid)`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a row whose own border wins, syncPinnedDividers leaves the pinned cell in carry with inline --_table-row-divider-app-*: 0px none transparent (:383-385), and those cell-level values outrank the ones the glide sets on the tr, so mid-glide this rule resolves to 0px none and the moving divider stops short of the pinned columns. Reading the row's carried values here while the cell glides (through a custom property the cell never sets, say), or clearing the cell's carry for the glide, would draw it.

return (
<div ref={containerRef} data-testid="gliding-rows" style={style}>
{themed && (
<style>{`[data-testid="gliding-rows"] tbody tr:nth-child(3) td { border-bottom-color: rgb(220, 38, 38); }`}</style>

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 override reaches td only, so neither this story nor TableRowGlide.a11y.browser.spec.ts exercises FR14's verification case of "an app override of a row's border; both mid-glide and on a pinned cell", which is where the glide currently draws the divider twice. A tr border in the themed state, with an assertion of the moving row's pinned and unpinned line and nothing at its static place, would cover it.

…, pinned cells included

CodeBunny on #8656 (Table FR14's row-border case):
- The table kept painting a row's own border (an app's tr rule) at the
  row's static place while the line drew the carried copy, so the divider
  showed twice. The row's own bottom colour now goes transparent with the
  marks and comes back with them.
- A pinned cell's own carry (0px none for a row winner) outranked the
  values the glide set on the row, so the moving divider stopped short of
  the pinned cells. The glide carries the row's divider in names no cell
  sets (--_table-row-glide-*), which the pinned divider reads while it
  glides.

GlidingRows gains rowBorder (an app's 3 px green border on a tr); a browser
case checks the moving line pinned and unpinned, and nothing at the static
place. It fails on the previous head. derivedVarRegistry lists the glide
names.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 6b1bb99

@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.

An app's border on a row's tr now rides with its row, and the glide matches component:Table FR15 and spec:AST-105, but two divider cases that FR14 covers still need fixing before this can be approved. A tbody bottom border, or a top border on the next row's cells, is still drawn twice while rows glide, because only the row's and its cells' own bottom borders are hidden while the moving copy reproduces whichever edge wins the collapse; and a tr border's moving copy sits about 3.75 px above where the border rests, so it steps at the pinned columns' edge and jumps at the start and end of each glide. Please hide the winning edge's source for the glide, offset the line by the row's own border width, and add story and browser-spec cases that compare the pinned and unpinned lines' positions (the current check's 7 px window passes the step).

[Automated review]

// by the table at the row's static place: it goes transparent too, and
// the line carries it.
inlineColorOf.set(row, row.style.borderBottomColor);
row.style.borderBottomColor = 'transparent';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only the row's and its cells' own bottom colours go transparent here, but the copy carries whichever edge rowEdge reads from collapsedBottom, which can be the next row's or cell's top border or, under the body's last row, the tbody border (rowDivider.ts:80-87). Those stay painted at the static place, so with tbody { border-bottom: 3px solid … } or a 3 px top border on the next row's cells the divider is drawn twice for each glide (FR14 names tbody and td overrides). Hiding the source of the winning edge with the marks, and restoring it with them, would cover both.

content: {default: null, [CELL_LINE]: '""'},
position: {default: null, [CELL_LINE]: 'absolute'},
insetInline: {default: null, [CELL_LINE]: 0},
bottom: {default: null, [CELL_LINE]: 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.

bottom: 0 places the copy inside the row's padding box, which ends above the row's own bottom border. With a 3 px tr border (the story's rowBorder), 90 ms into opening the copy under the unpinned cells is 3.75 px above the pinned cells' copy and above where the border rests, so the line steps at the pinned edge and jumps at the start and end of each glide. Offsetting the line by the row's own bottom width would level it.

['pinned', pinnedX],
['unpinned', unpinnedX],
] as const) {
const [r, g] = await darkestNear(page, x, y);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both columns are read in the same 7 px window around the pinned cell's bottom (darkestNear), so this passes while the unpinned copy sits 3.75 px above the pinned one. Comparing the y of the green line under each column would catch it.

…s own border's line sits where it rests

CodeBunny on #8656 (Table FR14):
- The moving line carries whichever edge wins the collapse, but only the
  row's and its cells' bottoms were hidden, so a tbody's bottom border or a
  top border on the next row's cells stayed at the static place and the
  divider showed twice. A glide now hides every edge the collapse can take
  the divider from (the row's bottom, the row below's and its cells' tops,
  and at a section edge the row groups' borders) and gives them back with
  the marks.
- A row's own border is drawn below its box, so the line sat its width
  above where the border rests and stepped at the pinned edge. The line is
  offset by the row's own border width.

GlidingRows gains edgeBorder (tbody, top). Browser cases: the tr line is
level across the pinned and scrolling cells (within 1.5 px), and a tbody or
next-row-top divider is drawn once while it glides; all three fail on the
previous head.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 3ecdc48

@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.

The glide matches component:Table FR15 and spec:AST-105, and a tbody or next-row top border now rides once with its row, but one divider case still needs a small fix before this can be approved. For any divider wider than 1 px (an app's 2–3 px border, or a theme's --border-width: 2px), the moving copy under the scrolling cells sits 1 px above the pinned copy and above where the border rests, so the line steps at the pinned edge and drops 1 px as the glide ends; lowering the line by the part of the collapsed edge that lies below the row's box, or placing it from a cell's border box as the pinned layer does, should fix it. Please also make the browser spec's level check fail on a 1 px step and add a 2 px divider case; the Reduce Motion wording in the doc entry and changeset (a finger sees a fade, not an instant change) can be corrected alongside.

[Automated review]

Comment on lines +250 to +258
const ownWidth = Number.parseFloat(
getComputedStyle(row).borderBottomWidth,
);
// (Read before any write of this batch's next row: the row's own
// width does not change with its color.)
row.style.setProperty(
'--_table-row-glide-offset',
`${Number.isFinite(ownWidth) ? ownWidth : 0}px`,
);

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 offset counts only the row's own border, but the collapsed edge also extends below the row's box by part of its own width, so any divider wider than 1 px still glides 1 px high under the scrolling cells. In Chromium on Gliding rows, with edgeBorder: tbody held at 0 ms the pinned copy covers rows 354.0–356.5 and the scrolling copy 353.0–355.5; a 3 px tr border, a next row's 3 px top border and a theme's --border-width: 2px show the same 1 px, while the default 1 px divider is level. Adding the edge's lower part (half its width rounded down in Chromium: 1 px at 2 and 3 px), or placing the line from a cell's border box as the pinned layer does, would put it where the border rests.

Comment on lines +266 to +271
expect(Math.abs(Math.min(...pinned) - Math.min(...unpinned))).toBeLessThan(
1.5,
);
expect(Math.abs(Math.max(...pinned) - Math.max(...unpinned))).toBeLessThan(
1.5,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With a 1.5 px tolerance this passes while the scrolling copy sits a full pixel above the pinned one. Requiring the two copies' rows to match within under 1 px, and adding a 2 px divider case, would make it fail on the step it is meant to catch.

name: 'animatedLayout',
type: 'AnimatedLayout',
description:
"A handle from useAnimatedLayout. With it, opening or closing a panel glides the rows after it (and the content after the table) to their new places instead of jumping, the opening panel plays in on the same motion, and each row's divider moves with it. Under Reduce Motion, and in a table with column lines (dividers columns or grid, which do not move yet), the rows change at once. Without it, rows jump.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under Reduce Motion with a coarse primary pointer the rows fade in place (spec:AST-105 FR4), as this PR's own browser test asserts, so "the rows change at once" holds only for a fine pointer. "Move without travel" would cover both; the same sentence is in the dense entry and the changeset.

…idth

CodeBunny on #8656 (Table FR14):
- For a divider wider than 1 px (a theme's 2 px, an app's 3 px tr border)
  the moving line sat a pixel above the pinned copy and above where the
  border rests, so it stepped at the pinned edge and dropped as the glide
  ended. When it marks a row, the glide reads where the collapsed edge
  rests (a cell's padding-box bottom, from the whole pixel the table paints
  it on) against the row's padding box the line hangs from, and offsets the
  line by that.
- The browser level checks now fail on a 1 px step (exact rows of pixels),
  with a 2 px theme divider case beside the 3 px tr one; both fail on the
  previous head. GlidingRows gains wideBorder, and its rowBorder targets
  the fourth order by key, so the border stays on one row as a panel opens.
- The doc entry and Changeset say what Reduce Motion does: rows fade in
  place on touch and change at once with a mouse.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec eeacd26

@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.

The row glide now matches component:Table FR14, FR15 and DEC-3 and spec:AST-105, and dividers wider than 1 px (a theme's 2 px border or an app's 3 px row border) now glide level across the pinned columns and land where they rest, so nothing in this review blocks approval. Two small optional touch-ups: the markGlidingRows doc comment still says the function only writes (glidingDivider.ts:183-185) although it now reads each moving row's offset first, and the PR description still says only dividers="grid" skips the glide and that the branch targets a stack base. Separately, in Firefox at 1x the default 1 px divider sits 1 px below its resting place while a row opens, which could be a follow-up.

[Automated review]

* visible part, so this walks out from the changed row each way and stops at
* still rows: it never visits the rest of a long table. It asks the motion
* module which rows glide and takes the borders read before the glide
* started (`edges`), so it reads nothing from the layout: it only writes.

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 sentence is no longer true: lines 229-241 now read each moving row's and its last cell's rectangle and client box before the writes. Rewording it to say the function reads each moving row's edge offset once, before any write, keeps the comment accurate.

# Conflicts:
#	packages/core/src/motion/animatedLayout.boundary.test.ts
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec b7aaa63

@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.

The merge of main keeps the row glide in line with component:Table FR14, FR15 and DEC-3 and spec:AST-105: the rows after an opening or closing panel glide with their dividers, nothing changes without the handle, and nothing in this review blocks approval. Three optional touch-ups remain from the last review: the markGlidingRows doc comment still says the function only writes (glidingDivider.ts:184-185) although it now reads each moving row's offset first; the PR description still says only dividers="grid" skips the glide, that the branch is based on a stack branch, and that the glide reads nothing after it starts; and in Firefox at 1x each moving divider starts 1 px below its resting place while a row opens, which could be a follow-up.

[Automated review]

@cixzhang
cixzhang merged commit 58fa49d into main Oct 28, 2026
76 checks passed
@github-actions
github-actions Bot deleted the feat/table-row-expansion-glide branch October 31, 2026 06:09

This branch was successfully deployed

1 active deployment
Preview — b7aaa637 Deployed Oct 28, 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.

1 participant