diff --git a/docs/data-table/00-library-comparison.md b/docs/data-table/00-library-comparison.md new file mode 100644 index 0000000000..bff61741b8 --- /dev/null +++ b/docs/data-table/00-library-comparison.md @@ -0,0 +1,85 @@ +# DataTable — library comparison and rationale + +> Background for the decision on which library to build the new `DataTable` component in `libs/ui` on. +> Each library's documentation was fetched via **context7 MCP** (official sources) and mapped onto our requirement list. +> Analysis date: 2026-07-25. + +## TL;DR + +The hard requirement was: **headless, with open paths through props — able to shape the DOM and reach nested layers**, so the table fits our DS (Zag.js + Tailwind, DOM-first, `data-[...]` states, design tokens via CSS). + +The whole comparison turns on **a single axis: canvas vs. DOM rendering.** + +| Library | Rendering | Headless | Tailwind on cells | DS integration | Verdict | +|---|---|---|---|:--:|---| +| **TanStack Table** v8/v9 | **DOM** (you render it) | **yes, 100%** | **yes** | **1–2 / 5** | ✅ natural fit for the DS | +| **VTable** (Bytedance) | **canvas** (VRender) | no | no | 5 / 5 | ⚠️ most features out of the box, but outside the DS | +| **AntV S2** (Alibaba/Ant) | **canvas** (AntV/G) | no | no | 5 / 5 | ❌ pivot/analytics, not a styleable grid | +| ali-react-table (Alibaba) | DOM | partially | yes | 4 / 5 | ❌ dead project, Chinese-only docs, no context7 coverage | +| _(baseline)_ antd Table | DOM | no (CSS-in-JS) | override wars | 3 / 5 | ⚠️ not headless | + +> **The "DS integration" scale is integration cost/effort (1 = least, 5 = most), not library quality.** That is why TanStack scores `1–2 / 5` and wins, while the canvas libraries score `5 / 5` and lose. + +**A clarification on "the Alibaba table":** what we had in mind (`ali-react-table`) is effectively unmaintained, documented only in Chinese, with zero context7 coverage. The genuinely live "Alibaba/Ant Group" table is **AntV S2** — but that is a canvas pivot/analytics table. So VTable (Bytedance) and S2 (Alibaba) are **both canvas** and hit the same problem. + +## Why canvas is a problem for our DS + +VTable and S2 both draw cells as **pixels on a single ``**: + +- **No per-cell DOM nodes** → no `className`, no `data-[validation=error]`, no CSS variables, no Tailwind on the table's content. +- Styling goes through a **parallel JS "theme" object** (`themeCfg`, VRender style objects) — we would have to **duplicate our entire token system** into a second language and maintain a bridge adapter. +- "Nested layers through props" does not really exist — you only get **overlay escape hatches** (absolutely positioned HTML/React above the canvas for editors, filter menus, popups). That does not scale to styling every cell. +- Bonus problems: accessibility (ARIA on elements) and debugging (S2 needs a dedicated "G devtools" plugin). + +That is the philosophical opposite of a Zag.js + Tailwind DS, whose whole premise is "you own the DOM and the styling, we own the behaviour". + +## Requirement matrix (native support) + +Legend: ✅ native · 🟡 logic yes / you build the UI (or via a plugin) · ⚙️ only through custom or external code · ❌ missing + +| # | Requirement | TanStack | VTable | AntV S2 | +|---|---|:--:|:--:|:--:| +| 1 | Column filters with conditions | 🟡 logic, UI DIY | ✅ FilterPlugin (byCondition) | 🟡 `onFilter` pipeline | +| 2 | Header filter template | ⚙️ build it in `` | ✅ `headerCustomLayout` | ✅ `custom-header`/`colCell` | +| 3 | Fulltext search | ✅ `globalFilter` | 🟡 search plugin | ⚙️ pre-filter the data | +| 4 | Sorting | ✅ `getSortedRowModel` | ✅ `sort` + comparator | ✅ `sortParams` | +| 5 | Empty data template | ⚙️ DIY | ✅ empty-tip | ✅ `placeholder` | +| 6 | Row actions | ⚙️ display column | ✅ `cellType:'button'`/icon | ⚙️ custom/overlay | +| 7 | Freeze columns L/R | 🟡 `columnPinning` (+CSS) | ✅ `frozenColCount`/`rightFrozenColCount` | ✅ `frozen{...}` | +| 8 | Sticky header | ⚙️ CSS `sticky` | ✅ native | ✅ `stickyHeader` | +| 9 | Striped rows | ⚙️ CSS `nth-child` | 🟡 via theme | 🟡 via theme | +| 10 | Infinite scroll / virtual | ⚙️ +`@tanstack/react-virtual` | ✅ strong native | ✅ strong native | +| 11 | ColSpan / RowSpan | ❌ header groups only | ✅ `customMergeCell` | ✅ `mergedCell` | +| 12 | onRowClick | ⚙️ `onClick` on `` | ✅ `click_cell`→row | ✅ `ROW_CELL_CLICK` | +| 13 | Selectable rows (checkbox) | 🟡 `rowSelection` state | ✅ `cellType:'checkbox'` | 🟡 cell selection | +| 14 | Column reorder | 🟡 `columnOrder` (drag=dnd-kit) | ✅ `dragOrder` | 🟡 via API | +| 15 | Row reorder | ⚙️ dnd-kit + mutate data | ✅ `rowSeriesNumber.dragOrder` | ⚙️ custom | +| 16 | Show/hide columns | ✅ `columnVisibility` | 🟡 `updateColumns` | ✅ `fields.columns` | +| 17 | Row custom content template | ✅ `cell` + `flexRender` | ✅ `customLayout`/VRender | ✅ subclass `DataCell` | +| 18 | Tree structure | ✅ `getExpandedRowModel` | ✅ strong `tree:true` | ✅ strong tree mode | +| 19 | Quick actions | ⚙️ display column | ✅ button/icon | ⚙️ interaction API | +| 20 | Inline row edit | ❌ DIY (`meta.updateData`) | ✅ `vtable-editors` | 🟡 editable-sheet/custom | +| 21 | Pagination (count, page size) | ✅ `getPaginationRowModel` | ✅ `pagination` | ✅ `pagination` | + +**How to read this:** VTable and S2 win on ✅ count — they are "batteries-included". TanStack has more 🟡/⚙️ because it **deliberately ships no UI** — it gives you the state machine and you build the markup from your own atoms (which is an advantage for a DS, not a drawback). + +## Integration effort for our DS (Zag.js + Tailwind) + +- **TanStack — 1–2/5:** the same philosophy as Zag.js (logic only, zero markup/CSS). Filter inputs, checkboxes, pager and sort icons are rendered through our existing Zag atoms → the table inherits our tokens and the `data-[validation]` pattern for free. The cost: you build every piece of UI yourself, and add virtualization (`@tanstack/react-virtual`), drag (`dnd-kit`) and inline edit by hand; **rowSpan/colSpan on data cells is genuinely missing**. +- **VTable — 5/5:** canvas cannot be styled with Tailwind or wired to tokens. You get many features out of the box but lose the whole DS — cells are not members of the headless family, just an isolated widget behind a token→theme adapter. It only makes sense at canvas-scale performance (100k+ cells, pivot). +- **AntV S2 — 5/5:** the same, plus it is over-engineered for pivot analytics, and the English docs are a thin subset. +- **ali-react-table — 4/5 with high uncertainty:** DOM-based and virtualized, but a dead project with Chinese-only docs and no context7 coverage. + +## Decision (final) + +**Chosen: `@tanstack/react-table` v9.** For a **headless, Tailwind-styleable, DOM-accessible** grid inside a Zag.js DS it is architecturally the only clean fit. VTable (Bytedance) and AntV S2 (Alibaba) are **canvas** → they cannot use our stack (`--color-table-*` tokens, `tailwind-variants` slots, `data-[…]` states, Zag atoms), so they were ruled out. Bonus: `@tanstack/react-table` was already in the repo (`apps/medusa-be`), so no new third-party platform. The organism was first built against v8 and migrated to v9 before merge; v9 moves feature registration and the row models onto a module-scope `tableFeatures()` set and renames column pinning from `left`/`right` to `start`/`end`. + +**Why not VTable/S2 despite more out-of-the-box features:** their canvas rendering would mean a parallel JS theme system (a duplicate of our token set), no per-cell DOM access through props, and losing accessibility and DOM-level testability. They only make sense as an **isolated canvas analytics widget** outside the DS (canvas-scale 100k+ cells, pivot). + +**How we covered TanStack's weak spots:** +- colSpan/rowSpan on data cells → a custom `getCellSpan` prop (TanStack has none). +- virtualization / infinite scroll → `@tanstack/react-virtual` (windowing that preserves native table alignment) + `onReachEnd`. +- column & row reorder → `@dnd-kit`. +- inline edit → `meta.updateData` → `onCellEditCommit`. + +**Implementation:** the `DataTable` organism in `libs/ui/src/organisms/data-table.tsx`, rendering through the existing presentational `Table` organism (so it inherits the `--color-table-*` tokens). It covers all 21 requirements, every feature exposes a callback, with Storybook stories carrying `play` interaction tests and the `data-table-usage` usage skill. The MVP is styled with existing and semantic tokens; component-level `--color-data-table-*` tokens and the Figma export follow once the MVP look is signed off. diff --git a/libs/ui/agent-plugin/README.md b/libs/ui/agent-plugin/README.md index 875657a09b..0cb2f3c1f2 100644 --- a/libs/ui/agent-plugin/README.md +++ b/libs/ui/agent-plugin/README.md @@ -18,7 +18,7 @@ no spec-driven workflow. | Type | Count | Purpose | | --- | --- | --- | | Workflow skills | 8 | `$ui-*` entry points: scaffold, tokens, stories, theming, Figma sync, validation, release, usage routing | -| Bundled deep skills | 58 | Synced 1:1 from `libs/ui/skills/`: per-component `*-usage` guides, `component-authoring`, `tailwind-token-authoring`, `storybook-authoring`, `zag-compound-components`, … | +| Bundled deep skills | 60 | Synced 1:1 from `libs/ui/skills/`: per-component `*-usage` guides, `component-authoring`, `tailwind-token-authoring`, `storybook-authoring`, `zag-compound-components`, … | | Subagents (Codex TOML) | 6 | design-system expert, component-dev orchestrator, token/story/figma specialists, QA gate | | Hooks | 2 | Real git `pre-push` gate (auto-installed in the ui-kit source repo only) + a `--no-verify` guard | | MCP servers | 3 | context7 (docs), figma (design context + Code Connect), chrome-devtools (browser) | @@ -171,7 +171,7 @@ techsio-ui-kit-ai/ ├── AGENTS.md # routing table ├── .mcp.json # context7, figma, chrome-devtools ├── agents/*.toml # 6 Codex subagents -├── skills/ # 8 authored workflow skills + 58 bundled deep skills +├── skills/ # 8 authored workflow skills + 60 bundled deep skills ├── hooks/ │ ├── hooks.json # SessionStart installer + --no-verify guard │ └── pre-push # the real gate (git hands it the exact refs/SHAs) diff --git a/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md b/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md new file mode 100644 index 0000000000..9fb4b965bd --- /dev/null +++ b/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md @@ -0,0 +1,358 @@ +--- +component_version: "1.0.0" +name: data-table-usage +description: > + Use after component-usage-ux when an app needs the @techsio/ui-kit DataTable — + a headless, data-driven grid built on @tanstack/react-table v9 that renders into + the presentational Table organism. Covers column defs, sorting, conditional + column filters, global search, row selection, column visibility/pinning/reorder, + row reorder, tree/expanding rows, inline edit, colSpan/rowSpan, virtualization / + infinite scroll and pagination — every feature behind a flag with a callback. +type: core +library: "@techsio/ui-kit" +library_version: "0.3.2" +requires: + - component-usage-ux + - app-token-overrides + - table-usage +sources: + - "libs/ui/src/organisms/data-table.tsx" + - "libs/ui/src/organisms/data-table.helpers.ts" + - "libs/ui/stories/organisms/data-table.stories.tsx" +--- + +# @techsio/ui-kit DataTable Usage + +`DataTable` is the data-driven grid. It owns the TanStack table instance and +renders into the presentational `Table` organism, so it inherits every +`--color-table-*` / `--padding-table-cell-*` token. Reach for the plain `Table` +when you only need static markup; reach for `DataTable` when you need +sorting/filtering/selection/pagination and friends. + +## Setup + +```tsx +import { DataTable } from "@techsio/ui-kit/organisms/data-table" +import type { ColumnDef } from "@techsio/ui-kit/organisms/data-table" + +type Order = { id: string; customer: string; total: number; status: string } + +const STATUS_OPTIONS = [ + { label: "Paid", value: "paid" }, + { label: "Pending", value: "pending" }, +] + +const columns: ColumnDef[] = [ + { accessorKey: "customer", header: "Customer" }, + { + accessorKey: "total", + header: "Total", + meta: { align: "end", type: "number" }, + cell: (info) => `${info.getValue()} €`, + }, + { + accessorKey: "status", + header: "Status", + meta: { type: "enum", options: STATUS_OPTIONS }, + }, +] + + open(row.original)} +/> +``` + +## Column types drive the filter and the editor + +Declare `meta.type` and DataTable renders the matching ui-kit control in both the +header filter row and the inline row editor, at the table's `size`: + +| `meta.type` | filter control | editor control | +|---|---|---| +| `string` | Input + condition menu (icon) | Input | +| `int` / `number` | Input + condition menu (`between` adds a second Input) | NumericInput | +| `boolean` | tri-state Select (All/Yes/No) | Switch | +| `enum` | Select (+ "All") | Select | +| `multiEnum` | Combobox `multiple` | Combobox `multiple` | +| `date` / `datetime` | Input `date` / `datetime-local` | same | +| `time` | from/to time Inputs (window may cross midnight) | Input `time` | +| `dateRange` | from/to date Inputs | from/to date Inputs | +| `custom` | nothing — supply `meta.renderFilter` | supply `meta.renderEditor` | + +Filter values are objects — `{ operator, value, to? }` for text/number, +`{ values: [...] }` for the enum types, `{ value }` for boolean, `{ from, to }` +for date/time ranges. A bare value from the plain TanStack API +(`column.setFilterValue("Ada")`, or a controlled `columnFilters` entry) is +coerced to the right shape for every type: an array becomes `{ values }`, a +lone date/time becomes a closed range on itself (a whole day for `date`, that +exact minute for `time`), and `"true"` / `"false"` strings are parsed rather +than coerced. + +Give `enum`/`multiEnum` their choices via `meta.options`. Register +`filterFn: "typed"` on the column so filtering matches the declared type +(`time` compares minutes-since-midnight; a `dateRange` cell compares interval +overlap). There is no date-picker component yet, so date/time fields use the +native `Input` types. + +The filter row puts the value control first and the operator behind a compact +icon button (a Menu of conditions), so the input gets the width and the header +stays on one line. The active condition is in the button's `aria-label`, and for +`Is empty` / `Is not empty` the input is disabled and shows the condition as its +placeholder. + +Row actions use the `Button` atom icon-only at `size="sm"`, `theme="borderless"`, +with `variant` carrying the semantics (`danger` for destructive actions). + +Escape hatches, in precedence order: `meta.renderFilter` / `meta.renderEditor` +per column → the table-wide `renderHeaderFilter` slot → `filterRenderers` / +`editorRenderers` maps → the type default. All receive a context with +`{ column, type, value, setValue, disabled, size, options }` (editors also get +`row`, `error`, `commit`, `cancel`). + +## Column widths and alignment + +```tsx +{ accessorKey: "age", meta: { width: 80, align: "end" } } +{ accessorKey: "email", meta: { width: "var(--dimension-200)", minWidth: 120 } } +{ accessorKey: "active", meta: { width: "15%", align: "center" } } +``` + +`meta.width` / `meta.minWidth` / `meta.maxWidth` take a number (px) or any CSS +length, so tokens, `%` and `ch` all work. Pair them with `tableLayout="fixed"` +— under the default `"auto"` a width is only a hint and long content can still +stretch the column. + +Use `meta.width`, not TanStack's `columnDef.size`: TanStack merges `size: 150` +into every column def, so `size` cannot express "no width declared". Numeric +widths are mirrored into `size`/`minSize`/`maxSize` internally, which keeps the +rendered width and the sticky offsets of pinned columns in agreement. While +resizing is on the live dragged width wins. + +Give **pinned (frozen) columns a numeric width**. Sticky offsets are summed from +the numeric sizes, so a `%` or token width on a frozen column cannot be resolved +to pixels and the frozen block can misalign. Unpinned columns take any unit. + +`meta.align` (`start | center | end`, default `start`) is forwarded to the +`Table` cell as `data-align`, which is where the alignment is actually styled — +so a hand-written `Table` gets the same three options. Nothing is inferred from +the column type: center an icon/boolean column or right-align a number only if +you say so. `Table`'s older `numeric` prop still right-aligns, but it means +"this value is a number"; set one or the other, not both. + +## Inline editing and interaction locking + +The table renders read-only until the user opts in: the right-hand actions cell +holds an edit icon, and clicking it swaps that row's editable cells +(`meta.editable`) to type-driven editors with save and cancel beside them. +`enableInlineEdit` is what wires this up. One row is editable at a time; Enter +commits, Escape cancels. Apply the committed `draft` to your own state in +`onEditCommit` — DataTable does not mutate `data`. + +For a column that should always be an editor instead, skip `enableInlineEdit` +and render your own control in `columnDef.cell`, pushing values through +`table.options.meta.updateData` (see "Inline edit" below). Validation runs on +commit from `meta.required` and `meta.validate(value, draft)`; failures block the +commit and surface through `onEditValidationError`. + +While a row is being edited, `lockInteractionsWhileEditing` (default `true`) +disables sorting, column filters, global search, pagination, selection, row and +column reorder, and row click — anything that could move the row out from under +the user. Blocked attempts report through +`onInteractionBlocked({ action, reason: "editing", rowId })`, but only for +`globalFilter`, `paginate`, `columnVisibility` and `rowClick`. Every other +locked control — sort, column filters, selection, both reorder flavours — is +natively `disabled` or non-draggable during an edit, so the interaction never +reaches a handler and there is nothing to report; `globalFilter` is the one +exception, reachable through `SearchForm`'s clear button even while its input +is disabled. Filtering and sorting still compose freely with each other when +no edit is active. + +Edit callbacks: `onEditStart`, `onEditChange`, `onEditCommit`, `onEditCancel` +(with `dirty`), `onEditValidationError`, plus controlled `editingRowId` / +`onEditingRowIdChange`. + +Slot return contract: `renderRowActions` and `renderHeaderFilter` treat +`undefined` as "not handling this one" (falls through to the built-in edit +button / the type-driven filter) and `null` as "render nothing here". + +## Toolbar + +The toolbar is one row: the global search stretches to fill the free width, and +custom actions sit at the trailing edge in a flex group. + +```tsx + +``` + +Each entry takes the full `Button` API (minus `size`, which follows the table); +`label` is a convenience alias for `children`. Keep to +`DATA_TABLE_MAX_TOOLBAR_ACTIONS` (3) — more still render, but DataTable +`console.warn`s, because a crowded toolbar usually means the extras belong in a +menu. It is a recommendation, not an error. + +The search is the `SearchForm` molecule: a clear button appears inside the field +once there is a value, and a submit button is joined to its trailing edge with +the touching corners squared off (`gapped={false}`). Filtering is live on every +keystroke, so the submit button is a confirm affordance rather than the trigger. +`translations.searchLabel`, `searchPlaceholder`, `clearSearchLabel` and +`searchButtonLabel` cover its text. + +## Loading states + +- `loading` replaces the body with `loadingRowCount` skeleton rows (default 5) + while keeping the header, so the layout does not jump when data arrives. +- `loadingMore` appends a single skeleton row — pair it with `onReachEnd` for + infinite scroll so the user sees the next page being fetched. + +## Drag affordances + +Reorder handles are always rendered but stay fully transparent until the row or +header is hovered or receives focus, so the table stays calm while still being +discoverable. They also reveal while their row/header is being dragged, so the +handle does not vanish when the pointer leaves the source. +During a drag the source is dimmed and lifted (`data-dragging`), +and the drop target shows an insertion edge — a border on the leading or +trailing side for columns, top or bottom for rows — so it is clear where the +item will land. + +## Accessibility + +Sortable headers carry `aria-sort`; the expander carries `aria-expanded`; the +table carries `aria-busy` while loading and skeleton rows are hidden from +assistive tech. Rows with `onRowClick` are focusable and activate on Enter or +Space (the handler ignores keys bubbling from controls inside the row). Both +drag handles receive dnd-kit's keyboard attributes, so reordering works without +a mouse. Pass `getRowLabel` so selection checkboxes and the edit action are +labelled by row content instead of an opaque row id. Inline-edit validation +messages render next to the field with `role="alert"` and are linked through +`aria-describedby`; focus moves into the edited row on start and returns to the +control that opened it on commit or cancel. + +## Sizing + +`size` (`sm | md | lg`) is forwarded to the underlying `Table` **and** to every +nested control — filter inputs, inline editors, page-size select, pagination, +action icons and the column menu — so the whole table scales as one. +`paginationProps` exposes the full `Pagination` molecule API (variant, compact, +siblingCount, translations, …) except the table-owned count/page/pageSize. The +footer splits: the record range sits on the left, the pager and page-size select +on the right. It shares the header's background so the two frame the table +consistently. `translations.rangeLabel({ start, end, total })` builds the range +text; `translations.pageSizeLabel` is the page-size select's accessible name +(the design shows no visible label beside it). + +## Feature flags (all opt-in unless noted) + +- `enableSorting` (default `true`) — click header to sort; `meta.align: "end"` right-aligns numeric columns. +- `enableGlobalFilter` — renders the toolbar search (`DataTable.GlobalSearch`). +- `enableColumnFilters` — renders a per-column filter row. Pick the control with `meta.type` (`"string" | "int" | "number" | "boolean" | "enum" | "multiEnum" | "date" | "datetime" | "dateRange" | "time" | "custom"`), plus `meta.options` for the enum types. Columns get `filterFn: "typed"` by default, which dispatches on that same `meta.type`; set `filterFn: "conditional"` for the operator-based ("with conditions") comparator instead, or override the whole UI with `meta.renderFilter` / `renderHeaderFilter`. `meta.filterVariant` and `meta.filterOptions` are deprecated aliases kept for older columns: both still work — `filterVariant` is resolved to a `meta.type` (`text`→`string`, `number`/`range`→`number`, `select`→`enum`) and selects that type's control and matcher, and `filterOptions` is used when `options` is absent. Prefer `meta.type` / `meta.options` in new code. +- `enableRowSelection` — injects a leading checkbox column; header checkbox toggles all. + Constrain it with `selectionMode: "single" | "multiple"` (single replaces the + selection), `maxSelectedRows: N` (a hard cap — unselected rows disable once it + is reached, selected ones stay deselectable, and `onSelectionLimitReached` + fires) and/or `canSelectRow(row, { selectedCount, isSelected })` for rules + those two can't express. All three compose; the select-all header checkbox is + hidden unless selection is unbounded multiple. +- `enableColumnVisibility` — an icon-only cog button (tooltipped with `translations.columnsLabel`) opening a checkbox list of hideable columns. The list stays open while toggling, so several columns can be hidden without reopening it. +- `enableColumnPinning` + controlled `columnPinning` — freeze columns to either edge (sticky, with an edge shadow). TanStack Table v9 names the two sides logically, so the state is `{ start: string[], end: string[] }` (v8's `left`/`right`) and pinned cells carry `data-pinned="start" | "end"`. The sticky offsets use `insetInlineStart`/`insetInlineEnd`, so a start-pinned column freezes against the right edge in RTL. +- `enableRowSelection` shift-click range selection is **off**. TanStack v9 enables it by default, but it applies a whole range in one update and would sail past `maxSelectedRows`; DataTable sets `enableRowRangeSelection: false` until it can offer a cap-aware version. +- Column `filterFn` accepts `"conditional"`, `"typed"` and `"includesString"` by name. Other built-in comparators are not registered on the table's feature set (registering them all would pull every built-in into the bundle) — pass the function itself instead, which needs no registration. `sortFn: "auto"` works: `alphanumeric`, `datetime` and `text` are registered. +- `enableColumnReorder` — drag column headers (dnd-kit); fires `onColumnReorder`. +- `enableRowReorder` — injects a drag handle column; fires `onRowReorder`. +- `enableExpanding` — the expand toggle lives in the trailing actions cell, so it never collides with the selection checkbox. Pair with `getSubRows` for tree rows, or with `renderExpandedRow` alone for master-detail (every row becomes expandable; narrow it with `getRowCanExpand`). The detail renders as one full-width cell spanning every column, containing a plain `div` — put any layout inside, no nested table cells. +- `enablePagination` — renders `DataTable.Pagination` ("start–end of total" + page-size select + pager). Configure `pageSizeOptions`. +- `enableVirtualization` + `maxHeight` — windowed rendering for large datasets (keeps native column alignment). Set `estimateRowHeight`. +- `enableColumnResizing` — draggable column widths (`columnResizeMode: "onChange"`); widths are applied inline per cell. +- `enableInlineEdit` — type-driven row editing with an edit/save/cancel actions cell (see above). +- `stickyActions` (default `true`) — pin the row-actions cell to the right edge. +- `hideHeader` — render without the column header row(s). +- `onReachEnd` + `maxHeight` — infinite scroll; called once when scrolled near the bottom. + +Server-driven data: set `manualSorting` / `manualFiltering` / `manualPagination` and supply `rowCount` (or `pageCount`) so pagination totals stay correct. Prefer `rowCount` — it makes the "start–end of total" label exact. With only `pageCount`, the total is derived as `pageCount * pageSize`, so every page is reachable but the figure is an upper bound on the last page. + +## Callbacks (for interaction tests + app wiring) + +Every stateful feature is controllable via a `state` + `onXChange` pair and also +exposes a plain callback: `onRowClick`, `onSortingChange`, `onColumnFiltersChange`, +`onGlobalFilterChange`, `onRowSelectionChange`, `onColumnVisibilityChange`, +`onColumnOrderChange`, `onColumnPinningChange`, `onExpandedChange`, +`onPaginationChange`, `onColumnReorder`, `onRowReorder`, `onCellEditCommit`, and +`onReady(table)`. + +`onColumnReorder`'s `from`/`to`/`order` are relative to your own `columns` +array — the injected selection and drag-handle columns are stripped out — so +`arrayMove(myColumns, from, to)` reproduces the new order directly. + +`onReady` fires once on mount. Its methods stay live, but read `table.state` +and `table.options` off it with care: `useTable` hands back a spread copy, so +those two fields are a mount-time snapshot. Use `renderToolbar` for live +state. + +## Slots (open DOM paths) + +`renderToolbar(table)`, `renderEmpty()`, `renderRowActions(row)`, +`renderHeaderFilter(column)`, `renderExpandedRow(row)`, +and `slotProps.{root,header,body,row}` for className/data-attr/ref passthrough. +Compose the toolbar/pagination yourself with `DataTable.Toolbar`, +`DataTable.GlobalSearch`, `DataTable.ColumnVisibility`, `DataTable.Pagination`. + +## colSpan / rowSpan + +TanStack has no body-cell spanning model. Pass `getCellSpan(cell, ctx)` returning +`{ colSpan?, rowSpan?, hidden? }`; mark cells swallowed by a span with `hidden: true`. + +## Inline edit + +Set `meta.editable` on a column and render an editable control in `columnDef.cell` +that calls `table.options.meta.updateData(rowId, columnId, value)`; DataTable +forwards it to `onCellEditCommit`. + +## Presentation + +`variant` (`line | outline | striped`), `size` (`sm | md | lg`), `stickyHeader`, +`showColumnBorder`, `hideHeader`, `caption` — all forwarded to the underlying +`Table`. Use `stickyHeader` with `maxHeight` for a scrolling body and +`hideHeader` for headerless layouts. + +The whole grid is one rounded card: the wrapper carries `rounded-table` and +clips its children, so the corners are correct with a toolbar, a pagination +footer, both, or neither — including the empty state. `variant="outline"` draws +its border on that wrapper rather than on the ``, so the toolbar and +footer sit inside the outline instead of beside it. + +The wrapper always draws a border in `--color-table-border`, the same colour as +the row separators, so the grid reads as one bounded block. `variant="outline"` +adds the outline shadow on top of it. + +`striped` is a standalone boolean, so zebra rows compose with any variant — +prefer it over `variant="striped"`, which cannot be combined with `outline`. +A striped row drops its own bottom border (the alternating background is +already a separator; drawing both looked doubled-up), so an unstriped table +still has the row-divider border and a striped one doesn't. + +`tintNestedRows` shades rows by `row.depth` — a tree (`getSubRows`) or +expanded-detail child reads as "inside" its parent instead of just being the +next row. Layered as an inset shadow rather than a background, so it composes +with `striped` instead of losing the conflict against its zebra background. + +Setting `onRowClick` automatically makes rows interactive (pointer cursor + +hover background); the affordance is dropped while an inline edit locks row +clicks, so rows never look clickable when they are not. + +## Don'ts + +- Do not hardcode colors/padding — the grid is fully tokenised via `Table`. +- Do not reach for a canvas grid (VTable/S2): they cannot use our Tailwind tokens. +- Do not mutate `data` in place for row reorder — apply the `onRowReorder` `data` result to your state. diff --git a/libs/ui/agent-plugin/skills/table-usage/SKILL.md b/libs/ui/agent-plugin/skills/table-usage/SKILL.md index 2e4c7a1de7..8a0a247729 100644 --- a/libs/ui/agent-plugin/skills/table-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/table-usage/SKILL.md @@ -1,5 +1,5 @@ --- -component_version: "1.0.0" +component_version: "1.1.0" name: table-usage description: > Use after component-usage-ux when an app needs @techsio/ui-kit Table for @@ -45,7 +45,7 @@ size: sm | md | lg interactive, stickyHeader, stickyFirstColumn, showColumnBorder captionPlacement: top | bottom Row selected -ColumnHeader/Cell numeric +ColumnHeader/Cell numeric, data-align: start | center | end parts: Caption, Header, Body, Footer, Row, ColumnHeader, Cell ``` @@ -57,7 +57,22 @@ Use Table.Header/Body/Footer and ColumnHeader/Cell rather than div grids. ### Mark numeric columns -Use `numeric` on headers and cells for right alignment. +Use `numeric` on headers and cells for right alignment. It asserts that the +value *is* a number, so keep it for money, counts and measures. + +### Align a column deliberately + +`data-align="start | center | end"` on a ColumnHeader/Cell is a pure +presentation choice — reach for it when the column is not numeric, e.g. a +centred icon, status or boolean column. + +```tsx +In stock +{inStock ? 'Yes' : 'No'} +``` + +Set `numeric` or `data-align`, not both — combining them with conflicting +directions leaves the winner up to stylesheet order. ### Use selected/interactive props diff --git a/libs/ui/docs/size-scale-consistency.md b/libs/ui/docs/size-scale-consistency.md new file mode 100644 index 0000000000..8a179e8c6f --- /dev/null +++ b/libs/ui/docs/size-scale-consistency.md @@ -0,0 +1,101 @@ +# Size-scale consistency across overlay and form components + +Findings from building `DataTable`, where form controls, a Menu and a Select sit +side by side in one dense filter strip and the size mismatch became obvious. +This is a plan, not something the DataTable branch changes. + +## What was measured + +| Token | Resolves to | +|---|---| +| `--text-sm` | `0.88rem` (~14px) | +| `--text-md` | `clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)` (~18–20px) | +| `--text-lg` | `1.5rem` (24px) | +| `--text-input-md` | `var(--text-md)` | +| `--text-button-md` | `var(--text-md)` | +| `--text-combobox-md` | `var(--text-md)` | + +So `md` is **not** inconsistent between components in value — Input, Button and +Combobox all alias the same `--text-md`. The real issues are different. + +## Issue 1 — `md` is large for dense UI + +`--text-md` is 18–20px. That reads fine for a standalone form, but in a data +grid (filter row, menu of conditions, page-size select) it is visually heavy. +`DataTable` works around this by pinning its condition menu to `sm`. + +Worth deciding as a system: either `md` stays the comfortable default and dense +surfaces opt into `sm` explicitly, or the scale shifts down and today's `md` +becomes `lg`. The second is a breaking visual change across every consumer. + +## Issue 2 — overlay components bypass component tokens + +| Component | Generic `text-sm/md/lg` usages | +|---|---| +| `select.tsx` | 0 | +| `combobox.tsx` | 0 | +| `menu.tsx` | 6 | +| `popover.tsx` | 2 | + +`Menu` and `Popover` style themselves with the global typography scale instead +of `--text-menu-*` / `--text-popover-*` component tokens. The rendered value is +identical today, so nothing looks wrong — but those two components cannot be +re-themed or re-scaled independently, and they are invisible to the +component-token validation the rest of the library passes. + +## Issue 3 — two spacing scales for the same kind of control + +| Token | Value | +|---|---| +| `--padding-menu-item-x` | `var(--dimension-20)` | +| `--padding-menu-item-y` | `var(--dimension-16)` | +| `--padding-input-md` | `var(--spacing-150)` | + +Menu items are spaced off the `--dimension-*` scale while form controls use +`--spacing-*`. Combined with 18–20px text this is what makes menu items look +chunky next to an Input of the "same" size. A menu item and a select option +represent the same thing to a user — a pickable row — and should share a scale. + +## Proposed tasks + +1. **Audit the `md` step.** Decide whether `--text-md` stays at 18–20px. Capture + a side-by-side of Input / Button / Select / Combobox / Menu / Popover at each + size before changing anything. +2. **Give `Menu` and `Popover` component tokens.** Replace the 8 generic + `text-*` usages with `--text-menu-*` / `--text-popover-*` aliases, so both + join the two-layer token contract and the validation scripts see them. +3. **Unify item spacing.** Move `--padding-menu-item-*` onto `--spacing-*` and + align a menu item's height with a form control of the same size, so + `size="md"` means one height everywhere. +4. **Align option rows across pickers.** A Select option, a Combobox option and + a Menu item should be interchangeable in height, padding and text size. +5. **Add a size matrix story.** One Storybook page rendering every control at + `sm`/`md`/`lg` in a row, so drift like this is visible in review instead of + being discovered inside a feature. +6. **Introduce a width scale.** See issue 4 below — there is currently no token + scale a consumer can reach for when sizing a table column, a sidebar or any + other fixed-width block. + +## Issue 4 — no usable width scale + +`DataTable` needs per-column widths (`meta.width`), and there is no token scale +that fits: + +| Scale | Range | Why it does not work | +|---|---|---| +| `--dimension-*` | breaks down past ~124 | The name stops tracking the value: `--dimension-100` is 100px but `--dimension-700` is 4.38rem (70px), and the scale is sparse — no 150, 180, 250. | +| `--container-*` | `16rem`–`72rem` | Page-container widths, an order of magnitude too large for a column. | +| `--spacing-*` | padding-sized | Meant for gaps and insets, not block widths. | + +So `meta.width` accepts a raw number (px) or any CSS length, and a consumer who +wants a token has to inline `"var(--dimension-120)"` themselves. That works, but +it means column widths are the one part of the grid not expressed in tokens and +therefore not themeable or exportable to Figma. + +Proposed: a `--width-*` scale (e.g. `--width-3xs` … `--width-3xl` mirroring the +t-shirt naming already used by `--container-*`) authored in Figma, aliased in +the semantic layer, and then `--width-table-column-*` component tokens on top. +Until that exists, `meta.width` numbers in app code are expected, not a smell. + +Once 1–4 land, `DataTable` can drop its `size="sm"` pin on the condition menu +and simply follow the table's size like every other nested control. diff --git a/libs/ui/package.json b/libs/ui/package.json index 1e5c3d201a..4ed885ad19 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -78,10 +78,16 @@ "semantic-release": "semantic-release" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi-light": "^1.2.2", "@iconify-json/svg-spinners": "^1.2.4", "@iconify/tailwind4": "^1.1.0", + "@tanstack/react-table": "^9.1.2", + "@tanstack/react-virtual": "^3.14.8", "@zag-js/accordion": "^1.41.2", "@zag-js/carousel": "^1.41.2", "@zag-js/checkbox": "^1.41.2", diff --git a/libs/ui/skills/data-table-usage/SKILL.md b/libs/ui/skills/data-table-usage/SKILL.md new file mode 100644 index 0000000000..9fb4b965bd --- /dev/null +++ b/libs/ui/skills/data-table-usage/SKILL.md @@ -0,0 +1,358 @@ +--- +component_version: "1.0.0" +name: data-table-usage +description: > + Use after component-usage-ux when an app needs the @techsio/ui-kit DataTable — + a headless, data-driven grid built on @tanstack/react-table v9 that renders into + the presentational Table organism. Covers column defs, sorting, conditional + column filters, global search, row selection, column visibility/pinning/reorder, + row reorder, tree/expanding rows, inline edit, colSpan/rowSpan, virtualization / + infinite scroll and pagination — every feature behind a flag with a callback. +type: core +library: "@techsio/ui-kit" +library_version: "0.3.2" +requires: + - component-usage-ux + - app-token-overrides + - table-usage +sources: + - "libs/ui/src/organisms/data-table.tsx" + - "libs/ui/src/organisms/data-table.helpers.ts" + - "libs/ui/stories/organisms/data-table.stories.tsx" +--- + +# @techsio/ui-kit DataTable Usage + +`DataTable` is the data-driven grid. It owns the TanStack table instance and +renders into the presentational `Table` organism, so it inherits every +`--color-table-*` / `--padding-table-cell-*` token. Reach for the plain `Table` +when you only need static markup; reach for `DataTable` when you need +sorting/filtering/selection/pagination and friends. + +## Setup + +```tsx +import { DataTable } from "@techsio/ui-kit/organisms/data-table" +import type { ColumnDef } from "@techsio/ui-kit/organisms/data-table" + +type Order = { id: string; customer: string; total: number; status: string } + +const STATUS_OPTIONS = [ + { label: "Paid", value: "paid" }, + { label: "Pending", value: "pending" }, +] + +const columns: ColumnDef[] = [ + { accessorKey: "customer", header: "Customer" }, + { + accessorKey: "total", + header: "Total", + meta: { align: "end", type: "number" }, + cell: (info) => `${info.getValue()} €`, + }, + { + accessorKey: "status", + header: "Status", + meta: { type: "enum", options: STATUS_OPTIONS }, + }, +] + + open(row.original)} +/> +``` + +## Column types drive the filter and the editor + +Declare `meta.type` and DataTable renders the matching ui-kit control in both the +header filter row and the inline row editor, at the table's `size`: + +| `meta.type` | filter control | editor control | +|---|---|---| +| `string` | Input + condition menu (icon) | Input | +| `int` / `number` | Input + condition menu (`between` adds a second Input) | NumericInput | +| `boolean` | tri-state Select (All/Yes/No) | Switch | +| `enum` | Select (+ "All") | Select | +| `multiEnum` | Combobox `multiple` | Combobox `multiple` | +| `date` / `datetime` | Input `date` / `datetime-local` | same | +| `time` | from/to time Inputs (window may cross midnight) | Input `time` | +| `dateRange` | from/to date Inputs | from/to date Inputs | +| `custom` | nothing — supply `meta.renderFilter` | supply `meta.renderEditor` | + +Filter values are objects — `{ operator, value, to? }` for text/number, +`{ values: [...] }` for the enum types, `{ value }` for boolean, `{ from, to }` +for date/time ranges. A bare value from the plain TanStack API +(`column.setFilterValue("Ada")`, or a controlled `columnFilters` entry) is +coerced to the right shape for every type: an array becomes `{ values }`, a +lone date/time becomes a closed range on itself (a whole day for `date`, that +exact minute for `time`), and `"true"` / `"false"` strings are parsed rather +than coerced. + +Give `enum`/`multiEnum` their choices via `meta.options`. Register +`filterFn: "typed"` on the column so filtering matches the declared type +(`time` compares minutes-since-midnight; a `dateRange` cell compares interval +overlap). There is no date-picker component yet, so date/time fields use the +native `Input` types. + +The filter row puts the value control first and the operator behind a compact +icon button (a Menu of conditions), so the input gets the width and the header +stays on one line. The active condition is in the button's `aria-label`, and for +`Is empty` / `Is not empty` the input is disabled and shows the condition as its +placeholder. + +Row actions use the `Button` atom icon-only at `size="sm"`, `theme="borderless"`, +with `variant` carrying the semantics (`danger` for destructive actions). + +Escape hatches, in precedence order: `meta.renderFilter` / `meta.renderEditor` +per column → the table-wide `renderHeaderFilter` slot → `filterRenderers` / +`editorRenderers` maps → the type default. All receive a context with +`{ column, type, value, setValue, disabled, size, options }` (editors also get +`row`, `error`, `commit`, `cancel`). + +## Column widths and alignment + +```tsx +{ accessorKey: "age", meta: { width: 80, align: "end" } } +{ accessorKey: "email", meta: { width: "var(--dimension-200)", minWidth: 120 } } +{ accessorKey: "active", meta: { width: "15%", align: "center" } } +``` + +`meta.width` / `meta.minWidth` / `meta.maxWidth` take a number (px) or any CSS +length, so tokens, `%` and `ch` all work. Pair them with `tableLayout="fixed"` +— under the default `"auto"` a width is only a hint and long content can still +stretch the column. + +Use `meta.width`, not TanStack's `columnDef.size`: TanStack merges `size: 150` +into every column def, so `size` cannot express "no width declared". Numeric +widths are mirrored into `size`/`minSize`/`maxSize` internally, which keeps the +rendered width and the sticky offsets of pinned columns in agreement. While +resizing is on the live dragged width wins. + +Give **pinned (frozen) columns a numeric width**. Sticky offsets are summed from +the numeric sizes, so a `%` or token width on a frozen column cannot be resolved +to pixels and the frozen block can misalign. Unpinned columns take any unit. + +`meta.align` (`start | center | end`, default `start`) is forwarded to the +`Table` cell as `data-align`, which is where the alignment is actually styled — +so a hand-written `Table` gets the same three options. Nothing is inferred from +the column type: center an icon/boolean column or right-align a number only if +you say so. `Table`'s older `numeric` prop still right-aligns, but it means +"this value is a number"; set one or the other, not both. + +## Inline editing and interaction locking + +The table renders read-only until the user opts in: the right-hand actions cell +holds an edit icon, and clicking it swaps that row's editable cells +(`meta.editable`) to type-driven editors with save and cancel beside them. +`enableInlineEdit` is what wires this up. One row is editable at a time; Enter +commits, Escape cancels. Apply the committed `draft` to your own state in +`onEditCommit` — DataTable does not mutate `data`. + +For a column that should always be an editor instead, skip `enableInlineEdit` +and render your own control in `columnDef.cell`, pushing values through +`table.options.meta.updateData` (see "Inline edit" below). Validation runs on +commit from `meta.required` and `meta.validate(value, draft)`; failures block the +commit and surface through `onEditValidationError`. + +While a row is being edited, `lockInteractionsWhileEditing` (default `true`) +disables sorting, column filters, global search, pagination, selection, row and +column reorder, and row click — anything that could move the row out from under +the user. Blocked attempts report through +`onInteractionBlocked({ action, reason: "editing", rowId })`, but only for +`globalFilter`, `paginate`, `columnVisibility` and `rowClick`. Every other +locked control — sort, column filters, selection, both reorder flavours — is +natively `disabled` or non-draggable during an edit, so the interaction never +reaches a handler and there is nothing to report; `globalFilter` is the one +exception, reachable through `SearchForm`'s clear button even while its input +is disabled. Filtering and sorting still compose freely with each other when +no edit is active. + +Edit callbacks: `onEditStart`, `onEditChange`, `onEditCommit`, `onEditCancel` +(with `dirty`), `onEditValidationError`, plus controlled `editingRowId` / +`onEditingRowIdChange`. + +Slot return contract: `renderRowActions` and `renderHeaderFilter` treat +`undefined` as "not handling this one" (falls through to the built-in edit +button / the type-driven filter) and `null` as "render nothing here". + +## Toolbar + +The toolbar is one row: the global search stretches to fill the free width, and +custom actions sit at the trailing edge in a flex group. + +```tsx + +``` + +Each entry takes the full `Button` API (minus `size`, which follows the table); +`label` is a convenience alias for `children`. Keep to +`DATA_TABLE_MAX_TOOLBAR_ACTIONS` (3) — more still render, but DataTable +`console.warn`s, because a crowded toolbar usually means the extras belong in a +menu. It is a recommendation, not an error. + +The search is the `SearchForm` molecule: a clear button appears inside the field +once there is a value, and a submit button is joined to its trailing edge with +the touching corners squared off (`gapped={false}`). Filtering is live on every +keystroke, so the submit button is a confirm affordance rather than the trigger. +`translations.searchLabel`, `searchPlaceholder`, `clearSearchLabel` and +`searchButtonLabel` cover its text. + +## Loading states + +- `loading` replaces the body with `loadingRowCount` skeleton rows (default 5) + while keeping the header, so the layout does not jump when data arrives. +- `loadingMore` appends a single skeleton row — pair it with `onReachEnd` for + infinite scroll so the user sees the next page being fetched. + +## Drag affordances + +Reorder handles are always rendered but stay fully transparent until the row or +header is hovered or receives focus, so the table stays calm while still being +discoverable. They also reveal while their row/header is being dragged, so the +handle does not vanish when the pointer leaves the source. +During a drag the source is dimmed and lifted (`data-dragging`), +and the drop target shows an insertion edge — a border on the leading or +trailing side for columns, top or bottom for rows — so it is clear where the +item will land. + +## Accessibility + +Sortable headers carry `aria-sort`; the expander carries `aria-expanded`; the +table carries `aria-busy` while loading and skeleton rows are hidden from +assistive tech. Rows with `onRowClick` are focusable and activate on Enter or +Space (the handler ignores keys bubbling from controls inside the row). Both +drag handles receive dnd-kit's keyboard attributes, so reordering works without +a mouse. Pass `getRowLabel` so selection checkboxes and the edit action are +labelled by row content instead of an opaque row id. Inline-edit validation +messages render next to the field with `role="alert"` and are linked through +`aria-describedby`; focus moves into the edited row on start and returns to the +control that opened it on commit or cancel. + +## Sizing + +`size` (`sm | md | lg`) is forwarded to the underlying `Table` **and** to every +nested control — filter inputs, inline editors, page-size select, pagination, +action icons and the column menu — so the whole table scales as one. +`paginationProps` exposes the full `Pagination` molecule API (variant, compact, +siblingCount, translations, …) except the table-owned count/page/pageSize. The +footer splits: the record range sits on the left, the pager and page-size select +on the right. It shares the header's background so the two frame the table +consistently. `translations.rangeLabel({ start, end, total })` builds the range +text; `translations.pageSizeLabel` is the page-size select's accessible name +(the design shows no visible label beside it). + +## Feature flags (all opt-in unless noted) + +- `enableSorting` (default `true`) — click header to sort; `meta.align: "end"` right-aligns numeric columns. +- `enableGlobalFilter` — renders the toolbar search (`DataTable.GlobalSearch`). +- `enableColumnFilters` — renders a per-column filter row. Pick the control with `meta.type` (`"string" | "int" | "number" | "boolean" | "enum" | "multiEnum" | "date" | "datetime" | "dateRange" | "time" | "custom"`), plus `meta.options` for the enum types. Columns get `filterFn: "typed"` by default, which dispatches on that same `meta.type`; set `filterFn: "conditional"` for the operator-based ("with conditions") comparator instead, or override the whole UI with `meta.renderFilter` / `renderHeaderFilter`. `meta.filterVariant` and `meta.filterOptions` are deprecated aliases kept for older columns: both still work — `filterVariant` is resolved to a `meta.type` (`text`→`string`, `number`/`range`→`number`, `select`→`enum`) and selects that type's control and matcher, and `filterOptions` is used when `options` is absent. Prefer `meta.type` / `meta.options` in new code. +- `enableRowSelection` — injects a leading checkbox column; header checkbox toggles all. + Constrain it with `selectionMode: "single" | "multiple"` (single replaces the + selection), `maxSelectedRows: N` (a hard cap — unselected rows disable once it + is reached, selected ones stay deselectable, and `onSelectionLimitReached` + fires) and/or `canSelectRow(row, { selectedCount, isSelected })` for rules + those two can't express. All three compose; the select-all header checkbox is + hidden unless selection is unbounded multiple. +- `enableColumnVisibility` — an icon-only cog button (tooltipped with `translations.columnsLabel`) opening a checkbox list of hideable columns. The list stays open while toggling, so several columns can be hidden without reopening it. +- `enableColumnPinning` + controlled `columnPinning` — freeze columns to either edge (sticky, with an edge shadow). TanStack Table v9 names the two sides logically, so the state is `{ start: string[], end: string[] }` (v8's `left`/`right`) and pinned cells carry `data-pinned="start" | "end"`. The sticky offsets use `insetInlineStart`/`insetInlineEnd`, so a start-pinned column freezes against the right edge in RTL. +- `enableRowSelection` shift-click range selection is **off**. TanStack v9 enables it by default, but it applies a whole range in one update and would sail past `maxSelectedRows`; DataTable sets `enableRowRangeSelection: false` until it can offer a cap-aware version. +- Column `filterFn` accepts `"conditional"`, `"typed"` and `"includesString"` by name. Other built-in comparators are not registered on the table's feature set (registering them all would pull every built-in into the bundle) — pass the function itself instead, which needs no registration. `sortFn: "auto"` works: `alphanumeric`, `datetime` and `text` are registered. +- `enableColumnReorder` — drag column headers (dnd-kit); fires `onColumnReorder`. +- `enableRowReorder` — injects a drag handle column; fires `onRowReorder`. +- `enableExpanding` — the expand toggle lives in the trailing actions cell, so it never collides with the selection checkbox. Pair with `getSubRows` for tree rows, or with `renderExpandedRow` alone for master-detail (every row becomes expandable; narrow it with `getRowCanExpand`). The detail renders as one full-width cell spanning every column, containing a plain `div` — put any layout inside, no nested table cells. +- `enablePagination` — renders `DataTable.Pagination` ("start–end of total" + page-size select + pager). Configure `pageSizeOptions`. +- `enableVirtualization` + `maxHeight` — windowed rendering for large datasets (keeps native column alignment). Set `estimateRowHeight`. +- `enableColumnResizing` — draggable column widths (`columnResizeMode: "onChange"`); widths are applied inline per cell. +- `enableInlineEdit` — type-driven row editing with an edit/save/cancel actions cell (see above). +- `stickyActions` (default `true`) — pin the row-actions cell to the right edge. +- `hideHeader` — render without the column header row(s). +- `onReachEnd` + `maxHeight` — infinite scroll; called once when scrolled near the bottom. + +Server-driven data: set `manualSorting` / `manualFiltering` / `manualPagination` and supply `rowCount` (or `pageCount`) so pagination totals stay correct. Prefer `rowCount` — it makes the "start–end of total" label exact. With only `pageCount`, the total is derived as `pageCount * pageSize`, so every page is reachable but the figure is an upper bound on the last page. + +## Callbacks (for interaction tests + app wiring) + +Every stateful feature is controllable via a `state` + `onXChange` pair and also +exposes a plain callback: `onRowClick`, `onSortingChange`, `onColumnFiltersChange`, +`onGlobalFilterChange`, `onRowSelectionChange`, `onColumnVisibilityChange`, +`onColumnOrderChange`, `onColumnPinningChange`, `onExpandedChange`, +`onPaginationChange`, `onColumnReorder`, `onRowReorder`, `onCellEditCommit`, and +`onReady(table)`. + +`onColumnReorder`'s `from`/`to`/`order` are relative to your own `columns` +array — the injected selection and drag-handle columns are stripped out — so +`arrayMove(myColumns, from, to)` reproduces the new order directly. + +`onReady` fires once on mount. Its methods stay live, but read `table.state` +and `table.options` off it with care: `useTable` hands back a spread copy, so +those two fields are a mount-time snapshot. Use `renderToolbar` for live +state. + +## Slots (open DOM paths) + +`renderToolbar(table)`, `renderEmpty()`, `renderRowActions(row)`, +`renderHeaderFilter(column)`, `renderExpandedRow(row)`, +and `slotProps.{root,header,body,row}` for className/data-attr/ref passthrough. +Compose the toolbar/pagination yourself with `DataTable.Toolbar`, +`DataTable.GlobalSearch`, `DataTable.ColumnVisibility`, `DataTable.Pagination`. + +## colSpan / rowSpan + +TanStack has no body-cell spanning model. Pass `getCellSpan(cell, ctx)` returning +`{ colSpan?, rowSpan?, hidden? }`; mark cells swallowed by a span with `hidden: true`. + +## Inline edit + +Set `meta.editable` on a column and render an editable control in `columnDef.cell` +that calls `table.options.meta.updateData(rowId, columnId, value)`; DataTable +forwards it to `onCellEditCommit`. + +## Presentation + +`variant` (`line | outline | striped`), `size` (`sm | md | lg`), `stickyHeader`, +`showColumnBorder`, `hideHeader`, `caption` — all forwarded to the underlying +`Table`. Use `stickyHeader` with `maxHeight` for a scrolling body and +`hideHeader` for headerless layouts. + +The whole grid is one rounded card: the wrapper carries `rounded-table` and +clips its children, so the corners are correct with a toolbar, a pagination +footer, both, or neither — including the empty state. `variant="outline"` draws +its border on that wrapper rather than on the `
`, so the toolbar and +footer sit inside the outline instead of beside it. + +The wrapper always draws a border in `--color-table-border`, the same colour as +the row separators, so the grid reads as one bounded block. `variant="outline"` +adds the outline shadow on top of it. + +`striped` is a standalone boolean, so zebra rows compose with any variant — +prefer it over `variant="striped"`, which cannot be combined with `outline`. +A striped row drops its own bottom border (the alternating background is +already a separator; drawing both looked doubled-up), so an unstriped table +still has the row-divider border and a striped one doesn't. + +`tintNestedRows` shades rows by `row.depth` — a tree (`getSubRows`) or +expanded-detail child reads as "inside" its parent instead of just being the +next row. Layered as an inset shadow rather than a background, so it composes +with `striped` instead of losing the conflict against its zebra background. + +Setting `onRowClick` automatically makes rows interactive (pointer cursor + +hover background); the affordance is dropped while an inline edit locks row +clicks, so rows never look clickable when they are not. + +## Don'ts + +- Do not hardcode colors/padding — the grid is fully tokenised via `Table`. +- Do not reach for a canvas grid (VTable/S2): they cannot use our Tailwind tokens. +- Do not mutate `data` in place for row reorder — apply the `onRowReorder` `data` result to your state. diff --git a/libs/ui/skills/table-usage/SKILL.md b/libs/ui/skills/table-usage/SKILL.md index 2e4c7a1de7..8a0a247729 100644 --- a/libs/ui/skills/table-usage/SKILL.md +++ b/libs/ui/skills/table-usage/SKILL.md @@ -1,5 +1,5 @@ --- -component_version: "1.0.0" +component_version: "1.1.0" name: table-usage description: > Use after component-usage-ux when an app needs @techsio/ui-kit Table for @@ -45,7 +45,7 @@ size: sm | md | lg interactive, stickyHeader, stickyFirstColumn, showColumnBorder captionPlacement: top | bottom Row selected -ColumnHeader/Cell numeric +ColumnHeader/Cell numeric, data-align: start | center | end parts: Caption, Header, Body, Footer, Row, ColumnHeader, Cell ``` @@ -57,7 +57,22 @@ Use Table.Header/Body/Footer and ColumnHeader/Cell rather than div grids. ### Mark numeric columns -Use `numeric` on headers and cells for right alignment. +Use `numeric` on headers and cells for right alignment. It asserts that the +value *is* a number, so keep it for money, counts and measures. + +### Align a column deliberately + +`data-align="start | center | end"` on a ColumnHeader/Cell is a pure +presentation choice — reach for it when the column is not numeric, e.g. a +centred icon, status or boolean column. + +```tsx +In stock +{inStock ? 'Yes' : 'No'} +``` + +Set `numeric` or `data-align`, not both — combining them with conflicting +directions leaves the winner up to stylesheet order. ### Use selected/interactive props diff --git a/libs/ui/src/organisms/data-table.fields.tsx b/libs/ui/src/organisms/data-table.fields.tsx new file mode 100644 index 0000000000..877752d623 --- /dev/null +++ b/libs/ui/src/organisms/data-table.fields.tsx @@ -0,0 +1,1168 @@ +/** + * DataTable field registry — column-type driven filter and editor controls. + * + * A column declares `meta.type` (`string | int | number | boolean | enum | + * multiEnum | date | dateRange | custom`) and DataTable renders the matching + * ui-kit control in both the header filter row and the inline row editor, all + * at `size={size}`. Anything non-standard uses the per-column escape hatches + * `meta.renderFilter` / `meta.renderEditor`, or the table-wide + * `filterRenderers` / `editorRenderers` overrides. + * + * The kit has no date-picker component yet, so date fields use the native + * `Input type="date"` / `datetime-local`; swapping in a real picker later only + * touches this file. + */ +import type { RowData } from "@tanstack/react-table" +import type { ReactNode } from "react" +import { ActionIcon } from "../atoms/action-icon" +import { Input } from "../atoms/input" +import { NumericInput } from "../atoms/numeric-input" +import { Combobox } from "../molecules/combobox" +import { Menu } from "../molecules/menu" +import { Select, type SelectItem } from "../molecules/select" +import { Switch } from "../molecules/switch" +import type { Column, DataTableFilterOperator, Row } from "./data-table.helpers" + +export type DataTableColumnType = + | "string" + | "int" + | "number" + | "boolean" + | "enum" + | "multiEnum" + | "date" + | "datetime" + | "time" + | "dateRange" + | "custom" + +/** Control size scale shared by the table and every nested form control. */ +export type DataTableControlSize = "sm" | "md" | "lg" + +export type DataTableOption = { label: string; value: string } + +/* ── Filter value shapes (per column type) ───────────────────────────────── */ + +export type TextFilterValue = { operator?: string; value?: string } +export type NumberFilterValue = { + operator?: string + value?: string + to?: string +} +export type BooleanFilterValue = { value?: boolean } +export type EnumFilterValue = { values?: string[] } +export type DateRangeFilterValue = { from?: string; to?: string } + +export type DataTableFilterValue = + | TextFilterValue + | NumberFilterValue + | BooleanFilterValue + | EnumFilterValue + | DateRangeFilterValue + +/* ── Render contexts (public — consumers implement these for custom types) ── */ + +export type DataTableFilterContext = { + column: Column + /** Localised operator labels, keyed by operator. */ + operatorLabels?: Partial> + type: DataTableColumnType + value: DataTableFilterValue | undefined + setValue: (value: DataTableFilterValue | undefined) => void + /** True while an inline edit locks the table's filter controls. */ + disabled: boolean + size: DataTableControlSize + options: DataTableOption[] +} + +export type DataTableEditorContext = { + row: Row + /** Id of the element rendering `error`, for `aria-describedby`. */ + errorId?: string + column: Column + type: DataTableColumnType + value: unknown + setValue: (value: unknown) => void + disabled: boolean + size: DataTableControlSize + options: DataTableOption[] + /** Validation message for this field, if the last commit attempt failed. */ + error?: string + /** Commit the whole row (e.g. on Enter). */ + commit: () => void + /** Discard the row draft (e.g. on Escape). */ + cancel: () => void +} + +export type DataTableFilterRenderer = ( + ctx: DataTableFilterContext +) => ReactNode +export type DataTableEditorRenderer = ( + ctx: DataTableEditorContext +) => ReactNode + +/* ── Shared helpers ──────────────────────────────────────────────────────── */ + +/** Treats `null`, `undefined` and `""` alike — shared with the conditional filter. */ +export const isBlank = (v: unknown) => v == null || v === "" + +/** + * The operators a text column offers, in menu order. This is the single list: + * the typed filter row renders it, the conditional (operator-based) filter + * imports it through `data-table.helpers`, and `matchText` below handles every + * entry. Adding one here without a branch in `matchText` makes that operator + * fall through to "contains". + */ +export const TEXT_FILTER_OPERATORS: { + label: string + value: DataTableFilterOperator +}[] = [ + { label: "Contains", value: "contains" }, + { label: "Does not contain", value: "notContains" }, + { label: "Equals", value: "equals" }, + { label: "Does not equal", value: "notEquals" }, + { label: "Starts with", value: "startsWith" }, + { label: "Ends with", value: "endsWith" }, + { label: "Is empty", value: "empty" }, + { label: "Is not empty", value: "notEmpty" }, +] + +/** Same contract as `TEXT_FILTER_OPERATORS`, for numeric columns. */ +export const NUMBER_FILTER_OPERATORS: { + label: string + value: DataTableFilterOperator +}[] = [ + { label: "=", value: "equals" }, + { label: "≠", value: "notEquals" }, + { label: ">", value: "gt" }, + { label: "≥", value: "gte" }, + { label: "<", value: "lt" }, + { label: "≤", value: "lte" }, + { label: "Between", value: "between" }, + { label: "Is empty", value: "empty" }, + { label: "Is not empty", value: "notEmpty" }, +] + +const TEXT_OPS: DataTableOption[] = TEXT_FILTER_OPERATORS +const NUMBER_OPS: DataTableOption[] = NUMBER_FILTER_OPERATORS + +const BOOLEAN_FILTER_ITEMS: SelectItem[] = [ + { label: "All", value: "" }, + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, +] + +const HHMM_RE = /^(\d{1,2}):(\d{2})/ + +const toSelectItems = (options: DataTableOption[]): SelectItem[] => + options.map((o) => ({ label: o.label, value: o.value })) + +/** Human-readable column name for generated aria-labels: string header, else id. */ +export function columnLabel(column: Column) { + const header = column.columnDef.header + return typeof header === "string" && header ? header : column.id +} + +/** Single-value Select used for operators / enums / booleans. */ +/** + * The single-value `Select` every DataTable control uses — filter dropdowns, + * the inline editor and the footer's page-size picker. `value: undefined` + * leaves it uncontrolled; `invalid` is what the editor uses to show a failed + * field. + */ +/** + * `Select`'s trigger sizes to its selected label, so a filter or editor whose + * value changes shape between options (`"All"` vs. `"Administrator"`) resizes + * its own table cell on every change — which reflows the column, and with it + * the whole table. + * + * `ch` is defined as the width of the font's `"0"` glyph, not an average + * character — proportional fonts render `"W"` two to three times wider than + * `"i"`, so a same-length label can legitimately need more room than the + * `ch` count alone predicts. Measured against this control's own font + * (`Admin`/`Editor`/`Viewer` at the `sm` size), the actual chrome — icon, + * gaps, padding, border — plus that per-glyph variance came out to roughly + * double a naive "length + a little slack" estimate; `+ 7` is that margin + * with headroom, not `+ 4`. + */ +function selectMinWidthCh(items: SelectItem[], placeholder?: string): string { + const longest = items.reduce( + (max, item) => Math.max(max, String(item.label ?? item.value).length), + placeholder?.length ?? 0 + ) + return `${longest + 7}ch` +} + +export function FieldSelect({ + items, + value, + ariaLabel, + placeholder, + disabled, + size = "sm", + invalid, + onChange, +}: { + items: SelectItem[] + value?: string + ariaLabel?: string + placeholder?: string + disabled?: boolean + size?: DataTableControlSize + invalid?: boolean + onChange: (value: string) => void +}) { + return ( +
+ +
+ ) +} + +/** + * Operator picker for the filter row. An icon button rather than a full Select, + * so the value control gets the width and the header stays compact. + */ +function FilterConditionMenu({ + operators, + operatorLabels, + value, + onChange, + disabled, + size, + columnName, +}: { + operators: DataTableOption[] + operatorLabels?: Partial> + value: string + onChange: (value: string) => void + disabled?: boolean + size: DataTableControlSize + columnName: string +}) { + const labelled = operators.map((o) => ({ + ...o, + label: operatorLabels?.[o.value] ?? o.label, + })) + const active = labelled.find((o) => o.value === value) + return ( + + } + items={labelled.map((o) => ({ + type: "radio" as const, + value: o.value, + label: o.label, + name: `filter-operator-${columnName}`, + checked: o.value === value, + }))} + onSelect={(d) => onChange(d.value)} + size={size} + /> + ) +} + +/* ── Default FILTER renderers, keyed by column type ──────────────────────── */ + +export const DEFAULT_FILTER_RENDERERS: Record< + DataTableColumnType, + DataTableFilterRenderer +> = { + string: ({ column, value, setValue, disabled, size, operatorLabels }) => { + const v = (value ?? {}) as TextFilterValue + const operator = v.operator ?? "contains" + const activeLabel = + operatorLabels?.[operator] ?? + TEXT_OPS.find((o) => o.value === operator)?.label + const needsValue = operator !== "empty" && operator !== "notEmpty" + return ( + <> + setValue({ ...v, operator, value: e.target.value })} + placeholder={needsValue ? "Value" : activeLabel} + size={size} + value={needsValue ? (v.value ?? "") : ""} + /> + setValue({ ...v, operator: op })} + operatorLabels={operatorLabels} + operators={TEXT_OPS} + size={size} + value={operator} + /> + + ) + }, + + int: (ctx) => DEFAULT_FILTER_RENDERERS.number(ctx), + + number: ({ column, value, setValue, disabled, size, operatorLabels }) => { + const v = (value ?? {}) as NumberFilterValue + const operator = v.operator ?? "equals" + /* `empty` / `notEmpty` test the cell, not a typed number. Leaving the input + * live would show a constraint that `matchNumber` ignores — the string + * filter has always gated it this way. */ + const needsValue = operator !== "empty" && operator !== "notEmpty" + const activeLabel = + operatorLabels?.[operator] ?? + NUMBER_FILTER_OPERATORS.find((o) => o.value === operator)?.label + let valuePlaceholder = activeLabel + if (needsValue) { + valuePlaceholder = operator === "between" ? "From" : "Value" + } + return ( + <> + setValue({ ...v, operator, value: e.target.value })} + placeholder={valuePlaceholder} + size={size} + type={needsValue ? "number" : "text"} + value={needsValue ? (v.value ?? "") : ""} + /> + {operator === "between" && ( + setValue({ ...v, operator, to: e.target.value })} + placeholder="To" + size={size} + type="number" + value={v.to ?? ""} + /> + )} + + setValue( + op === "between" + ? { ...v, operator: op } + : { operator: op, value: v.value } + ) + } + operatorLabels={operatorLabels} + operators={NUMBER_OPS} + size={size} + value={operator} + /> + + ) + }, + + boolean: ({ column, value, setValue, disabled, size }) => { + const v = (value ?? {}) as BooleanFilterValue + const current = v.value === undefined ? "" : String(v.value) + return ( + + setValue(next === "" ? undefined : { value: next === "true" }) + } + placeholder="All" + size={size} + value={current} + /> + ) + }, + + enum: ({ column, value, setValue, disabled, options, size }) => { + const v = (value ?? {}) as EnumFilterValue + const current = v.values?.[0] ?? "" + return ( + setValue(next ? { values: [next] } : undefined)} + placeholder="All" + size={size} + value={current} + /> + ) + }, + + multiEnum: ({ column, value, setValue, disabled, options, size }) => { + const v = (value ?? {}) as EnumFilterValue + return ( + { + const arr = Array.isArray(next) ? next : [next].filter(Boolean) + setValue(arr.length ? { values: arr as string[] } : undefined) + }} + placeholder={`Filter ${columnLabel(column)}`} + size={size} + value={v.values ?? []} + /> + ) + }, + + date: ({ column, value, setValue, disabled, size }) => { + const v = (value ?? {}) as DateRangeFilterValue + return ( + + setValue( + e.target.value + ? { from: e.target.value, to: e.target.value } + : undefined + ) + } + size={size} + type="date" + value={v.from ?? ""} + /> + ) + }, + + // Sets only `from`, so a single picked instant filters "at or after" rather + // than an exact match — a datetime-local value has minute precision while the + // cell usually carries seconds, and an equality filter would match nothing. + datetime: ({ column, value, setValue, disabled, size }) => { + const v = (value ?? {}) as DateRangeFilterValue + return ( + + setValue(e.target.value ? { from: e.target.value } : undefined) + } + size={size} + type="datetime-local" + value={v.from ?? ""} + /> + ) + }, + + // Time-only: compared as minutes-since-midnight, so it filters a "start/end" + // window independently of any date part. + time: ({ column, value, setValue, disabled, size }) => { + const v = (value ?? {}) as DateRangeFilterValue + const patch = (next: DateRangeFilterValue) => + setValue(next.from || next.to ? next : undefined) + return ( + <> + patch({ ...v, from: e.target.value })} + size={size} + type="time" + value={v.from ?? ""} + /> + patch({ ...v, to: e.target.value })} + size={size} + type="time" + value={v.to ?? ""} + /> + + ) + }, + + dateRange: ({ column, value, setValue, disabled, size }) => { + const v = (value ?? {}) as DateRangeFilterValue + const patch = (next: DateRangeFilterValue) => + setValue(next.from || next.to ? next : undefined) + return ( + <> + patch({ ...v, from: e.target.value })} + size={size} + type="date" + value={v.from ?? ""} + /> + to mistake at the control level. + min={v.from || undefined} + onChange={(e) => patch({ ...v, to: e.target.value })} + size={size} + type="date" + value={v.to ?? ""} + /> + + ) + }, + + // Non-standard columns must supply meta.renderFilter; rendering nothing keeps + // the filter row aligned instead of guessing a control. + custom: () => null, +} + +/* ── Default EDITOR renderers, keyed by column type ──────────────────────── */ + +const editorKeyHandlers = (commit: () => void, cancel: () => void) => ({ + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + commit() + } else if (e.key === "Escape") { + e.preventDefault() + cancel() + } + }, +}) + +/** + * The `date`, `datetime` and `time` editors are the same native-`Input` + * control differing only in its `type`, so they share one factory — a fix to + * the keyboard handling or ARIA wiring lands on all three instead of two of + * them plus whichever was forgotten. + */ +function dateLikeEditor( + inputType: "date" | "datetime-local" | "time" +): DataTableEditorRenderer { + return ({ + column, + value, + setValue, + disabled, + error, + commit, + cancel, + size, + errorId, + }) => ( + setValue(e.target.value)} + size={size} + type={inputType} + value={(value as string) ?? ""} + {...editorKeyHandlers(commit, cancel)} + /> + ) +} + +export const DEFAULT_EDITOR_RENDERERS: Record< + DataTableColumnType, + DataTableEditorRenderer +> = { + string: ({ + column, + value, + setValue, + disabled, + error, + commit, + cancel, + size, + errorId, + }) => ( + setValue(e.target.value)} + size={size} + value={(value as string) ?? ""} + {...editorKeyHandlers(commit, cancel)} + /> + ), + + int: (ctx) => DEFAULT_EDITOR_RENDERERS.number(ctx), + + number: ({ + column, + value, + setValue, + disabled, + commit, + cancel, + size, + error, + errorId, + }) => ( + /* + * `NumericInput` is a compound component whose root renders only its + * children — self-closing it produces an empty div with no field to type + * into. The label/validation wiring belongs on `.Input` (which spreads + * rest props onto the real ``); `describedBy` and `invalid` are + * the root's own props and reach the input through context. + */ + setValue(Number.isNaN(next) ? undefined : next)} + size={size} + value={ + typeof value === "number" && !Number.isNaN(value) ? value : undefined + } + /* + * Commit/cancel keys live on the root, not on `.Input`. `.Input` + * renders `` — props + * last — so an `onKeyDown` there replaces Zag's outright, silently + * killing ArrowUp/ArrowDown stepping and Home/End. The root has no + * keydown of its own, and keydown bubbles, so Zag's input handler + * runs first and Enter/Escape still reach this one. + */ + {...editorKeyHandlers(commit, cancel)} + > + + + + + + + + + ), + + /* + * The three editors below deliberately omit `editorKeyHandlers`, unlike + * every Input/NumericInput-based one: + * + * - `Switch`, `FieldSelect` and `Combobox` all expose closed prop surfaces + * (no `onKeyDown`, no rest spread), so the handlers cannot be forwarded + * without widening those components' public props. + * - For the two dropdowns, Enter and Escape already belong to the Zag + * select/combobox machines — Enter picks the highlighted option, Escape + * closes the popup. Intercepting them here would break option selection + * rather than add a commit shortcut. + * + * Committing these cells therefore goes through the row's Save action. + */ + boolean: ({ column, value, setValue, disabled, error }) => ( + // No `aria-describedby`/`aria-invalid` here: `Switch`'s prop surface is + // closed, so those attributes never reach the DOM and only read as + // though the error were wired up. `validateStatus` is the supported + // channel and does drive the underlying invalid state. Associating the + // error text itself needs `Switch`/`Combobox` to accept `describedBy` + // (as `NumericInput` already does) — a change to those components, + // versioned separately from this organism. + + {`Edit ${columnLabel(column)}`} + + ), + + enum: ({ column, value, setValue, disabled, options, error, size }) => ( + + ), + + multiEnum: ({ column, value, setValue, disabled, options, error, size }) => ( + setValue(Array.isArray(next) ? next : [next])} + placeholder={`Edit ${columnLabel(column)}`} + size={size} + validateStatus={error ? "error" : "default"} + value={(value as string[]) ?? []} + /> + ), + + date: dateLikeEditor("date"), + + datetime: dateLikeEditor("datetime-local"), + + time: dateLikeEditor("time"), + + dateRange: ({ + column, + value, + setValue, + disabled, + error, + errorId, + commit, + cancel, + size, + }) => { + const v = (value ?? {}) as { from?: string; to?: string } + return ( + <> + setValue({ ...v, from: e.target.value })} + size={size} + type="date" + value={v.from ?? ""} + {...editorKeyHandlers(commit, cancel)} + /> + setValue({ ...v, to: e.target.value })} + size={size} + type="date" + value={v.to ?? ""} + {...editorKeyHandlers(commit, cancel)} + /> + + ) + }, + + custom: () => null, +} + +/* ── Type-aware filter function ──────────────────────────────────────────── */ + +function matchText(cell: unknown, f: TextFilterValue) { + const operator = f.operator ?? "contains" + if (operator === "empty") { + return isBlank(cell) + } + if (operator === "notEmpty") { + return !isBlank(cell) + } + if (isBlank(f.value)) { + return true + } + const text = String(cell ?? "").toLowerCase() + const q = String(f.value).toLowerCase() + switch (operator) { + case "equals": + return text === q + case "notEquals": + return text !== q + case "notContains": + return !text.includes(q) + case "startsWith": + return text.startsWith(q) + case "endsWith": + return text.endsWith(q) + default: + return text.includes(q) + } +} + +/** The `between` arm of `matchNumber`, split out to keep either half legible. */ +/** + * Exported so `evaluateCondition` (data-table.helpers.ts) can share this + * exact bounds logic for its own `between` operator instead of keeping a + * second hand-copied implementation that could silently diverge from this + * one on an edge case (e.g. inclusive/exclusive bounds). + */ +export function matchBetween( + n: number, + cellHasNoNumber: boolean, + f: NumberFilterValue +) { + let lo = Number(f.value) + let hi = Number(f.to) + const hasLo = !(isBlank(f.value) || Number.isNaN(lo)) + const hasHi = !(isBlank(f.to) || Number.isNaN(hi)) + if (!(hasLo || hasHi)) { + return true + } + if (cellHasNoNumber) { + return false + } + // Both bounds typed but in the wrong order (e.g. "From" edited after "To" + // without clearing it first) would otherwise reject every value: nothing is + // both >= 100 and <= 10. Read as an unordered interval instead of a + // silently-empty table. + if (hasLo && hasHi && lo > hi) { + ;[lo, hi] = [hi, lo] + } + if (hasLo && n < lo) { + return false + } + return !(hasHi && n > hi) +} + +function matchNumber(cell: unknown, f: NumberFilterValue) { + const operator = f.operator ?? "equals" + // Blankness is a property of the cell, not of the filter value, so these two + // are answered before the "no value typed yet" short-circuit below. + if (operator === "empty") { + return isBlank(cell) + } + if (operator === "notEmpty") { + return !isBlank(cell) + } + const n = Number(cell) + /* `Number(null)` and `Number("")` are both 0, so without this a blank cell + * would satisfy `equals 0`, `lt 5`, `lte 0` and friends. A cell with no + * number in it satisfies no numeric comparison — but only once the filter is + * actually constraining something, which is why each branch below returns + * early when no bound has been typed yet. */ + const cellHasNoNumber = isBlank(cell) || Number.isNaN(n) + + if (operator === "between") { + return matchBetween(n, cellHasNoNumber, f) + } + if (isBlank(f.value)) { + return true + } + const target = Number(f.value) + if (Number.isNaN(target)) { + return true + } + // A cell with no number in it satisfies no *positive* numeric comparison, + // but it does satisfy a negative one: a blank cell is trivially "not 5", and + // dropping it made `≠` hide rows that plainly do not equal the target. The + // text matcher already reads this way — `notContains` keeps blank cells — so + // excluding them here made the same filter behave oppositely on a string and + // a number column. + if (operator === "notEquals") { + return cellHasNoNumber || n !== target + } + if (cellHasNoNumber) { + return false + } + switch (operator) { + case "gt": + return n > target + case "gte": + return n >= target + case "lt": + return n < target + case "lte": + return n <= target + default: + return n === target + } +} + +// `to` bounds (both the cell's and the filter's) are treated as date-only — +// adding this makes an inclusive "to" mean the whole of that day, matching +// how a date picker's end date is understood. A `to` holding a full +// timestamp instead of a date-only value would shift the effective bound by +// up to a day; there's no runtime signal to distinguish the two, so this is +// a documented convention rather than something validated. +const END_OF_DAY_MS = 24 * 60 * 60 * 1000 - 1 +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Parse to a timestamp, reading a bare `YYYY-MM-DD` as *local* midnight. + * + * ECMAScript parses a date-only string as UTC but a zoneless date-time + * (`2024-01-01T20:00:00`) as local, so comparing the two directly mixes + * frames of reference. West of UTC that silently excluded rows from the day + * they belong to: in UTC-5, a cell at 20:00 on Jan 1 resolves to Jan 2 + * 01:00Z and falls outside a "Jan 1" filter's UTC window. Appending a time + * puts both sides on the same local clock. + */ +const time = (v: unknown) => { + const raw = String(v) + const t = new Date(DATE_ONLY_RE.test(raw) ? `${raw}T00:00:00` : raw).getTime() + return Number.isNaN(t) ? undefined : t +} + +/** + * An inclusive upper bound as a timestamp. + * + * A date-*only* bound ("2024-06-01") means the whole of that day, so it runs + * to 23:59:59.999. A bound that carries a time ("2024-06-01T20:00") means + * that instant and must not be widened — extending it unconditionally turned + * a `datetime` filter into a rolling ~24-hour window. + */ +const endOfBound = (v: unknown): number | undefined => { + const t = time(v) + if (t === undefined) { + return + } + return DATE_ONLY_RE.test(String(v)) ? t + END_OF_DAY_MS : t +} + +/** True when the cell's own {from,to} interval overlaps the filter interval. */ +function matchRangeOverlap( + cell: { from?: unknown; to?: unknown }, + f: DateRangeFilterValue +) { + const cellFrom = time(cell.from) ?? Number.NEGATIVE_INFINITY + const cellTo = cell.to + ? (endOfBound(cell.to) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY + const filterFrom = f.from + ? (time(f.from) ?? Number.NEGATIVE_INFINITY) + : Number.NEGATIVE_INFINITY + const filterTo = f.to + ? (endOfBound(f.to) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY + return cellFrom <= filterTo && cellTo >= filterFrom +} + +function matchDateRange(cell: unknown, f: DateRangeFilterValue) { + // Clearing a date input leaves `{ from: "" }` behind rather than dropping the + // filter value, so "is anything constrained?" has to be asked before "is the + // cell blank?" — otherwise an empty filter still hides every row with no + // date. Same rule `matchNumber` follows. + if (isBlank(f.from) && isBlank(f.to)) { + return true + } + if (isBlank(cell)) { + return false + } + // A dateRange column stores {from,to} per cell — compare interval overlap. + if (typeof cell === "object" && ("from" in cell || "to" in cell)) { + return matchRangeOverlap(cell as { from?: unknown; to?: unknown }, f) + } + // Via `time`, so a date-only bound is read as local midnight and lands in + // the same frame of reference as a zoneless cell timestamp. + const t = time(cell) + if (t === undefined) { + return false + } + return withinDateBounds(t, f) +} + +/** + * Inclusive `[from, to]` test for a single timestamp. `to` extends to the end + * of its day so a single-day range matches that whole day. + */ +function withinDateBounds(t: number, f: DateRangeFilterValue): boolean { + const from = f.from ? time(f.from) : undefined + if (from !== undefined && t < from) { + return false + } + const to = f.to ? endOfBound(f.to) : undefined + return !(to !== undefined && t > to) +} + +/** Minutes since midnight from "HH:mm", an ISO datetime, or a Date. */ +function toMinutes(value: unknown): number | undefined { + if (isBlank(value)) { + return + } + const s = String(value) + const hhmm = s.match(HHMM_RE) + if (hhmm) { + return Number(hhmm[1]) * 60 + Number(hhmm[2]) + } + const d = new Date(s) + return Number.isNaN(d.getTime()) + ? undefined + : d.getHours() * 60 + d.getMinutes() +} + +function matchTime(cell: unknown, f: DateRangeFilterValue) { + const from = toMinutes(f.from) + const to = toMinutes(f.to) + // No bound set means no constraint — see `matchDateRange`. + if (from === undefined && to === undefined) { + return true + } + const t = toMinutes(cell) + if (t === undefined) { + return false + } + // An inverted window (22:00 → 06:00) is read as crossing midnight. + if (from !== undefined && to !== undefined && from > to) { + return t >= from || t <= to + } + if (from !== undefined && t < from) { + return false + } + if (to !== undefined && t > to) { + return false + } + return true +} + +/** + * Filter function that dispatches on the column's declared `meta.type`. + * Reached through `filterFn: "typed"`, which `applyColumnDefaults` puts on + * every column that does not name its own. + */ +/** + * Coerce a bare filter value into the object shape the matchers expect. + * + * DataTable's own controls always write objects, but `filterFn: "typed"` is + * applied to every column that does not name one — so a consumer using the + * plain TanStack API (`column.setFilterValue("Ada")`, or a controlled + * `columnFilters={[{ id, value: "Ada" }]}`) reached a matcher that read + * `.operator`/`.value` off a string, found `undefined`, and returned `true` + * for every row. The filter was stored and looked active while matching + * everything — a silent no-op rather than an error. + * + * Every type is coerced — an earlier version exempted the date/time types on + * the grounds that a single value is ambiguous, but "leave it alone" is not + * an escape from the problem: `matchDateRange`/`matchTime` read only + * `from`/`to`, so an untouched string still reads as "no bounds" and matches + * every row. A lone value is therefore read as a closed range on itself, + * which those matchers already handle exactly right: for a date that is the + * whole of that day (the `to` bound extends to end-of-day), and for a time + * that single minute. + */ +function normalizeFilterValue( + type: DataTableColumnType, + filterValue: DataTableFilterValue +): DataTableFilterValue { + // Arrays first: `typeof [] === "object"`, so bailing out on that check + // let `setFilterValue(["react"])` through untouched, the matcher read + // `values` as undefined, and every row matched — reproducing the exact + // silent no-op this function exists to prevent. An array is an + // unambiguous `values` list for the enum types. + if (Array.isArray(filterValue)) { + return { values: filterValue.map(String) } + } + if (typeof filterValue === "object") { + return filterValue + } + if (type === "enum" || type === "multiEnum") { + return { values: [String(filterValue)] } + } + if (type === "boolean") { + // Parsed, not coerced: every non-empty string is truthy, so `Boolean()` + // turned `setFilterValue("false")` — the natural shape from a
` itself (not on + // ``, which must keep the filter row visible), so `closest("tr")` + // is already the element carrying it. + await expect(headers[0]?.closest("tr")).toHaveClass("sr-only") + await expect(canvas.getByText("Lovelace")).toBeInTheDocument() + }, +} + +/* ── 11. Striped rows ────────────────────────────────────────────────────── */ + +export const StripedRows: Story = { + args: { ...base, striped: true }, +} + +/* ── 11b. Striped + outline (boolean composes with any variant) ──────────── */ + +export const StripedOutlined: Story = { + args: { ...base, striped: true, variant: "outline" }, +} + +/* ── 12. Infinite scroll / virtualization ────────────────────────────────── */ + +export const InfiniteScrollVirtualized: Story = { + args: { + columns, + data: bigData, + enableVirtualization: true, + maxHeight: "400px", + onReachEnd: fn(), + }, +} + +/** + * Without `maxHeight`, `onReachEnd` watches the *page* scroll instead of an + * internal container. A second, unrelated effect used to force-measure the + * (always unbounded, so always "at the bottom") scroll container on every + * appended page — re-arming the "already reported" latch the instant new rows + * landed, regardless of where the page had actually scrolled to. On a fast, + * continuous scroll (no incidental upward wobble to reset it first) the next + * real reach-end went silently missing. Both existing infinite-scroll stories + * set `maxHeight`, which is exactly why this went unnoticed. + */ +export const WindowScrollInfiniteLoad: Story = { + render: () => { + const [rows, setRows] = useState(bigData.slice(0, 30)) + return ( + { + setRows((prev) => + prev.length < bigData.length + ? bigData.slice(0, prev.length + 30) + : prev + ) + }} + /> + ) + }, + play: async ({ canvasElement }) => { + const scrollTo = async (y: number) => { + window.scrollTo(0, y) + window.dispatchEvent(new Event("scroll")) + await new Promise((r) => setTimeout(r, 150)) + } + const rowCount = () => canvasElement.querySelectorAll("tbody tr").length + + await new Promise((r) => setTimeout(r, 150)) + const initial = rowCount() + await scrollTo(999_999) + const afterFirst = rowCount() + await expect(afterFirst).toBeGreaterThan(initial) + + // The bug this regresses: the appended page pushes the real bottom + // further away without firing a scroll event on its own, so + // `reachedEndRef` only ever finds out once an actual scroll event + // reports it — scrolling away first, the way a continuous scroll + // gesture naturally would, then back down to the new bottom. + await scrollTo(0) + await scrollTo(999_999) + const afterSecond = rowCount() + await expect(afterSecond).toBeGreaterThan(afterFirst) + }, +} + +/** + * `enableVirtualization` without `maxHeight` has no bounded scroll container + * to measure, so windowing falls back to rendering every row — this asserts + * that fallback rather than a truncated table. Check the browser console for + * the accompanying dev warning. + */ +export const VirtualizationWithoutMaxHeight: Story = { + args: { + columns, + data: bigData, + enableVirtualization: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const rows = canvas.getAllByRole("row") + // Header row(s) plus every one of bigData's rows — not just the first + // windowed slice. + await expect(rows.length).toBeGreaterThan(bigData.length) + }, +} + +/* ── 13. colSpan / rowSpan ───────────────────────────────────────────────── */ + +const getCellSpan: DataTableGetCellSpan = (cell, { rows, rowIndex }) => { + if (cell.column.id !== "role") { + return undefined + } + const role = cell.row.original.role + const prev = rows[rowIndex - 1]?.original.role + if (prev === role) { + return { hidden: true } + } + let rowSpan = 1 + for (let i = rowIndex + 1; i < rows.length; i++) { + if (rows[i]?.original.role === role) { + rowSpan++ + } else { + break + } + } + return { rowSpan } +} + +export const ColSpanRowSpan: Story = { + args: { + columns, + // Sorted by role so equal roles are adjacent and merge vertically. + data: [...people].sort((a, b) => a.role.localeCompare(b.role)), + getCellSpan, + showColumnBorder: true, + }, +} + +/* ── 14. onRowClick ──────────────────────────────────────────────────────── */ + +export const RowClick: Story = { + args: { ...base, onRowClick: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByText("Lovelace")) + await expect(args.onRowClick).toHaveBeenCalled() + }, +} + +/* ── 15. Selectable rows with checkbox ───────────────────────────────────── */ + +export const RowSelection: Story = { + args: { ...base, enableRowSelection: true, onRowSelectionChange: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Select row 1")) + await expect(args.onRowSelectionChange).toHaveBeenCalled() + }, +} + +/* ── 16. Column reorder ──────────────────────────────────────────────────── */ + +export const ColumnReorder: Story = { + args: { ...base, enableColumnReorder: true, onColumnReorder: fn() }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect( + canvas.getAllByLabelText(/^Drag to reorder /).length + ).toBeGreaterThan(0) + }, +} + +/* ── 17. Row reorder ─────────────────────────────────────────────────────── */ + +export const RowReorder: Story = { + render: (args) => { + // DataTable does not own `data`; the consumer applies the reordered array. + const [rows, setRows] = useState(people) + return ( + { + setRows(details.data) + args.onRowReorder?.(details) + }} + /> + ) + }, + args: { ...base, enableRowReorder: true, onRowReorder: fn() }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect( + canvas.getAllByLabelText(/^Drag to reorder /).length + ).toBeGreaterThan(0) + }, +} + +/* ── 18. Column visibility (show/hide) ───────────────────────────────────── */ + +export const ColumnVisibility: Story = { + args: { + ...base, + enableColumnVisibility: true, + onColumnVisibilityChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // Icon-only cog trigger, named by its tooltip text. + const trigger = canvas.getByRole("button", { name: /Column settings/i }) + await userEvent.click(trigger) + + // Toggling a column keeps the list open, so several can be hidden in a row. + const items = await canvas.findAllByRole("menuitemcheckbox") + await userEvent.click(items[0] as HTMLElement) + await expect( + await canvas.findAllByRole("menuitemcheckbox") + ).not.toHaveLength(0) + }, +} + +/* ── 19. Custom cell content template ────────────────────────────────────── */ + +export const CustomCellTemplate: Story = { + args: { + columns: [ + { + id: "person", + header: "Person", + cell: ({ row }) => ( +
+ + {row.original.firstName} {row.original.lastName} + + + {row.original.email} + +
+ ), + }, + { + accessorKey: "status", + header: "Status", + cell: (info) => ( + ()} /> + ), + }, + { accessorKey: "role", header: "Role" }, + ], + data: people, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText("ada@calc.io")).toBeInTheDocument() + }, +} + +/* ── 20. Tree structure ──────────────────────────────────────────────────── */ + +type Node = Person & { children?: Node[] } + +const p = (i: number) => people[i] as Person + +const tree: Node[] = [ + { + ...p(0), + children: [ + { ...p(1), id: "1-1" }, + { ...p(2), id: "1-2" }, + ], + }, + { ...p(3), children: [{ ...p(4), id: "3-1" }] }, +] + +export const TreeStructure: Story = { + args: { + columns: columns as ColumnDef[], + data: tree, + enableExpanding: true, + getSubRows: (row) => (row as Node).children, + onExpandedChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getAllByLabelText("Expand row")[0] as HTMLElement) + await expect(args.onExpandedChange).toHaveBeenCalled() + }, +} + +/* ── 20a2. Tree structure with nested rows tinted by depth ───────────────── */ + +export const TreeStructureTinted: Story = { + args: { + columns: columns as ColumnDef[], + data: tree, + enableExpanding: true, + tintNestedRows: true, + getSubRows: (row) => (row as Node).children, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getAllByLabelText("Expand row")[0] as HTMLElement) + const rows = canvasElement.querySelectorAll("tbody tr") + const childRow = [...rows].find( + (r) => r.getAttribute("data-depth") === "1" + ) + await expect(childRow).toBeTruthy() + await expect( + childRow && getComputedStyle(childRow).boxShadow + ).not.toBe("none") + }, +} + +/* ── 20b. Master-detail: the expanded row is one free-form box ───────────── */ + +export const ExpandedRowDetail: Story = { + args: { + columns: columns as ColumnDef[], + data: tree, + enableExpanding: true, + getSubRows: () => undefined, + // One full-width cell; the content is whatever layout the consumer wants. + renderExpandedRow: (row) => ( +
+
+

Contact

+

{row.original.email}

+
+
+

Activity

+

+ {row.original.visits} visits · {row.original.status} +

+
+
+ ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getAllByLabelText("Expand row")[0] as HTMLElement) + await expect(canvas.getByText("Contact")).toBeInTheDocument() + }, +} + +/* ── 21. Inline row edit ─────────────────────────────────────────────────── */ + +/** + * Escape hatch: a column can render its own always-live editor from + * `columnDef.cell` and push values through `table.options.meta.updateData` + * instead of using the edit/save/cancel row flow. + */ +function EditableRoleCell({ getValue, row, column, table }: CellContext) { + const [value, setValue] = useState(String(getValue() ?? "")) + return ( + table.options.meta?.updateData?.(row.id, column.id, value)} + onChange={(e) => setValue(e.target.value)} + size="sm" + value={value} + /> + ) +} + +const inlineEditColumns: ColumnDef[] = [ + { + accessorKey: "firstName", + header: "First name", + meta: { type: "string", editable: true, required: true }, + }, + { + accessorKey: "lastName", + header: "Last name", + meta: { type: "string", editable: true, required: true }, + }, + { + accessorKey: "role", + header: "Role", + meta: { type: "enum", editable: true, options: ROLE_OPTIONS }, + }, + { + accessorKey: "age", + header: "Age", + meta: { type: "int", editable: true, align: "end", width: 100 }, + }, + { + accessorKey: "email", + header: "Email", + meta: { type: "string" }, + }, +] + +export const InlineEdit: Story = { + args: { + columns: inlineEditColumns, + data: people, + enableInlineEdit: true, + getRowLabel: (row) => row.original.firstName, + onEditStart: fn(), + onEditCommit: fn(), + onEditCancel: fn(), + }, + render: (args) => { + const [rows, setRows] = useState(people) + return ( + { + setRows((current) => + current.map((person) => + person.id === details.row.id + ? ({ ...person, ...details.draft } as Person) + : person + ) + ) + args.onEditCommit?.(details) + }} + /> + ) + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + + // The table starts in its normal, read-only state — no editors rendered. + await expect(canvas.queryByLabelText("Edit First name")).toBeNull() + + await userEvent.click(canvas.getByLabelText("Edit Ada")) + await expect(args.onEditStart).toHaveBeenCalled() + + // Every editable cell of that row swaps to a type-driven editor. + const firstName = canvas.getByLabelText("Edit First name") + await expect(canvas.getByLabelText("Edit Age")).toBeInTheDocument() + // The non-editable column keeps rendering its value. + await expect(canvas.getByText("ada@calc.io")).toBeInTheDocument() + + await userEvent.clear(firstName) + await userEvent.type(firstName, "Augusta") + await userEvent.click(canvas.getByLabelText("Save row")) + + await expect(args.onEditCommit).toHaveBeenCalled() + await expect(canvas.getByText("Augusta")).toBeInTheDocument() + await expect(canvas.queryByLabelText("Edit First name")).toBeNull() + }, +} + +/* Cancelling restores the original values and leaves edit mode. */ +export const InlineEditCancel: Story = { + args: { + columns: inlineEditColumns, + data: people, + enableInlineEdit: true, + getRowLabel: (row) => row.original.firstName, + onEditCancel: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Edit Ada")) + + const firstName = canvas.getByLabelText("Edit First name") + await userEvent.clear(firstName) + await userEvent.type(firstName, "Discarded") + await userEvent.click(canvas.getByLabelText("Cancel edit")) + + await expect(args.onEditCancel).toHaveBeenCalledWith( + expect.objectContaining({ dirty: true }) + ) + await expect(canvas.getByText("Ada")).toBeInTheDocument() + await expect(canvas.queryByText("Discarded")).toBeNull() + }, +} + +/* Always-live cell editor via `meta.updateData`, without the row edit flow. */ +export const InlineEditCustomCell: Story = { + args: { + columns: [ + { accessorKey: "firstName", header: "First name" }, + { accessorKey: "lastName", header: "Last name" }, + { + accessorKey: "role", + header: "Role", + meta: { editable: true }, + cell: (ctx) => , + }, + ], + data: people, + onCellEditCommit: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + const input = canvas.getByLabelText("Edit role for Ada") + await userEvent.clear(input) + await userEvent.type(input, "Owner") + await userEvent.tab() + await expect(args.onCellEditCommit).toHaveBeenCalled() + }, +} + +/* ── 22. Pagination ──────────────────────────────────────────────────────── */ + +export const Pagination: Story = { + args: { + columns, + data: bigData, + enablePagination: true, + pageSizeOptions: [5, 10, 25], + onPaginationChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await expect(canvas.getByText(/of\s+500/i)).toBeInTheDocument() + // With 500 rows and a page size of 5, page 2 always exists — hard-assert it + // so a broken pager fails the story instead of silently skipping. + // Pager items render as without href, so they expose no `link` role; + // scope to the pagination widget and match the page number as text. + const pager = canvasElement.querySelector( + '[data-scope="pagination"]' + ) as HTMLElement + await userEvent.click(within(pager).getByText("2")) + await expect(args.onPaginationChange).toHaveBeenCalled() + }, +} + +/* ── 23. Typed columns: auto-rendered filter controls per column type ────── */ + +type Employee = { + id: string + name: string + department: string + skills: string[] + active: boolean + salary: number + startDate: string + shiftStart: string +} + +const DEPARTMENTS: DataTableOption[] = [ + { label: "Engineering", value: "engineering" }, + { label: "Design", value: "design" }, + { label: "Sales", value: "sales" }, +] + +const SKILLS: DataTableOption[] = [ + { label: "React", value: "react" }, + { label: "Node", value: "node" }, + { label: "Figma", value: "figma" }, + { label: "SQL", value: "sql" }, +] + +const employees: Employee[] = [ + { id: "e1", name: "Ada Lovelace", department: "engineering", skills: ["react", "node"], active: true, salary: 5200, startDate: "2021-03-01", shiftStart: "08:00" }, + { id: "e2", name: "Grace Hopper", department: "engineering", skills: ["node", "sql"], active: false, salary: 6100, startDate: "2019-09-15", shiftStart: "09:30" }, + { id: "e3", name: "Katherine Johnson", department: "design", skills: ["figma"], active: true, salary: 4800, startDate: "2022-01-10", shiftStart: "07:00" }, + { id: "e4", name: "Margaret Hamilton", department: "sales", skills: ["sql"], active: true, salary: 4300, startDate: "2020-06-20", shiftStart: "10:00" }, + { id: "e5", name: "Barbara Liskov", department: "design", skills: ["figma", "react"], active: false, salary: 5900, startDate: "2018-11-05", shiftStart: "08:30" }, +] + +const typedColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + filterFn: "typed", + meta: { type: "string", editable: true, required: true }, + }, + { + accessorKey: "department", + header: "Department", + filterFn: "typed", + meta: { type: "enum", options: DEPARTMENTS, editable: true, required: true }, + cell: (info) => + DEPARTMENTS.find((d) => d.value === info.getValue())?.label, + }, + { + accessorKey: "skills", + header: "Skills", + filterFn: "typed", + meta: { type: "multiEnum", options: SKILLS, editable: true }, + cell: (info) => info.getValue().join(", "), + }, + { + accessorKey: "active", + header: "Active", + filterFn: "typed", + meta: { type: "boolean", editable: true }, + cell: (info) => (info.getValue() ? "Yes" : "No"), + }, + { + accessorKey: "salary", + header: "Salary", + filterFn: "typed", + meta: { + type: "number", + align: "end", + editable: true, + validate: (v) => (Number(v) < 0 ? "Must be positive" : undefined), + }, + }, + { + accessorKey: "startDate", + header: "Start date", + filterFn: "typed", + meta: { type: "date", editable: true }, + }, + { + accessorKey: "shiftStart", + header: "Shift", + filterFn: "typed", + meta: { type: "time", editable: true }, + }, +] + +/** The Employee stories are typed against their own row shape rather than + * being cast through `Person`, so a `meta` that does not match the column-meta + * contract this component introduces fails the build. */ +type EmployeeStory = StoryObj> + +const typedMeta = { + columns: typedColumns, + data: employees, +} satisfies Partial> + +export const TypedColumnFilters: EmployeeStory = { + args: { + ...typedMeta, + enableColumnFilters: true, + enableSorting: true, + onColumnFiltersChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await expect(canvas.getByLabelText("Filter Active")).toBeInTheDocument() + await expect(canvas.getByLabelText("Filter Shift from")).toBeInTheDocument() + await userEvent.type(canvas.getByLabelText("Filter Start date"), "2021-01-01") + await expect(args.onColumnFiltersChange).toHaveBeenCalled() + }, +} + +/* ── 23b. Deprecated `filterVariant`, no explicit filterFn ────────────────── */ + +/** + * A column declaring only the deprecated `meta.filterVariant` — no + * `meta.type`, no explicit `filterFn` — is what `applyColumnDefaults` and + * `resolveColumnType` exist to keep working: the default `filterFn: "typed"` + * and the number control both have to resolve the same type from + * `filterVariant` alone, or the control writes an operator object the + * matcher reads as plain text. + */ +export const DeprecatedFilterVariant: Story = { + args: { + columns: [ + { accessorKey: "firstName", header: "First name" }, + { + accessorKey: "age", + header: "Age", + meta: { align: "end", filterVariant: "number" }, + }, + ], + data: people, + enableColumnFilters: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const ageInput = canvas.getByLabelText("Filter value for Age") + await expect(ageInput).toHaveAttribute("type", "number") + // Default operator is "equals"; no fixture row is age 4. Correct numeric + // matching shows the empty state. Misread as `meta.type: "string"`, the + // `{ operator: "equals", value: "4" }` object reaches `matchText`, whose + // switch has no "equals"-on-an-object case and falls to + // `String(cell).includes("4")` — five ages (41/44/45/47/48) contain "4" + // as a substring, so a still-populated table means the bug is back. + await userEvent.type(ageInput, "4") + await expect(canvas.getByText("No records")).toBeInTheDocument() + }, +} + +/* ── 24. Custom filter template for a non-standard column type ───────────── */ + +export const CustomFilterTemplate: EmployeeStory = { + args: { + ...typedMeta, + columns: [ + ...typedColumns.slice(0, 2), + { + accessorKey: "salary", + header: "Salary band", + filterFn: "typed", + meta: { + type: "custom", + renderFilter: ({ + setValue, + disabled, + size, + }: DataTableFilterContext) => ( + + ), + }, + }, + ] satisfies ColumnDef[], + enableColumnFilters: true, + onColumnFiltersChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByLabelText("Salary band")).toBeInTheDocument() + }, +} + +/* ── 25. Inline edit driven by column type + sticky actions ──────────────── */ + +export const InlineEditByColumnType: EmployeeStory = { + args: { + ...typedMeta, + enableInlineEdit: true, + stickyActions: true, + onEditStart: fn(), + onEditCommit: fn(), + onEditCancel: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Edit row 0")) + await expect(args.onEditStart).toHaveBeenCalled() + // Type-driven editors replace the cells of the edited row only. + await expect(canvas.getByLabelText("Edit Name")).toBeInTheDocument() + await expect(canvas.getByLabelText("Edit Start date")).toBeInTheDocument() + await userEvent.click(canvas.getByLabelText("Save row")) + await expect(args.onEditCommit).toHaveBeenCalled() + }, +} + +/* ── 26. Edit mode locks filtering, selection and sorting ────────────────── */ + +export const EditModeLocksInteractions: EmployeeStory = { + args: { + ...typedMeta, + enableInlineEdit: true, + enableColumnFilters: true, + enableRowSelection: true, + enableSorting: true, + onInteractionBlocked: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Edit row 0")) + // Filter inputs, selection checkboxes and sort buttons are disabled. + await expect(canvas.getByLabelText("Select row 0")).toBeDisabled() + await expect(canvas.getByRole("button", { name: /Salary/i })).toBeDisabled() + // Cancelling releases the lock again. + await userEvent.click(canvas.getByLabelText("Cancel edit")) + await expect(canvas.getByLabelText("Select row 0")).not.toBeDisabled() + await expect(args.onInteractionBlocked).not.toHaveBeenCalled() + }, +} + +/* ── 27. Size propagates to every nested control ─────────────────────────── */ + +export const SizeSynchronised: EmployeeStory = { + args: { + ...typedMeta, + size: "lg", + enableColumnFilters: true, + enableGlobalFilter: true, + enableInlineEdit: true, + enablePagination: true, + pageSizeOptions: [2, 5], + }, +} + +/* ── 28. Single-row selection ────────────────────────────────────────────── */ + +export const SingleRowSelection: Story = { + args: { + ...base, + enableRowSelection: true, + selectionMode: "single", + onRowSelectionChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Select row 0")) + await expect(canvas.getByLabelText("Select row 0")).toBeChecked() + // Selecting another row replaces the first instead of adding to it. + await userEvent.click(canvas.getByLabelText("Select row 1")) + await expect(canvas.getByLabelText("Select row 1")).toBeChecked() + await expect(canvas.getByLabelText("Select row 0")).not.toBeChecked() + }, +} + +/* ── 29. Capped selection (max 2 rows) ───────────────────────────────────── */ + +export const MaxTwoRowsSelectable: Story = { + args: { + ...base, + enableRowSelection: true, + maxSelectedRows: 2, + onSelectionLimitReached: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Select row 0")) + await userEvent.click(canvas.getByLabelText("Select row 1")) + await expect(args.onSelectionLimitReached).toHaveBeenCalled() + // Unselected rows lock once the cap is hit… + await expect(canvas.getByLabelText("Select row 2")).toBeDisabled() + // …while the selected ones can still be released. + await expect(canvas.getByLabelText("Select row 0")).not.toBeDisabled() + await userEvent.click(canvas.getByLabelText("Select row 0")) + await expect(canvas.getByLabelText("Select row 2")).not.toBeDisabled() + }, +} + +/* ── 30. Custom selectability rule ───────────────────────────────────────── */ + +export const ConditionalRowSelection: Story = { + args: { + ...base, + enableRowSelection: true, + canSelectRow: (row) => row.original.status === "active", + onRowSelectionChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // Row 0 is active, row 2 is invited. + await expect(canvas.getByLabelText("Select row 0")).not.toBeDisabled() + await expect(canvas.getByLabelText("Select row 2")).toBeDisabled() + }, +} + +/* ── 31. Per-row action permissions ──────────────────────────────────────── */ + +const onArchive = fn() +const onDelete = fn() + +export const PerRowActionPermissions: Story = { + args: { + ...base, + enableInlineEdit: true, + // Suspended records are read-only for the current user. + canEditRow: (row) => row.original.status !== "suspended", + rowActions: [ + { + id: "archive", + label: "Archive", + icon: "icon-[mdi--archive]", + // Already-suspended rows cannot be archived again. + disabled: (row) => row.original.status === "suspended", + onAction: (row) => onArchive(row.original.id), + }, + { + id: "delete", + label: "Delete", + icon: "token-icon-trash", + tone: "danger", + // Admins may not be deleted at all — hide rather than disable. + hidden: (row) => row.original.role === "Admin", + onAction: (row) => onDelete(row.original.id), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const rows = canvas.getAllByRole("row") + // Row 1 (Ada, Admin, active): editable, archivable, no delete action. + const adaRow = within(rows[1] as HTMLElement) + await expect(adaRow.getByLabelText("Edit row 0")).not.toBeDisabled() + await expect(adaRow.getByLabelText("Archive")).not.toBeDisabled() + await expect(adaRow.queryByLabelText("Delete")).not.toBeInTheDocument() + // Row 5 (Margaret, Viewer, suspended): edit and archive both blocked. + const suspendedRow = within(rows[5] as HTMLElement) + await expect(suspendedRow.getByLabelText("Edit row 4")).toBeDisabled() + await expect(suspendedRow.getByLabelText("Archive")).toBeDisabled() + await expect(suspendedRow.getByLabelText("Delete")).toBeInTheDocument() + }, +} + +/* ── 32. Loading skeletons ───────────────────────────────────────────────── */ + +export const LoadingSkeletons: Story = { + args: { ...base, loading: true, loadingRowCount: 6, enableSorting: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // Header still renders so the layout does not jump once data arrives. + await expect(canvas.getByText("First name")).toBeInTheDocument() + await expect(canvas.queryByText("Lovelace")).not.toBeInTheDocument() + }, +} + +/* ── 33. Loading more (infinite scroll footer) ───────────────────────────── */ + +export const LoadingMore: Story = { + args: { + columns, + data: people, + loadingMore: true, + maxHeight: "320px", + onReachEnd: fn(), + }, +} + +/* ── 34. Drag affordances ────────────────────────────────────────────────── */ + +export const DragAffordances: Story = { + args: { + ...base, + enableColumnReorder: true, + enableRowReorder: true, + onColumnReorder: fn(), + onRowReorder: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // Handles name what they move, so 50 rows aren't 50 identical buttons. + await expect( + canvas.getByLabelText("Drag to reorder First name") + ).toBeInTheDocument() + await expect( + canvas.getByLabelText("Drag to reorder row 0") + ).toBeInTheDocument() + }, +} + +/* ── 35. Accessibility semantics ─────────────────────────────────────────── */ + +export const AccessibleSemantics: Story = { + args: { + ...base, + enableSorting: true, + enableRowSelection: true, + enableExpanding: true, + getSubRows: () => undefined, + getRowLabel: (row) => `${row.original.firstName} ${row.original.lastName}`, + onRowClick: fn(), + onSortingChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + // Sortable headers expose their sort state to assistive tech. + const ageHeader = canvas.getByRole("columnheader", { name: /Age/i }) + await expect(ageHeader).toHaveAttribute("aria-sort", "none") + await userEvent.click(canvas.getByRole("button", { name: /Age/i })) + // TanStack sorts numeric columns descending on the first click. + await expect(ageHeader).toHaveAttribute("aria-sort", "descending") + // Selection checkboxes are labelled by row content, not the opaque row id. + await expect(canvas.getByLabelText("Select Ada Lovelace")).toBeInTheDocument() + // Clickable rows are reachable and activatable from the keyboard. + const row = canvas.getAllByRole("row")[1] as HTMLElement + await expect(row).toHaveAttribute("tabindex", "0") + row.focus() + await userEvent.keyboard("{Enter}") + await expect(args.onRowClick).toHaveBeenCalled() + }, +} diff --git a/libs/ui/stories/organisms/table.stories.tsx b/libs/ui/stories/organisms/table.stories.tsx index d67c4ea583..5ee4523112 100644 --- a/libs/ui/stories/organisms/table.stories.tsx +++ b/libs/ui/stories/organisms/table.stories.tsx @@ -743,3 +743,40 @@ export const WithSelectionAndActions: Story = { ) }, } + +/** + * Per-cell horizontal alignment via `data-align` (`start | center | end`). + * Unlike `numeric` — which says "this value is a number" — `data-align` is a + * pure presentation choice, so an icon or boolean column can be centred without + * claiming to be numeric. + */ +export const Alignment: Story = { + args: { + variant: 'outline', + size: 'md', + }, + render: (args) => ( +
`s are raw elements and so never pick up the `sticky top-0 + * z-10` the `Table` organism puts on real header cells. Without a level of + * their own they sat at `z-index: auto` while pinned *body* cells carry + * `pinnedCell`, so scrolling painted a frozen column straight over the + * sticky filter row. Above every body level, below the pinned header ones. + */ + stickyHeaderCell: 10, + pinnedHeaderCell: 20, + stickyActionsHeaderCell: 21, +} as const + +export function getPinningStyles( + column: Column, + kind: "header" | "body" = "body" +): CSSProperties { + const pinned = column.getIsPinned() + if (!pinned) { + return {} + } + return { + position: "sticky", + // Logical, not physical: v9 pins to `start`/`end` rather than left/right, so + // in an RTL table a start-pinned column has to freeze against the right + // edge. `insetInline*` is what the TanStack migration guide recommends for + // exactly this, and it matches the logical Tailwind utilities the rest of + // the library uses. + insetInlineStart: + pinned === "start" ? `${column.getStart("start")}px` : undefined, + insetInlineEnd: + pinned === "end" ? `${column.getAfter("end")}px` : undefined, + zIndex: + kind === "header" + ? DATA_TABLE_Z.pinnedHeaderCell + : DATA_TABLE_Z.pinnedCell, + } +} + +/** True when this column is the last of the start-pinned group (edge shadow). */ +export function isLastStartPinned( + column: Column +): boolean { + return ( + column.getIsPinned() === "start" && column.getIsLastColumn("start") === true + ) +} + +/** True when this column is the first of the end-pinned group (edge shadow). */ +export function isFirstEndPinned( + column: Column +): boolean { + return ( + column.getIsPinned() === "end" && column.getIsFirstColumn("end") === true + ) +} + +/* ── colSpan / rowSpan ──────────────────────────────────────────────────── + * TanStack has no body-cell spanning model, so DataTable exposes a `getCellSpan` + * hook returning this shape. `hidden` drops a cell swallowed by a preceding + * span. */ +export type DataTableCellSpan = { + colSpan?: number + rowSpan?: number + hidden?: boolean +} + +export type DataTableGetCellSpan = ( + cell: Cell, + context: { row: Row; rows: Row[]; rowIndex: number } +) => DataTableCellSpan | undefined + +/* Convenience re-export so consumers can type the instance from one import. */ +export type DataTableInstance = TanstackTable + +/* ── TanStack Table v9 feature set ──────────────────────────────────────── + * v9 no longer ships every feature with the table: each one has to be listed + * here, and the row-model factories that used to be per-instance options + * (`getSortedRowModel()` and friends) are registered alongside them. The set is + * built once at module scope — TanStack requires it to be referentially stable, + * and it is what every `Column`/`Row`/`Cell` type below is parameterised by. + * + * Features stay registered even when the matching `enable*` prop is off: with + * no state to act on, each row model short-circuits to the model before it, so + * this costs nothing beyond the import. */ +export const dataTableFeatures = tableFeatures({ + columnFilteringFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + globalFilteringFeature, + rowExpandingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + expandedRowModel: createExpandedRowModel(), + filteredRowModel: createFilteredRowModel(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + /* Registered individually rather than by spreading the `filterFns`/`sortFns` + * registries: those exports are deprecated in v9 precisely because spreading + * them pins every built-in into the bundle, and this library ships unbundled + * so the reference would survive the consumer's tree-shake. + * + * `includesString` backs the toolbar's global filter. `conditional` and + * `typed` are DataTable's own; a column wanting some other comparator passes + * the function itself, which needs no registration. */ + filterFns: { + conditional: conditionalFilterFn, + includesString: filterFn_includesString, + typed: typedFilterFn, + }, + /* These three names are not decorative: `sortFn: "auto"` samples the first + * rows and looks up `datetime`, `alphanumeric` or `text` by name, falling + * back to a basic comparator (and warning in dev) when one is missing. */ + sortFns: { + alphanumeric: sortFn_alphanumeric, + datetime: sortFn_datetime, + text: sortFn_text, + }, +}) diff --git a/libs/ui/src/organisms/data-table.tsx b/libs/ui/src/organisms/data-table.tsx new file mode 100644 index 0000000000..8d8c93280c --- /dev/null +++ b/libs/ui/src/organisms/data-table.tsx @@ -0,0 +1,3759 @@ +/** + * DataTable — @techsio/ui-kit organism. + * + * @component DataTable + * @componentVersion v1.0.0 + * @skill data-table-usage + * @changelog libs/ui/stories/changelog/changelog.stories.tsx + * + * Versioning is enforced at commit by scripts/check-skill-sync.mjs: @componentVersion must match + * the data-table-usage skill's component_version and a changelog entry. Bump all three together. + * + * Headless data grid: a TanStack Table (`@tanstack/react-table`) controller that + * renders into the presentational `Table` organism, so every cell/row/header + * inherits the `--color-table-*` / `--padding-table-cell-*` tokens. Optional + * virtualization (`@tanstack/react-virtual`) and drag reorder (`@dnd-kit`) load + * only when their feature flags are set. Every interactive feature exposes a + * callback so Storybook interaction tests can assert behaviour. + */ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + restrictToHorizontalAxis, + restrictToVerticalAxis, +} from "@dnd-kit/modifiers" +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + type ColumnFiltersState, + type ColumnPinningState, + type ColumnSizingState, + type ColumnVisibilityState, + type ExpandedState, + flexRender, + type OnChangeFn, + type PaginationState, + type RowData, + type RowSelectionState, + type SortingState, + useTable, +} from "@tanstack/react-table" +import { useVirtualizer } from "@tanstack/react-virtual" +import { + type CSSProperties, + createContext, + Fragment, + type ReactNode, + type Ref, + type RefObject, + type UIEvent, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { ActionIcon } from "../atoms/action-icon" +import { Button, type ButtonProps } from "../atoms/button" +import { Checkbox } from "../atoms/checkbox" +import { Icon, type IconType } from "../atoms/icon" +import { Skeleton } from "../atoms/skeleton" +import { Menu, type MenuItem } from "../molecules/menu" +import { Pagination, type PaginationProps } from "../molecules/pagination" +import { SearchForm } from "../molecules/search-form" +import type { SelectItem } from "../molecules/select" +import { tv } from "../utils" +import { + columnLabel, + type DataTableColumnType, + type DataTableControlSize, + type DataTableEditorContext, + type DataTableEditorRenderer, + type DataTableFilterContext, + type DataTableFilterRenderer, + type DataTableFilterValue, + DEFAULT_EDITOR_RENDERERS, + DEFAULT_FILTER_RENDERERS, + FieldSelect, + isBlank, +} from "./data-table.fields" +import { + applyColumnDefaults, + type Cell, + type Column, + type ColumnDef, + DATA_TABLE_Z, + type DataTableGetCellSpan, + dataTableFeatures, + getColumnSizeStyles, + getPinningStyles, + type Header, + isFirstEndPinned, + isLastStartPinned, + type Row, + resolveColumnType, + type TanstackTable, +} from "./data-table.helpers" +import { Table } from "./table" + +// Re-exported so DataTable's own documented filterRenderers/editorRenderers/ +// renderHeaderFilter/renderEditor props are typeable without reaching into +// the internal data-table.fields module. +export type { + DataTableColumnType, + DataTableControlSize, + DataTableEditorContext, + DataTableEditorRenderer, + DataTableFilterContext, + DataTableFilterRenderer, + DataTableOption, +} from "./data-table.fields" +export type { + Cell, + CellContext, + Column, + ColumnDef, + DataTableCellSpan, + DataTableColumnWidth, + DataTableConditionalFilterValue, + DataTableFeatures, + DataTableFilterOperator, + DataTableGetCellSpan, + DataTableInstance, + Header, + Row, + TanstackTable, +} from "./data-table.helpers" +// biome-ignore lint/performance/noBarrelFile: DataTable's public API intentionally re-exports the conditional-filter helpers it is designed to be used with +export { conditionalFilterFn } from "./data-table.helpers" + +/** + * PROTOTYPE styling: reuses the existing `Table` component tokens + * (`--color-table-*`, `--border-table-width`) plus semantic tokens + * (`--color-fg-*`, `--spacing-*`). Once the MVP look is signed off, these + * semantic references are lifted into `--color-data-table-*` component tokens + * and mirrored into Figma via the figma-token-binding skill. + */ +const dataTableVariants = tv({ + slots: { + wrapper: [ + "flex w-full flex-col overflow-hidden rounded-table", + "border-(length:--border-table-width) border-table-border", + ], + toolbar: [ + "flex items-center justify-between gap-200", + "bg-table-header-bg text-table-header-fg", + "px-300 py-200", + "border-b-(length:--border-table-width) border-table-border", + ], + /* The search takes the remaining width; actions keep their intrinsic size + * and stay pinned to the trailing edge. */ + toolbarSearch: ["min-w-0 flex-1"], + toolbarActions: ["flex shrink-0 items-center gap-200"], + scroll: ["relative w-full overflow-auto"], + headerLabel: ["inline-flex items-center gap-100 whitespace-nowrap"], + sortButton: [ + "inline-flex items-center gap-100 whitespace-nowrap", + "cursor-pointer select-none bg-transparent text-left", + "data-[disabled=true]:cursor-default", + ], + sortIcon: [ + "text-fg-secondary", + "data-[active=true]:text-fg-accent-primary", + ], + dragHandle: [ + "inline-flex cursor-grab items-center rounded-table text-fg-secondary", + "opacity-0 transition-opacity duration-200 motion-reduce:transition-none", + "hover:bg-table-row-bg-hover hover:opacity-100 focus-visible:opacity-100", + "group-hover/header:opacity-100 group-hover/row:opacity-100", + "group-focus-within/header:opacity-100 group-focus-within/row:opacity-100", + "group-data-dragging/header:opacity-100 group-data-dragging/row:opacity-100", + "active:cursor-grabbing", + ], + resizeHandle: [ + "absolute end-0 top-0 h-full w-100 cursor-col-resize touch-none select-none", + "opacity-0 transition-opacity duration-200 motion-reduce:transition-none", + "bg-table-border hover:opacity-100 focus-visible:opacity-100", + "group-hover/header:opacity-100", + ], + filterRow: ["bg-table-header-bg"], + filterCell: [ + "px-200 py-100", + "min-w-fit", + "border-b-(length:--border-table-width) border-table-border", + ], + filterControl: ["flex flex-wrap items-center gap-100"], + editorControl: ["flex flex-col gap-50"], + // `-fg`, not the bare semantic: `--color-danger` is the fill/surface red, + // while AGENTS.md pins status *text* to `--color--fg`, which is + // the contrast-checked foreground. The bare token rendered the validation + // message at surface red on the table background. + editorError: ["text-danger-fg text-table-sm"], + actionsCell: ["flex items-center justify-end gap-100"], + empty: [ + "flex flex-col items-center justify-center gap-200", + "p-700 text-center text-fg-secondary", + ], + /* Same background as the toolbar so the header and footer frame the table + * as one pair; without it the bar falls through to --color-table-bg. */ + paginationBar: [ + "flex flex-wrap items-center justify-between gap-300", + "bg-table-header-bg text-table-header-fg", + "px-300 py-200", + "border-t-(length:--border-table-width) border-table-border", + ], + detailBox: ["w-full p-300"], + paginationInfo: ["whitespace-nowrap text-table-sm"], + paginationControls: ["flex items-center gap-300"], + }, + variants: { + /** + * `variant="outline"` draws its border on the wrapper rather than on the + * ``, so the toolbar and pagination bar sit inside the same rounded + * card instead of the table alone being outlined. + */ + outlined: { + true: { wrapper: "shadow-table-outline" }, + }, + }, +}) + +/** + * A custom toolbar action, rendered as a `Button` at the table's `size`. + * Accepts the full Button API, so icon-only, themed and loading actions all + * work; `label` is a convenience alias for `children`. + */ +export type DataTableToolbarAction = Omit & { + /** + * Stable React key; falls back to the array index. The fallback is fine for + * a fixed list, but a `toolbarActions` array that adds, removes or + * reorders entries based on state (e.g. permission changes) needs a real + * `id` — an index-keyed action can otherwise inherit a sibling's DOM node, + * along with its transient disabled/hover/focus state, across a re-render. + */ + id?: string + /** Button text. Omit for icon-only actions, but keep an `aria-label`. */ + label?: ReactNode +} + +/** + * Recommended ceiling for `toolbarActions`. Exceeding it warns rather than + * throws — a fourth action still renders, it just crowds the toolbar and is + * usually a sign the extra actions belong in a menu. + */ +export const DATA_TABLE_MAX_TOOLBAR_ACTIONS = 3 + +/* ── Controllable state ──────────────────────────────────────────────────── */ + +/** + * Controlled/uncontrolled state pair with a change callback. + * + * The updater is resolved synchronously against a ref mirroring the latest + * value rather than inside a `setState` updater, for two reasons: React may + * invoke a `setState` updater more than once (Strict Mode does in development), + * which would fire `callback` twice per interaction; and the ref is written + * before `setInternal`, so several updates batched in one tick still compose + * instead of the last one overwriting the rest. + * + * Callers may therefore assume the updater they pass runs exactly once, and + * synchronously. + */ +function useControllable( + controlled: S | undefined, + initial: S, + callback?: (next: S) => void +): [S, OnChangeFn] { + const [internal, setInternal] = useState(initial) + const isControlled = controlled !== undefined + const value = isControlled ? (controlled as S) : internal + const latest = useRef(value) + latest.current = value + + const onChange: OnChangeFn = (updater) => { + const next = + typeof updater === "function" + ? (updater as (o: S) => S)(latest.current) + : updater + latest.current = next + if (!isControlled) { + setInternal(next) + } + callback?.(next) + } + return [value, onChange] +} + +/* ── Small single-value Select wrapper (page size, filter operator/value) ─── */ + +/** Which edge of the hovered sortable should show the drop indicator. */ +function dropEdge( + sortable: { + isOver: boolean + isDragging: boolean + activeIndex: number + overIndex: number + }, + edges: { after: A; before: B } +) { + if (!sortable.isOver || sortable.isDragging) { + return + } + return sortable.activeIndex < sortable.overIndex ? edges.after : edges.before +} + +/* ── Sortable header cell (column reorder) ───────────────────────────────── */ + +function SortableHeaderContent({ + columnId, + children, +}: { + columnId: string + children: (args: { + setActivatorNodeRef: (node: HTMLElement | null) => void + listeners: Record | undefined + style: CSSProperties + setNodeRef: (node: HTMLElement | null) => void + isDragging: boolean + dropSide?: "start" | "end" + attributes: Record + }) => ReactNode +}) { + // `data.type` tags what is being dragged. Routing by "is this id in the + // column list?" misfired whenever a row id collided with a column id — with + // `getRowId={(r) => r.slug}`, dragging the row keyed "name" reordered the + // columns and never fired `onRowReorder`. + const sortable = useSortable({ id: columnId, data: { type: "column" } }) + const style: CSSProperties = { + transform: CSS.Translate.toString(sortable.transform), + transition: sortable.transition, + opacity: sortable.isDragging ? 0.4 : 1, + } + const dropSide = dropEdge( + { + isOver: sortable.isOver, + isDragging: sortable.isDragging, + activeIndex: sortable.activeIndex ?? 0, + overIndex: sortable.overIndex ?? 0, + }, + { after: "end" as const, before: "start" as const } + ) + return ( + <> + {children({ + setActivatorNodeRef: sortable.setActivatorNodeRef, + listeners: sortable.listeners, + style, + setNodeRef: sortable.setNodeRef, + isDragging: sortable.isDragging, + dropSide, + attributes: sortable.attributes as unknown as Record, + })} + + ) +} + +/* ── Sortable body row (row reorder) ─────────────────────────────────────── */ + +function SortableRow({ + row, + enabled, + children, +}: { + row: Row + enabled: boolean + children: (args: { + setNodeRef: (node: HTMLElement | null) => void + style: CSSProperties + dragHandleProps: Record + isDragging: boolean + dropSide?: "top" | "bottom" + }) => ReactNode +}) { + const sortable = useSortable({ + id: row.id, + disabled: !enabled, + data: { type: "row" }, + }) + const style: CSSProperties = { + transform: CSS.Transform.toString(sortable.transform), + transition: sortable.transition, + opacity: sortable.isDragging ? 0.4 : 1, + position: "relative", + zIndex: sortable.isDragging ? 1 : undefined, + } + const dropSide = dropEdge( + { + isOver: sortable.isOver, + isDragging: sortable.isDragging, + activeIndex: sortable.activeIndex ?? 0, + overIndex: sortable.overIndex ?? 0, + }, + { after: "bottom" as const, before: "top" as const } + ) + const dragHandleProps = { + ref: sortable.setActivatorNodeRef, + ...sortable.attributes, + ...sortable.listeners, + } + return ( + <> + {children({ + setNodeRef: sortable.setNodeRef, + style, + dragHandleProps, + isDragging: sortable.isDragging, + dropSide, + })} + + ) +} + +/** + * Declarative row action. `hidden` and `disabled` are evaluated per row, so + * availability can follow the row's id, ownership or the current user's + * permissions. + */ +export type DataTableRowAction = { + id: string + label: string + icon: IconType + tone?: "neutral" | "danger" + /** Omit the action entirely for this row. */ + hidden?: (row: Row) => boolean + /** Render the action but block it for this row. */ + disabled?: (row: Row) => boolean + onAction: (row: Row) => void +} + +/** + * Interactions that report through `onInteractionBlocked` while a row is being + * edited inline. + * + * This is exactly the set `blocked()` is called with. Sorting, selection and + * both reorder flavours are absent on purpose: sorting and selection controls + * are natively `disabled` during an edit and reorder simply stops being + * draggable, so in every one of those cases nothing reaches a handler and + * nothing could ever report. Making any of them reportable means making the + * control clickable-but-inert first. + */ +export type DataTableBlockedAction = + | "globalFilter" + | "paginate" + | "columnVisibility" + | "rowClick" + | "expand" + +/* ── Context for composable sub-components ────────────────────────────────── */ + +type DataTableContextValue = { + table: TanstackTable + pageSizeOptions: number[] + translations: Required + /** True while an inline edit locks table-level controls. */ + locked: boolean + /** Reports a blocked interaction; returns true when it must not proceed. */ + blocked: (action: DataTableBlockedAction) => boolean + /** Control size shared by every nested form control. */ + size: DataTableControlSize + paginationProps?: DataTableProps["paginationProps"] + toolbarActions?: DataTableToolbarAction[] +} + +const DataTableContext = createContext | null>( + null +) + +function useDataTableContext() { + const ctx = useContext(DataTableContext) as DataTableContextValue | null + if (!ctx) { + throw new Error("DataTable sub-components must be used within DataTable") + } + return ctx +} + +/* ── Props ───────────────────────────────────────────────────────────────── */ + +export type DataTableTranslations = { + searchPlaceholder?: string + /** Accessible name of the global search input. */ + searchLabel?: string + /** Accessible name of the search field's clear button. */ + clearSearchLabel?: string + /** Accessible name of the search submit button joined to the input. */ + searchButtonLabel?: string + columnsLabel?: string + emptyTitle?: string + emptyDescription?: string + pageSizeLabel?: string + actionsLabel?: string + filtersLabel?: string + selectAllLabel?: string + loadingLabel?: string + /** Override the filter condition labels, keyed by operator. */ + operatorLabels?: Partial> + editingLabel?: string + rangeLabel?: (info: { start: number; end: number; total: number }) => string + /** Inline-edit validation error for a blank `meta.required` field. */ + requiredLabel?: string +} + +const DEFAULT_TRANSLATIONS: Required = { + searchPlaceholder: "Search…", + searchLabel: "Search", + clearSearchLabel: "Clear search", + searchButtonLabel: "Submit search", + columnsLabel: "Column settings", + emptyTitle: "No records", + emptyDescription: "There is no data to display.", + pageSizeLabel: "Rows per page", + actionsLabel: "Actions", + filtersLabel: "Column filters", + selectAllLabel: "Select all rows", + loadingLabel: "Loading data", + operatorLabels: {}, + editingLabel: "Editing a row — other table controls are locked", + rangeLabel: ({ start, end, total }) => `${start}–${end} of ${total}`, + requiredLabel: "Required", +} + +export type DataTableProps = { + data: T[] + columns: ColumnDef[] + getRowId?: (row: T, index: number) => string + /** Stable id for the wrapper element; falls back to a generated one. */ + id?: string + className?: string + ref?: Ref + + /* Presentation (passed through to the Table organism) */ + variant?: "line" | "outline" | "striped" + /** + * Zebra-stripe body rows. Independent of `variant`, so striping composes + * with `variant="outline"`. Equivalent to `variant="striped"` — DataTable + * routes both through this prop's own row-index-based coloring rather than + * `Table`'s `odd:`/`even:` implementation, which drifts under + * `enableVirtualization` (a row's DOM sibling position, not its logical + * index, changes as the windowed slice scrolls). + */ + striped?: boolean + /** + * Tint rows by nesting depth (`getSubRows` tree data or `renderExpandedRow` + * detail rows) — same idea as `striped`, but keyed on `row.depth` instead of + * row index, so a child row reads as "inside" its parent instead of just + * being the next row in a flat zebra pattern. Composes with `striped`: the + * depth tint and the odd/even stripe are different CSS properties and stack + * rather than fight for the same one. + */ + tintNestedRows?: boolean + size?: "sm" | "md" | "lg" + stickyHeader?: boolean + interactive?: boolean + showColumnBorder?: boolean + caption?: ReactNode + /** Hide the column header row(s) entirely (headerless / borderless layouts). */ + hideHeader?: boolean + /** Height of the scroll container; enables sticky header / infinite scroll. */ + maxHeight?: string + + /* Feature flags */ + enableSorting?: boolean + enableGlobalFilter?: boolean + enableColumnFilters?: boolean + enableRowSelection?: boolean + /** + * `"multiple"` (default) lets any number of rows be selected; `"single"` + * replaces the selection so only one row is ever selected. + */ + selectionMode?: "single" | "multiple" + /** + * Hard cap on how many rows can be selected at once. Once reached, unselected + * rows report `getCanSelect() === false` and their checkboxes disable, while + * already-selected rows stay deselectable. + */ + maxSelectedRows?: number + /** + * Per-row selectability predicate, for rules the mode/cap can't express + * (e.g. "only rows with status=active"). Composed with — not instead of — + * `selectionMode` and `maxSelectedRows`. + */ + canSelectRow?: ( + row: Row, + context: { selectedCount: number; isSelected: boolean } + ) => boolean + /** Fired when a selection change reaches `maxSelectedRows`. */ + onSelectionLimitReached?: (details: { + limit: number + selectedCount: number + }) => void + enableColumnVisibility?: boolean + enableColumnPinning?: boolean + enableColumnReorder?: boolean + enableColumnResizing?: boolean + /** + * CSS `table-layout`. Under the default `"auto"` a column's `meta.width` is + * only a hint and long content can still stretch the column; `"fixed"` makes + * declared widths exact and lets the remaining columns share what is left. + */ + tableLayout?: "auto" | "fixed" + enableRowReorder?: boolean + enableExpanding?: boolean + enablePagination?: boolean + enableVirtualization?: boolean + + /* Tree / sub-rows */ + getSubRows?: (row: T) => T[] | undefined + /** + * Whether a row can expand. Defaults to "any row" when `renderExpandedRow` is + * given (master-detail), otherwise to "only rows that have sub-rows". + */ + getRowCanExpand?: (row: Row) => boolean + + /* colSpan / rowSpan */ + getCellSpan?: DataTableGetCellSpan + + /* Controlled state (+ change callbacks; all optional/uncontrolled by default) */ + sorting?: SortingState + onSortingChange?: (state: SortingState) => void + columnFilters?: ColumnFiltersState + onColumnFiltersChange?: (state: ColumnFiltersState) => void + globalFilter?: string + onGlobalFilterChange?: (value: string) => void + rowSelection?: RowSelectionState + onRowSelectionChange?: (state: RowSelectionState) => void + columnVisibility?: ColumnVisibilityState + onColumnVisibilityChange?: (state: ColumnVisibilityState) => void + columnOrder?: string[] + onColumnOrderChange?: (order: string[]) => void + columnPinning?: ColumnPinningState + onColumnPinningChange?: (state: ColumnPinningState) => void + expanded?: ExpandedState + onExpandedChange?: (state: ExpandedState) => void + pagination?: PaginationState + onPaginationChange?: (state: PaginationState) => void + pageSizeOptions?: number[] + + /* Server-side / manual mode */ + manualSorting?: boolean + manualFiltering?: boolean + manualPagination?: boolean + rowCount?: number + pageCount?: number + + /* Callbacks */ + onRowClick?: ( + row: Row, + event: React.MouseEvent + ) => void + onReachEnd?: () => void + onColumnReorder?: (details: { + from: number + to: number + columnId: string + order: string[] + }) => void + onRowReorder?: (details: { + from: number + to: number + rowId: string + data: T[] + }) => void + onCellEditCommit?: (details: { + rowId: string + columnId: string + value: unknown + row: T + }) => void + /** + * Called once on mount with the table instance, for imperative access + * (`table.setPageIndex(…)`, `table.getSelectedRowModel()`, …). + * + * Methods stay live — they close over the core instance — but read + * `table.state` / `table.options` off this object with care: `useTable` + * returns a spread copy rather than the live instance, so the two fields + * captured here are a mount-time snapshot and never update. For live + * state use `renderToolbar`, which re-runs on render, or the instance's + * own `Subscribe`. + */ + onReady?: (table: TanstackTable) => void + + /* ── Inline row editing ──────────────────────────────────────────────── + * Editing a row puts the table into a modal-ish state: the row's editable + * cells swap to type-driven editors and, unless opted out, every interaction + * that could move the row out from under the user (sort, filter, paginate, + * reorder, selection) is locked until commit/cancel. */ + enableInlineEdit?: boolean + /** + * Per-row gate for the built-in edit action. Returning false disables the + * edit affordance and refuses `startEdit` for that row. + */ + canEditRow?: (row: Row) => boolean + /** + * Declarative actions rendered in the actions cell, each able to hide or + * disable itself per row. + */ + rowActions?: DataTableRowAction[] + /** Controlled id of the row being edited (`null` = not editing). */ + editingRowId?: string | null + onEditingRowIdChange?: (rowId: string | null) => void + onEditStart?: (details: { rowId: string; row: T }) => void + onEditChange?: (details: { + rowId: string + columnId: string + value: unknown + draft: Record + }) => void + onEditCommit?: (details: { + rowId: string + draft: Record + row: T + }) => void + onEditCancel?: (details: { rowId: string; dirty: boolean }) => void + onEditValidationError?: (details: { + rowId: string + errors: Record + }) => void + /** + * Lock sorting/filtering/pagination/selection/reorder while a row is being + * edited (default `true`). Turning this off is possible but means the edited + * row can be re-sorted or filtered away mid-edit. + */ + lockInteractionsWhileEditing?: boolean + /** Fired when a locked interaction is attempted during an edit. */ + onInteractionBlocked?: (details: { + action: DataTableBlockedAction + reason: "editing" + rowId: string + }) => void + + /* Type-driven field renderers (per-table override; per-column via meta) */ + filterRenderers?: Partial< + Record> + > + editorRenderers?: Partial< + Record> + > + + /** Pin the row-actions column to the right edge (sticky). Default `true`. */ + stickyActions?: boolean + + /** + * Human-readable label for a row, used by selection checkboxes and the edit + * action instead of the opaque row id. + */ + getRowLabel?: (row: Row) => string + /** Replace the body with skeleton rows while the first page is being fetched. */ + loading?: boolean + /** How many skeleton rows to render while `loading`. */ + loadingRowCount?: number + /** Append a skeleton row while an infinite-scroll page is being fetched. */ + loadingMore?: boolean + + /** + * Everything configurable on the `Pagination` molecule, surfaced at table + * level. `count`/`page`/`pageSize` stay owned by the table's pagination + * state, and `getPageUrl` by the table too — paging runs through the + * table's own state, so `Pagination` is rendered with a placeholder href + * whose navigation is suppressed. Requiring it here would force every + * consumer who only wants (say) `siblingCount` to invent a `getPageUrl` + * that is then overridden. + */ + paginationProps?: Omit< + PaginationProps, + "count" | "page" | "pageSize" | "onPageChange" | "onChange" | "getPageUrl" + > + + /* Slots */ + renderToolbar?: (table: TanstackTable) => ReactNode + /** + * Custom actions trailing the toolbar search. At most + * `DATA_TABLE_MAX_TOOLBAR_ACTIONS` is recommended; more still render but warn. + */ + toolbarActions?: DataTableToolbarAction[] + renderEmpty?: () => ReactNode + /** + * Row actions; receives edit-mode state so you can swap in save/cancel. + * Return `null` to render no actions for that row, or `undefined` to fall + * through to the built-in edit button. + */ + renderRowActions?: ( + row: Row, + state: { + isEditing: boolean + startEdit: () => void + commitEdit: () => void + cancelEdit: () => void + disabled: boolean + } + ) => ReactNode + /** + * Replace the filter control for a column. Return `null` for no filter at + * all, or `undefined` to fall through to the type-driven default. + */ + renderHeaderFilter?: (column: Column) => ReactNode + renderExpandedRow?: (row: Row) => ReactNode + + /* Passthrough for DOM access to nested layers */ + slotProps?: { + root?: React.HTMLAttributes + header?: React.HTMLAttributes + body?: React.HTMLAttributes + row?: React.HTMLAttributes & { + ref?: Ref + } + } + + translations?: DataTableTranslations + /** Estimated row height (px) for virtualization. */ + estimateRowHeight?: number + /** Distance from bottom (px) that triggers `onReachEnd`. */ + reachEndThreshold?: number +} + +const SELECTION_COLUMN_ID = "__select" +const EMPTY_COLUMN_PINNING: ColumnPinningState = { start: [], end: [] } + +const DRAG_COLUMN_ID = "__drag" +const BUILTIN_COLUMN_IDS = new Set([ + SELECTION_COLUMN_ID, + DRAG_COLUMN_ID, +]) + +type DataTableStyles = ReturnType + +/** Sticky-edge classes for a pinned cell (opaque bg + edge shadow). */ +function pinClass( + column: Column, + kind: "header" | "body" +) { + if (!column.getIsPinned()) { + return + } + return [ + // Body cells inherit the row's own background rather than stamping the + // table surface on top of it: row colour (striped / selected / hover) + // lives on the ``, and an opaque ``, used to find it by DOM + * id rather than a ref — see the focus-management effect for why. */ +function editedRowElementId(instanceId: string, rowId: string): string { + return `${instanceId}-editing-${rowId}` +} + +/** `undefined` unless `rowId` is the one currently being edited. */ +function editingRowElementId( + editingRowId: string | null, + instanceId: string, + rowId: string +): string | undefined { + return editingRowId === rowId + ? editedRowElementId(instanceId, rowId) + : undefined +} + +function composeRowRef( + dnd: { setNodeRef: (node: HTMLElement | null) => void } | undefined, + consumerRef: Ref | undefined +) { + return ((node: HTMLTableRowElement | null) => { + dnd?.setNodeRef(node) + if (typeof consumerRef === "function") { + consumerRef(node) + } else if (consumerRef) { + ;(consumerRef as { current: HTMLTableRowElement | null }).current = node + } + }) as unknown as RefObject +} + +/* ── Component ───────────────────────────────────────────────────────────── */ + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: a feature-complete data-grid controller wiring ~20 optional features into one instance +export function DataTable(props: DataTableProps) { + const { + data, + columns: userColumns, + getRowId, + id: idProp, + className, + ref, + variant = "line", + size = "md", + stickyHeader, + interactive, + showColumnBorder, + caption, + hideHeader, + maxHeight, + enableSorting = true, + enableGlobalFilter = false, + enableColumnFilters = false, + enableRowSelection = false, + enableColumnVisibility = false, + enableColumnPinning = false, + enableColumnReorder = false, + enableColumnResizing = false, + tableLayout = "auto", + enableRowReorder = false, + enableExpanding = false, + enablePagination = false, + enableVirtualization = false, + getSubRows, + getRowCanExpand, + getCellSpan, + sorting: sortingProp, + onSortingChange, + columnFilters: columnFiltersProp, + onColumnFiltersChange, + globalFilter: globalFilterProp, + onGlobalFilterChange, + rowSelection: rowSelectionProp, + onRowSelectionChange, + columnVisibility: columnVisibilityProp, + onColumnVisibilityChange, + columnOrder: columnOrderProp, + onColumnOrderChange, + columnPinning: columnPinningProp, + onColumnPinningChange, + expanded: expandedProp, + onExpandedChange, + pagination: paginationProp, + onPaginationChange, + pageSizeOptions = [10, 25, 50, 100], + manualSorting, + manualFiltering, + manualPagination, + rowCount, + pageCount, + onRowClick, + onReachEnd, + onColumnReorder, + onRowReorder, + onCellEditCommit, + onReady, + renderToolbar, + toolbarActions, + renderEmpty, + renderRowActions, + renderHeaderFilter, + renderExpandedRow, + slotProps, + translations: translationsProp, + estimateRowHeight = 44, + reachEndThreshold = 240, + enableInlineEdit = false, + editingRowId: editingRowIdProp, + onEditingRowIdChange, + onEditStart, + onEditChange, + onEditCommit, + onEditCancel, + onEditValidationError, + lockInteractionsWhileEditing = true, + onInteractionBlocked, + filterRenderers, + editorRenderers, + stickyActions = true, + paginationProps, + canEditRow, + rowActions, + getRowLabel, + striped = false, + tintNestedRows = false, + loading = false, + loadingRowCount = 5, + loadingMore = false, + selectionMode = "multiple", + maxSelectedRows, + canSelectRow, + onSelectionLimitReached, + } = props + + /* + * Undefined entries are dropped rather than spread over the defaults. + * `translations={{ emptyTitle: t?.tableEmpty }}` is the ordinary call + * shape, and a missed i18n lookup passes `undefined` explicitly — which a + * plain spread writes straight over the default. Strings then rendered as + * literal "undefined", and `rangeLabel` is worse: `DataTable.Pagination` + * calls it unconditionally, so an undefined one threw "not a function" and + * took the whole table down the moment `enablePagination` was set. + */ + const translations = { ...DEFAULT_TRANSLATIONS } + if (translationsProp) { + for (const [key, value] of Object.entries(translationsProp)) { + if (value !== undefined) { + ;(translations as Record)[key] = value + } + } + } + const outlined = variant === "outline" + // `variant="striped"` forwards to the `Table` organism, which stripes via + // `odd:`/`even:` DOM-sibling pseudo-classes — the same drift bug fixed + // above for the `striped` boolean's rowIndex-based coloring. Route both + // spellings through the one correct implementation instead of letting a + // consumer pick the broken one. + const effectiveStriped = striped || variant === "striped" + const styles = dataTableVariants({ outlined }) + const generatedId = useId() + const instanceId = idProp ?? generatedId + const scrollRef = useRef(null) + const reachedEndRef = useRef(false) + // Row count at the last `onReachEnd`, so a request that brings back + // nothing doesn't immediately ask again. Written by every path that fires + // — the scroll handlers as well as the re-arm effects: when only the + // effects recorded it, a scroll-driven fire left this at its initial value + // and the following effect run happily issued one redundant duplicate + // request before the count was finally stored. + const lastReachEndCountRef = useRef(-1) + // Last over-cap selection reported via `onSelectionLimitReached`, so the + // shrink effect below reports each distinct one once. + const reportedSelectionLimitRef = useRef(null) + // Current row count, readable from the scroll listeners without putting + // `data.length` in their dependency arrays (which would re-subscribe the + // window listener on every append). + const dataLengthRef = useRef(data.length) + dataLengthRef.current = data.length + /** `onReachEnd`, recording the row count it fired at. */ + const fireReachEnd = () => { + lastReachEndCountRef.current = dataLengthRef.current + onReachEnd?.() + } + const headerRowRefs = useRef<(HTMLTableRowElement | null)[]>([]) + /** + * Cumulative sticky offset for each header label row, plus the total in the + * last slot. With a single header row this is `[0, rowHeight]` — exactly the + * previous behaviour. With grouped headers each row needs its own `top` or + * they all pile up at 0, and the filter row below them needs the total. + */ + const [headerOffsets, setHeaderOffsets] = useState([0]) + const headerHeight = headerOffsets.at(-1) ?? 0 + + /* Controlled/uncontrolled state slices */ + const [sorting, setSorting] = useControllable( + sortingProp, + [], + onSortingChange + ) + const [columnFilters, setColumnFilters] = useControllable( + columnFiltersProp, + [], + onColumnFiltersChange + ) + const [globalFilter, setGlobalFilter] = useControllable( + globalFilterProp, + "", + onGlobalFilterChange + ) + const [rowSelection, setRowSelectionState] = + useControllable( + rowSelectionProp, + {}, + onRowSelectionChange + ) + + /** + * Enforce `maxSelectedRows` on the resolved state rather than only through + * `getCanSelect`: TanStack applies sub-row and select-all toggles in a single + * updater, so a per-row predicate reading render-time state cannot see the + * count growing and the cap would be bypassed. + */ + const setRowSelection: OnChangeFn = (updater) => { + let limitReached: { limit: number; selectedCount: number } | undefined + setRowSelectionState((old) => { + const next = + typeof updater === "function" + ? (updater as (o: RowSelectionState) => RowSelectionState)(old) + : updater + if (maxSelectedRows == null) { + return next + } + const selectedIds = Object.keys(next).filter((id) => next[id]) + const previousCount = Object.keys(old).filter((id) => old[id]).length + if (selectedIds.length <= maxSelectedRows) { + // Reaching the cap is itself the signal worth reporting: once it is hit + // the remaining checkboxes disable, so the "exceeded" branch below is + // unreachable through normal row clicks and only bulk toggles hit it. + if ( + selectedIds.length === maxSelectedRows && + selectedIds.length > previousCount + ) { + limitReached = { + limit: maxSelectedRows, + selectedCount: selectedIds.length, + } + } + return next + } + const kept = clampSelection(selectedIds, old, maxSelectedRows) + limitReached = { limit: maxSelectedRows, selectedCount: kept.length } + return Object.fromEntries(kept.map((id) => [id, true])) + }) + // `useControllable` runs the updater exactly once and synchronously, so the + // notification lands after the state settles without firing twice. + if (limitReached) { + onSelectionLimitReached?.(limitReached) + } + } + + // `setRowSelection`'s clamp only runs when a selection *action* happens — + // it can't see `maxSelectedRows` itself shrinking below the current + // selection (e.g. a permission change re-renders with a lower cap). Trim + // the resolved state directly whenever that happens. + // + // Which rows survive is arbitrary but deterministic here: unlike the + // updater path there is no "before" selection to prioritise against — + // every selected row is equally pre-existing — and `RowSelectionState` is + // a plain record that carries no selection order (for default index-based + // ids `Object.keys` yields ascending numeric order, not recency). A + // caller that needs a specific survivor set should control `rowSelection` + // and trim it themselves alongside lowering the cap. + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-checks when the cap or the selection itself changes; setRowSelectionState/onSelectionLimitReached are stable + useEffect(() => { + if (maxSelectedRows == null) { + return + } + const selectedIds = Object.keys(rowSelection).filter( + (id) => rowSelection[id] + ) + if (selectedIds.length <= maxSelectedRows) { + // Back within the cap: clear the latch so a later over-cap selection is + // trimmed and reported again. Leaving it set meant a controlled parent + // that re-applied a previously-seen over-cap selection (select 3 with a + // cap of 2, clear, select the same 3 again) matched the stale signature, + // returned early, and rendered an over-cap selection with no callback. + reportedSelectionLimitRef.current = null + return + } + // In controlled mode `useControllable` does not hold state of its own, so + // if the parent ignores the trimmed selection this effect keeps seeing an + // over-cap value. Without this guard a parent that also rebuilds + // `rowSelection` each render would get `onSelectionLimitReached` fired on + // every render instead of once per distinct over-cap selection. + const signature = `${maxSelectedRows}:${selectedIds.join(",")}` + if (reportedSelectionLimitRef.current === signature) { + return + } + reportedSelectionLimitRef.current = signature + const kept = selectedIds.slice(0, maxSelectedRows) + setRowSelectionState(Object.fromEntries(kept.map((id) => [id, true]))) + onSelectionLimitReached?.({ + limit: maxSelectedRows, + selectedCount: kept.length, + }) + }, [maxSelectedRows, rowSelection]) + + const [columnVisibility, setColumnVisibility] = + useControllable( + columnVisibilityProp, + {}, + onColumnVisibilityChange + ) + const [columnOrder, setColumnOrder] = useControllable( + columnOrderProp, + [], + onColumnOrderChange + ) + const [columnPinning, setColumnPinning] = useControllable( + columnPinningProp, + // v9 made both halves of the pinning state required, so the uncontrolled + // default has to spell them out rather than start from `{}`. + EMPTY_COLUMN_PINNING, + onColumnPinningChange + ) + const [expanded, setExpanded] = useControllable( + expandedProp, + {}, + onExpandedChange + ) + const [pagination, setPagination] = useControllable( + paginationProp, + { pageIndex: 0, pageSize: pageSizeOptions[0] ?? 10 }, + onPaginationChange + ) + const [columnSizing, setColumnSizing] = useState({}) + + /* Inject built-in leading columns (drag handle, selection, expander). */ + const editTriggerIdRef = useRef(null) + // Row the current `draft` was seeded from, so a controlled `editingRowId` + // that changes without going through `startEdit` can be detected. + const draftRowIdRef = useRef(null) + const [editingRowId, setEditingRowId] = useControllable( + editingRowIdProp, + null, + onEditingRowIdChange + ) + const [draft, setDraft] = useState>({}) + // Mirrors `draft` so same-tick writes chain off each other rather than all + // resolving against one render's snapshot. Re-synced every render, so any + // other `setDraft` caller (seeding, reset) stays consistent with it. + const draftRef = useRef(draft) + draftRef.current = draft + const [editErrors, setEditErrors] = useState>({}) + const [dirty, setDirty] = useState(false) + + const isEditing = editingRowId != null + const locked = isEditing && lockInteractionsWhileEditing + + /** + * Guard for interactions that are locked during an inline edit. Reports the + * attempt through `onInteractionBlocked` and returns true when blocked. + */ + const blocked = ( + action: Parameters>[0]["action"] + ) => { + if (!locked) { + return false + } + onInteractionBlocked?.({ + action, + reason: "editing", + rowId: editingRowId as string, + }) + return true + } + + const selectedCount = Object.values(rowSelection).filter(Boolean).length + const selectionLimitReached = + maxSelectedRows != null && selectedCount >= maxSelectedRows + + /** + * Per-row selectability: the `canSelectRow` predicate, the `maxSelectedRows` + * cap (already-selected rows stay deselectable) and `enableRowSelection` + * composed into the single function TanStack expects. + */ + const rowSelectionPredicate = (row: Row) => { + const isSelected = rowSelection[row.id] === true + if (canSelectRow && !canSelectRow(row, { selectedCount, isSelected })) { + return false + } + if (selectionLimitReached && !isSelected) { + return false + } + return true + } + + /** + * TanStack keys its column cache on this array's identity, so rebuilding it + * every render discards every derived column — hence the memo, and hence + * nothing render-scoped in its deps. + */ + const columns = useMemo( + () => + buildColumns({ + userColumns: applyColumnDefaults(userColumns), + enableRowReorder, + enableRowSelection, + locked, + getRowLabel, + selectAllLabel: translations.selectAllLabel, + showSelectAll: selectionMode === "multiple" && maxSelectedRows == null, + }), + [ + userColumns, + enableRowReorder, + enableRowSelection, + locked, + getRowLabel, + translations.selectAllLabel, + selectionMode, + maxSelectedRows, + ] + ) + + /* + * Built-in columns are re-inserted at the front of a supplied + * `columnOrder`. + * + * `onColumnReorder` reports its `order` over the consumer's own columns + * (the injected `__drag`/`__select` ids stripped), which is what makes the + * indices usable — but TanStack appends any column *missing* from + * `columnOrder` to the end. So feeding that order straight back, or simply + * authoring `columnOrder` from your own ids, moved the checkbox and drag + * handle from the leading edge to the trailing edge. Verified against a + * real table: `columnOrder: ["age","name"]` rendered + * `age, name, __select`. + */ + const effectiveColumnOrder = useMemo(() => { + if (columnOrder.length === 0) { + return columnOrder + } + const builtIns = [DRAG_COLUMN_ID, SELECTION_COLUMN_ID].filter( + (id) => !columnOrder.includes(id) + ) + return builtIns.length > 0 ? [...builtIns, ...columnOrder] : columnOrder + }, [columnOrder]) + + const table = useTable({ + features: dataTableFeatures, + data, + columns, + getRowId, + state: { + sorting, + columnFilters, + globalFilter, + rowSelection, + columnVisibility, + columnOrder: effectiveColumnOrder, + columnPinning, + expanded, + pagination, + columnSizing, + }, + enableSorting, + enableRowSelection: enableRowSelection ? rowSelectionPredicate : false, + enableMultiRowSelection: selectionMode === "multiple", + // v9 turned shift-click range selection on by default. It applies the whole + // range in one updater, so every row in it is tested against the + // `selectedCount` captured before the click — which would let a shift-click + // sail past `maxSelectedRows`. Off until DataTable exposes range selection + // as a real, cap-aware feature. + enableRowRangeSelection: false, + enableColumnFilters, + enableColumnPinning, + enableColumnResizing, + columnResizeMode: "onChange", + manualSorting, + manualFiltering, + // v9 registers the paginated row model on the feature set rather than + // per instance, so it is always present. `manualPagination` is the switch + // that makes the pipeline hand back the un-paginated rows, which is what + // "pagination is off" has to mean now. + manualPagination: manualPagination || !enablePagination, + rowCount, + pageCount, + getSubRows, + getRowCanExpand: + getRowCanExpand ?? (renderExpandedRow ? () => true : undefined), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onRowSelectionChange: setRowSelection, + onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, + onColumnPinningChange: setColumnPinning, + onExpandedChange: setExpanded, + onPaginationChange: setPagination, + onColumnSizingChange: setColumnSizing, + globalFilterFn: "includesString", + meta: { + updateData: (rowId: string, columnId: string, value: unknown) => { + const target = table.getCoreRowModel().rowsById[rowId] + if (!target) { + return + } + onCellEditCommit?.({ rowId, columnId, value, row: target.original }) + }, + }, + }) + + // `useTable`'s returned wrapper is `useMemo(..., [table, tableOptions, state])` + // (@tanstack/react-table's useTable.js), and `tableOptions` is the options + // object literal built above — a fresh reference every render — so the + // wrapper's identity changes every render even though the underlying + // `constructTable` instance (created once via `useState`) never does. + // Firing on mount only, rather than keying off `table`, is what "ready" + // actually means here. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally mount-only — see comment above + useEffect(() => { + onReady?.(table) + }, []) + + useEffect(() => { + if (editingRowId) { + // Looked up by id — set on the row's own element above — rather than + // held in a ref written from each row's ref callback: when + // `editingRowId` jumps directly from one row to an earlier one (a + // controlled `editingRowId` change, not the internal `startEdit` guard + // that always requires the previous edit to close first), React + // detaches the old row's ref *after* attaching the new one, since + // detach/attach run in DOM order across siblings — the old row's + // detach would null out a ref the new row had just set. A DOM id + // resolved fresh, after the whole commit lands, isn't exposed to that + // ordering at all. + // Scope to the editor wrappers so the (disabled) selection checkbox and + // other row controls are never picked, and include button-based editors. + const row = document.getElementById( + editedRowElementId(instanceId, editingRowId) + ) + const candidates = row?.querySelectorAll( + "[data-editor-control] input, [data-editor-control] select, [data-editor-control] textarea, [data-editor-control] button" + ) + const first = Array.from(candidates ?? []).find( + (node) => !(node as HTMLButtonElement).disabled + ) + first?.focus() + return + } + // The pencil that opened the edit is unmounted while editing, so restore + // focus by id rather than by holding a (now detached) node. + const trigger = editTriggerIdRef.current + ? document.getElementById(editTriggerIdRef.current) + : null + trigger?.focus() + editTriggerIdRef.current = null + }, [editingRowId, instanceId]) + + // Draft state is cleared when the edit actually ends, so a controlled + // `editingRowId` held open across an async save keeps its values. + // + // A non-null id the draft was not seeded for means the edit was opened + // *without* going through `startEdit` — a parent setting `editingRowId` + // directly, or switching row A → B without passing through `null`. Both + // used to leave the draft as-is: the first opened every editor empty and + // committed `{}` over the row's real values, the second showed row A's + // values under row B and wrote A's data to B's id. Seed from the row the + // id actually names. + // biome-ignore lint/correctness/useExhaustiveDependencies: seeds on id changes only; `table` is rebuilt every render by design + useEffect(() => { + if (!editingRowId) { + draftRowIdRef.current = null + setDraft({}) + setEditErrors({}) + setDirty(false) + return + } + if (draftRowIdRef.current === editingRowId) { + return + } + const row = table.getCoreRowModel().rowsById[editingRowId] + if (row) { + seedDraft(row) + } + }, [editingRowId]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: watches the edited row's existence, not the cancel identity + useEffect(() => { + if (editingRowId && !table.getCoreRowModel().rowsById[editingRowId]) { + cancelEdit() + } + }, [editingRowId, data]) + + const headerGroupCount = table.getHeaderGroups().length + + // Measured before paint: `headerOffsets` starts at `[0]`, so a post-paint + // `useEffect` would render every sticky header row at `top: 0` for one + // frame on mount (and whenever the group count changes), flashing the + // grouped rows stacked on top of each other before they settle. + // + // Deps are `stickyHeader` + `headerGroupCount` on purpose: re-measure when + // the header gains or loses a row, which is what the group count tracks. + useIsomorphicLayoutEffect(() => { + if (!stickyHeader) { + return + } + // `!= null`, not `!== null`: this array is written by index, so an + // unwritten slot is `undefined` — which passes a `!== null` filter and + // then throws on `getBoundingClientRect`, taking the layout effect (and + // the render) down with it. + const nodes = headerRowRefs.current.filter((n) => n != null) + if (nodes.length === 0) { + return + } + const measure = () => { + const offsets = [0] + for (const node of nodes) { + offsets.push( + (offsets.at(-1) ?? 0) + node.getBoundingClientRect().height + ) + } + setHeaderOffsets(offsets) + } + const observer = new ResizeObserver(measure) + for (const node of nodes) { + observer.observe(node) + } + measure() + return () => observer.disconnect() + }, [stickyHeader, headerGroupCount]) + + /** + * Load a row's editable values into the draft. The single seeding path, so + * the pencil button and a controlled `editingRowId` cannot disagree about + * what "start editing this row" means. + */ + const seedDraft = (row: Row) => { + const initial: Record = {} + for (const column of table.getAllLeafColumns()) { + if (column.columnDef.meta?.editable) { + initial[column.id] = row.getValue(column.id) + } + } + draftRowIdRef.current = row.id + setDraft(initial) + setEditErrors({}) + setDirty(false) + } + + const startEdit = (row: Row) => { + if (isEditing || (canEditRow && !canEditRow(row))) { + return + } + editTriggerIdRef.current = `${instanceId}-edit-${row.id}` + seedDraft(row) + setEditingRowId(row.id) + onEditStart?.({ rowId: row.id, row: row.original }) + } + + const setDraftValue = (rowId: string, columnId: string, value: unknown) => { + // Built from a ref, not the render-scoped `draft`: two fields committing + // in the same tick — the `dateRange` editor's two inputs, or a blur and a + // change batched together — otherwise both resolve against the same stale + // object and the first write is silently dropped. A functional updater + // would fix the stored state but not `onEditChange`'s payload, since + // React may run the updater after this call returns; the ref advances + // synchronously so both agree. + const next = { ...draftRef.current, [columnId]: value } + draftRef.current = next + setDraft(next) + onEditChange?.({ rowId, columnId, value, draft: next }) + setDirty(true) + // Clear this field's stale error as soon as the user edits it again. + setEditErrors((old) => (old[columnId] ? { ...old, [columnId]: "" } : old)) + } + + const cancelEdit = () => { + if (!isEditing) { + return + } + const rowId = editingRowId as string + setEditingRowId(null) + onEditCancel?.({ rowId, dirty }) + } + + const commitEdit = () => { + if (!isEditing) { + return + } + const rowId = editingRowId as string + const row = table.getCoreRowModel().rowsById[rowId] + if (!row) { + // The record disappeared mid-edit (deleted, replaced, refetched); + // releasing the lock beats throwing or committing onto a stranger. + cancelEdit() + return + } + // Only columns that actually render an editor for the user. + // + // Two ways a `meta.editable` column ends up without one: it is hidden via + // `columnVisibility`, or it is `type: "custom"` with no `renderEditor` + // (the default custom editor renders nothing, so the cell stays + // read-only). Validating either produced an error for a field with no + // input on screen — stored where nothing displays it, while `commitEdit` + // returned early — so Save silently did nothing with no way out but + // cancelling. A field the user cannot see is a field they cannot fix. + const editableColumns = table.getVisibleLeafColumns().filter((column) => { + const meta = column.columnDef.meta + if (!meta?.editable) { + return false + } + if (resolveColumnType(meta) !== "custom") { + return true + } + return Boolean(meta.renderEditor || editorRenderers?.custom) + }) + // `draftRef.current`, not the render-scoped `draft`. `setDraftValue` + // maintains the ref precisely so a same-tick `setValue(v); commit()` — + // the natural "pick an option, save" shape for the enum and boolean + // editors, which have no key handlers, and what Zag's INPUT.ENTER does on + // the numeric editor — sees the value that was just written. Reading the + // closure here dropped it: the new value never reached `onEditCommit`, + // and a `required` field the user had just filled in failed as blank. + const committed = draftRef.current + const errors = validateDraft( + editableColumns, + committed, + translations.requiredLabel + ) + + if (Object.keys(errors).length > 0) { + setEditErrors(errors) + onEditValidationError?.({ rowId, errors }) + return + } + + onEditCommit?.({ rowId, draft: committed, row: row.original }) + setEditingRowId(null) + } + + const rows = table.getRowModel().rows + // Memoized for the same reason as `reorderableLeafIds`: a fresh array + // identity on every render defeats `SortableContext`'s own change + // detection regardless of whether the row order actually changed. + const rootRowIds = useMemo( + () => rows.filter((r) => r.depth === 0).map((r) => r.id), + [rows] + ) + /* + * Leaf columns in *render* order. + * + * `getVisibleLeafColumns()` is only `getAllLeafColumns().filter(isVisible)` + * — it does not apply pinning. `getHeaderGroups()` and `row.getVisibleCells()` + * both reorder to `[...start, ...center, ...end]`, so reading the plain leaf + * list put anything derived from it out of step with the header and body the + * moment a column was pinned: the filter row rendered its controls under the + * wrong headers, the tree indent attached to a column that was no longer + * leftmost, and the loading skeleton's columns did not line up with the table + * it stands in for. The last header group is the leaf row, so its headers are + * exactly what the header renders. + */ + const headerGroups = table.getHeaderGroups() + // Memoized on `headerGroups`, which TanStack itself memoizes on columns / + // order / grouping / pinning / visibility. Mapping it inline produced a + // fresh array every render, which silently defeated the `useMemo` on + // `reorderableLeafIds` below — the identity `SortableContext` reads as + // "the list changed". + // `table` is deliberately NOT a dependency: `useTable` returns + // `useMemo(() => ({...table, options, state}), [table, tableOptions, state])` + // and `tableOptions` is the object literal built above — a fresh identity + // every render (the same reason `onReady` fires mount-only). Including it + // made this memo recompute every render, which is exactly the churn it + // exists to prevent. `headerGroups` is memoized by TanStack on + // columns/order/grouping/pinning/visibility, and the fallback below reads + // the same underlying state, so keying on it alone is both stable and + // correct. + // biome-ignore lint/correctness/useExhaustiveDependencies: see above — `table`'s identity churns every render by design + const leafColumns = useMemo( + () => + headerGroups.at(-1)?.headers.map((h) => h.column as Column) ?? + table.getVisibleLeafColumns(), + [headerGroups] + ) + const columnCount = leafColumns.length + + /* Virtualization (windowing that preserves native table column alignment). + * + * The virtualizer measures `scrollTop` on `scrollRef`'s element, which is + * only the actual scrolling element when `maxHeight` bounds it — without a + * height the div grows to fit its content, the *page* scrolls instead, and + * `scrollTop` sits at 0 forever. Windowing then silently renders only the + * first `overscan`-sized slice of rows and nothing past it, however far the + * user scrolls. Rather than ship that quietly, virtualization is disabled + * (all rows render) whenever `maxHeight` is missing, with a dev warning. + */ + const virtualizationUsable = enableVirtualization && !!maxHeight + const hasWarnedAboutUnboundedVirtualization = useRef(false) + // `getCellSpan`'s rowSpan relies on the owning `` and its merged-away + // followers being actual, adjacent DOM siblings — an HTML rowSpan can't + // reach past rows that were never rendered. Once virtualization windows + // the body, the owner can scroll out while a `hidden: true` follower is + // still mounted, leaving a visible gap. There's no general fix short of + // recomputing spans per rendered window (which breaks the moment the owner + // itself is offscreen in the other direction), so this is a documented + // incompatibility, surfaced once via a dev warning rather than silently + // producing gaps. + const hasWarnedAboutVirtualizedCellSpan = useRef(false) + useEffect(() => { + if ( + !(virtualizationUsable && getCellSpan) || + hasWarnedAboutVirtualizedCellSpan.current || + typeof process === "undefined" || + process.env?.NODE_ENV === "production" + ) { + return + } + hasWarnedAboutVirtualizedCellSpan.current = true + console.warn( + "DataTable: getCellSpan and enableVirtualization are both set. " + + "rowSpan/colSpan require the merged rows to be adjacent DOM " + + "siblings, which virtualization's windowing does not guarantee — " + + "a spanning cell's owner row can scroll out of view while a " + + "`hidden: true` follower row stays rendered, leaving a gap." + ) + }, [virtualizationUsable, getCellSpan]) + // Row reorder's `SortableContext` is seeded with every root row id + // (`rootRowIds`), but only the virtualized window's rows actually mount a + // `useSortable` node — dnd-kit's collision detection can't resolve ids + // with no registered DOM node, so dragging toward an off-screen target + // can misfire. Same "documented, not fixable without a deeper rework" + // treatment as the getCellSpan incompatibility above. + const hasWarnedAboutVirtualizedRowReorder = useRef(false) + useEffect(() => { + if ( + !(virtualizationUsable && enableRowReorder) || + hasWarnedAboutVirtualizedRowReorder.current || + typeof process === "undefined" || + process.env?.NODE_ENV === "production" + ) { + return + } + hasWarnedAboutVirtualizedRowReorder.current = true + console.warn( + "DataTable: enableRowReorder and enableVirtualization are both set. " + + "Dragging a row toward a target outside the currently rendered " + + "window is unreliable — dnd-kit only tracks rows that are actually " + + "mounted, not the full row list." + ) + }, [virtualizationUsable, enableRowReorder]) + useEffect(() => { + if (!enableVirtualization || maxHeight) { + // Reset the latch whenever the config is valid (or virtualization is + // off), so a *later* transition back to invalid — e.g. maxHeight gets + // cleared again after being fixed — re-warns instead of staying silent + // because some earlier, unrelated invalid render already tripped it. + hasWarnedAboutUnboundedVirtualization.current = false + return + } + if ( + hasWarnedAboutUnboundedVirtualization.current || + typeof process === "undefined" || + process.env?.NODE_ENV === "production" + ) { + return + } + hasWarnedAboutUnboundedVirtualization.current = true + console.warn( + "DataTable: enableVirtualization has no effect without maxHeight — " + + "the scroll container never gets a bounded height to measure " + + "scrollTop against, so only the first batch of rows would render. " + + "Pass maxHeight to enable virtualization." + ) + // A warning that repeats on every keystroke is noise rather than a signal, + // so the ref fires it at most once per invalid stretch. The prop deps + // re-check configs that become invalid only after mount (e.g. a parent + // flips enableVirtualization on while maxHeight stays unset). + }, [enableVirtualization, maxHeight]) + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => estimateRowHeight, + overscan: 12, + enabled: virtualizationUsable, + }) + const virtualItems = virtualizationUsable + ? rowVirtualizer.getVirtualItems() + : [] + const firstVirtual = virtualItems[0] + const lastVirtual = virtualItems.at(-1) + const paddingTop = firstVirtual ? firstVirtual.start : 0 + const paddingBottom = lastVirtual + ? rowVirtualizer.getTotalSize() - lastVirtual.end + : 0 + /* + * Each rendered row is carried together with its index into `rows`, rather + * than re-derived positionally from `virtualItems` further down. A windowed + * index can miss transiently — `rows.length` shrinks on a filter or page + * change before the virtualizer's `count` catches up — and dropping those + * entries while indexing the window by position would shift every later row + * onto its predecessor's index, silently offsetting `aria-rowindex`, the + * `striped` parity and the `rowIndex` handed to `getCellSpan`. + */ + const renderRows: { row: Row; index: number }[] = virtualizationUsable + ? virtualItems.flatMap((vi) => { + const row = rows[vi.index] + return row ? [{ row, index: vi.index }] : [] + }) + : rows.map((row, index) => ({ row, index })) + + /* dnd sensors */ + // Which axis the in-flight drag is constrained to; set on drag start so the + // shared DndContext can apply the right modifier for rows vs columns. + const [activeDragAxis, setActiveDragAxis] = useState<"row" | "column" | null>( + null + ) + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // Without `maxHeight` the table does not scroll itself, so infinite scroll has + // to observe the page instead of the container. + // biome-ignore lint/correctness/useExhaustiveDependencies: `fireReachEnd` is rebuilt every render but only closes over refs plus `onReachEnd`, which is already a dep — listing it would re-subscribe the window listener on every render + useEffect(() => { + if (!onReachEnd || maxHeight) { + return + } + const onWindowScroll = () => { + // Same end-of-data guard as the scroll handler and both re-arm effects. + // `checkReachEnd`'s latch clears the moment the user scrolls up past the + // threshold, so without this an exhausted list re-issued the identical + // request every time they scrolled back down. + if (dataLengthRef.current === lastReachEndCountRef.current) { + return + } + const distance = + document.documentElement.scrollHeight - + window.scrollY - + window.innerHeight + checkReachEnd(distance, reachEndThreshold, reachedEndRef, fireReachEnd) + } + window.addEventListener("scroll", onWindowScroll, { passive: true }) + return () => window.removeEventListener("scroll", onWindowScroll) + }, [onReachEnd, maxHeight, reachEndThreshold]) + + // Window-mode counterpart of the bounded re-arm effect below. + // + // `checkReachEnd`'s latch only clears on a scroll event reporting + // `distance > threshold`. If an appended page is shorter than the + // threshold the document still ends inside it, so every subsequent scroll + // reads "at the bottom", the latch is never released and loading stalls + // for good — unless the user happens to scroll back up past the + // threshold. Measures the *document*: `scrollRef`'s element is unbounded + // in this mode and always reads as at the bottom, which is exactly why the + // effect below excludes the no-`maxHeight` case rather than sharing it. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-checks whenever the row count changes + useEffect(() => { + if (!onReachEnd || loadingMore || maxHeight) { + return + } + // Same end-of-data guard as the bounded path: a fetch that brings back + // nothing must not re-trigger itself forever. + if (data.length === lastReachEndCountRef.current) { + return + } + const distance = + document.documentElement.scrollHeight - + window.scrollY - + window.innerHeight + reachedEndRef.current = distance <= reachEndThreshold + if (reachedEndRef.current) { + fireReachEnd() + } + }, [data.length, loadingMore, reachEndThreshold, maxHeight]) + + // Re-arm after new rows land: if the freshly appended page is shorter than + // the threshold no further scroll event fires, and loading would stall. + // Internal-scroll only (`maxHeight` set) — without it `scrollRef`'s element + // is the unbounded div `onWindowScroll` deliberately ignores, so its + // `scrollHeight`/`clientHeight` are meaningless and always read as "at the + // bottom". Running this unconditionally forced `reachedEndRef.current` to + // `true` on every appended page in window-scroll mode too, re-arming the + // "already reported" latch the instant new rows landed — a user mid + // continuous-scroll (no incidental upward wobble to reset it first) would + // have the next real reach-end silently swallowed. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-checks whenever the row count changes + useEffect(() => { + if (!onReachEnd || loadingMore || !maxHeight) { + return + } + const el = scrollRef.current + if (!el) { + return + } + // Only re-fire once the row count has actually grown since the last + // request. `loadingMore` is a dependency, so at end-of-data the flip + // back to false would otherwise re-run this with the container still at + // the bottom and request the next page again — and again — forever, + // with no new rows to move the scroll position. A consumer whose fetch + // returns nothing is precisely how "no more data" is signalled. + if (data.length === lastReachEndCountRef.current) { + return + } + // No `scrollHeight > clientHeight` requirement here. Demanding that the + // content already overflow dead-ended the common case: a first page too + // short to fill `maxHeight` never scrolls, so no scroll event fires, and + // requiring overflow meant this effect refused to fire either — the list + // stuck on page one forever. Underflow is precisely when another page is + // needed. Runaway is prevented by the row-count guard above, not by + // overflow; the horizontal-scroll false positive it was guarding against + // belongs to the scroll handler, which still checks it. + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + reachedEndRef.current = distance <= reachEndThreshold + if (reachedEndRef.current) { + fireReachEnd() + } + }, [data.length, loadingMore, reachEndThreshold]) + + const handleScroll = (event: UIEvent) => { + // Mirrors the window-scroll effect's guard. Without `maxHeight` this div + // has no bounded height, so `scrollHeight - scrollTop - clientHeight` is + // permanently 0 — a single *horizontal* scroll on a wide table would + // then read as "at the bottom" and fire `onReachEnd`. The page-scroll + // listener owns the unbounded case. + if (!(onReachEnd && maxHeight)) { + return + } + const el = event.currentTarget + // A bounded container that does not scroll vertically (rows shorter than + // `maxHeight`) reports `distance === 0` forever, so a *horizontal* scroll + // on a wide table would read as "at the bottom" and refetch. That case is + // handled by the re-arm effect above, which fires on underflow directly + // rather than waiting for a scroll that will never come. + if (el.scrollHeight <= el.clientHeight) { + return + } + // Same end-of-data guard the re-arm effects apply: once a fetch has come + // back with no new rows, scrolling up past the threshold and back down + // would otherwise re-issue the identical request. + if (data.length === lastReachEndCountRef.current) { + return + } + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + checkReachEnd(distance, reachEndThreshold, reachedEndRef, fireReachEnd) + } + + const handleColumnDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) { + return + } + // Seed from ALL leaf columns (not just visible ones) so a reorder while + // some columns are hidden doesn't drop the hidden ids from columnOrder. + const current = table.state.columnOrder.length + ? table.state.columnOrder + : table.getAllLeafColumns().map((c) => c.id) + const from = current.indexOf(active.id as string) + const to = current.indexOf(over.id as string) + if (from === -1 || to === -1) { + return + } + const next = arrayMove(current, from, to) + setColumnOrder(next) + // Reported in the consumer's own frame of reference. `current` carries + // the injected `__drag`/`__select` columns, which the consumer never + // declared — indices into it are offset by one or two from their + // `columns` array, so `arrayMove(myColumns, from, to)` would move the + // wrong column. Strip the built-ins from both the order and the indices. + const publicBefore = current.filter((id) => !BUILTIN_COLUMN_IDS.has(id)) + const publicOrder = next.filter((id) => !BUILTIN_COLUMN_IDS.has(id)) + onColumnReorder?.({ + from: publicBefore.indexOf(active.id as string), + to: publicOrder.indexOf(active.id as string), + columnId: active.id as string, + order: publicOrder, + }) + } + + const handleRowDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) { + return + } + // Map the dragged/target display rows back to their positions in the + // original `data` array — display order may be sorted/filtered/paginated, + // so row-model indices must not be applied to `data` directly. The core + // (unsorted, unfiltered) row model's `.index` already *is* that position, + // keyed by id in O(1) via `rowsById` — unlike `data.indexOf(row.original)`, + // this doesn't depend on `row.original` being reference-identical to an + // entry in `data`, so it doesn't silently no-op when `data` holds + // duplicate or content-equal objects. + const coreRowsById = table.getCoreRowModel().rowsById + const from = coreRowsById[active.id as string]?.index + const to = coreRowsById[over.id as string]?.index + if (from === undefined || to === undefined) { + return + } + const next = arrayMove([...data], from, to) + onRowReorder?.({ from, to, rowId: active.id as string, data: next }) + } + + const hasFooter = table + .getAllLeafColumns() + .some((c) => c.columnDef.footer != null) + + const indentColumnId = leafColumns.find( + (c) => !BUILTIN_COLUMN_IDS.has(c.id) + )?.id + // Must match `renderHeaderCell`'s own `reorderable` check exactly: dnd-kit's + // `SortableContext` computes drag index/offset math from this `items` + // array assuming every id in it has a registered `useSortable` node, and + // pinned columns are the one leaf column `renderHeaderCell` deliberately + // never wraps in `SortableHeaderContent`. + // + // Memoized: `SortableContext` treats a new `items` identity as "the list + // changed" regardless of content, forcing dnd-kit to rebuild its id index — + // this recomputes only when the actual leaf columns change, not on every + // unrelated render (a filter keystroke, a hover state elsewhere). + const reorderableLeafIds = useMemo( + () => + leafColumns + .filter((c) => !(BUILTIN_COLUMN_IDS.has(c.id) || c.getIsPinned())) + .map((c) => c.id), + [leafColumns] + ) + + /** + * The expand toggle lives in the trailing actions cell (so it cannot collide + * with the selection checkbox), which means expanding needs that cell to + * exist. Tree rows expand through `getSubRows`/`getRowCanExpand` and never set + * `renderExpandedRow`, so gating on the latter alone left them with no toggle. + */ + const hasActionsColumn = + !!renderRowActions || + !!rowActions?.length || + enableInlineEdit || + (enableExpanding && + (!!renderExpandedRow || !!getSubRows || !!getRowCanExpand)) + + // An end-pinned column and the sticky actions cell both freeze at the + // trailing edge, and the actions column is not in TanStack's column model + // so `getAfter("end")` cannot reserve room for it — they overlap. The + // actions cell now wins the stacking deterministically, but the pinned + // column is still hidden underneath, so say so rather than let it look + // like a rendering glitch. + const hasWarnedAboutPinnedActionsOverlap = useRef(false) + useEffect(() => { + const endPinned = + enableColumnPinning && + table.getAllLeafColumns().some((c) => c.getIsPinned() === "end") + if ( + !(endPinned && stickyActions && hasActionsColumn) || + hasWarnedAboutPinnedActionsOverlap.current || + typeof process === "undefined" || + process.env?.NODE_ENV === "production" + ) { + return + } + hasWarnedAboutPinnedActionsOverlap.current = true + console.warn( + "DataTable: a column pinned to 'end' and the sticky actions cell both " + + "freeze at the trailing edge, so they overlap — the actions column " + + "is not part of the column model, so end-pinned offsets cannot " + + "account for its width. Use stickyActions={false}, or pin to " + + "'start' instead." + ) + }, [enableColumnPinning, stickyActions, hasActionsColumn, table]) + + // Frozen offsets (`getStart`/`getAfter`) are sums of `getSize()`, and only a + // *numeric* `meta.width` is mirrored into `size` — a CSS string cannot be + // resolved without measuring. So a pinned column sized `"20%"` or + // `"var(--dimension-120)"` is laid out at its real width but offset as if it + // were the 150px default, and the frozen columns overlap. + const hasWarnedAboutPinnedStringWidth = useRef(false) + useEffect(() => { + const offender = + enableColumnPinning && + table + .getAllLeafColumns() + .find( + (c) => c.getIsPinned() && typeof c.columnDef.meta?.width === "string" + ) + if ( + !offender || + hasWarnedAboutPinnedStringWidth.current || + typeof process === "undefined" || + process.env?.NODE_ENV === "production" + ) { + return + } + hasWarnedAboutPinnedStringWidth.current = true + console.warn( + `DataTable: pinned column "${offender.id}" declares a string ` + + "meta.width, which cannot be mirrored into TanStack's numeric " + + "`size`. Frozen offsets are computed from `size`, so this column " + + "will be positioned as if it were the default width and overlap its " + + "neighbours. Give pinned columns a numeric meta.width." + ) + }, [enableColumnPinning, table]) + + /** + * Resolve a column's header filter: per-column `meta.renderFilter`, then the + * table-wide `renderHeaderFilter` slot, then the type-driven default. + * `resolveColumnType` is the same resolution `typedFilterFn` uses, so the + * control shown here and the matcher that runs against it never disagree. + */ + const renderColumnFilter = (column: Column) => { + const meta = column.columnDef.meta + const type: DataTableColumnType = resolveColumnType(meta) + const ctx: DataTableFilterContext = { + column, + type, + value: column.getFilterValue() as DataTableFilterValue | undefined, + // Every DEFAULT_FILTER_RENDERERS control is `disabled={locked}` (see + // `disabled` below), with no clear-button style escape hatch the way + // `GlobalSearch` has — so unlike globalFilter, no path here can ever + // reach `setValue` while locked, and a `blocked("filter")` guard is + // unreachable. Removed for the same reason "sort" and "select" were. + setValue: (next) => column.setFilterValue(next), + disabled: locked, + size, + operatorLabels: translations.operatorLabels, + options: meta?.options ?? meta?.filterOptions ?? [], + } + if (meta?.renderFilter) { + return meta.renderFilter(ctx) + } + // Same `undefined` vs `null` contract as `rowActionsContent`: `null` is a + // deliberate "no filter for this column", not a fall-through. + const slot = renderHeaderFilter?.(column) + if (slot !== undefined) { + return slot + } + const renderer = + filterRenderers?.[type] ?? + (DEFAULT_FILTER_RENDERERS[type] as DataTableFilterRenderer) + return renderer?.(ctx) ?? null + } + + const renderHeaderCell = ( + header: Header, + groupIndex: number, + dnd?: { + setNodeRef: (node: HTMLElement | null) => void + setActivatorNodeRef: (node: HTMLElement | null) => void + listeners: Record | undefined + style: CSSProperties + isDragging: boolean + dropSide?: "start" | "end" + attributes: Record + } + ) => { + const column = header.column + const ariaSort = sortState(column, enableSorting) + let dropClass = "" + if (dnd?.dropSide === "start") { + dropClass = "border-primary border-s-2" + } else if (dnd?.dropSide === "end") { + dropClass = "border-primary border-e-2" + } + return ( + } + style={{ + ...OPAQUE_HEADER_BG, + ...stickyRowOffset(stickyHeader, headerOffsets, groupIndex), + ...getPinningStyles(column, "header"), + ...dnd?.style, + ...getColumnSizeStyles(column, enableColumnResizing, columnSizing), + }} + > +
+ {dnd && ( + + )} + +
+ {enableColumnResizing && column.getCanResize() ? ( +
+ {leafColumns.map((column) => ( + + ))} + {hasActionsColumn && ( + + )} + + ) + + /** + * Resolve the inline editor for a cell, or `undefined` when the cell is not + * currently editable. Per-column `meta.renderEditor` wins over the + * table-wide `editorRenderers` override, which wins over the type default. + */ + const renderCellEditor = (row: Row, column: Column) => { + const meta = column.columnDef.meta + if (!(enableInlineEdit && meta?.editable) || editingRowId !== row.id) { + return + } + const type: DataTableColumnType = resolveColumnType(meta) + const ctx: DataTableEditorContext = { + row, + column, + type, + value: draft[column.id], + setValue: (next) => setDraftValue(row.id, column.id, next), + disabled: false, + size, + // `meta.filterOptions` is deprecated in favour of `meta.options`, but a + // column not yet migrated still needs its enum/multiEnum editor + // populated — the header filter (above) already falls back the same way. + options: meta.options ?? meta.filterOptions ?? [], + error: editErrors[column.id] || undefined, + errorId: `${instanceId}-err-${row.id}-${column.id}`, + commit: commitEdit, + cancel: cancelEdit, + } + if (meta.renderEditor) { + return meta.renderEditor(ctx) + } + const renderer = + editorRenderers?.[type] ?? + (DEFAULT_EDITOR_RENDERERS[type] as DataTableEditorRenderer) + return renderer?.(ctx) ?? undefined + } + + /** Edit/delete affordances used when no `renderRowActions` slot is given. */ + const defaultRowActions = (row: Row) => { + if (!enableInlineEdit) { + return null + } + if (editingRowId === row.id) { + return ( + <> + ` rows from the row model instead, with no single container + * id to point at, so `aria-controls` naming a non-existent element would be + * worse than omitting it. + */ + const expandAriaControls = (rowId: string) => + renderExpandedRow ? `${instanceId}-detail-${rowId}` : undefined + + const renderActionsCell = (row: Row) => ( + +
+ {enableExpanding && row.getCanExpand() ? ( +
+
+ ) + + // Spread the passthrough first, then internal props, so DataTable's own click + // handler, sortable ref and transform compose with (not get replaced by) + // slotProps.row. + const { + onClick: rowOnClick, + style: rowStyle, + ref: rowRef, + ...restRowProps + } = slotProps?.row ?? {} + + /** One row's data cells, honouring cell spans and any active inline editor. */ + const renderRowCells = ( + row: Row, + cells: Cell[], + rowIndex: number, + dnd?: { dragHandleProps: Record } + ) => { + const rowLabel = getRowLabel?.(row) ?? `row ${row.id}` + return cells.map((cell) => { + const span = getCellSpan?.(cell, { row, rows, rowIndex }) + if (span?.hidden) { + return null + } + const column = cell.column as Column + return ( + + ) + }) + } + + /** + * `aria-rowindex` is 1-based across the whole table including header rows, + * and must stay absolute when the body is paginated or virtualised — so body + * rows are offset by the rendered header rows plus the current page start. + */ + const headerRowCount = + table.getHeaderGroups().length + (enableColumnFilters ? 1 : 0) + const pageRowOffset = enablePagination + ? pagination.pageIndex * pagination.pageSize + : 0 + + type RowDnd = { + setNodeRef: (node: HTMLElement | null) => void + style: CSSProperties + dragHandleProps: Record + isDragging?: boolean + dropSide?: "top" | "bottom" + } + + /** The visible `
` for one row — split out so its own attribute + * ternaries don't count against `renderBodyRow`'s complexity budget. */ + const buildMainRow = ( + row: Row, + rowIndex: number, + dnd: RowDnd | undefined, + opts: { + rowIsClickable: boolean + rowElementId: string | undefined + rowKeyDown: + | ((event: React.KeyboardEvent) => void) + | undefined + } + ) => ( + { + restRowProps.onKeyDown?.(event) + opts.rowKeyDown?.(event) + }} + ref={composeRowRef(dnd, rowRef)} + // Sortable ref wins while reordering; otherwise keep the consumer's ref. + selected={enableRowSelection ? row.getIsSelected() : undefined} + style={{ ...rowStyle, ...dnd?.style }} + tabIndex={opts.rowIsClickable ? 0 : restRowProps.tabIndex} + > + {renderRowCells(row, row.getVisibleCells(), rowIndex, dnd)} + {hasActionsColumn ? renderActionsCell(row) : null} + + ) + + /* ── Body row renderer ──────────────────────────────────────────────── */ + const renderBodyRow = (row: Row, rowIndex: number, dnd?: RowDnd) => { + const rowIsClickable = !!onRowClick && !locked + const actionsColumn = hasActionsColumn ? 1 : 0 + const rowElementId = editingRowElementId(editingRowId, instanceId, row.id) + const rowKeyDown = rowIsClickable + ? buildRowKeyDownHandler(row, onRowClick, blocked) + : undefined + + const mainRow = buildMainRow(row, rowIndex, dnd, { + rowIsClickable, + rowElementId, + rowKeyDown, + }) + + const expandedRow = + renderExpandedRow && row.getIsExpanded() + ? renderExpandedDetailRow({ + row, + renderExpandedRow, + colSpan: columnCount + actionsColumn, + instanceId, + styles, + tintNestedRows, + }) + : null + + return ( + + {mainRow} + {expandedRow} + + ) + } + + // Row order only maps back to `data` while the view is unsorted/unfiltered. + const rowReorderActive = + enableRowReorder && + !locked && + sorting.length === 0 && + columnFilters.length === 0 && + !globalFilter + + const bodyRows = renderRows.map(({ row, index: rowIndex }) => { + // Only top-level rows are reorderable — sub-rows aren't in the top-level + // `data` array, so dragging them could not be applied to it. + return rowReorderActive && row.depth === 0 ? ( + + {(dnd) => renderBodyRow(row, rowIndex, dnd)} + + ) : ( + renderBodyRow(row, rowIndex) + ) + }) + + const skeletonRows = (count: number, keyPrefix: string) => + Array.from({ length: count }, (_, i) => `${keyPrefix}-${i}`).map( + (rowKey) => ( + + ) + ) + + const emptyState = renderEmpty ? ( + renderEmpty() + ) : ( +
+ +
+

{translations.emptyTitle}

+

{translations.emptyDescription}

+
+
+ ) + + let statusMessage = "" + if (loading) { + statusMessage = translations.loadingLabel + } else if (rows.length === 0) { + statusMessage = translations.emptyTitle + } else if (isEditing) { + statusMessage = translations.editingLabel + } + + const emptyRow = ( +
+ + {emptyState} + + + ) + + let bodyState: "loading" | "empty" | "rows" = "rows" + if (loading) { + bodyState = "loading" + } else if (rows.length === 0) { + // `loadingMore` with nothing rendered yet is still loading, not empty. + // The append skeleton lives in the "rows" branch, so an infinite-scroll + // table fetching its first page — or one whose filter emptied the view + // while a page was in flight — showed "No records" during the fetch, + // with `aria-busy` set but nothing visible saying so. + bodyState = loadingMore ? "loading" : "empty" + } + + const bodyContent = ( + + {bodyState === "loading" && skeletonRows(loadingRowCount, "skeleton")} + {bodyState === "empty" && emptyRow} + {bodyState === "rows" && ( + <> + {paddingTop > 0 && ( + + + )} + {bodyRows} + {loadingMore && skeletonRows(1, "skeleton-more")} + {paddingBottom > 0 && ( + + + )} + + )} + + ) + + const footerContent = hasFooter ? ( + + {table.getFooterGroups().map((footerGroup) => ( + + {footerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.footer, + header.getContext() + )} + + ))} + {hasActionsColumn && ( + // Footer actions cell freezes with the rest of the column. + + )} + + ))} + + ) : null + + const tableEl = ( +
` background hid all three in + // every frozen column. `inherit` is still fully opaque — the row now + // carries a concrete base — so scrolled content cannot show through. + // Body cells composite the row's tint over an opaque surface. Plain + // `bg-inherit` was see-through: the row tints are alpha overlays and the + // even stripe is fully transparent, so scrolled content bled through the + // frozen column. See `data-table-frozen-cell`. + kind === "header" ? "bg-table-header-bg" : "data-table-frozen-cell", + isLastStartPinned(column) + ? "border-e-(length:--border-table-width) border-table-border" + : "", + isFirstEndPinned(column) + ? "border-s-(length:--border-table-width) border-table-border" + : "", + ].join(" ") +} + +/** Column drag handle rendered in the header cell. */ +function HeaderDragHandle({ + styles, + setActivatorNodeRef, + listeners, + attributes, + label, +}: { + styles: DataTableStyles + label: string + setActivatorNodeRef: (node: HTMLElement | null) => void + listeners: Record | undefined + attributes?: Record +}) { + return ( + + ) +} + +/** Header label with the optional sort toggle + direction icon. */ +function HeaderSortLabel({ + header, + styles, + enableSorting, + locked, +}: { + header: Header + styles: DataTableStyles + enableSorting: boolean + locked?: boolean +}) { + const column = header.column + const canSort = enableSorting && column.getCanSort() + const sortDir = column.getIsSorted() + const label = header.isPlaceholder + ? null + : flexRender(column.columnDef.header, header.getContext()) + + if (!canSort) { + return <>{label} + } + + let sortIconName: IconType = "icon-[mdi--unfold-more-horizontal]" + if (sortDir === "desc") { + sortIconName = "token-icon-chevron-down" + } else if (sortDir === "asc") { + sortIconName = "token-icon-chevron-up" + } + + return ( + + ) +} + +/** Cell body: drag handle, active inline editor, or the column's cell template. */ +function renderCellContent({ + cell, + column, + styles, + enableRowReorder, + dnd, + editor, + editorError, + editorErrorId, + isDragCol, + rowLabel, +}: { + cell: Cell + column: Column + rowLabel: string + styles: DataTableStyles + enableRowReorder: boolean + dnd?: { dragHandleProps: Record } + editor?: ReactNode + editorError?: string + editorErrorId?: string + isDragCol: boolean +}) { + if (isDragCol && enableRowReorder && dnd) { + return ( + + ) + } + if (editor) { + return ( +
+ {editor} + {editorError ? ( + + {editorError} + + ) : null} +
+ ) + } + return flexRender(column.columnDef.cell, cell.getContext()) +} + +/** + * Sticky `top` for one header label row. + * + * `Table.ColumnHeader` sticks every header row at `top: 0` through a class, + * which is right for a single row and wrong the moment there are grouped + * headers — they would all pile up on each other. This inline value wins over + * the class and stacks them. + */ +function stickyRowOffset( + stickyHeader: boolean | undefined, + offsets: number[], + groupIndex: number +): CSSProperties | undefined { + if (!stickyHeader) { + return + } + return { top: offsets[groupIndex] ?? 0 } +} + +function DataTableBodyCell({ + cell, + span, + row, + styles, + columnSizing, + enableColumnResizing, + enableRowReorder, + dnd, + editor, + editorError, + editorErrorId, + indentColumnId, + rowLabel, +}: { + cell: Cell + span: { colSpan?: number; rowSpan?: number } | undefined + row: Row + styles: DataTableStyles + columnSizing: ColumnSizingState + enableColumnResizing: boolean + enableRowReorder: boolean + dnd?: { dragHandleProps: Record } + editor?: ReactNode + editorError?: string + editorErrorId?: string + indentColumnId?: string + rowLabel: string +}) { + const column = cell.column + const pinned = column.getIsPinned() + const isDragCol = column.id === DRAG_COLUMN_ID + const indent = + row.depth > 0 && column.id === indentColumnId + ? { paddingInlineStart: `${row.depth * 1.5}rem` } + : undefined + + return ( + + {renderCellContent({ + cell, + column, + styles, + enableRowReorder, + dnd, + editor, + editorError, + editorErrorId, + isDragCol, + rowLabel, + })} + + ) +} + +/** Run `required` + `meta.validate` over a row draft, returning field errors. */ +function validateDraft( + columns: Column[], + draft: Record, + requiredLabel: string +): Record { + const errors: Record = {} + for (const column of columns) { + const meta = column.columnDef.meta + if (!meta?.editable) { + continue + } + const value = draft[column.id] + // `NaN` is what a cleared numeric input parses to, and it is neither + // null nor "" — so it has to be spelled out or a required number column + // would validate as filled. + const empty = + isBlank(value) || + (typeof value === "number" && Number.isNaN(value)) || + (Array.isArray(value) && value.length === 0) + if (meta.required && empty) { + errors[column.id] = requiredLabel + continue + } + const message = meta.validate?.(value, draft) + if (message) { + errors[column.id] = message + } + } + return errors +} + +/** + * One master-detail row: a single full-width cell holding whatever the + * `renderExpandedRow` slot returns. Split out of `renderBodyRow` to keep that + * function's branching under the complexity ceiling. + */ +function renderExpandedDetailRow({ + row, + renderExpandedRow, + colSpan, + instanceId, + styles, + tintNestedRows, +}: { + row: Row + renderExpandedRow: (row: Row) => ReactNode + colSpan: number + instanceId: string + styles: DataTableStyles + tintNestedRows: boolean +}) { + return ( + + +
+ {renderExpandedRow(row)} +
+
+
+ ) +} + +/** Row classes conveying drag state: lifted while dragging, edge while hovered. */ +function rowDragClass( + dnd: { isDragging?: boolean; dropSide?: "top" | "bottom" } | undefined, + opts: { + className?: string + striped?: boolean + tintNestedRows?: boolean + rowIndex?: number + } +) { + const { className, striped, tintNestedRows, rowIndex } = opts + return [ + "group/row", + "focus-visible:outline-(style:--default-ring-style) focus-visible:outline-(length:--default-ring-width) focus-visible:outline-primary", + /* Publish the row's current tint as a custom property. Frozen cells + * composite it over an opaque surface (`data-table-frozen-cell`), which + * is the only way to be both opaque and row-coloured: these tints are + * alpha overlays — `--color-table-row-striped-secondary` is fully + * transparent — so a frozen cell that merely inherited the row's + * background let the scrolled content show straight through it. + * Listed before the striped classes so specificity, not source order, + * decides: selection and hover outrank a bare class and so win. */ + "data-[selected=true]:[--dt-row-tint:var(--color-table-row-bg-selected)]", + "group-hover/row:[--dt-row-tint:var(--color-table-row-bg-hover)]", + "hover:[--dt-row-tint:var(--color-table-row-bg-hover)]", + // `Table.Row`'s own row-divider border and the zebra background are two + // ways of doing the same job — separating one row from the next — and + // showing both at once double-marks every boundary. `border-b-0` wins the + // conflict against Table's base `border-b-(length:--border-table-width)` + // through the same className-merge-order mechanism that made `relative` + // cancel `sticky` on the header cells. + // + // Colored from the row's logical index rather than `odd:`/`even:` + // structural pseudo-classes: under `enableVirtualization` only a windowed + // slice of rows is mounted as siblings, so a row's DOM sibling position + // (what `odd:`/`even:` key off) shifts as the window scrolls even though + // its logical index — and therefore its stripe color — must not. + striped + ? `${ + (rowIndex ?? 0) % 2 === 0 + ? "bg-table-row-striped-primary [--dt-row-tint:var(--color-table-row-striped-primary)]" + : "bg-table-row-striped-secondary [--dt-row-tint:var(--color-table-row-striped-secondary)]" + } border-b-0` + : "", + // `data-depth` is only ever set for `row.depth > 0` (see below). + // `data-table-row-nested-tint` (defined in + // tokens/components/organisms/_data-table.css) is an inset box-shadow, not + // a background — it paints as its own layer, so it composes with + // `striped`'s zebra background instead of losing the Tailwind-merge + // conflict a second `bg-*` class would. + tintNestedRows ? "data-[depth]:data-table-row-nested-tint" : "", + dnd?.isDragging ? "shadow-table-outline" : "", + dnd?.dropSide === "top" ? "border-primary border-t-2" : "", + dnd?.dropSide === "bottom" ? "border-primary border-b-2" : "", + className ?? "", + ] + .filter(Boolean) + .join(" ") +} + +/** + * Opaque header surface. `--color-table-header-bg` resolves to a ~5% tint + * (`--color-fill-base`), which is fine for a static header but lets scrolled + * content show through sticky or frozen header cells — two labels end up + * legible at once. Compositing the tint over the table surface keeps the exact + * same colour while being fully opaque. + */ +const OPAQUE_HEADER_BG: CSSProperties = { + backgroundColor: "var(--color-table-bg)", + backgroundImage: + "linear-gradient(var(--color-table-header-bg), var(--color-table-header-bg))", +} + +/** `aria-sort` for a header cell, or undefined when the column is not sortable. */ +function sortState( + column: Column, + enableSorting: boolean +) { + if (!(enableSorting && column.getCanSort())) { + return + } + const sorted = column.getIsSorted() + if (sorted === "asc") { + return "ascending" as const + } + if (sorted === "desc") { + return "descending" as const + } + return "none" as const +} + +/** Enter/Space activates a clickable row, ignoring keys bubbling from controls. */ +function activateRowByKeyboard( + event: React.KeyboardEvent, + activate: () => void +) { + if (event.key !== "Enter" && event.key !== " ") { + return + } + if (event.target !== event.currentTarget) { + return + } + event.preventDefault() + activate() +} + +/** + * Shared "reach end" latch used by both the window-scroll listener and the + * internal container's onScroll handler: fire `onReachEnd` once when the + * scroll distance crosses the threshold, then hold off until the user + * scrolls back away from the edge. Kept as one function so the two call + * sites (window vs. bounded container) can't drift the way the reach-end + * math already has twice in this component's history. + */ +function checkReachEnd( + distance: number, + reachEndThreshold: number, + reachedEndRef: { current: boolean }, + onReachEnd: () => void +): void { + if (distance <= reachEndThreshold) { + if (!reachedEndRef.current) { + reachedEndRef.current = true + onReachEnd() + } + } else { + reachedEndRef.current = false + } +} + +/** + * `useLayoutEffect` on the client, `useEffect` on the server — React warns + * about the former during SSR, and this library is consumed by server- + * rendered apps. Used where a measurement has to land before paint. + */ +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect + +/** + * Trim a selection down to `max`, keeping rows that were already selected + * before rows newly added by the same change. + * + * Used by the `setRowSelection` updater, where a selection *action* + * overshoots the cap and there is a meaningful "before" set to prioritise: + * for default index-based row ids `Object.keys` yields ascending numeric + * order rather than selection order, so slicing the raw list would keep + * whichever rows sort lowest instead of the ones already held. + * + * The effect that reacts to `maxSelectedRows` itself shrinking deliberately + * does *not* use this — every selected row there is equally pre-existing, so + * there is nothing to prioritise against. See its own comment. + */ +function clampSelection( + selectedIds: string[], + previous: RowSelectionState, + max: number +): string[] { + return [ + ...selectedIds.filter((id) => previous[id]), + ...selectedIds.filter((id) => !previous[id]), + ].slice(0, max) +} + +/** Row-level Enter/Space activation, gated by the edit lock. */ +function buildRowKeyDownHandler( + row: Row, + onRowClick: DataTableProps["onRowClick"], + blocked: (action: DataTableBlockedAction) => boolean +) { + return (event: React.KeyboardEvent) => + activateRowByKeyboard(event, () => { + if (!blocked("rowClick")) { + onRowClick?.( + row, + event as unknown as React.MouseEvent + ) + } + }) +} + +/** + * Row `onClick`: the consumer's raw `slotProps.row.onClick` always fires + * (it's a DOM passthrough they attached themselves) — DataTable's own edit + * lock governs only its own `onRowClick` dispatch, gated on `onRowClick` + * being set rather than on `rowIsClickable` so a table with no `onRowClick` + * never reports `onInteractionBlocked({ action: "rowClick" })` for stray + * clicks during an edit. + */ +function buildRowClickHandler( + row: Row, + rowOnClick: + | ((event: React.MouseEvent) => void) + | undefined, + onRowClick: DataTableProps["onRowClick"], + blocked: (action: DataTableBlockedAction) => boolean +) { + return (event: React.MouseEvent) => { + rowOnClick?.(event) + if (onRowClick && !blocked("rowClick")) { + onRowClick(row, event) + } + } +} + +/** Row ref: the sortable node ref, plus a handle on the row being edited. */ +/** Stable id for the currently-edited row's `
`s are raw elements, so unlike real + // header cells they never get Table's `z-10`, and at + // `auto` a pinned body cell (`pinnedCell`) scrolled + // straight over the filter row. + zIndex: DATA_TABLE_Z.stickyHeaderCell, + } + : undefined), + ...(column.getIsPinned() && stickyHeader + ? { zIndex: DATA_TABLE_Z.pinnedHeaderCell } + : undefined), + }} + > +
+ {column.getCanFilter() ? renderColumnFilter(column) : null} +
+
+ )} +
+
+
+ {caption && {caption}} + {/* + * Each SortableContext is scoped to the region whose items it lists. + * `useSortable` resolves to the *nearest* one, so wrapping the whole + * table in both (column inside row, as the DndContext nesting below + * implies) made body rows resolve against `reorderableLeafIds` — index + * -1, horizontal-axis modifier, and the row id handed to the column + * drag handler, which bails. Row reorder silently did nothing whenever + * `enableColumnReorder` was also on. SortableContext renders no DOM, so + * scoping it this way is safe inside `
` (a DndContext is not — + * its accessibility markup would render as a div child of the table). + */} + {enableColumnReorder ? ( + + {headerContent} + + ) : ( + headerContent + )} + {rowReorderActive ? ( + + {bodyContent} + + ) : ( + bodyContent + )} + {footerContent} +
+ ) + + /* + * One DndContext for both axes. Nesting two of them meant the inner one + * captured every drag — `useDraggable` resolves to the nearest context + * just as `useSortable` does — so with both reorder flags on, row drags + * were routed to the column handler. Dispatch on what is actually being + * dragged instead, and pick the axis modifier to match. + */ + const isColumnDrag = (event: { + active: { id: string | number; data: { current?: { type?: string } } } + }) => event.active.data.current?.type === "column" + const handleDragEnd = (event: DragEndEvent) => { + if (enableColumnReorder && isColumnDrag(event)) { + handleColumnDragEnd(event) + return + } + if (rowReorderActive) { + handleRowDragEnd(event) + } + } + + let scrollBody: ReactNode = tableEl + if (enableColumnReorder || rowReorderActive) { + scrollBody = ( + { + handleDragEnd(event) + setActiveDragAxis(null) + }} + onDragStart={(event) => + setActiveDragAxis( + enableColumnReorder && isColumnDrag(event) ? "column" : "row" + ) + } + sensors={sensors} + > + {scrollBody} + + ) + } + + const ctxValue: DataTableContextValue = { + table, + pageSizeOptions, + translations, + locked, + blocked, + size, + paginationProps, + toolbarActions, + } + + return ( + } + > +
+ + {statusMessage} + + {(enableGlobalFilter || + enableColumnVisibility || + toolbarActions?.length || + renderToolbar) && + (renderToolbar ? ( + renderToolbar(table) + ) : ( + + {enableGlobalFilter && } +
+ {enableColumnVisibility && } + +
+
+ ))} +
+ {scrollBody} +
+ {enablePagination && } +
+
+ ) +} + +/* ── Built-in leading columns ────────────────────────────────────────────── */ + +function buildColumns({ + userColumns, + enableRowReorder, + enableRowSelection, + locked, + getRowLabel, + selectAllLabel, + showSelectAll, +}: { + userColumns: ColumnDef[] + enableRowReorder: boolean + enableRowSelection: boolean + locked: boolean + getRowLabel?: (row: Row) => string + selectAllLabel: string + showSelectAll: boolean +}): ColumnDef[] { + const leading: ColumnDef[] = [] + + if (enableRowReorder) { + leading.push({ + id: DRAG_COLUMN_ID, + header: () => null, + // Cell body is replaced by the drag handle in renderBodyRow. + cell: () => null, + enableSorting: false, + enableColumnFilter: false, + size: 40, + meta: { width: 40 }, + }) + } + + if (enableRowSelection) { + leading.push({ + id: SELECTION_COLUMN_ID, + header: ({ table }) => + showSelectAll ? ( + + ) : null, + cell: ({ row }) => ( + e.stopPropagation()} + /> + ), + enableSorting: false, + enableColumnFilter: false, + size: 44, + meta: { width: 44 }, + }) + } + + return [...leading, ...userColumns] +} + +/* ── Composable sub-components ────────────────────────────────────────────── */ + +DataTable.Toolbar = function DataTableToolbar({ + children, +}: { + children: ReactNode +}) { + const styles = dataTableVariants() + return
{children}
+} + +DataTable.GlobalSearch = function DataTableGlobalSearch({ + className, +}: { + className?: string +}) { + const { table, translations, locked, blocked, size } = useDataTableContext() + const styles = dataTableVariants() + return ( + /* SearchForm renders
, and its `className` lands on the inner + * form — so the flex sizing has to go on a wrapper the toolbar can actually + * stretch. */ +
+ event.preventDefault()} + onValueChange={(value) => { + if (blocked("globalFilter")) { + return + } + table.setGlobalFilter(value) + }} + size={size} + value={(table.state.globalFilter as string) ?? ""} + > + + + + {/* `gapped` defaults to false, so the button is joined to the input + * with the touching corners squared off. */} + + + +
+ ) +} + +/** + * Consumer-supplied toolbar actions, trailing the search. Capped at + * `DATA_TABLE_MAX_TOOLBAR_ACTIONS` by convention only — see the warning below. + */ +DataTable.ToolbarActions = function DataTableToolbarActions() { + const { toolbarActions, size, locked } = useDataTableContext() + const styles = dataTableVariants() + const count = toolbarActions?.length ?? 0 + + const hasWarnedAboutToolbarOverflow = useRef(false) + useEffect(() => { + if (count <= DATA_TABLE_MAX_TOOLBAR_ACTIONS) { + hasWarnedAboutToolbarOverflow.current = false + return + } + if ( + hasWarnedAboutToolbarOverflow.current || + typeof process === "undefined" || + process.env?.NODE_ENV === "production" + ) { + return + } + hasWarnedAboutToolbarOverflow.current = true + console.warn( + `[DataTable] toolbarActions has ${count} actions; at most ${DATA_TABLE_MAX_TOOLBAR_ACTIONS} is recommended. They still render, but the toolbar gets crowded — consider moving the extras into a menu.` + ) + }, [count]) + + if (!count) { + return null + } + + return ( +
+ {toolbarActions?.map( + ({ id, label, children, disabled, ...action }, i) => ( + + ) + )} +
+ ) +} + +DataTable.ColumnVisibility = function DataTableColumnVisibility() { + const { table, translations, blocked, size } = useDataTableContext() + const hideableColumns = table + .getAllLeafColumns() + .filter( + (c) => + c.getCanHide() && ![SELECTION_COLUMN_ID, DRAG_COLUMN_ID].includes(c.id) + ) + + const items: MenuItem[] = hideableColumns.map((column) => ({ + type: "checkbox", + value: column.id, + label: columnLabel(column), + checked: column.getIsVisible(), + })) + + return ( + + } + items={items} + onCheckedChange={(item) => { + if (item.type === "checkbox" && !blocked("columnVisibility")) { + table.getColumn(item.value)?.toggleVisibility() + } + }} + size={size} + /> + ) +} + +DataTable.Pagination = function DataTablePagination() { + const { + table, + pageSizeOptions, + translations, + locked, + blocked, + size, + paginationProps, + } = useDataTableContext() + const state = table.state.pagination + /* + * `Pagination` builds its page list from `count`/`pageSize` and has no + * `pageCount` prop, while `getRowCount()` is + * `options.rowCount ?? prePaginatedRowModel.rows.length`. So a consumer + * following the documented server-side contract with `manualPagination` + + * `pageCount` (but no `rowCount`) handed us a single page of rows, the + * pager rendered exactly one page, and every other page was unreachable. + * + * Fall back to the span `pageCount` implies. Exact whenever `rowCount` is + * given; otherwise an upper bound on the last page, which is the most + * `pageCount` alone can express. + */ + const declaredPageCount = table.options.pageCount + const total = + table.options.rowCount ?? + (declaredPageCount != null && declaredPageCount >= 0 + ? declaredPageCount * state.pageSize + : table.getRowCount()) + const start = total === 0 ? 0 : state.pageIndex * state.pageSize + 1 + const end = Math.min((state.pageIndex + 1) * state.pageSize, total) + const styles = dataTableVariants() + + const pageSizeItems: SelectItem[] = pageSizeOptions.map((n) => ({ + label: String(n), + value: String(n), + })) + + return ( +
+ + {translations.rangeLabel({ start, end, total })} + +
+ "#"} + // `Pagination` renders its items as links, and a real `href="#"` + // navigates: the viewport jumps to the top of the document and a + // `#` entry is pushed onto history on every page change. Paging + // here is driven entirely by `onPageChange`, so the default is + // pure side effect. Zag's `mergeProps` composes handlers, so + // suppressing it does not stop the page from changing. + // + // Declared *after* the `paginationProps` spread and composed with + // whatever the consumer passed, so supplying `linkProps` (say, to + // add a `data-testid`) cannot silently drop the guard. + linkProps={{ + ...paginationProps?.linkProps, + onClick: (event: React.MouseEvent) => { + paginationProps?.linkProps?.onClick?.(event) + event.preventDefault() + }, + }} + onPageChange={(page) => { + if (!blocked("paginate")) { + table.setPageIndex(page - 1) + } + }} + page={state.pageIndex + 1} + pageSize={state.pageSize} + /> + {/* The page-size control is labelled only for assistive tech; the + design shows the bare select next to the pager. */} + { + if (!blocked("paginate")) { + table.setPageSize(Number(v)) + } + }} + size={size} + value={String(state.pageSize)} + /> +
+
+ ) +} + +DataTable.displayName = "DataTable" diff --git a/libs/ui/src/organisms/table.tsx b/libs/ui/src/organisms/table.tsx index afa6c4661d..9fb5c5a8d3 100644 --- a/libs/ui/src/organisms/table.tsx +++ b/libs/ui/src/organisms/table.tsx @@ -2,7 +2,7 @@ * Table — @techsio/ui-kit organism. * * @component Table - * @componentVersion v1.0.0 + * @componentVersion v1.1.0 * @skill table-usage * @changelog libs/ui/stories/changelog/changelog.stories.tsx * @@ -30,11 +30,18 @@ const tableVariants = tv({ "data-[selected=true]:bg-table-row-bg-selected", "transition-colors duration-200 motion-reduce:transition-none", ], + /* `numeric` states that a value *is* a number; `data-align` is a pure + * presentation choice. Set one or the other — combining `numeric` with a + * conflicting `data-align` leaves the winner up to stylesheet order. */ columnHeader: [ "text-start data-[numeric=true]:text-end", + "data-[align=center]:text-center data-[align=start]:text-start data-[align=end]:text-end", "font-table-header", ], - cell: ["text-start data-[numeric=true]:text-end"], + cell: [ + "text-start data-[numeric=true]:text-end", + "data-[align=center]:text-center data-[align=start]:text-start data-[align=end]:text-end", + ], }, variants: { variant: { @@ -113,7 +120,7 @@ const tableVariants = tv({ }) // Context for sharing state between sub-components -interface TableContextValue { +type TableContextValue = { variant?: "line" | "outline" | "striped" size?: "sm" | "md" | "lg" interactive?: boolean @@ -291,10 +298,20 @@ Table.Row = function TableRow({ ) } +/** + * Horizontal alignment of a header or body cell. Typed so a misspelling like + * `"centre"` fails the build instead of silently rendering unaligned — passing + * `data-align` through the prop spread still works for existing call sites. + */ +export type TableAlign = "start" | "center" | "end" + // ColumnHeader component -interface TableColumnHeaderProps extends ComponentPropsWithoutRef<"th"> { +// `align` is a deprecated HTML attribute typed as a bare string on th/td; +// omitting it lets the typed prop above take the name. +type TableColumnHeaderProps = Omit, "align"> & { ref?: RefObject numeric?: boolean + align?: TableAlign } Table.ColumnHeader = function TableColumnHeader({ @@ -302,6 +319,7 @@ Table.ColumnHeader = function TableColumnHeader({ ref, className, numeric, + align, ...props }: TableColumnHeaderProps) { const { styles } = useTableContext() @@ -309,6 +327,7 @@ Table.ColumnHeader = function TableColumnHeader({ return (
{ +type TableCellProps = Omit, "align"> & { ref?: RefObject numeric?: boolean + align?: TableAlign } Table.Cell = function TableCell({ @@ -330,6 +350,7 @@ Table.Cell = function TableCell({ ref, className, numeric, + align, ...props }: TableCellProps) { const { styles, stickyFirstColumn } = useTableContext() @@ -337,6 +358,7 @@ Table.Cell = function TableCell({ return ( ` *does* paint under `border-collapse` in + * Blink (Chromium 149) — a differential screenshot of a plain vs tinted row in + * a collapsed table measures 255,255,255 vs 178,178,178. This has been + * reported several times as a no-op; it is not one in Chrome. Not verified in + * WebKit/Safari, so if the tint is ever confirmed missing there, the fix is a + * `background-image` gradient layer (as `data-table-frozen-cell` uses), which + * composes with `striped` without the Tailwind-merge conflict a `background` + * would cause. + */ +@utility data-table-row-nested-tint { + box-shadow: inset 0 0 0 9999px var(--color-table-row-bg-hover); +} + +/* + * DataTable — frozen (pinned / sticky-actions) cell surface. + * + * A frozen cell must be opaque, or the horizontally-scrolled content passes + * straight under it. It must *also* show the row's own colour, or striping and + * selection stop dead at the frozen column. + * + * `background: inherit` gives the second without the first: every row-level + * tint in this system is an alpha overlay meant to composite over a surface + * (`--color-table-row-striped-secondary` is literally `oklch(0 0 0 / 0)`), so + * inheriting it yields a see-through cell. + * + * Compositing solves both at once — the same trick `OPAQUE_HEADER_BG` uses for + * the header. The opaque table surface is the background-*color*; the row tint + * is painted over it as a gradient image, read from a custom property the row + * sets. Custom properties inherit, so the row can drive its cells' tint + * without either knowing about the other. + */ +@utility data-table-frozen-cell { + background-color: var(--color-table-bg); + background-image: linear-gradient( + var(--dt-row-tint, transparent), + var(--dt-row-tint, transparent) + ); +} diff --git a/libs/ui/stories/changelog/changelog.stories.tsx b/libs/ui/stories/changelog/changelog.stories.tsx index ce5d9e0fb2..e6d3b3715d 100644 --- a/libs/ui/stories/changelog/changelog.stories.tsx +++ b/libs/ui/stories/changelog/changelog.stories.tsx @@ -23,6 +23,9 @@ const CHANGELOG = ` ### Accordion v1.0.0 - Opted into per-component versioning; paired 1:1 with the accordion-usage skill and this changelog entry, enforced by the check-skill-sync pre-commit gate. +### DataTable v1.0.0 +- New headless data-grid organism built on \`@tanstack/react-table\` v9, rendering into the presentational \`Table\` organism so it inherits the \`--color-table-*\` tokens. Covers sorting, conditional column filters, global search, row selection, column visibility/pinning/reorder, row reorder, tree/expanding rows, inline edit, colSpan/rowSpan, virtualization/infinite scroll and pagination. Every feature exposes a callback for Storybook interaction tests. Paired 1:1 with the data-table-usage skill and this changelog entry. + ### ActionIcon v1.0.0 - Opted into per-component versioning; paired 1:1 with the action-icon-usage skill and this changelog entry, enforced by the check-skill-sync pre-commit gate. @@ -134,6 +137,9 @@ const CHANGELOG = ` ### Switch v1.0.0 - Opted into per-component versioning; paired 1:1 with the switch-usage skill and this changelog entry, enforced by the check-skill-sync pre-commit gate. +### Table v1.1.0 +- Cells and column headers style horizontal alignment from \`data-align\` (\`start | center | end\`). Unlike \`numeric\`, which asserts the value *is* a number, \`data-align\` is a pure presentation choice, so icon/boolean columns can be centred. Set one or the other, not both. + ### Table v1.0.0 - Opted into per-component versioning; paired 1:1 with the table-usage skill and this changelog entry, enforced by the check-skill-sync pre-commit gate. diff --git a/libs/ui/stories/organisms/data-table.stories.tsx b/libs/ui/stories/organisms/data-table.stories.tsx new file mode 100644 index 0000000000..640bc23c0c --- /dev/null +++ b/libs/ui/stories/organisms/data-table.stories.tsx @@ -0,0 +1,1482 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { type ComponentType, useState } from "react" +import { expect, fn, userEvent, within } from "storybook/test" +import { ActionIcon } from "../../src/atoms/action-icon" +import { Badge } from "../../src/atoms/badge" +import { Input } from "../../src/atoms/input" +import { Select } from "../../src/molecules/select" +import { + type CellContext, + type ColumnDef, + DataTable, + type DataTableFilterContext, + type DataTableGetCellSpan, + type DataTableOption, + type DataTableProps, +} from "../../src/organisms/data-table" + +const SALARY_BANDS = [ + { label: "Any band", value: "" }, + { label: "Junior (< 5000)", value: "junior", min: "0", max: "4999" }, + { label: "Senior (5000+)", value: "senior", min: "5000", max: "99999" }, +] + +/* ── Sample data ─────────────────────────────────────────────────────────── */ + +type Person = { + id: string + firstName: string + lastName: string + email: string + role: string + status: "active" | "invited" | "suspended" + age: number + visits: number +} + +const people: Person[] = [ + { id: "1", firstName: "Ada", lastName: "Lovelace", email: "ada@calc.io", role: "Admin", status: "active", age: 36, visits: 812 }, + { id: "2", firstName: "Alan", lastName: "Turing", email: "alan@calc.io", role: "Admin", status: "active", age: 41, visits: 640 }, + { id: "3", firstName: "Grace", lastName: "Hopper", email: "grace@navy.mil", role: "Editor", status: "invited", age: 45, visits: 305 }, + { id: "4", firstName: "Katherine", lastName: "Johnson", email: "kat@nasa.gov", role: "Editor", status: "active", age: 52, visits: 210 }, + { id: "5", firstName: "Margaret", lastName: "Hamilton", email: "maggie@nasa.gov", role: "Viewer", status: "suspended", age: 33, visits: 98 }, + { id: "6", firstName: "Dennis", lastName: "Ritchie", email: "dmr@bell.labs", role: "Admin", status: "active", age: 48, visits: 540 }, + { id: "7", firstName: "Ken", lastName: "Thompson", email: "ken@bell.labs", role: "Editor", status: "invited", age: 47, visits: 430 }, + { id: "8", firstName: "Barbara", lastName: "Liskov", email: "barbara@mit.edu", role: "Viewer", status: "active", age: 39, visits: 156 }, + { id: "9", firstName: "Linus", lastName: "Torvalds", email: "linus@kernel.org", role: "Editor", status: "active", age: 44, visits: 999 }, + { id: "10", firstName: "Radia", lastName: "Perlman", email: "radia@net.io", role: "Viewer", status: "suspended", age: 50, visits: 77 }, +] + +const bigData: Person[] = Array.from({ length: 500 }, (_, i) => { + const src = people[i % people.length] as Person + return { ...src, id: `row-${i}`, firstName: `${src.firstName} ${i}` } +}) + +const ROLE_OPTIONS = [ + { label: "Admin", value: "Admin" }, + { label: "Editor", value: "Editor" }, + { label: "Viewer", value: "Viewer" }, +] + +const STATUS_VARIANT: Record = { + active: "success", + invited: "info", + suspended: "danger", +} + +const StatusBadge = ({ status }: { status: Person["status"] }) => ( + {status} +) + +const columns: ColumnDef[] = [ + { + accessorKey: "firstName", + header: "First name", + filterFn: "conditional", + meta: { filterVariant: "text" }, + }, + { + accessorKey: "lastName", + header: "Last name", + filterFn: "conditional", + meta: { filterVariant: "text" }, + }, + { + accessorKey: "email", + header: "Email", + filterFn: "conditional", + meta: { filterVariant: "text" }, + }, + { + accessorKey: "role", + header: "Role", + // No `filterFn: "conditional"`: a select has no operators to pick, so the + // default `"typed"` filter (which matches the enum's `{ values }` shape) + // is the right pairing and the clearer example. + meta: { filterVariant: "select", filterOptions: ROLE_OPTIONS }, + }, + { + accessorKey: "status", + header: "Status", + cell: (info) => ()} />, + }, + { + accessorKey: "age", + header: "Age", + filterFn: "conditional", + meta: { align: "end", filterVariant: "number" }, + }, + { + accessorKey: "visits", + header: "Visits", + filterFn: "conditional", + meta: { align: "end", filterVariant: "range" }, + }, +] + +/* ── Meta ────────────────────────────────────────────────────────────────── */ + +const meta = { + title: "Organisms/DataTable", + component: DataTable as ComponentType>, + parameters: { layout: "padded" }, + tags: ["autodocs"], + argTypes: { + variant: { control: "select", options: ["line", "outline", "striped"] }, + size: { control: "select", options: ["sm", "md", "lg"] }, + stickyHeader: { control: "boolean" }, + striped: { control: "boolean" }, + hideHeader: { control: "boolean" }, + }, +} satisfies Meta> + +export default meta +type Story = StoryObj> + +const base = { columns, data: people } + +/* ── 1. Playground ───────────────────────────────────────────────────────── */ + +export const Playground: Story = { + args: { + ...base, + enableSorting: true, + enableGlobalFilter: true, + enableColumnFilters: true, + enableRowSelection: true, + enableColumnVisibility: true, + enablePagination: true, + caption: "Team members", + onRowClick: fn(), + onSortingChange: fn(), + onRowSelectionChange: fn(), + }, +} + +/* ── 2. Sorting ──────────────────────────────────────────────────────────── */ + +export const Sorting: Story = { + args: { ...base, enableSorting: true, onSortingChange: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole("button", { name: /Age/i })) + await expect(args.onSortingChange).toHaveBeenCalled() + }, +} + +/* ── 3. Column filters with conditions ───────────────────────────────────── */ + +export const ColumnFiltersWithConditions: Story = { + args: { ...base, enableColumnFilters: true, onColumnFiltersChange: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + const valueInput = canvas.getByLabelText("Filter value for First name") + await userEvent.type(valueInput, "Ada") + await expect(args.onColumnFiltersChange).toHaveBeenCalled() + }, +} + +/* ── 4. Header filter template (custom slot) ─────────────────────────────── */ + +export const HeaderFilterTemplate: Story = { + args: { + ...base, + enableColumnFilters: true, + onColumnFiltersChange: fn(), + renderHeaderFilter: (column) => + column.id === "email" ? ( + + column.setFilterValue({ operator: "contains", value: e.target.value }) + } + placeholder="Search e-mail…" + size="sm" + /> + ) : null, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.type(canvas.getByLabelText("Custom email filter"), "nasa") + await expect(args.onColumnFiltersChange).toHaveBeenCalled() + }, +} + +/* ── 5. Global fulltext search ───────────────────────────────────────────── */ + +export const GlobalSearch: Story = { + args: { ...base, enableGlobalFilter: true, onGlobalFilterChange: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + await userEvent.type(canvas.getByLabelText("Search"), "Hopper") + await expect(args.onGlobalFilterChange).toHaveBeenCalled() + }, +} + +/* ── 5b. Toolbar: search fills the row, custom actions trail it ──────────── */ + +export const ToolbarActions: Story = { + args: { + ...base, + enableGlobalFilter: true, + onGlobalFilterChange: fn(), + toolbarActions: [ + { + id: "refresh", + "aria-label": "Refresh", + icon: "icon-[mdi--refresh]", + theme: "outlined", + variant: "secondary", + onClick: fn(), + }, + { + id: "export", + label: "Export", + icon: "icon-[mdi--tray-arrow-down]", + variant: "warning", + onClick: fn(), + }, + { + id: "filters", + label: "Filtry", + icon: "token-icon-chevron-down", + iconPosition: "right", + variant: "secondary", + onClick: fn(), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole("button", { name: "Export" })).toBeVisible() + await expect(canvas.getByRole("button", { name: "Refresh" })).toBeVisible() + + // The search is joined to its submit button and stretches to fill the row. + await expect(canvas.getByLabelText("Submit search")).toBeVisible() + + // Typing reveals the SearchForm clear button, which empties the field. + const input = canvas.getByLabelText("Search") + await userEvent.type(input, "Hopper") + await userEvent.click(canvas.getByLabelText("Clear search")) + await expect(input).toHaveValue("") + }, +} + +/** + * Toolbar actions on their own, with no global search and no column-visibility + * cog. The toolbar has to render for these alone — it used to be gated on the + * other two, so a table configured this way silently dropped its actions. + */ +export const ToolbarActionsOnly: Story = { + args: { + ...base, + toolbarActions: [ + { + id: "export", + label: "Export", + icon: "icon-[mdi--tray-arrow-down]", + variant: "warning", + onClick: fn(), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect( + canvas.getByRole("button", { name: /Export/i }) + ).toBeInTheDocument() + }, +} + +/* ── 6. Empty state ──────────────────────────────────────────────────────── */ + +export const EmptyState: Story = { + args: { + ...base, + data: [], + translations: { + emptyTitle: "No team members", + emptyDescription: "Invite someone to get started.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // The title also appears in the sr-only aria-live status region, so both the + // visible empty block and the announcement are expected. + await expect(canvas.getAllByText("No team members")).toHaveLength(2) + await expect( + canvas.getByText("Invite someone to get started.") + ).toBeInTheDocument() + }, +} + +/* ── 7. Row actions ──────────────────────────────────────────────────────── */ + +const onDeleteClick = fn() + +export const RowActions: Story = { + args: { + ...base, + renderRowActions: (row) => ( + onDeleteClick(row.original.id)} + size="sm" + tone="danger" + /> + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByLabelText("Delete Ada")) + await expect(onDeleteClick).toHaveBeenCalledWith("1") + }, +} + +/* ── 8b. Column widths + text alignment ──────────────────────────────────── */ + +export const ColumnWidthsAndAlignment: Story = { + args: { + ...base, + tableLayout: "fixed", + columns: [ + { + accessorKey: "firstName", + header: "First name", + meta: { width: 120 }, + }, + { + accessorKey: "email", + header: "Email", + meta: { width: "var(--dimension-200)", align: "start" }, + }, + { + accessorKey: "status", + header: "Status", + cell: (info) => ( + ()} /> + ), + meta: { width: 140, align: "center" }, + }, + { + accessorKey: "age", + header: "Age", + meta: { width: 80, align: "end" }, + }, + { + accessorKey: "visits", + header: "Visits", + meta: { width: "15%", align: "end" }, + }, + ] as ColumnDef[], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const ageHeader = canvas.getByRole("columnheader", { name: /Age/ }) + await expect(ageHeader).toHaveAttribute("data-align", "end") + await expect(ageHeader).toHaveStyle({ width: "80px" }) + + const statusHeader = canvas.getByRole("columnheader", { name: /Status/ }) + await expect(statusHeader).toHaveAttribute("data-align", "center") + }, +} + +/** + * A numeric `meta.width` must also reach TanStack's size model, otherwise the + * sticky offset of the next pinned column is computed from the default 150 and + * the frozen block drifts out of alignment. + */ +export const FrozenColumnWidths: Story = { + args: { + ...base, + tableLayout: "fixed", + maxHeight: "320px", + enableColumnPinning: true, + columnPinning: { end: [], start: ["firstName", "lastName"] }, + // Widths deliberately overflow the container: with `table-layout: fixed` any + // leftover space is redistributed across columns, which would make the + // rendered widths drift from the declared ones. Frozen columns only make + // sense when the table scrolls horizontally anyway. + columns: [ + { accessorKey: "firstName", header: "First name", meta: { width: 200 } }, + { accessorKey: "lastName", header: "Last name", meta: { width: 240 } }, + { accessorKey: "email", header: "Email", meta: { width: 400 } }, + { accessorKey: "role", header: "Role", meta: { width: 300 } }, + { accessorKey: "age", header: "Age", meta: { width: 200, align: "end" } }, + { accessorKey: "visits", header: "Visits", meta: { width: 300 } }, + ] as ColumnDef[], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const firstName = canvas.getByRole("columnheader", { name: /First name/ }) + const lastName = canvas.getByRole("columnheader", { name: /Last name/ }) + // The second pinned column must start exactly where the first one ends, + // which only holds if the declared width also reached TanStack's size model. + await expect(lastName).toHaveStyle({ left: "200px" }) + await expect(Math.round(firstName.getBoundingClientRect().width)).toBe(200) + }, +} + +/* ── 9. Frozen columns (left + right) ────────────────────────────────────── */ + +export const FrozenColumns: Story = { + args: { + ...base, + enableColumnPinning: true, + columnPinning: { end: ["visits"], start: ["firstName"] }, + maxHeight: "320px", + }, +} + +/* ── 10. Sticky header ───────────────────────────────────────────────────── */ + +export const StickyHeader: Story = { + args: { ...base, data: bigData.slice(0, 40), stickyHeader: true, maxHeight: "300px" }, +} + +/* ── 10a. Grouped headers, sticky, with a filter row ─────────────────────── */ + +/** + * Grouped headers stack two label rows above the filter row. Each row needs its + * own sticky offset — `Table.ColumnHeader` sticks them all at `top: 0` by + * default, which piles them on top of each other — and the filter row has to + * clear both. Scroll the body to check nothing overlaps. + */ +export const GroupedStickyHeader: Story = { + args: { + data: bigData.slice(0, 40), + stickyHeader: true, + maxHeight: "320px", + enableColumnFilters: true, + columns: [ + { + header: "Person", + columns: [ + { accessorKey: "firstName", header: "First name" }, + { accessorKey: "lastName", header: "Last name" }, + ], + }, + { + header: "Activity", + columns: [ + { accessorKey: "age", header: "Age", meta: { type: "number" } }, + { accessorKey: "visits", header: "Visits", meta: { type: "number" } }, + ], + }, + ] satisfies ColumnDef[], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const rows = canvasElement.querySelectorAll("thead tr") + // Two label rows plus the filter row. + await expect(rows.length).toBe(3) + await expect(canvas.getByText("Person")).toBeInTheDocument() + const topOf = (index: number) => + Number.parseFloat( + getComputedStyle( + rows[index]?.querySelector("th, td") as HTMLElement + ).top + ) + // Each row clears the one above it instead of stacking at 0. + await expect(topOf(0)).toBe(0) + await expect(topOf(1)).toBeGreaterThan(0) + await expect(topOf(2)).toBeGreaterThan(topOf(1)) + }, +} + +/* ── 10b. Hidden header (headerless layout) ──────────────────────────────── */ + +export const HiddenHeader: Story = { + args: { ...base, hideHeader: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + // Headers stay in the accessibility tree so the table keeps its column + // names; `hideHeader` only hides them visually. + const headers = canvas.getAllByRole("columnheader") + await expect(headers.length).toBeGreaterThan(0) + // `hideHeader` puts `sr-only` on the header `
+ + + Product + In stock + Price + + + + {sampleProducts.map((product) => ( + + {product.name} + + {product.stock > 0 ? 'Yes' : 'No'} + + + ${product.price.toFixed(2)} + + + ))} + +
+ ), +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f418dad6fd..6a303c6677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,25 +5,6 @@ settings: excludeLinksFromLockfile: false injectWorkspacePackages: true -catalogs: - vite5-testing: - vite: - specifier: 5.4.21 - version: 5.4.21 - vitest: - specifier: ^3.2.7 - version: 3.2.7 - vite8-testing: - '@vitest/coverage-v8': - specifier: ^4.1.5 - version: 4.1.5 - vite: - specifier: 8.1.3 - version: 8.1.3 - vitest: - specifier: ^4.1.5 - version: 4.1.5 - overrides: csstype: 3.1.3 react: 19.2.3 @@ -211,7 +192,7 @@ importers: version: 2.18.0 '@medusajs/types': specifier: ^2.18.0 - version: 2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-form': specifier: ^1.27.4 version: 1.27.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -220,7 +201,7 @@ importers: version: 5.90.10(react@19.2.3) '@techsio/storefront-data': specifier: workspace:* - version: file:libs/storefront-data(@medusajs/js-sdk@2.18.0)(@medusajs/types@2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)))(@tanstack/react-query@5.90.10(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: file:libs/storefront-data(@medusajs/js-sdk@2.18.0)(@medusajs/types@2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)))(@tanstack/react-query@5.90.10(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@techsio/storefront-i18n': specifier: workspace:* version: file:libs/storefront-i18n(@medusajs/js-sdk@2.18.0)(next-intl@4.13.4(@swc/helpers@0.5.23)(next@16.3.0-preview.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.84.0))(react@19.2.3)(typescript@5.9.3)) @@ -359,7 +340,7 @@ importers: version: 2.18.0 '@medusajs/medusa': specifier: ^2.18.0 - version: 2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc) + version: 2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac) '@medusajs/ui': specifier: ^4.2.0 version: 4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) @@ -513,7 +494,7 @@ importers: devDependencies: '@medusajs/medusa-oas-cli': specifier: ^2.18.0 - version: 2.18.0(eba74a54e57d941e51e9650c0f97fc4b) + version: 2.18.0(bbf6064d62da1a7267c643cbf91691d5) '@medusajs/test-utils': specifier: ^2.18.0 version: 2.18.0(4fc20f7692e271715c16fc3027a5bef6) @@ -1011,6 +992,18 @@ importers: libs/ui: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.3) '@iconify-json/mdi': specifier: ^1.2.3 version: 1.2.3 @@ -1023,6 +1016,12 @@ importers: '@iconify/tailwind4': specifier: ^1.1.0 version: 1.1.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tailwindcss@4.1.17) + '@tanstack/react-table': + specifier: ^9.1.2 + version: 9.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': + specifier: ^3.14.8 + version: 3.14.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: 19.2.3 version: 19.2.3 @@ -8933,6 +8932,12 @@ packages: peerDependencies: react: 19.2.3 + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: 19.2.3 + react-dom: 19.2.3 + '@tanstack/react-store@0.8.0': resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} peerDependencies: @@ -8946,12 +8951,21 @@ packages: react: 19.2.3 react-dom: 19.2.3 + '@tanstack/react-table@9.1.2': + resolution: {integrity: sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==} + engines: {node: '>=20'} + peerDependencies: + react: 19.2.3 + '@tanstack/react-virtual@3.14.8': resolution: {integrity: sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==} peerDependencies: react: 19.2.3 react-dom: 19.2.3 + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} @@ -8962,6 +8976,10 @@ packages: resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} engines: {node: '>=12'} + '@tanstack/table-core@9.1.2': + resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==} + engines: {node: '>=20'} + '@tanstack/virtual-core@3.17.6': resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==} @@ -23823,9 +23841,9 @@ snapshots: dependencies: '@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3) - '@medusajs/medusa-oas-cli@2.18.0(eba74a54e57d941e51e9650c0f97fc4b)': + '@medusajs/medusa-oas-cli@2.18.0(bbf6064d62da1a7267c643cbf91691d5)': dependencies: - '@medusajs/medusa': 2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc) + '@medusajs/medusa': 2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac) '@medusajs/utils': 2.18.0(@types/node@24.12.4)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1) '@readme/json-schema-ref-parser': 1.2.0 '@readme/openapi-parser': 2.7.0(openapi-types@12.1.3) @@ -23886,7 +23904,7 @@ snapshots: - yalc - yaml - '@medusajs/medusa@2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc)': + '@medusajs/medusa@2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac)': dependencies: '@inquirer/checkbox': 2.5.0 '@inquirer/confirm': 2.0.17 @@ -24296,7 +24314,7 @@ snapshots: - supports-color - tedious - '@medusajs/types@2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))': + '@medusajs/types@2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@medusajs/deps': 2.18.0(@types/node@24.12.4)(mysql2@3.15.3)(supports-color@8.1.1) bignumber.js: 9.3.1 @@ -24598,7 +24616,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.3 optional: true '@napi-rs/wasm-runtime@1.1.1': @@ -27546,7 +27564,7 @@ snapshots: '@medusajs/admin-sdk': 2.18.0 '@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3) '@medusajs/js-sdk': 2.18.0 - '@medusajs/medusa': 2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc) + '@medusajs/medusa': 2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac) '@medusajs/ui': 4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@medusajs/utils': 2.18.0(@types/node@24.12.4)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1) '@medusajs/workflows-sdk': 2.18.0(@types/node@24.12.4)(express@4.22.2(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1) @@ -28524,6 +28542,13 @@ snapshots: '@tanstack/query-core': 5.90.14 react: 19.2.3 + '@tanstack/react-store@0.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + '@tanstack/react-store@0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/store': 0.8.0 @@ -28537,24 +28562,38 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@tanstack/react-table@9.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/react-store': 0.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/table-core': 9.1.2 + react: 19.2.3 + transitivePeerDependencies: + - react-dom + '@tanstack/react-virtual@3.14.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/virtual-core': 3.17.6 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@tanstack/store@0.11.1': {} + '@tanstack/store@0.7.7': {} '@tanstack/store@0.8.0': {} '@tanstack/table-core@8.20.5': {} + '@tanstack/table-core@9.1.2': + dependencies: + '@tanstack/store': 0.11.1 + '@tanstack/virtual-core@3.17.6': {} - '@techsio/storefront-data@file:libs/storefront-data(@medusajs/js-sdk@2.18.0)(@medusajs/types@2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)))(@tanstack/react-query@5.90.10(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@techsio/storefront-data@file:libs/storefront-data(@medusajs/js-sdk@2.18.0)(@medusajs/types@2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)))(@tanstack/react-query@5.90.10(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@medusajs/js-sdk': 2.18.0 - '@medusajs/types': 2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + '@medusajs/types': 2.18.0(@types/node@24.12.4)(ioredis@5.11.1(supports-color@8.1.1))(supports-color@8.1.1)(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-query': 5.90.10(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -28587,10 +28626,17 @@ snapshots: '@techsio/ui-kit@file:libs/ui(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(debug@4.4.3(supports-color@8.1.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)(tailwindcss@4.1.17)': dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/modifiers': 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) '@iconify-json/mdi': 1.2.3 '@iconify-json/mdi-light': 1.2.2 '@iconify-json/svg-spinners': 1.2.4 '@iconify/tailwind4': 1.1.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tailwindcss@4.1.17) + '@tanstack/react-table': 8.20.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': 3.14.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/table-core': 9.1.2 '@types/react': 19.2.3 '@types/react-dom': 19.2.3(@types/react@19.2.3) '@zag-js/accordion': 1.42.0 @@ -28628,10 +28674,17 @@ snapshots: '@techsio/ui-kit@file:libs/ui(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(debug@4.4.3(supports-color@8.1.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)(tailwindcss@4.2.4)': dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/modifiers': 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) '@iconify-json/mdi': 1.2.3 '@iconify-json/mdi-light': 1.2.2 '@iconify-json/svg-spinners': 1.2.4 '@iconify/tailwind4': 1.1.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(tailwindcss@4.2.4) + '@tanstack/react-table': 8.20.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': 3.14.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/table-core': 9.1.2 '@types/react': 19.2.3 '@types/react-dom': 19.2.3(@types/react@19.2.3) '@zag-js/accordion': 1.42.0 @@ -29489,7 +29542,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.6)(jsdom@27.4.0(supports-color@8.1.1))(msw@2.12.10(@types/node@24.12.4)(typescript@5.9.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.10.6)(jsdom@27.4.0(supports-color@8.1.1))(msw@2.12.10(@types/node@24.12.4)(typescript@5.9.3))(vite@8.1.3(@types/node@24.12.4)(esbuild@0.27.3)(jiti@2.7.0)(less@4.1.3)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0)(tsx@4.22.4)(yaml@2.7.1)) '@vitest/expect@3.2.4': dependencies: @@ -30273,7 +30326,7 @@ snapshots: acorn-walk@8.3.4: dependencies: - acorn: 8.16.0 + acorn: 8.17.0 acorn@7.4.1: {} @@ -35647,7 +35700,7 @@ snapshots: '@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3) '@medusajs/icons': 2.18.0(react@19.2.3) '@medusajs/js-sdk': 2.18.0 - '@medusajs/medusa': 2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc) + '@medusajs/medusa': 2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac) '@medusajs/ui': 4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@tanstack/react-query': 5.64.2(react@19.2.3) react: 19.2.3 @@ -35660,7 +35713,7 @@ snapshots: '@mdxeditor/editor': 4.2.0(@codemirror/language@6.12.3)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(supports-color@8.1.1)(typescript@5.9.3)(yjs@13.6.30) '@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3) '@medusajs/js-sdk': 2.18.0 - '@medusajs/medusa': 2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc) + '@medusajs/medusa': 2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac) '@shikijs/rehype': 4.3.1 multer: 2.2.0 rehype-sanitize: 6.0.0 @@ -35689,7 +35742,7 @@ snapshots: '@medusajs/cli': 2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1) '@medusajs/framework': 2.18.0(@medusajs/cli@2.18.0(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1))(@types/node@24.12.4)(debug@4.4.3(supports-color@8.1.1))(ioredis@5.11.1(supports-color@8.1.1))(mysql2@3.15.3)(supports-color@8.1.1)(vite@5.4.21(@types/node@24.12.4)(less@4.1.3)(lightningcss@1.33.0)(sass@1.84.0)(stylus@0.64.0(supports-color@8.1.1))(terser@5.49.0))(zod@4.4.3) '@medusajs/icons': 2.18.0(react@19.2.3) - '@medusajs/medusa': 2.18.0(8d85190fcba0d1ec8c6ed57af1db96cc) + '@medusajs/medusa': 2.18.0(0afb163bbd918071ed7ed2c0cc7f68ac) '@medusajs/ui': 4.2.0(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) jose: 6.2.3 react: 19.2.3 @@ -39673,7 +39726,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 24.12.4 - acorn: 8.16.0 + acorn: 8.17.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 @@ -39693,7 +39746,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 24.12.4 - acorn: 8.16.0 + acorn: 8.17.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1