Skip to content

feat(Table): make row expansion behave like its sibling plugins - #5995

Open
ernestt wants to merge 7 commits into
mainfrom
core-row-expansion-chevron-indent
Open

feat(Table): make row expansion behave like its sibling plugins#5995
ernestt wants to merge 7 commits into
mainfrom
core-row-expansion-chevron-indent

Conversation

@ernestt

@ernestt ernestt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

Four changes to useTableRowExpansion. Two are fixes to where things sit, two close gaps against the plugin's siblings. Grouped because they are one read of the same file, and each one is small enough that splitting them costs more in review overhead than it saves.

fix the chevron turned the button, not the arrow
fix the detail panel started at the row edge
fix the row divider split a row from its own panel
feat hasRowClickExpansion — the whole row toggles, as in useTableTreeData
feat panelVariant — opt the panel's wash out

fix — the divider split a row from its own detail

A row and its panel are one unit, but the line was landing between them. The row drew its own bottom border, putting a divider between the row and the detail it had just opened; the panel drew none, so it ran flush into the next row. The pair was cut down the middle and fused to the row below — the reverse of the grouping it should express.

The expanded row now gives up its border and the panel takes one.

Before — the line sits under Operating, and its detail runs straight into Payroll:

divider before

After — the row and its detail read as one block, and the line closes underneath:

divider after

Only the panel consults the divider mode, and only to know whether to draw at all. On a table with no row dividers the suppression removes a border that was never there, and the panel's is never applied. The panel row carries tableRowMarker — the same marker TableCell scopes its "no trailing line under the last row" rule to — so an expanded last row still ends the table cleanly.

fix — the chevron turned the button, not the arrow

transform: rotate(90deg) was on the <button>, which is the hit target and carries the ghost hover chip, so expanding a row swung that rounded rectangle and its highlight through a quarter turn along with the glyph. A finished 90° turn on a 24px rounded square lands back on itself, so this only shows up in motion, where the chip passes through a diamond. The transform now sits on the glyph; the button stays put.

Frames are mid-animation, transition stretched from 150ms to 3s so the middle is photographable.

Before — the chip is a diamond, dragged round by the arrow:

chevron before

After — the chip is square and still; only the arrow moves:

chevron after

fix — the panel started at the row edge

The panel is one cell spanning the whole row with a flat 20px inline padding, so its content began under the chevron — a column to the left of every label it describes. It now indents to the first real column:

panel alignment

The start padding is the chevron column's fixed width plus the inline padding a cell of that density gives its own content — calc(40px + var(--spacing-3)) at balanced. Density comes off the table context, which is why the panel had to become a small component (ExpansionPanelCell) rather than a bare <td>: the plugin builds this row inside transformBodyRow, outside the Table's own render, so the context is not otherwise in hand.

It is a logical property, so RTL mirrors it, and it is not configurable — a panel starting anywhere else reads as a misalignment rather than as a choice.

feat — hasRowClickExpansion

useTableTreeData already lets the row body toggle its own disclosure; this plugin only accepted the chevron, so two tables with the same affordance behaved differently depending on which plugin drew it. Same prop name, same default of false, so nothing changes for existing callers.

useTableRowExpansion({
  renderExpanded: item => <AccountDetail account={item} />,
  hasRowClickExpansion: true,
});

The handler steps aside for anything that has its own answer to a click — button, a, input, select, textarea, [role="button"], [role="checkbox"], [contenteditable] — and for a live text selection, so dragging across a cell to copy a figure does not also collapse what you were reading. The chevron is a button, so it is covered by the same guard rather than by a special case, and does not double-toggle. Rows the predicate says are not expandable stay inert and keep the default cursor.

row hover

row opened

feat — panelVariant

The panel's muted wash was hardcoded. It reads well on a table sitting on the page, but inside a card the panel becomes a third surface between the card and the row and the band works against the grouping. panelVariant: 'transparent' drops it and lets the panel inherit whatever is behind the table.

'muted' (default) 'transparent'
muted transparent

Worth knowing before you reach for it: the default is close to a no-op in dark mode. --color-background-muted and --color-background-card resolve to the same value in the dark theme, so a panel inside a card already looks transparent there. The prop is really about light mode; in dark mode it mostly changes what happens when the theme later separates those two tokens. Kept 'muted' as the default anyway, since flipping it would move every existing panel.

Two things worth a reviewer's eye

The indent lands 1px short, and that 1px is not mine. Measured in the browser: panel content at x=184, first column's text at x=185. The formula is exact; the gap is the UA stylesheet's td { padding: 1px } leaking into cells that own a context menu. TableCell relocates density padding onto the right-click trigger for those cells and leaves the <td> with font/box-sizing only — no padding, which is true of the authored rules but not of the computed result. This plugin puts a context menu on every cell, so every one of them carries the stray pixel. The fix is a one-line padding: 0 in TableCell, but it shifts every context-menu cell in the system by 1px in both axes, so it wants its own PR and its own visual pass rather than riding along here.

The indent assumes the chevron column is the leading one. It is, for every current caller, and the plugin already makes exactly this assumption for the panel's colSpan (columnCountRef is captured in transformColumns, so a plugin that prepended a column later would throw both off). No regression, but it is the same latent limit in a second place.

Test plan

  • vitest run packages/core/src/Table — 517 passed across 23 files, up from 503.
  • 14 new cases in useTableRowExpansion.test.tsx:
    • placement (3) — the glyph carries the rotation and the button does not; the panel's start padding is calc(40px + var(--spacing-3)) at the default density; it follows density to --spacing-4 at spacious.
    • whole-row-click expansion (8) — inert by default; expands; collapses; no double-toggle from the chevron; yields to interactive cell content; yields to a text selection; leaves non-expandable rows inert; cursor: pointer only when wired up.
    • panel variant (2) — washed by default, on the surface behind the table when transparent.
    • row divider placement (4) — closes below the panel; collapsed rows keep their own; nothing drawn on a table without row dividers; drawn under grid dividers too.
  • tsc --noEmit and eslint clean.
  • Measured before/after in the grouped-accounts template: panel start padding 20px → 48px, content x=156 → x=184 against a first column at x=185.

Four changesets included, one per change.

Made with Cursor

…el to the first column

The chevron's rotation was on the <button>, which is the hit target and
carries the hover chip, so opening a row swung that rounded rectangle and
its highlight around with the arrow. Moves the transform onto the glyph.

The detail panel spanned the row with a flat inline padding, leaving its
content under the chevron — a column to the left of every label it
describes. It now indents by the chevron column's width plus the density's
own cell padding, read off the table context.

Co-authored-by: Cursor <cursoragent@cursor.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Sep 3, 2026
@vercel

vercel Bot commented Sep 3, 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 Sep 5, 2026 1:18am UTC

Request Review

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions github-actions Bot added the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Sep 3, 2026
@ernestt

ernestt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

The red test and visual-acceptance checks here are not from this PR — main is currently broken.

packages/cli/api/search/search.mjs:277 returns without the matched/total its JSDoc declares, which is a TS2739 under checkJs. That fails the "Verify the published CLI ./api type surface" step, which runs before Run pnpm test (so test fails with zero tests executed) and also fails build-storybook, which skips the visual-evidence job and leaves visual-acceptance with nothing to publish.

Fix is up as #5996 (one line). Once that lands I'll rebase this and the checks should clear.

github-actions Bot added a commit that referenced this pull request Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Table (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1960 -
Complexity N/A Very High (112) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

Status: No visual change across 2 compared shot(s).


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@ernestt

ernestt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the note above: I've closed #5996 rather than landing it, since the break is not mine to fix. The diagnosis is preserved on that PR for whoever owns it.

Nothing here changes — the red test and visual-acceptance on this PR are still caused by main, not by this change, and they'll clear on a rebase once main is fixed.

useTableRowExpansion accepts hasRowClickExpansion, matching the flag
useTableTreeData has carried since it shipped. The two plugins are halves of
one pair — expand into child rows, expand into a panel — and moving between
them silently lost whole-row clicking.

It could not be added from outside either: the handler has to know not to fire
on a checkbox, a link, or the end of a text drag.

Off by default and pointer-only; the chevron stays the accessible control.
Collapsed rows are wired up too, which is most of the point — the row worth
clicking is the one that has not opened yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ernestt ernestt changed the title fix(Table): turn only the row-expansion chevron glyph, indent its panel to the first column feat(Table): make row expansion behave like its sibling plugins Sep 4, 2026
@ernestt

ernestt commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Added: hasRowClickExpansion

Pushed a third change, which is why the title now reads more broadly. useTableRowExpansion gains hasRowClickExpansion — click anywhere on a row to toggle its panel.

Why it belongs in core rather than in the caller. useTableTreeData has carried this flag since it shipped, under the same name, with the same guards. The two plugins are halves of one pair — one expands into child rows, one into a panel — and a caller moving between them lost whole-row clicking with nothing to say why. It also cannot be added from outside: a row-level click handler has to know not to fire on a checkbox, a link, or the end of a text drag, and none of that is reachable through the plugin API. The alternative was every caller reimplementing the same three guards, which is what the template that prompted this was about to do.

Shape. Off by default. Pointer-only when on — the chevron button stays the accessible control, so this is a mouse shortcut, not a second way to operate the table. Clicks landing on interactive cell content, clicks ending a text selection, and rows getIsItemExpandable has ruled out all pass through. The chevron already stops propagation, so it does not toggle twice.

The one non-obvious bit: the handler is attached before the collapsed early-return, not after. transformBodyRow used to bail as soon as it saw a row was not expanded, since there was no panel to build — but a collapsed row is exactly the one a click needs to reach.

Tests. Seven new cases, mirroring the tree plugin's coverage: inert by default; expands a collapsed row; collapses an expanded one; no double-toggle via the chevron; yields to a button in a cell; yields to a text selection; leaves non-expandable rows alone; and cursor: pointer appears only when the flag is on. vitest run packages/core/src/Table/plugins/rowExpansion — 24 passed.

Separate [feat] changeset, so the fix and the feature stay legible in the release notes.

The panel row painted --color-background-muted unconditionally, and being a
<tr> the plugin builds itself, nothing a caller rendered could reach it.

That wash is the right default: in a bare table it is the only thing telling a
detail panel apart from another row of data. It is wrong for a table already on
a Card, where it reads as a third surface rather than as a distinction.

panelVariant: 'muted' | 'transparent', matching Card's vocabulary. Default
unchanged. Worth noting the wash is a low-alpha near-black, so over a dark card
it is nearly invisible — dark themes have been rendering 'transparent' all
along.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ernestt

ernestt commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Added: panelVariant

Fourth change. The panel row painted --color-background-muted unconditionally, and since the plugin builds that <tr> itself, nothing a caller renders can reach the background. panelVariant: 'muted' | 'transparent' — matching Card's vocabulary, default unchanged.

Why not just change the default. The wash is genuinely load-bearing in a bare table: no card, no dividers, and it is the only thing telling a detail panel apart from another row of data. It is wrong specifically for a table already on a Card or Section, where it stacks a third surface on the second. That is a caller's context, not something the plugin can infer, so it is a prop.

One finding worth recording. --color-background-muted resolves to #0536590C in light and #1111127F in dark. Over the dark card at #1F1F22, that half-alpha near-black is close to a no-op — the muted and transparent frames are indistinguishable in dark mode. So the wash is effectively a light-theme feature, and dark themes have been rendering transparent all along. That is not a regression and I have not touched it, but it is why turning the wash off costs less than it looks like it should, and it may be worth a separate look at whether the dark token is doing what it intends.

Two new tests: the default washes the panel; transparent does not. vitest run packages/core/src/Table/plugins/rowExpansion — 26 passed.

Separate [feat] changeset. The PR now carries two fixes and two features; happy to split the features out into their own PR if that reads better for review — they are independent of the chevron and indent work, just in the same file.

A row and its detail panel are one unit, but the divider was landing between
them: the row drew its own bottom border, putting a line between the row and
the detail it had just opened, and the panel drew none, so it ran flush into
the next row. The pair was split down the middle and fused to the row below.

The expanded row now gives up its border and the panel takes one. With no row
dividers the suppression removes a border that was never there and the panel's
is never applied, so only the panel consults the divider mode, to know whether
to draw at all.

The panel row carries tableRowMarker, which is how TableCell scopes its "no
trailing line under the last row" rule, so an expanded last row still ends the
table cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
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:code-review High-risk change (new package/component/API) — needs human code review before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant