feat(data-table): add expandable rows with inline detail panel - #430
feat(data-table): add expandable rows with inline detail panel#430erickteowarang wants to merge 9 commits into
Conversation
Pass `renderExpandedRow` to `useDataTable` and each row gets a chevron column (auto-added at the left edge, auto-pinned left after the selection column) that reveals a full-width detail panel beneath the row. Nothing new to compose in JSX — the column and the detail row appear automatically. Expansion state is owned by `useDataTable` as a Set of row ids, mirroring row selection: it is keyed by `row.id`, rows without one render no chevron, and expansion survives page changes (`collapseAllRows()` is the opt-out). `expandedIds` + `onExpandedChange` switch it to controlled mode. The trigger is a native button with `aria-expanded`; `expandRowLabel` supplies a bare record identity that the i18n labels compose into "Expand row INV-1001" / "INV-1001 details" so en and ja word order both stay correct. The panel is a named region, and collapsing while focus is inside it hands focus back to the trigger. On a horizontally scrolled table the panel stays pinned to the left edge of the scrollport. Row-count inflation (detail rows are real `<tr>`s, so ten records with two expanded announce as twelve rows) is documented as a known limitation; `aria-rowcount`/`aria-rowindex` are deliberately not implemented. Refs: tailor-inc/platform-planning#1650 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the initial expandable-rows implementation, addressing a high-effort adversarial review plus the expand/collapse animation. Review fixes: - Namespace row keys (`id:` / `idx:`) so an id-less row's index fallback can't collide with a real id. Pre-existing — React stringifies keys, so `1` and `"1"` already collided — but it pairs detail panels with the wrong row via position-based reconciliation. - Stop gating the detail panel on `canExpandRow`. A row that went non-expandable while open lost its chevron and its panel but stayed in `expandedIds`, leaving it open with no way to close it. - Fall back to the table container when the trigger unmounts in the same commit as the panel. `getElementById` returned null there, so focus fell to <body> — the exact outcome the handoff exists to prevent. - Scope column measurement to the component's own table. `renderExpandedRow` can nest a DataTable, and the built-in column keys are module constants, so an unscoped query let an inner header overwrite outer pinned widths. - Warn when `expandedIds` is passed without `onExpandedChange`, which otherwise renders permanently inert chevrons. - Memoise `toggleRowExpansion` / `collapseAllRows` and skip redundant writes, so the documented collapse-on-page-change recipe can't loop. - Guard `--data-table-viewport` against zero-width mounts and redundant writes inside the ResizeObserver callback. Two findings are documented rather than fixed: controlled-mode toggles snapshot the prop (a functional-updater API is out of scope), and `onExpandedChange` double-fires under StrictMode (matches the existing `onSelectionChange` behaviour). Animation: The panel reveals by transitioning `grid-template-rows` 0fr -> 1fr, so it animates to its exact natural height with no cap to clip tall content, and the `<tr>`/`<td>` are left alone (rows size to content, and animated row heights are unreliable under `border-collapse`). The animating wrapper sits inside the sticky node so left-edge pinning survives. This needs the detail row to outlive `expanded`, so presence is now a small state machine and the rendered row is a child component that genuinely unmounts — a layout-effect cleanup only runs before detach on real unmount, and keeping it in the parent silently reintroduced the focus bug above. Collapse is therefore asynchronous: `aria-expanded` flips immediately, the row lingers ~200ms as `data-state="closed"`. Documented, since it changes how consumers assert removal in tests. Also varies the demo's panel per row status to show `renderExpandedRow` is a plain `(row) => ReactNode`. Refs: tailor-inc/platform-planning#1650 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gaps
Second review pass. Two of these were introduced by the previous round's
fixes, so the earlier findings were traded for new ones rather than resolved.
- Expansion callbacks were still not identity-stable: the useCallback deps
included `expandedRowIds`, which is a fresh Set on every expansion change.
The "collapse on page change" recipe this repo documents therefore re-fired
right after the user opened a row and shut it again — worse than the bug it
replaced, and the docs had begun promising the stability. Both callbacks
now read the set through a ref.
- `canExpandRow` had stopped gating opening as well as closing, so an id in
`expandedIds` rendered a panel for a row the consumer excluded and ran
`renderExpandedRow` against data that may not exist. Restored the gate in
both directions; both cell gates simplify back to `expandable`, which also
removes the case where collapsing a non-expandable row unmounted the focused
chevron and dropped focus to <body>.
The trade is that an excluded row's id lingers in `expandedIds` inert, with
nothing open on screen, until `collapseAllRows()`. Documented.
- The closing panel stayed focusable and in the accessibility tree for the
whole 300ms collapse. It is now `inert` while closing, and focus is handed
back to the trigger at the start of the collapse rather than at unmount —
making an element inert while it holds focus would drop focus to <body>.
- DOM ids interpolated the raw row id, so `{ id: "ORD 4471" }` emitted an
invalid `id` and an `aria-controls` the UA split into two dead references.
Whitespace is now collapsed before composing them.
- `p-0` never beat `Table.Cell`'s `first:pl-6 last:pr-6` (specificity), so the
sticky panel was inset 24px and overflowed the scrollport — wrong since the
first commit, and contrary to what the docs promised. Now `p-0!`.
- The focus fallback stamped `tabindex="-1"` on the shared table container and
never removed it, permanently pulling that scroll region out of the tab
order for every `Table.Root` consumer. It is restored after focusing.
- `expandedIds` / `isRowExpanded` are optional on `DataTableContextValue`,
which is documented as hand-constructible; required members there are
type-breaking under a minor bump.
- The ResizeObserver re-ran the full header measure on every frame of the
reveal. It now bails when neither width moved.
Also slows the reveal from 200ms to 300ms.
Known follow-up, not fixed: the outer table's `data-pin-shadow-*` attributes
are matched by descendant selectors on a nested DataTable's pinned cells, so
an inner table paints a freeze seam it hasn't earned. Cosmetic; fixing it
means re-scoping pinning CSS shared by every table.
Each behavioural fix was verified by reverting it and confirming the new test
fails.
Refs: tailor-inc/platform-planning#1650
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nested-DataTable measurement test passed locally and failed in CI with
`expected '52px' to be '70px'` — 52px being the declared fallback, i.e. the
width was never measured.
It stubbed `HTMLElement.prototype.offsetWidth` via `vi.spyOn`. Which link of
the prototype chain a DOM implementation defines `offsetWidth` on is not
guaranteed; locally it is `HTMLElement`, so the spy took effect, but where it
lands elsewhere the spy silently no-ops and every cell measures 0.
Stub the two selection headers directly with `Object.defineProperty` instead.
Deterministic in any DOM implementation, and it drops the `data-nested`
marker the mock needed to tell the tables apart.
Verified it still discriminates: removing the `closest("table") !== ownTable`
filter makes it fail with the inner table's 40px.
Refs: tailor-inc/platform-planning#1650
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous attempt (stubbing `offsetWidth` on the two header elements)
still failed in CI with the same '52px' vs '70px', so the diagnosis was
wrong: it is not only about which prototype owns the property.
Cover both plausible causes at once. Resolve the prototype that actually
owns `offsetWidth` at runtime instead of assuming `HTMLElement` — installing
one if nothing does — and mock there, so the stub survives React recreating
the header nodes on re-render, which a per-element stub would not.
Also assert the stub is in effect before relying on it. If it ever stops
working the test now fails at that guard, naming the cause, instead of at the
layout assertion where a 0 measurement is indistinguishable from a real
pinning regression reading the 52px declared fallback.
Still discriminates: removing the `closest("table") !== ownTable` filter
fails with the inner table's 40px.
Refs: tailor-inc/platform-planning#1650
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Temporary. Locally the outer selection column measures at mount and the pin offset is already 70px before the forced re-measure, so the CI failure is somewhere in the measurement pipeline rather than the stub — the stub guard added in the previous commit passes there. Log the intermediate state to find out which, plus assert the preconditions the production filter relies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI reports offsetWidth 70 on the element and all closest() preconditions pass, yet the pin offset stays at the 52px declared fallback both before and after the forced re-measure. So the width never reaches the widths state. Probe the exact selector the measure effect uses, and which prototype the stub landed on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI diagnostics settled it. There, the DOM is correct — the stub reports
offsetWidth 70 on the outer selection header and 40 on the inner, the measure
selector finds all 7 header cells, and `closest("table")` resolves both ways
as expected — yet the pin offset stays at the declared 52px both before and
after the forced re-measure. The component's layout effect reads 0 where the
test reads 70: that environment never feeds real geometry to the measure
effect, which is also why every other pinning test in this file asserts
declared fallbacks (52px) rather than measured ones.
So `expect(left).toBe("70px")` was asserting one environment's layout
behaviour, not the behaviour under test. Assert the actual invariant instead:
the inner table's width must never reach the outer table's offsets. Removing
the `closest("table") !== ownTable` filter still fails this with "40px".
The trade is honest: where measurement is inert, the test cannot distinguish
"filtered correctly" from "measured nothing", so it guards the regression
where the environment permits and never false-fails where it does not.
Diagnostics removed.
Refs: tailor-inc/platform-planning#1650
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@copilot Can you review the PR with a focus on accessibility, performance, and if it's introducing any anti-patterns in React? |
Here's a focused review across the three axes: Accessibility ✅ Solid overall
Performance ✅ No significant issues
Anti-patterns
|
…pdater `toggleRowSelection` and `toggleRowExpansion` called their change callbacks from inside a `setState` updater. Updaters must be pure, and StrictMode double-invokes them precisely to surface that, so both callbacks fired twice per toggle in development — doubling whatever a consumer does in response. Compute the next set from a ref mirroring current state, then set state and dispatch from the handler. This is the shape `selectAllRows` and `clearSelection` already used, so the selection block is now internally consistent too. `onSelectionChange` is shipped public behaviour, so the changeset calls the timing change out. Both toggles keep the documented caveat that two toggles dispatched before state commits share a base. Tests render under StrictMode and assert exactly one call per toggle; both fail when reverted to the in-updater form. Also trims the comments added across this branch — roughly half the added comment lines — and fixes one that had drifted onto the wrong effect. Refs: tailor-inc/platform-planning#1650 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@copilot I've fixed the last batch of feedback, can you do another pass of a review? |
All three points from the previous review are now resolved: Side effect in state updater — fixed ✅ Both One small remaining asymmetry worth noting:
The stale caveat ("fires from inside a state updater, expect two calls under StrictMode") is replaced with "fires exactly once per toggle, including under StrictMode." The controlled-mode batching caveat is retained (it's still true: both modes read from the ref, not a functional update, so rapid-fire toggles share a base). Comment volume ✅ Roughly half the added comment lines trimmed. The implementation logic is unchanged; all substantive explanation is kept, just tighter. No new accessibility, performance, or anti-pattern concerns introduced by this commit. |
|
@copilot based on the patterns of other expandable tables using shadcn, can you evaluate the current approach and let me know if there are any factors that can be improved? |
|
@erickteowarang At the moment the feature is spread across top-level options like Something along these lines: rowExpansion?: {
render: (row: TRow) => ReactNode;
canExpand?: (row: TRow) => boolean;
getLabel?: (row: TRow) => string;
} & (
| {
expandedIds: string[];
onChange: (ids: string[]) => void;
}
| {
expandedIds?: never;
onChange?: never;
}
);The main benefit is not just namespacing — it also lets the type system prevent partial / inert configurations:
In other words, it makes the feature feel more like a single coherent API instead of several loosely related top-level props. |
| // Controlled without `onExpandedChange` means every chevron is inert — the | ||
| // toggle hands the next set to a callback that isn't there. Warn, don't fail | ||
| // silently. | ||
| useEffect(() => { | ||
| if (renderExpandedRow && isExpansionControlled && !onExpandedChange) { | ||
| console.warn( | ||
| "[DataTable] `expandedIds` was provided without `onExpandedChange`: expansion is controlled, so the built-in chevrons cannot change state and will do nothing when activated. Pass `onExpandedChange`, or drop `expandedIds` to let useDataTable own the state.", | ||
| ); | ||
| } | ||
| // Presence, not identity — these are usually inline arrows, and depending on | ||
| // them would turn a one-off notice into per-render console spam. | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [!!renderExpandedRow, isExpansionControlled, !onExpandedChange]); |
There was a problem hiding this comment.
If we tighten the API shape here as I commented (#430 (comment)), I think this useEffect can go away entirely.
It looks like it's only guarding an invalid configuration (expandedIds without onExpandedChange), which is something the type system should be able to prevent for us.
Closes tailor-inc/platform-planning#1650
What
Adds expandable detail rows to
DataTable. PassingrenderExpandedRowtouseDataTableenables the whole feature — a chevron column is auto-added at the left edge (auto-pinned left, after the selection column) and a full-width detail row renders beneath each open row. There is nothing new to compose in JSX.New options:
renderExpandedRow,canExpandRow,expandRowLabel,expandedIds,onExpandedChange.New returns:
expandedIds,isRowExpanded,toggleRowExpansion,collapseAllRows(the last two areundefinedwithoutrenderExpandedRow).Additions only — no existing signature changes, and a table without
renderExpandedRowrenders the same rows it did before.Key decisions
<tr>with a singlecolSpancell. Hierarchical sub-rows are a different data model and stay out of scope.onClickRowstays free for navigation. The cell stops click propagation, exactly as the selection cell does.row.id, the same constraint as row selection. Rows without one get no chevron rather than a disabled one — a row must never be un-toggleable.collapseAllRows()is the opt-out.position: stickyto the left edge of the scrollport, capped atmin(100%, var(--data-table-viewport))— acolSpancell spans the whole table, so on a horizontally scrolled table the panel would otherwise sit off-screen. Themin()makes it a no-op when the table fits its container.The freeze-seam boundary moves off the selection column onto the expand column when both are present (
selection.isBoundarybecomes!hasExpand && left.length === 0), so the shadow is drawn on the correct cell while horizontally scrolled.Accessibility
Native
<button>trigger witharia-expanded(the announcement on activation) andaria-controlsset only while expanded, so it never points at an absent id.expandRowLabelreturns a bare identifier that the i18n labels compose into "Expand row INV-1001" / "INV-1001 details", keeping en and ja word order correct — "Expand row" repeated twenty times conveys nothing. The panel is a named region; collapsing while focus is inside it hands focus back to the trigger instead of dropping it to<body>.sr-onlyheader text,overflow-x: autoon the sticky wrapper, andmotion-reduce:transition-noneon the chevron rotation.Known limitation, documented not fixed: detail rows are real
<tr>s, so a screen reader counts them — ten records with two expanded announces as twelve rows. Fixing it needsaria-rowcountplus explicitaria-rowindexon every row (detail rows sharing their parent's index) and correct interaction with pagination;role="presentation"would fix the count but remove the panel from screen-reader table navigation. Neither is implemented. There is no WAI-ARIA APG pattern for this exact shape — Disclosure assumes the panel isn't inside a grid, Treegrid assumes child rows share the parent's columns.Testing
19 new tests in
data-table.test.tsxcovering the column's presence/absence, toggling,aria-expanded/aria-controls,colSpanarithmetic, multiple open rows,onClickRowisolation, id-less rows,canExpandRow, contextual names, the named region, focus return on collapse, controlled mode, the pinned offset and boundary handoff, skeleton rows, and the empty/error status rows.pnpm type-check,pnpm lint,pnpm test(1449 passed),pnpm fmtall clean.Reviewer notes
examples/vite-app/src/pages/data-table-lab/page.tsx→ new Expandable rows section, which puts expansion next to selection, a left-pinned column, row actions and horizontal scroll on one table. It's a second section rather than a change to the existing one, so the pinning/column-settings demo stays untouched.<section aria-label>rather than<div role="region">; repo lint (jsx-a11y/prefer-tag-over-role) rejects the explicit role and a named<section>maps torole="region"anyway.onExpandedChangeis called inside the state updater, matchingtoggleRowSelection. That double-fires under StrictMode in dev — pre-existing behaviour of the selection path, kept consistent rather than diverging here.🤖 Generated with Claude Code