Skip to content

feat(data-table): sticky columns and column settings#382

Open
itsprade wants to merge 9 commits into
mainfrom
feat/data-table-columns-and-row-nav
Open

feat(data-table): sticky columns and column settings#382
itsprade wants to merge 9 commits into
mainfrom
feat/data-table-columns-and-row-nav

Conversation

@itsprade

@itsprade itsprade commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Column controls for DataTable, from the "too many columns / constant scroll-right" feedback in platform-planning#1489.

Scope update: this PR is now columns-only. Per @IzumiSy's review, the row-navigation work (rowHref) has been removed and the row-interaction redesign is tracked separately in platform-planning#1498. The existing onClickRow is unchanged.

  • Pinned/sticky columnspin: "left" | "right" on a Column (requires width). Selection auto-pins left, row-actions auto-pins right, with a scroll-aware freeze shadow that appears once content scrolls under the edge.
  • DataTable.ColumnSettings — a "Columns" toolbar popover with three drop zones (Fixed left / Scrollable / Fixed right): show/hide via checkboxes, drag to reorder, and drag between zones to pin/unpin.
  • Persisted column layout — pass a stable tableId to persist each user's visibility/order/pinning to localStorage. Per-user preference, not URL. Omit for in-memory only.
  • Table.Root gains an optional containerRef.

Docs (docs/components/data-table.md), the catalogue skill (components.md / list-dense-scan → regenerated app-shell-patterns), a demo DataTable Lab page, tests, and a minor changeset are included.

Persistence

Resolved per @sean on Slack — localStorage is fine for now (per-device layout is acceptable; a synced user-settings store would be more complexity for little benefit).

Try it

pnpm devDataTable Lab in the sidebar: open Columns to show/hide, drag to reorder, drag between zones to pin; scroll horizontally to see pinned columns + freeze shadow.

🤖 Generated with Claude Code

@itsprade
itsprade requested a review from a team as a code owner July 13, 2026 09:28
@itsprade
itsprade marked this pull request as draft July 13, 2026 10:39
@IzumiSy

IzumiSy commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Overall this looks good to me, but I have one strong concern about adding rowHref as a public API in this PR.

The main issue is that the relationship between rowHref and onClickRow is not clear from the API surface. At the call site, they look very similar, but they actually represent different interaction models. rowHref is not just another way to express row navigation, and that difference is not obvious from the prop name, so the API feels surprising.

They are also effectively mutually exclusive, but that is only enforced by a runtime warning and a precedence rule where rowHref wins. I don’t think that is a good public API shape. If two props conflict, that relationship should be explicit in the API rather than encoded in warning behavior.

More broadly, I think row interaction should be expressed through a single API surface rather than split across multiple top-level props. Otherwise users have to learn when to use onClickRow + navigate(...) vs rowHref, even though both are trying to represent row-level navigation.

For this PR, I would prefer to keep onClickRow only and not merge rowHref.

If we later want a first-class row navigation API with explicit modes, I think that should be a separate design discussion or follow-up PR. For example, I could imagine something like:

type RowInteraction<TRow> =
  | { type: "click"; onClick: (row: TRow) => void }
  | { type: "link"; href: (row: TRow) => string; primaryColumnId?: string };

useDataTable({
  columns,
  data,
  rowInteraction: { type: "link", href: (row) => `/orders/${row.id}` },
});

That would make the modes explicit and avoid having both onClickRow and rowHref at the top level.

@itsprade itsprade changed the title feat(data-table): sticky columns, column settings, and accessible row navigation feat(data-table): sticky columns and column settings Jul 14, 2026
@itsprade

Copy link
Copy Markdown
Contributor Author

Thanks @IzumiSy — agree on all points. I've removed rowHref from this PR; it now ships columns-only with the existing onClickRow untouched, so the diff is focused on columns. (rowHref was never released, so no breaking change.)

Row interaction is split into a separate design discussion: tailor-inc/platform-planning#1498, seeded with your rowInteraction discriminated-union proposal. The onClickRow-vs-rowInteraction decision (keep+deprecate vs replace) and the clickable-cells-plus-row-click case (@jason) are captured there as open decisions.

Also noted from Slack: @sean confirmed localStorage is fine for persistence, so that's settled here.

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

Reviewed the current columns-only scope (noting rowHref was dropped in de74a41), via a local QA pass + adversarial review. Marking as Comment — not requesting changes.

One item I'd flag as a potential DevX blocker: pinning any content column flips the whole table to table-fixed, which silently requires every column to declare a width or it collapses/overlaps — details inline on data-table.tsx, and I'll attach a short video of the collapse. The rest are hardening: an off-by-one in drag-reorder, a width-less-pin UI/table mismatch, an unguarded onClickRow vs custom interactive cells, a positional column-key edge case, and notes on tableId persistence/uniqueness.

col-sticky-table.mp4

Credit where due — the pin offset math, the localStorage parse/validate/reconcile path, and the column-order self-healing are all solid.

right: buckets.right.filter((k) => k !== dragKey),
};
const target = next[section];
target.splice(Math.max(0, Math.min(index, target.length)), 0, dragKey);

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.

Downward same-zone reorder lands one slot too far. dropTarget.index is measured against buckets[section], which still contains dragKey, but here we filter dragKey out of the bucket before splicing at that same index. So any drop at/below the item's original slot over-inserts by one — e.g. [A,B,C], drag A onto the lower half of B: the indicator promises [B,A,C] but you get [B,C,A], and the wrong order persists to localStorage when tableId is set. Upward drags are fine.

Fix: with fromIdx = buckets[section].indexOf(dragKey), when fromIdx > -1 && fromIdx < index decrement the insert index by one (cross-zone stays as-is, since fromIdx === -1).

const target = next[section];
target.splice(Math.max(0, Math.min(index, target.length)), 0, dragKey);
setColumnOrder([...next.left, ...next.scrollable, ...next.right]);
setPin(dragKey, SECTION_PIN[section]);

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.

setPin here (and effectiveSection, which derives a row's zone from pin state alone) never checks width — but the table's computePinLayout ignores a pin without a width. Net effect: dragging a width-less column into Fixed left/right shows it as pinned in this popover while the table renders it in the scrollable region. The only signal is a dev-console warning the end user never sees, and the bogus pin still persists.

Suggest making the settings UI width-aware: treat an unresolvable-width column as scrollable, or refuse the drop into a Fixed zone with visible feedback.

<Table.Cell
style={style}
className={className}
onClick={(e) => e.stopPropagation()}

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.

Now that the row is clickable via onClickRow (the onClick on <Table.Row> just above), any click that bubbles up from a cell fires the row handler. The built-in selection cell (here) and the actions cell below guard against that with e.stopPropagation() — but a consumer who renders a <button> / toggle / inline menu inside a normal column via render gets no such guard, so clicking it also triggers onClickRow. Worth either wrapping cell content so interactive children stop propagation automatically, or documenting that custom interactive cells must call e.stopPropagation().

actions?: PinPlacement;
}

function columnKeyAt<TRow extends Record<string, unknown>>(

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.

col.id ?? col.label ?? String(index) is evaluated in two different index spaces: useDataTable / ColumnSettings key off the definition-order column list (where pin/visibility/order are stored — e.g. the col.id ?? col.label ?? String(i) around L902), while columnKeyAt is called at render with the index into the filtered/reordered visible array (L258 / L505 / L742). For any column with an id or label the String(index) branch never fires, so it's masked today — but id and label are both optional (documented for icon-only / render-only columns). For such a column, hiding or reordering a sibling shifts its visible index and its stored pin/visibility silently detaches.

Suggest resolving the key once from a definition-order col -> key Map and threading it through, rather than recomputing positionally at render.

containerRef={containerRef}
containerClassName="astw:min-h-0 astw:overflow-auto"
className={className}
className={cn(hasColumnPin && "astw:table-fixed", className)}

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.

Potential DevX blocker. hasColumnPin (L899, scanning ctx.visibleColumns) flips the whole table to table-fixed whenever any content column is pinned. That imposes a hidden contract: every column must now set an explicit width or it collapses/overlaps once total width exceeds the viewport (table-fixed drops content-sizing for unsized columns). It's infeasible for inferred tables — inferColumns / infer() don't emit a width, and column({ ...infer('field') }) is the idiomatic pattern, so the metadata path we encourage is exactly the one that breaks.

Worth noting the actions menu does NOT trigger this — hasColumnPin scans only content columns, and a lone sticky actions/selection column renders fine in table-auto (offset 0, no width sum to get wrong). A stacked pin needs table-fixed because sticky offsets sum declared widths, which are only authoritative under table-fixed; but table-layout is table-wide, so the pinned stack's requirement leaks onto every unrelated column.

Options: emit a <colgroup> giving width-less columns a min-width / auto share so mixing pinned + unsized degrades gracefully; or avoid the table-wide switch and only enforce fixed widths where stacking actually occurs; at minimum, give inferColumns a default width and console.warn when a pinned table contains width-less columns. Video of the collapse attached to the PR overview.

// current defaults when there's nothing stored (or the id was cleared). This
// read does NOT write back, so an existing stored value is never clobbered.
useEffect(() => {
if (!tableId) 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.

Two things worth flagging on tableId-based persistence. (1) When tableId is omitted this hook is still mounted but goes pure in-memory — it silently never reads or writes localStorage, so column state just doesn't persist, with no signal to the developer. That matches the PR description; calling out the silent part. (2) tableId isn't required to be unique and nothing enforces it — it maps straight to a single localStorage key (astw:data-table:v1:<tableId>), so two tables mounted with the same id will compete for and clobber each other's column state.

Suggest a dev-only duplicate-id warning (a mounted-id registry) and/or documenting a namespacing convention (e.g. <route>:<entity>).

itsprade added a commit that referenced this pull request Jul 15, 2026
…ening

Round-2 review follow-ups on the columns PR (#382):

- Measure-based sticky offsets: drop the table-wide `table-fixed` switch and
  compute offsets from measured header-cell widths (PinMeasureContext + an
  isomorphic layout effect + ResizeObserver). Pinning no longer requires a
  `width`, so inferColumns/content-sized tables no longer collapse.
- Fix off-by-one in same-zone downward drag-reorder in ColumnSettings.
- Warn on duplicate `tableId` (a shared localStorage key clobbers layout);
  docs note on uniqueness.
- Balance the row-actions column padding (was 24px right / 8px left).
- ColumnSettings drop indicator is now a floating line between rows.

Docs, catalogue skill, changeset, and the demo lab updated to match. The
onClickRow-vs-custom-interactive-cells item is deferred to the row-interaction
issue (tailor-inc/platform-planning#1498).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@itsprade

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass @interacsean — pushed fixes in 13ed93d. Rundown by finding:

✅ table-fixed DevX blocker (the big one). Removed the table-wide table-fixed switch entirely. Sticky offsets are now computed from measured header-cell widths (a PinMeasureContext published by DataTable.Table via an isomorphic layout effect + ResizeObserver), so the table keeps its natural table-auto sizing. Pinning no longer requires width — content-sized / inferColumns tables pin correctly and don't collapse. Verified with a width-less column pinned (offsets track the real rendered widths).

✅ Off-by-one drag reorder. Same-zone downward drops now decrement the insert index (fromIdx < index), so it lands where the indicator shows. Cross-zone unaffected.

✅ Width-less pin UI/table mismatch. Resolved by the measure-based change above — a width-less column now genuinely pins in both the popover and the table (no more phantom pin).

✅ Duplicate tableId. Added a dev-mode warning via a mounted-id registry (StrictMode-safe), plus a docs note on uniqueness + the silent in-memory behavior when omitted.

↪️ onClickRow + custom interactive cells. Pre-existing onClickRow behavior, and row-click semantics are being redesigned — deferred to the row-interaction issue tailor-inc/platform-planning#1498 (where the stopPropagation/guard approach will be part of the API).

➖ Positional column-key edge case. Left as-is: it only affects columns with neither id nor label, which already can't reliably persist (we dev-warn for that). The render-side key is now resolved once and threaded, so header measurement + offsets stay consistent regardless.

Also, while testing: balanced the row-actions column padding (kebab was pushed left) and reworked the ColumnSettings drop indicator into a floating line between rows. 🙏

itsprade and others added 6 commits July 15, 2026 11:14
… navigation

Adds three DataTable capabilities driven by the "too many columns / hard to
reach the View button" feedback (platform-planning#1489):

- Pinned/sticky columns via `pin: "left" | "right"` (selection auto-pins left,
  row-actions auto-pins right) with a scroll-aware freeze shadow.
- `DataTable.ColumnSettings` popover: show/hide, drag-reorder, and pin columns
  by dragging between Fixed left / Scrollable / Fixed right zones.
- Per-user column layout (visibility, order, pinning) persisted to localStorage
  via a new `tableId` option.
- `rowHref` for accessible whole-row navigation (primary cell as a real <Link>),
  replacing per-row "View" buttons; `onClickRow` kept for non-navigation.

Also: `Table.Root` gains an optional `containerRef`; docs, catalogue skill, and
a demo lab page updated; changeset added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e dragging

The insertion line was a flow element, so inserting it shifted the rows under
the cursor, which recomputed the drop target on every reflow — a flicker loop.
Draw the indicator as an inset box-shadow on the row instead (no reflow), and
remove the inter-row gap so the end-of-section handler no longer fires as the
cursor passes between rows. Empty zones get a dashed placeholder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hovering a column's checkbox shows "Hide column" (when visible) or
"Show column" (when hidden). Adds showColumn/hideColumn i18n keys (en + ja).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Row interaction is being split out of this PR per review (@IzumiSy): the
`rowHref` + `onClickRow` split is a confusing public API, and row interaction
should be one explicit surface designed separately. Removes `rowHref` /
`primaryColumnId` and the navigation rendering (NavigableRows / <Link> wrap /
useNavigate), keeping the existing `onClickRow` untouched. `rowHref` was never
released, so this is not a breaking change.

Column visibility, sticky/pinned columns, ColumnSettings, tableId persistence,
and the Table.Root containerRef are unchanged. Docs, catalogue skill, demo lab
page, tests, and changeset updated to columns-only.

Row-interaction redesign tracked in tailor-inc/platform-planning#1498.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ening

Round-2 review follow-ups on the columns PR (#382):

- Measure-based sticky offsets: drop the table-wide `table-fixed` switch and
  compute offsets from measured header-cell widths (PinMeasureContext + an
  isomorphic layout effect + ResizeObserver). Pinning no longer requires a
  `width`, so inferColumns/content-sized tables no longer collapse.
- Fix off-by-one in same-zone downward drag-reorder in ColumnSettings.
- Warn on duplicate `tableId` (a shared localStorage key clobbers layout);
  docs note on uniqueness.
- Balance the row-actions column padding (was 24px right / 8px left).
- ColumnSettings drop indicator is now a floating line between rows.

Docs, catalogue skill, changeset, and the demo lab updated to match. The
onClickRow-vs-custom-interactive-cells item is deferred to the row-interaction
issue (tailor-inc/platform-planning#1498).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@itsprade
itsprade force-pushed the feat/data-table-columns-and-row-nav branch from 13ed93d to 80aac8a Compare July 15, 2026 05:56
@itsprade
itsprade marked this pull request as ready for review July 15, 2026 05:57
@itsprade
itsprade requested a review from Copilot July 15, 2026 05:57

Copilot AI 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.

Pull request overview

Adds column-focused UX improvements to the core DataTable component: sticky/pinned columns, a user-facing column settings popover (show/hide, reorder, pin/unpin), and optional per-user persistence via localStorage keyed by a new tableId. Also expands the underlying Table.Root API to expose its scroll container ref, and updates docs/examples/tests accordingly.

Changes:

  • Introduces sticky/pinned column rendering (including selection/actions auto-pinning and scroll-aware “freeze shadow”) and pin-related APIs (Column.pin, pinnedColumns, setPin).
  • Adds persisted column layout support via tableId (visibility/order/pinning stored in localStorage) and new ordering APIs (columnOrder, moveColumn, setColumnOrder).
  • Adds DataTable.ColumnSettings UI + supporting docs, examples (DataTable Lab), and test coverage.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/src/components/table.tsx Adds containerRef to Table.Root so consumers can observe the scroll container.
packages/core/src/components/data-table/use-persistent-column-state.ts New SSR-safe localStorage persistence hook for column layout state.
packages/core/src/components/data-table/use-data-table.ts Replaces in-memory hidden-column state with persisted visibility/order/pinning and exposes new APIs.
packages/core/src/components/data-table/use-data-table.test.ts Adds tests for column ordering/pinning and persistence behavior.
packages/core/src/components/data-table/types.ts Adds Column.pin, UseDataTableOptions.tableId, and new return fields for order/pinning.
packages/core/src/components/data-table/i18n.ts Adds labels for column settings UI.
packages/core/src/components/data-table/data-table.tsx Implements sticky column layout, width measurement, scroll-edge shadowing, and exposes DataTable.ColumnSettings.
packages/core/src/components/data-table/data-table.test.tsx Updates truncate assertions and adds pinned-column behavior tests.
packages/core/src/components/data-table/data-table-context.tsx Extends context with order/pinning APIs.
packages/core/src/components/data-table/column-settings.tsx New DataTable.ColumnSettings popover UI for show/hide, reorder, and pin/unpin.
packages/core/src/components/data-table/column-settings.test.tsx Adds UI-level tests for column settings behavior (toggle, drag/drop pinning, show/hide all).
examples/vite-app/src/routes.generated.ts Adds route typing for the new DataTable Lab page.
examples/vite-app/src/pages/data-table-lab/page.tsx Adds a demo “DataTable Lab” page exercising the new column features.
examples/vite-app/src/App.tsx Adds sidebar navigation entry for DataTable Lab.
docs/components/data-table.md Documents pinning, column settings, and tableId persistence.
catalogue/src/pattern/list/dense-scan/PATTERN.md Updates pattern guidance to recommend pinning + column settings for wide lists.
catalogue/src/fundamental/components.md Updates DataTable catalogue documentation with pinning and column settings usage.
.changeset/data-table-columns-and-row-nav.md Minor changeset describing the new DataTable column features and Table.Root.containerRef.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +103 to +109
// Hydrate from storage on mount / when the table id changes. Falls back to the
// current defaults when there's nothing stored (or the id was cleared). This
// read does NOT write back, so an existing stored value is never clobbered.
useEffect(() => {
if (!tableId) return;
setStateRaw(readState(tableId) ?? defaultsRef.current);
}, [tableId]);
Comment on lines +169 to +182
const moveColumn = useCallback(
(key: string, toIndex: number) => {
setPersisted((prev) => {
const order = [...columnOrder];
const from = order.indexOf(key);
if (from === -1) return prev;
const clamped = Math.max(0, Math.min(toIndex, order.length - 1));
order.splice(from, 1);
order.splice(clamped, 0, key);
return { ...prev, order };
});
},
[setPersisted, columnOrder],
);
- usePersistentColumnState: reset to defaults when `tableId` is cleared instead
  of keeping the previous table's persisted layout (in-memory mode shouldn't
  inherit a prior id's state).
- moveColumn: reconcile column order from `prev` inside the state updater (via a
  shared `reconcileColumnOrder` helper) rather than a captured `columnOrder`, so
  reorder calls batched before a re-render compose instead of clobbering.

Adds regression tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@itsprade

Copy link
Copy Markdown
Contributor Author

Addressed the Copilot review comments in b60d069:

  • usePersistentColumnState — stale layout when tableId cleared: the hydrate effect now resets to defaults when tableId becomes undefined (in-memory mode) instead of keeping the previous table's persisted layout.
  • moveColumn — stale order under batching: it now reconciles from prev inside the state updater (shared reconcileColumnOrder helper) rather than a captured columnOrder, so reorders batched before a re-render compose instead of clobbering.

Both are low-severity edge cases (the built-in drag UI uses setColumnOrder, and tableId is typically static) but valid — added regression tests for each.

@itsprade
itsprade requested review from IzumiSy and interacsean July 15, 2026 06:15
itsprade and others added 2 commits July 15, 2026 12:10
Collapse the two footer buttons into a single right-aligned button whose
label and action follow visibility state: "Hide all" when every column is
visible, "Show all" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants