@payglocal_ui/flux-ui 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,41 @@
1
1
  "use client";
2
2
 
3
- import type { ReactNode } from "react";
3
+ import type { CSSProperties, ReactNode } from "react";
4
4
  import { cn } from "./utils";
5
- import { TableRowSkeleton } from "./skeleton";
5
+ import { Shimmer } from "./skeleton";
6
6
  import { EmptyState } from "./empty-state";
7
- import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
8
- import { useState } from "react";
7
+ import {
8
+ ArrowDown,
9
+ ArrowUp,
10
+ ChevronDown,
11
+ ChevronLeft,
12
+ ChevronRight,
13
+ ChevronsUpDown,
14
+ } from "lucide-react";
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from "./select";
9
23
 
10
24
  export type DataTableDensity = "default" | "comfortable" | "compact";
11
25
  export type DataTableHeaderStyle = "surface" | "minimal";
12
- export type DataTableFooterSummary = "range" | "count";
26
+
27
+ /**
28
+ * Footer summary text.
29
+ * - `range` — "Showing 1–15 of 141 results" (or "Showing 1–15" when no total
30
+ * is knowable, i.e. cursor pagination).
31
+ * - `count` — "141 items".
32
+ * - `none` — no summary, just the pager.
33
+ */
34
+ export type DataTableFooterSummary = "range" | "count" | "none";
35
+
36
+ export type SortOrder = "ascend" | "descend";
37
+ /** `null` means unsorted — the table is in the order the data arrived in. */
38
+ export type DataTableSortState = { columnKey: string; order: SortOrder } | null;
13
39
 
14
40
  /** Builds the visible page numbers including ellipsis markers */
15
41
  function getPageRange(current: number, total: number): (number | "…")[] {
@@ -24,6 +50,98 @@ function getPageRange(current: number, total: number): (number | "…")[] {
24
50
  return pages;
25
51
  }
26
52
 
53
+ // ── Pagination ───────────────────────────────────────────────────────────────
54
+
55
+ /** Controls shared by every paginated mode. */
56
+ type PagerCommon = {
57
+ /**
58
+ * Summary text at the left of the footer. Defaults to `range`.
59
+ */
60
+ summary?: DataTableFooterSummary;
61
+ /** Noun after the number when `summary="count"`. Default `item` / `items`. */
62
+ countLabels?: { singular: string; plural: string };
63
+ /**
64
+ * Page-size choices. Pass these — with `onPageSizeChange` — and the footer
65
+ * grows a "Rows per page" picker at its far left. Omit and there is none.
66
+ *
67
+ * This lives here rather than being hand-passed as a footer slot because a
68
+ * grid that had to hand-roll its own page-size control is how two different
69
+ * pagers end up in the same app.
70
+ */
71
+ pageSizeOptions?: readonly number[];
72
+ onPageSizeChange?: (size: number) => void;
73
+ /** Escape hatch: an extra control at the far left, before the summary. */
74
+ leading?: ReactNode;
75
+ };
76
+
77
+ /**
78
+ * How the table pages. Which member you use is decided by the endpoint, not by
79
+ * taste:
80
+ *
81
+ * - `client` — every row is already in `data`; the table slices it. The
82
+ * default when `pagination` is omitted.
83
+ * - `page` — the response carries a row **total**, so the footer can show
84
+ * "Showing 1–15 of 141 results" and a full numbered strip with ellipses.
85
+ * - `cursor` — the response carries no total (a `nextCursor` /
86
+ * `exclusiveStartKey` API). The footer shows "Showing 1–15" with **no**
87
+ * total and no page count, and the numbered strip is only ever the page
88
+ * before, the current page, and — when `hasNext` says so — the page after.
89
+ * Those are the only pages a cursor can actually reach in one step, so they
90
+ * are the only ones offered.
91
+ * - `none` — no footer at all.
92
+ */
93
+ export type DataTablePagination =
94
+ | ({
95
+ mode: "client";
96
+ /** Rows per page. Default 10. */
97
+ pageSize?: number;
98
+ } & PagerCommon)
99
+ | ({
100
+ mode: "page";
101
+ /** 1-indexed. */
102
+ page: number;
103
+ pageSize: number;
104
+ /** Total rows across all pages, from the response. */
105
+ total: number;
106
+ onPageChange: (page: number) => void;
107
+ } & PagerCommon)
108
+ | ({
109
+ mode: "cursor";
110
+ /** 1-indexed, for the "Showing x–y" range and the page marker. */
111
+ page: number;
112
+ pageSize: number;
113
+ /** Whether a page exists after this one. Drives the next control. */
114
+ hasNext: boolean;
115
+ /** Defaults to `page > 1`. */
116
+ hasPrev?: boolean;
117
+ onNext: () => void;
118
+ onPrev: () => void;
119
+ } & PagerCommon)
120
+ | { mode: "none" };
121
+
122
+ // ── Sorting ──────────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Sorting, modelled on antd's `Table`: a column opts in with `sorter`, and the
126
+ * table reports state as `{ columnKey, order }` with antd's `"ascend"` /
127
+ * `"descend"` vocabulary.
128
+ *
129
+ * The one deliberate difference is that client and server sorting are told
130
+ * apart by the **column**, not by a table-level flag: `sorter: true` means "the
131
+ * caller orders this", a comparator means "the table orders this". A grid can
132
+ * therefore mix the two, which matters when one column is a derived value the
133
+ * server does not know about.
134
+ */
135
+ export type DataTableSorting = {
136
+ /**
137
+ * Controlled sort state. Omit for uncontrolled — the table remembers, which
138
+ * is all a client-sorted grid needs.
139
+ */
140
+ value?: DataTableSortState;
141
+ /** Fires on every header activation, with the state being moved to. */
142
+ onChange?: (next: DataTableSortState) => void;
143
+ };
144
+
27
145
  /**
28
146
  * Row expansion: a disclosure column plus a full-width panel rendered directly
29
147
  * beneath the expanded row. Use it when the detail belongs *with* the row in
@@ -68,6 +186,33 @@ export type Column<T> = {
68
186
  wrap?: boolean;
69
187
  /** Extra classes on `<th>` / `<td>` (e.g. wider horizontal padding per column) */
70
188
  cellClassName?: string;
189
+ /**
190
+ * Inline styles on `<th>` / `<td>`.
191
+ *
192
+ * For a value Tailwind cannot generate a class for because it is computed —
193
+ * a sticky column's `left`, which is the running total of the widths before
194
+ * it. Expressing that as a class means keeping a hand-written lookup table of
195
+ * every offset the layout can produce, and silently getting the wrong one the
196
+ * moment a column width changes. See `frozenColumn`.
197
+ */
198
+ cellStyle?: CSSProperties;
199
+ /**
200
+ * Makes this header a sort control.
201
+ *
202
+ * - `true` — the **caller** orders the rows (a server-side `sortBy` query).
203
+ * The table reports the change through `sorting.onChange` and leaves `data`
204
+ * exactly as given.
205
+ * - a comparator — the **table** orders the rows with it, before paging.
206
+ * Same contract as `Array.prototype.sort`'s argument, and the same as
207
+ * antd's `sorter`.
208
+ */
209
+ sorter?: boolean | ((a: T, b: T) => number);
210
+ /**
211
+ * The orders this header cycles through before returning to unsorted.
212
+ * Default `["ascend", "descend"]`. Pass `["descend", "ascend"]` for a column
213
+ * where "most recent" or "largest" is the obvious first click.
214
+ */
215
+ sortDirections?: SortOrder[];
71
216
  render: (row: T, index: number) => ReactNode;
72
217
  };
73
218
 
@@ -78,13 +223,13 @@ interface DataTableProps<T> {
78
223
  skeletonRows?: number;
79
224
  emptyTitle?: string;
80
225
  emptyDescription?: string;
81
- pageSize?: number;
82
- /** Controlled page number (1-indexed). Enables server-side pagination. */
83
- page?: number;
84
- /** Called when the user changes page in controlled mode. */
85
- onPageChange?: (page: number) => void;
86
- /** Total row count for server-side pagination (overrides data.length for page calculations). */
87
- totalRows?: number;
226
+ /**
227
+ * Pagination and the footer that carries it. Omit for client-side paging at
228
+ * 10 rows a page. See {@link DataTablePagination}.
229
+ */
230
+ pagination?: DataTablePagination;
231
+ /** Column sorting. See {@link DataTableSorting}. */
232
+ sorting?: DataTableSorting;
88
233
  className?: string;
89
234
  rowKey: (row: T) => string;
90
235
  /** Optional hover CTA shown on the right of every row */
@@ -135,25 +280,151 @@ interface DataTableProps<T> {
135
280
  tableLayout?: "auto" | "fixed" | "content";
136
281
  theadClassName?: string;
137
282
  headerStyle?: DataTableHeaderStyle;
138
- /** Footer: paginated range vs simple `n items` */
139
- footerSummary?: DataTableFooterSummary;
140
- /** Noun after the count when `footerSummary="count"` (default singular / plural `item` / `items`). */
141
- footerCountLabels?: { singular: string; plural: string };
142
283
  /** With `density="compact"`, use tighter cell gutters (`pl-1.5 pr-2.5` vs `px-3`). Footer keeps normal horizontal padding. */
143
284
  snug?: boolean;
144
- /**
145
- * Extra control at the far left of the built-in footer, before the
146
- * "Showing x–y of N" summary — a rows-per-page picker, typically.
147
- *
148
- * Without it a grid that needs a page-size control has to abandon the
149
- * built-in footer and hand-roll one, which is how two different pagers end up
150
- * in the same app.
151
- */
152
- footerLeading?: ReactNode;
153
285
  /** Per-row disclosure panel rendered beneath the row. See `DataTableExpandable`. */
154
286
  expandable?: DataTableExpandable<T>;
155
287
  }
156
288
 
289
+ /**
290
+ * The pagination union flattened into one shape the render can read without
291
+ * re-narrowing at every use.
292
+ *
293
+ * `total` and `totalPages` stay optional on purpose: `undefined` is the honest
294
+ * answer for a cursor API, and keeping it optional here is what stops the
295
+ * footer from quietly falling back to a made-up count.
296
+ */
297
+ export type NormalizedPager = {
298
+ showFooter: boolean;
299
+ /** Whether the table slices `data` itself (client mode only). */
300
+ sliceLocally: boolean;
301
+ page: number;
302
+ pageSize: number;
303
+ total?: number;
304
+ totalPages?: number;
305
+ hasNext?: boolean;
306
+ hasPrev?: boolean;
307
+ onNext?: () => void;
308
+ onPrev?: () => void;
309
+ summary: DataTableFooterSummary;
310
+ countLabels: { singular: string; plural: string };
311
+ leading?: ReactNode;
312
+ pageSizeOptions?: readonly number[];
313
+ onPageSizeChange?: (size: number) => void;
314
+ };
315
+
316
+ const DEFAULT_COUNT_LABELS = { singular: "item", plural: "items" };
317
+
318
+ export function normalizePagination(
319
+ pagination: DataTablePagination,
320
+ rowsHeld: number,
321
+ internalPage: number
322
+ ): NormalizedPager {
323
+ if (pagination.mode === "none") {
324
+ return {
325
+ showFooter: false,
326
+ sliceLocally: false,
327
+ page: 1,
328
+ // A footerless table shows every row it was given; guard against a
329
+ // zero divisor for the range arithmetic that never renders.
330
+ pageSize: rowsHeld || 1,
331
+ summary: "none",
332
+ countLabels: DEFAULT_COUNT_LABELS,
333
+ };
334
+ }
335
+
336
+ const shared = {
337
+ showFooter: true,
338
+ summary: pagination.summary ?? "range",
339
+ countLabels: pagination.countLabels ?? DEFAULT_COUNT_LABELS,
340
+ leading: pagination.leading,
341
+ pageSizeOptions: pagination.pageSizeOptions,
342
+ onPageSizeChange: pagination.onPageSizeChange,
343
+ };
344
+
345
+ if (pagination.mode === "client") {
346
+ const pageSize = pagination.pageSize ?? 10;
347
+ return {
348
+ ...shared,
349
+ sliceLocally: true,
350
+ page: internalPage,
351
+ pageSize,
352
+ total: rowsHeld,
353
+ totalPages: Math.max(1, Math.ceil(rowsHeld / pageSize)),
354
+ };
355
+ }
356
+
357
+ if (pagination.mode === "page") {
358
+ return {
359
+ ...shared,
360
+ sliceLocally: false,
361
+ page: pagination.page,
362
+ pageSize: pagination.pageSize,
363
+ total: pagination.total,
364
+ totalPages: Math.max(1, Math.ceil(pagination.total / pagination.pageSize)),
365
+ };
366
+ }
367
+
368
+ // Cursor: no total, therefore no `totalPages`. That absence is what the
369
+ // footer keys off to switch to prev/current/next numbering.
370
+ return {
371
+ ...shared,
372
+ sliceLocally: false,
373
+ page: pagination.page,
374
+ pageSize: pagination.pageSize,
375
+ hasNext: pagination.hasNext,
376
+ hasPrev: pagination.hasPrev ?? pagination.page > 1,
377
+ onNext: pagination.onNext,
378
+ onPrev: pagination.onPrev,
379
+ };
380
+ }
381
+
382
+ /** The order a header moves to on its next activation. */
383
+ function nextSortOrder(
384
+ current: DataTableSortState,
385
+ columnKey: string,
386
+ directions: SortOrder[]
387
+ ): DataTableSortState {
388
+ if (!current || current.columnKey !== columnKey) {
389
+ return { columnKey, order: directions[0] };
390
+ }
391
+ const at = directions.indexOf(current.order);
392
+ const next = directions[at + 1];
393
+ // Past the end of the cycle is "unsorted" — a third click always gets the
394
+ // user back to the order the data arrived in, which is otherwise unreachable.
395
+ return next ? { columnKey, order: next } : null;
396
+ }
397
+
398
+ /**
399
+ * The sort glyph.
400
+ *
401
+ * Unsorted is one `ChevronsUpDown` rather than two chevrons stacked by hand:
402
+ * the pair had to be pulled together with a negative margin, which left them
403
+ * mis-kerned and heavier than the header text beside them. A single glyph is
404
+ * drawn as one shape on lucide's own grid, so it sits on the baseline properly
405
+ * at any size.
406
+ *
407
+ * Sorted is an arrow, not a highlighted half of a pair. An arrow says which way
408
+ * the rows are ordered on its own; a chevron pair with one half tinted asks the
409
+ * reader to compare two shapes to find out.
410
+ *
411
+ * The unsorted glyph stays visible rather than appearing on hover, so a column
412
+ * that *can* sort says so before it is pointed at — but at 40% it reads as an
413
+ * affordance rather than as state.
414
+ */
415
+ function SortIndicator({ order }: { order: SortOrder | null }) {
416
+ const Glyph = order === "ascend" ? ArrowUp : order === "descend" ? ArrowDown : ChevronsUpDown;
417
+ return (
418
+ <Glyph
419
+ aria-hidden
420
+ className={cn(
421
+ "ml-1.5 h-3 w-3 shrink-0",
422
+ order ? "text-primary" : "text-muted-foreground/40"
423
+ )}
424
+ />
425
+ );
426
+ }
427
+
157
428
  export function DataTable<T>({
158
429
  columns,
159
430
  data,
@@ -161,10 +432,8 @@ export function DataTable<T>({
161
432
  skeletonRows = 6,
162
433
  emptyTitle = "No data yet",
163
434
  emptyDescription,
164
- pageSize = 10,
165
- page: controlledPage,
166
- onPageChange,
167
- totalRows,
435
+ pagination = { mode: "client" },
436
+ sorting,
168
437
  className,
169
438
  rowKey,
170
439
  rowCta,
@@ -174,18 +443,64 @@ export function DataTable<T>({
174
443
  tableLayout = "fixed",
175
444
  theadClassName,
176
445
  headerStyle = "surface",
177
- footerSummary = "range",
178
- footerCountLabels = { singular: "item", plural: "items" },
179
446
  snug = false,
180
447
  expandable,
181
- footerLeading,
182
448
  }: DataTableProps<T>) {
183
- const isControlled = controlledPage !== undefined;
449
+ // ── Sorting ───────────────────────────────────────────────────────────────
450
+ const [internalSort, setInternalSort] = useState<DataTableSortState>(null);
451
+ const isSortControlled = sorting?.value !== undefined;
452
+ const sortState = isSortControlled ? sorting!.value! : internalSort;
453
+
454
+ const columnByKey = useMemo(() => new Map(columns.map((c) => [c.key, c])), [columns]);
455
+
456
+ /**
457
+ * Rows in sorted order. Only a **comparator** sorts here: `sorter: true`
458
+ * means the caller has already ordered the rows (or asked the server to), so
459
+ * re-sorting them locally would fight the response.
460
+ */
461
+ const sortedData = useMemo(() => {
462
+ if (!sortState) return data;
463
+ const sorter = columnByKey.get(sortState.columnKey)?.sorter;
464
+ if (typeof sorter !== "function") return data;
465
+ // Copy first: sorting the caller's array in place mutates their state.
466
+ const out = data.slice().sort(sorter);
467
+ return sortState.order === "descend" ? out.reverse() : out;
468
+ }, [data, sortState, columnByKey]);
469
+
470
+ // ── Pagination ────────────────────────────────────────────────────────────
184
471
  const [internalPage, setInternalPage] = useState(1);
185
- const page = isControlled ? controlledPage : internalPage;
186
- const setPage = isControlled
187
- ? (p: number) => onPageChange?.(p)
188
- : (p: number) => setInternalPage(p);
472
+
473
+ /**
474
+ * The discriminated union collapsed into one flat shape, so the render below
475
+ * reads the same regardless of mode and every `undefined` is a deliberate
476
+ * "nobody knows" rather than a missing prop.
477
+ */
478
+ const pager = normalizePagination(pagination, sortedData.length, internalPage);
479
+
480
+ /** Only client mode holds every row, so only client mode slices. */
481
+ const paginated = pager.sliceLocally
482
+ ? sortedData.slice((pager.page - 1) * pager.pageSize, pager.page * pager.pageSize)
483
+ : sortedData;
484
+
485
+ /** Zero-based offset of this page's first row, for the "Showing x–y" range. */
486
+ const from = (pager.page - 1) * pager.pageSize;
487
+
488
+ const setSort = (next: DataTableSortState) => {
489
+ if (isSortControlled) sorting!.onChange?.(next);
490
+ else {
491
+ setInternalSort(next);
492
+ sorting?.onChange?.(next);
493
+ }
494
+ // A re-sort makes the current page number meaningless — the rows under it
495
+ // have all changed. Only client mode can act on that; controlled callers
496
+ // decide for themselves, since they may be paging by cursor.
497
+ if (pagination.mode === "client") setInternalPage(1);
498
+ };
499
+
500
+ const goToPage = (p: number) => {
501
+ if (pagination.mode === "page") pagination.onPageChange(p);
502
+ else if (pagination.mode === "client") setInternalPage(p);
503
+ };
189
504
 
190
505
  // The right-pinned row action. `rowAction` takes precedence over `rowCta`.
191
506
  // It is NOT a real column: it renders as a per-row overlay floating at the
@@ -223,10 +538,6 @@ export function DataTable<T>({
223
538
  if (!isOpen) expandable!.onExpand?.(row, index);
224
539
  };
225
540
 
226
- const total = totalRows ?? data.length;
227
- const totalPages = Math.ceil(total / pageSize);
228
- const paginated = isControlled ? data : data.slice((page - 1) * pageSize, page * pageSize);
229
-
230
541
  // Guard against duplicate / non-unique rowKey() results. React silently fails
231
542
  // to unmount old <tr> nodes when sibling keys collide, leaving stale rows
232
543
  // rendered on top of new data (or the empty state). De-dupe by suffixing
@@ -305,12 +616,57 @@ export function DataTable<T>({
305
616
  '[role="checkbox"], [role="menuitem"], [role="menu"], [role="dialog"], ' +
306
617
  "[data-row-click-ignore]";
307
618
 
619
+ /**
620
+ * Horizontal scroll position, so a frozen column can show a shadow only when
621
+ * there is actually something scrolled underneath it.
622
+ *
623
+ * A shadow that is always on is just a heavier border: it says "pinned" when
624
+ * nothing is hidden, and then says nothing new at the moment it matters. The
625
+ * point of the shadow is to mark the edge that content is passing beneath.
626
+ */
627
+ const scrollRef = useRef<HTMLDivElement | null>(null);
628
+ const [edge, setEdge] = useState({ start: false, end: false });
629
+
630
+ const syncEdges = useCallback(() => {
631
+ const el = scrollRef.current;
632
+ if (!el) return;
633
+ const max = el.scrollWidth - el.clientWidth;
634
+ setEdge({
635
+ start: el.scrollLeft > 1,
636
+ // 1px of slack: sub-pixel widths mean scrollLeft rarely reaches `max`
637
+ // exactly, which would leave the end shadow stuck on at full scroll.
638
+ end: max > 1 && el.scrollLeft < max - 1,
639
+ });
640
+ }, []);
641
+
642
+ /**
643
+ * Measured after layout rather than during render — it reads `scrollWidth`,
644
+ * which render cannot know. Re-run when the column or row count changes,
645
+ * since either changes whether the table overflows at all.
646
+ */
647
+ useEffect(() => {
648
+ const raf = requestAnimationFrame(syncEdges);
649
+ window.addEventListener("resize", syncEdges);
650
+ return () => {
651
+ cancelAnimationFrame(raf);
652
+ window.removeEventListener("resize", syncEdges);
653
+ };
654
+ }, [syncEdges, columns.length, paginated.length]);
655
+
308
656
  /** Whether a click/keypress inside a row should reach `onRowClick`. */
309
657
  const isRowClickTarget = (target: EventTarget | null, rowEl: HTMLElement) => {
310
658
  if (!(target instanceof Element)) return false;
659
+ // A React portal re-dispatches its clicks through the React tree, not the
660
+ // DOM tree, so a row's overflow menu — rendered into document.body — still
661
+ // arrives at this row's onClick. The target is not a DOM descendant of the
662
+ // row, and that is exactly what says the click belonged to the menu rather
663
+ // than to the row. Without this, choosing "Edit" from a row menu also fired
664
+ // the row's own action, opening the edit dialog and the preview at once.
665
+ if (!rowEl.contains(target)) return false;
666
+
667
+ // Within the row, a click on a control belongs to that control. `closest`
668
+ // can still walk above the row, so the match has to be inside it.
311
669
  const interactive = target.closest(ROW_CLICK_IGNORE);
312
- // `closest` can walk out of the row entirely (a portalled menu, say); only
313
- // a match inside THIS row means the click belonged to that control.
314
670
  return !(interactive && rowEl.contains(interactive));
315
671
  };
316
672
 
@@ -323,8 +679,13 @@ export function DataTable<T>({
323
679
  >
324
680
  {/* scrollbar space always reserved; thumb subtle on hover */}
325
681
  <div
682
+ ref={scrollRef}
683
+ onScroll={syncEdges}
684
+ // Read by `frozenColumn`'s shadow through `group-data-[…]/table-scroll`.
685
+ data-scrolled-start={edge.start ? "true" : "false"}
686
+ data-scrolled-end={edge.end ? "true" : "false"}
326
687
  className={cn(
327
- "overflow-x-auto",
688
+ "group/table-scroll overflow-x-auto",
328
689
  "[&::-webkit-scrollbar]:h-[4px] [&::-webkit-scrollbar-track]:bg-transparent",
329
690
  "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-transparent",
330
691
  "hover:[&::-webkit-scrollbar-thumb]:bg-border dark:hover:[&::-webkit-scrollbar-thumb]:bg-muted-foreground/35"
@@ -374,25 +735,84 @@ export function DataTable<T>({
374
735
  )}
375
736
  >
376
737
  {hasExpand ? <th className={cn(headPad, "w-10 p-0")} aria-hidden /> : null}
377
- {columns.map((col) => (
378
- <th
379
- key={col.key}
380
- className={cn(
381
- headPad,
382
- headText,
383
- "whitespace-nowrap align-middle",
384
- col.align === "right"
385
- ? "text-right"
386
- : col.align === "center"
387
- ? "text-center"
388
- : "text-left",
389
- // Width hints live in `cellClassName`; see `isEmpty`.
390
- !isEmpty && col.cellClassName
391
- )}
392
- >
393
- {col.header}
394
- </th>
395
- ))}
738
+ {columns.map((col) => {
739
+ const sortable = col.sorter != null && col.sorter !== false;
740
+ const activeOrder =
741
+ sortState?.columnKey === col.key ? sortState.order : null;
742
+ const align =
743
+ col.align === "right"
744
+ ? "text-right"
745
+ : col.align === "center"
746
+ ? "text-center"
747
+ : "text-left";
748
+ return (
749
+ <th
750
+ key={col.key}
751
+ style={col.cellStyle}
752
+ // Announced to assistive tech as the sort state of the
753
+ // column, which the caret pair alone does not convey.
754
+ aria-sort={
755
+ !sortable
756
+ ? undefined
757
+ : activeOrder === "ascend"
758
+ ? "ascending"
759
+ : activeOrder === "descend"
760
+ ? "descending"
761
+ : "none"
762
+ }
763
+ className={cn(
764
+ headPad,
765
+ headText,
766
+ "whitespace-nowrap align-middle",
767
+ align,
768
+ // Width hints live in `cellClassName`; see `isEmpty`.
769
+ !isEmpty && col.cellClassName
770
+ )}
771
+ >
772
+ {sortable ? (
773
+ <button
774
+ type="button"
775
+ onClick={() =>
776
+ setSort(
777
+ nextSortOrder(
778
+ sortState,
779
+ col.key,
780
+ col.sortDirections ?? ["ascend", "descend"]
781
+ )
782
+ )
783
+ }
784
+ className={cn(
785
+ // Inherits the header's own type styling rather than
786
+ // restating it, so a sortable header is visually
787
+ // identical to a plain one apart from the glyph.
788
+ //
789
+ // Deliberately NOT truncating: a plain header does
790
+ // not, so a sortable one that did would ellipsise
791
+ // names the column beside it shows in full.
792
+ // A bare <button>, so Preflight's `font: inherit`
793
+ // already hands it the header's own type. The
794
+ // `text-[inherit]` that used to be here did nothing
795
+ // for size — Tailwind reads `text-[<non-length>]` as
796
+ // a colour — and only looked like it worked because
797
+ // nothing here sets a competing font-size.
798
+ "-mx-1 inline-flex items-center whitespace-nowrap rounded px-1 py-0.5 transition-colors",
799
+ "hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
800
+ activeOrder && "text-foreground",
801
+ // The glyph follows the text, so on a right-aligned
802
+ // column it has to lead instead — otherwise it sits
803
+ // between the label and the numbers it describes.
804
+ col.align === "right" && "flex-row-reverse [&>svg]:ml-0 [&>svg]:mr-1.5"
805
+ )}
806
+ >
807
+ {col.header}
808
+ <SortIndicator order={activeOrder} />
809
+ </button>
810
+ ) : (
811
+ col.header
812
+ )}
813
+ </th>
814
+ );
815
+ })}
396
816
  {hasSpacer ? <th className="w-full p-0" aria-hidden /> : null}
397
817
  {hasAction ? (
398
818
  <th className="sticky right-0 z-[1] w-0 p-0" aria-hidden />
@@ -402,13 +822,57 @@ export function DataTable<T>({
402
822
 
403
823
  <tbody>
404
824
  {isLoading ? (
405
- Array.from({ length: skeletonRows }).map((_, i) => (
406
- <TableRowSkeleton
407
- key={i}
408
- cols={columns.length + (hasExpand ? 1 : 0)}
409
- density={density}
410
- snug={snug}
411
- />
825
+ // The placeholder row mirrors the real row's cells exactly: one
826
+ // per column, carrying that column's own width, alignment and
827
+ // sticky classes, plus the same expand / spacer / action cells.
828
+ //
829
+ // It used to be a generic `TableRowSkeleton` with a cell count
830
+ // and nothing else. With `tableLayout="content"` the real row
831
+ // also carries a greedy spacer cell, so the skeleton was one cell
832
+ // short: the last shimmer stretched across the spacer's slot and
833
+ // the placeholder columns stopped lining up with the headers
834
+ // above them — which reads as a column missing while loading.
835
+ Array.from({ length: skeletonRows }).map((_, r) => (
836
+ <tr
837
+ key={r}
838
+ className={cn(
839
+ "border-b border-border/60",
840
+ comfortable && "min-h-[56px]",
841
+ compact && "min-h-[44px]"
842
+ )}
843
+ >
844
+ {hasExpand ? <td className={cn(cellPad, "w-10")} /> : null}
845
+ {columns.map((col, c) => (
846
+ <td
847
+ key={col.key}
848
+ style={col.cellStyle}
849
+ className={cn(
850
+ cellPad,
851
+ "align-middle overflow-hidden",
852
+ col.align === "right"
853
+ ? "text-right"
854
+ : col.align === "center"
855
+ ? "text-center"
856
+ : "text-left",
857
+ col.cellClassName
858
+ )}
859
+ >
860
+ {/* Varied widths so the block reads as rows of data
861
+ rather than a striped grid. Inline-block so the
862
+ column's own alignment still places it. */}
863
+ <Shimmer
864
+ className={cn(
865
+ "inline-block h-3.5 max-w-full",
866
+ c === 0 ? "w-20" : c === columns.length - 1 ? "w-14" : "w-28"
867
+ )}
868
+ />
869
+ </td>
870
+ ))}
871
+ {hasSpacer ? <td className="p-0" aria-hidden /> : null}
872
+ {hasAction ? (
873
+ <td className="sticky right-0 z-[1] w-0 p-0" aria-hidden />
874
+ ) : null}
875
+ </tr>
412
876
  ))
413
877
  ) : paginated.length === 0 ? (
414
878
  <tr>
@@ -490,6 +954,7 @@ export function DataTable<T>({
490
954
  {columns.map((col) => (
491
955
  <td
492
956
  key={col.key}
957
+ style={col.cellStyle}
493
958
  className={cn(
494
959
  cellPad,
495
960
  "align-middle",
@@ -594,90 +1059,357 @@ export function DataTable<T>({
594
1059
  </table>
595
1060
  </div>
596
1061
 
597
- {!isLoading && paginated.length > 0 && (
598
- <div
599
- className={cn(
600
- "flex items-center gap-4 flex-wrap border-t border-border",
601
- footerPad,
602
- footerSummary === "count" && totalPages <= 1 && !footerLeading
603
- ? "justify-start"
604
- : "justify-between"
605
- )}
606
- >
607
- {/* `footerLeading` (a rows-per-page picker, typically) groups with the
608
- summary on the left rather than becoming a third item the
609
- justify-between would fling to its own corner. */}
610
- <div className="flex items-center gap-3">
611
- {footerLeading}
612
- {footerSummary === "count" ? (
613
- <span className="text-[12px] text-muted-foreground tabular-nums">
614
- <span className="font-medium text-foreground">{total}</span>{" "}
615
- {total === 1
616
- ? footerCountLabels.singular
617
- : footerCountLabels.plural}
618
- </span>
619
- ) : (
620
- <span className="text-[12px] text-muted-foreground tabular-nums">
621
- Showing{" "}
622
- <span className="text-foreground font-medium">
623
- {Math.min((page - 1) * pageSize + 1, total)}–
624
- {Math.min(page * pageSize, total)}
625
- </span>{" "}
626
- of{" "}
627
- <span className="text-foreground font-medium">
628
- {total.toLocaleString()}
629
- </span>{" "}
630
- {total !== 1 ? "results" : "result"}
631
- </span>
632
- )}
1062
+ {pager.showFooter && !isLoading && paginated.length > 0 && (
1063
+ <DataTableFooter
1064
+ pad={footerPad}
1065
+ summary={pager.summary}
1066
+ countLabels={pager.countLabels}
1067
+ leading={pager.leading}
1068
+ pageSizeOptions={pager.pageSizeOptions}
1069
+ pageSize={pager.pageSize}
1070
+ onPageSizeChange={pager.onPageSizeChange}
1071
+ from={from}
1072
+ rowCount={paginated.length}
1073
+ total={pager.total}
1074
+ page={pager.page}
1075
+ totalPages={pager.totalPages}
1076
+ hasNext={pager.hasNext}
1077
+ hasPrev={pager.hasPrev}
1078
+ onNext={pager.onNext}
1079
+ onPrev={pager.onPrev}
1080
+ onPageChange={goToPage}
1081
+ />
1082
+ )}
1083
+ </div>
1084
+ );
1085
+ }
1086
+
1087
+ // ── Footer ───────────────────────────────────────────────────────────────────
1088
+
1089
+ const PAGE_BUTTON =
1090
+ "w-7 h-7 rounded-md flex items-center justify-center text-[12px] font-medium tabular-nums transition-colors";
1091
+ const PAGE_BUTTON_IDLE =
1092
+ "text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed";
1093
+ const PAGE_BUTTON_ACTIVE = "bg-primary text-primary-foreground shadow-sm";
1094
+
1095
+ /**
1096
+ * One footer for every pagination mode, so a cursor-paged grid and a
1097
+ * page-paged one are indistinguishable apart from the two things that
1098
+ * genuinely differ: the total, and how many page numbers can honestly be
1099
+ * offered.
1100
+ *
1101
+ * `totalPages` being `undefined` is what marks cursor mode — it means "nobody
1102
+ * knows", and the footer answers by numbering only the pages a cursor can
1103
+ * actually reach in one step (previous, current, next).
1104
+ */
1105
+ function DataTableFooter({
1106
+ pad,
1107
+ summary,
1108
+ countLabels,
1109
+ leading,
1110
+ pageSizeOptions,
1111
+ pageSize,
1112
+ onPageSizeChange,
1113
+ from,
1114
+ rowCount,
1115
+ total,
1116
+ page,
1117
+ totalPages,
1118
+ hasNext,
1119
+ hasPrev,
1120
+ onNext,
1121
+ onPrev,
1122
+ onPageChange,
1123
+ }: {
1124
+ pad: string;
1125
+ summary: DataTableFooterSummary;
1126
+ countLabels: { singular: string; plural: string };
1127
+ leading?: ReactNode;
1128
+ pageSizeOptions?: readonly number[];
1129
+ pageSize: number;
1130
+ onPageSizeChange?: (size: number) => void;
1131
+ from: number;
1132
+ rowCount: number;
1133
+ total?: number;
1134
+ page: number;
1135
+ totalPages?: number;
1136
+ hasNext?: boolean;
1137
+ hasPrev?: boolean;
1138
+ onNext?: () => void;
1139
+ onPrev?: () => void;
1140
+ onPageChange: (page: number) => void;
1141
+ }) {
1142
+ const isCursor = totalPages === undefined;
1143
+ /** 0 in cursor mode, where it is never read. */
1144
+ const pageCount = totalPages ?? 0;
1145
+ /**
1146
+ * A pager with nowhere to go is noise. In `page` mode that is a single page;
1147
+ * in `cursor` mode it is a page with nothing before or after it, which is the
1148
+ * closest thing to a page count a cursor response gives us.
1149
+ */
1150
+ const showPager = isCursor ? !!hasPrev || !!hasNext : pageCount > 1;
1151
+ const showRowsPerPage = !!pageSizeOptions?.length && !!onPageSizeChange;
1152
+
1153
+ const start = from + 1;
1154
+ const end = total !== undefined ? Math.min(from + pageSize, total) : from + rowCount;
1155
+
1156
+ /**
1157
+ * Which page numbers to draw.
1158
+ *
1159
+ * With a total, the full strip with ellipses — any page is one click away.
1160
+ * Without one, only `page - 1`, `page`, and `page + 1`: those are the pages
1161
+ * a cursor can step to, and a number the user cannot actually reach is worse
1162
+ * than no number at all. `page + 1` appears only when `hasNext` says there is
1163
+ * something there, so the strip itself is the signal that more data exists.
1164
+ */
1165
+ const pages: (number | "…")[] = isCursor
1166
+ ? [...(hasPrev ? [page - 1] : []), page, ...(hasNext ? [page + 1] : [])]
1167
+ : getPageRange(page, pageCount);
1168
+
1169
+ const goPrev = () => (isCursor ? onPrev?.() : onPageChange(Math.max(1, page - 1)));
1170
+ const goNext = () => (isCursor ? onNext?.() : onPageChange(Math.min(pageCount, page + 1)));
1171
+
1172
+ /**
1173
+ * With a total, the arrows are always drawn and go disabled at the ends — the
1174
+ * strip has a known width, so nothing moves. Without one, they are drawn only
1175
+ * when the page they point at is known to exist: a cursor response says
1176
+ * whether there is a next page, and an arrow that is permanently disabled
1177
+ * says less than no arrow at all.
1178
+ */
1179
+ const showPrev = isCursor ? !!hasPrev : true;
1180
+ const showNext = isCursor ? !!hasNext : true;
1181
+ const prevDisabled = isCursor ? false : page === 1;
1182
+ const nextDisabled = isCursor ? false : page === pageCount;
1183
+
1184
+ /** In cursor mode a number is only ever one step away, so it maps to a step. */
1185
+ const goToNumber = (p: number) => {
1186
+ if (!isCursor) return onPageChange(p);
1187
+ if (p === page - 1) onPrev?.();
1188
+ else if (p === page + 1) onNext?.();
1189
+ };
1190
+
1191
+ return (
1192
+ <div
1193
+ className={cn(
1194
+ "flex items-center gap-4 flex-wrap border-t border-border",
1195
+ pad,
1196
+ summary === "none" && !showRowsPerPage && !leading ? "justify-end" : "justify-between"
1197
+ )}
1198
+ >
1199
+ {/* The rows-per-page picker and the summary group together on the left
1200
+ rather than becoming a third item that justify-between flings to its
1201
+ own corner. */}
1202
+ <div className="flex items-center gap-3">
1203
+ {leading}
1204
+
1205
+ {showRowsPerPage ? (
1206
+ <div className="flex items-center gap-2 text-[12px] text-muted-foreground">
1207
+ <span>Rows per page</span>
1208
+ <Select
1209
+ value={String(pageSize)}
1210
+ onValueChange={(v) => onPageSizeChange!(Number(v))}
1211
+ >
1212
+ <SelectTrigger size="sm" aria-label="Rows per page">
1213
+ <SelectValue />
1214
+ </SelectTrigger>
1215
+ <SelectContent>
1216
+ {pageSizeOptions!.map((n) => (
1217
+ <SelectItem key={n} value={String(n)}>
1218
+ {n}
1219
+ </SelectItem>
1220
+ ))}
1221
+ </SelectContent>
1222
+ </Select>
633
1223
  </div>
1224
+ ) : null}
634
1225
 
635
- {(footerSummary === "range" || footerSummary === "count") &&
636
- totalPages > 1 && (
637
- <div className="flex items-center gap-1">
638
- <button
639
- onClick={() => setPage(Math.max(1, page - 1))}
640
- disabled={page === 1}
641
- className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
642
- >
643
- <ChevronLeft className="w-3.5 h-3.5" />
644
- </button>
1226
+ {summary === "count" && total !== undefined ? (
1227
+ <span className="text-[12px] text-muted-foreground tabular-nums">
1228
+ <span className="font-medium text-foreground">{total.toLocaleString()}</span>{" "}
1229
+ {total === 1 ? countLabels.singular : countLabels.plural}
1230
+ </span>
1231
+ ) : summary === "range" ? (
1232
+ <span className="text-[12px] text-muted-foreground tabular-nums">
1233
+ Showing{" "}
1234
+ <span className="text-foreground font-medium">
1235
+ {Math.min(start, total ?? start)}–{end}
1236
+ </span>
1237
+ {/* No total, no "of N" and no results noun — cursor responses do not
1238
+ carry a count, and inventing one would be a lie the user would
1239
+ page against. */}
1240
+ {total !== undefined ? (
1241
+ <>
1242
+ {" "}
1243
+ of{" "}
1244
+ <span className="text-foreground font-medium">{total.toLocaleString()}</span>{" "}
1245
+ {total !== 1 ? "results" : "result"}
1246
+ </>
1247
+ ) : null}
1248
+ </span>
1249
+ ) : null}
1250
+ </div>
645
1251
 
646
- {getPageRange(page, totalPages).map((p, idx) =>
647
- p === "…" ? (
648
- <span
649
- key={`ellipsis-${idx}`}
650
- className="w-7 h-7 flex items-center justify-center text-[12px] text-muted-foreground select-none"
651
- >
652
- …
653
- </span>
654
- ) : (
655
- <button
656
- key={p}
657
- onClick={() => setPage(p as number)}
658
- className={cn(
659
- "w-7 h-7 rounded-md text-[12px] font-medium transition-colors tabular-nums flex items-center justify-center",
660
- page === p
661
- ? "bg-primary text-primary-foreground shadow-sm"
662
- : "text-muted-foreground hover:bg-muted hover:text-foreground"
663
- )}
664
- >
665
- {p}
666
- </button>
667
- )
668
- )}
1252
+ {showPager && (
1253
+ <div className="flex items-center gap-1">
1254
+ {showPrev ? (
1255
+ <button
1256
+ type="button"
1257
+ aria-label="Previous page"
1258
+ onClick={goPrev}
1259
+ disabled={prevDisabled}
1260
+ className={cn(PAGE_BUTTON, PAGE_BUTTON_IDLE)}
1261
+ >
1262
+ <ChevronLeft className="w-3.5 h-3.5" />
1263
+ </button>
1264
+ ) : null}
669
1265
 
1266
+ {pages.map((p, idx) =>
1267
+ p === "…" ? (
1268
+ <span
1269
+ key={`ellipsis-${idx}`}
1270
+ className="w-7 h-7 flex items-center justify-center text-[12px] text-muted-foreground select-none"
1271
+ >
1272
+ …
1273
+ </span>
1274
+ ) : (
670
1275
  <button
671
- onClick={() => setPage(Math.min(totalPages, page + 1))}
672
- disabled={page === totalPages}
673
- className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
1276
+ key={p}
1277
+ type="button"
1278
+ aria-current={p === page ? "page" : undefined}
1279
+ onClick={() => goToNumber(p)}
1280
+ className={cn(PAGE_BUTTON, p === page ? PAGE_BUTTON_ACTIVE : PAGE_BUTTON_IDLE)}
674
1281
  >
675
- <ChevronRight className="w-3.5 h-3.5" />
1282
+ {p}
676
1283
  </button>
677
- </div>
1284
+ )
678
1285
  )}
1286
+
1287
+ {showNext ? (
1288
+ <button
1289
+ type="button"
1290
+ aria-label="Next page"
1291
+ onClick={goNext}
1292
+ disabled={nextDisabled}
1293
+ className={cn(PAGE_BUTTON, PAGE_BUTTON_IDLE)}
1294
+ >
1295
+ <ChevronRight className="w-3.5 h-3.5" />
1296
+ </button>
1297
+ ) : null}
679
1298
  </div>
680
1299
  )}
681
1300
  </div>
682
1301
  );
683
1302
  }
1303
+
1304
+ // ── Frozen columns ───────────────────────────────────────────────────────────
1305
+
1306
+ /**
1307
+ * The classes and offset for a column frozen to the left edge, so a grid that
1308
+ * pins its identifier columns does not have to reinvent the recipe. Five
1309
+ * feature files in the internal console had five copies of it, all carrying the
1310
+ * same two faults below.
1311
+ *
1312
+ * Spread the result onto the column:
1313
+ *
1314
+ * ```tsx
1315
+ * let left = 0;
1316
+ * columns.map((col) => {
1317
+ * if (!FROZEN.includes(col.key)) return col;
1318
+ * const frozen = { ...col, ...frozenColumn({ left, isLast: col.key === lastFrozen }) };
1319
+ * left += widthOf(col);
1320
+ * return frozen;
1321
+ * });
1322
+ * ```
1323
+ *
1324
+ * Two things it gets right that a hand-rolled version tends not to:
1325
+ *
1326
+ * **The background is opaque and is the table's own.** It has to be opaque or
1327
+ * the rows scrolling underneath show through the pinned block. It should not be
1328
+ * a tint, because a tint at full strength next to a row that highlights at 40%
1329
+ * makes the frozen block the heaviest thing on screen — the divider and the
1330
+ * shadow are what say "pinned", and the shadow is the honest signal anyway,
1331
+ * since it is what reads as content passing underneath.
1332
+ *
1333
+ * **It follows the row's hover.** The frozen cells are part of the row; pinning
1334
+ * them to a fixed colour makes a hovered row highlight in two different shades
1335
+ * and read as two rows. The hover colour is mixed rather than given an alpha,
1336
+ * for the same opacity reason.
1337
+ */
1338
+ export function frozenColumn({
1339
+ left,
1340
+ right,
1341
+ isLast = false,
1342
+ }: {
1343
+ /** Offset from the left edge in px — the widths of the frozen columns before this one. */
1344
+ left?: number;
1345
+ /** Offset from the right edge in px, for a column pinned to that side instead. */
1346
+ right?: number;
1347
+ /** The column at the boundary, which carries the divider and the shadow. */
1348
+ isLast?: boolean;
1349
+ }): Pick<Column<unknown>, "cellClassName" | "cellStyle"> {
1350
+ // Pinned right is the trailing actions column; pinned left is the identifier
1351
+ // block. The divider and shadow face the scrolling content either way.
1352
+ const toRight = right !== undefined;
1353
+
1354
+ /**
1355
+ * The divider and the shadow are pseudo-elements, NOT `border-r` and
1356
+ * `box-shadow`.
1357
+ *
1358
+ * That is not a style preference. Tailwind's Preflight sets
1359
+ * `border-collapse: collapse` on every table, and in the collapsed model a
1360
+ * cell's border belongs to the **table**, not the cell — so it is painted in
1361
+ * the table's layer and does not travel with a sticky cell. The border sits
1362
+ * still while the frozen column slides over the scrolling content, which
1363
+ * looks exactly like the divider vanishing the moment you scroll. A
1364
+ * `box-shadow` on the cell fails the same way.
1365
+ *
1366
+ * A pseudo-element paints inside the cell's own box, so it moves with it.
1367
+ * Both are anchored *inside* the cell's edge rather than hanging off it,
1368
+ * because the cell carries `overflow-hidden` at compact density and anything
1369
+ * outside would be clipped.
1370
+ *
1371
+ * That placement is why the gradient is kept deliberately slight — 4px at 7%,
1372
+ * barely a hairline of depth beside the divider. Sitting inside the cell, it
1373
+ * tints the pinned column itself rather than falling on the content passing
1374
+ * beneath, so anything heavier stops reading as a shadow and starts reading
1375
+ * as a smudge down the edge of the column. Dark mode carries more (35%)
1376
+ * because the same tint over a dark surface is close to invisible.
1377
+ */
1378
+ /**
1379
+ * Both branches are written out as complete literal class strings. Tailwind
1380
+ * scans source text for class names, so a name assembled at runtime —
1381
+ * `` `after:${edge}` `` — generates no CSS whatsoever. The duplication is the
1382
+ * price of the utilities existing at all.
1383
+ */
1384
+ const boundary = toRight
1385
+ ? [
1386
+ "after:pointer-events-none after:absolute after:inset-y-0 after:left-0 after:w-px after:bg-border",
1387
+ "before:pointer-events-none before:absolute before:inset-y-0 before:left-0 before:w-1",
1388
+ "before:opacity-0 before:transition-opacity before:duration-200",
1389
+ "before:bg-[linear-gradient(to_right,rgb(0_0_0/0.07),transparent)]",
1390
+ "dark:before:bg-[linear-gradient(to_right,rgb(0_0_0/0.35),transparent)]",
1391
+ "group-data-[scrolled-end=true]/table-scroll:before:opacity-100",
1392
+ ]
1393
+ : [
1394
+ "after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-border",
1395
+ "before:pointer-events-none before:absolute before:inset-y-0 before:right-0 before:w-1",
1396
+ "before:opacity-0 before:transition-opacity before:duration-200",
1397
+ "before:bg-[linear-gradient(to_left,rgb(0_0_0/0.07),transparent)]",
1398
+ "dark:before:bg-[linear-gradient(to_left,rgb(0_0_0/0.35),transparent)]",
1399
+ "group-data-[scrolled-start=true]/table-scroll:before:opacity-100",
1400
+ ];
1401
+
1402
+ return {
1403
+ cellStyle: toRight ? { right } : { left: left ?? 0 },
1404
+ cellClassName: cn(
1405
+ "sticky z-10 bg-card",
1406
+ // Follow the row's own hover rather than sitting at a fixed tint: the
1407
+ // frozen cells are part of the row, and a row that highlights in two
1408
+ // shades reads as two rows. Mixed to an opaque colour, never an alpha —
1409
+ // a translucent pinned cell lets the rows scrolling beneath show through.
1410
+ "group-hover:bg-[color-mix(in_oklab,var(--muted)_40%,var(--card))]",
1411
+ "dark:group-hover:bg-[color-mix(in_oklab,var(--muted)_25%,var(--card))]",
1412
+ isLast && boundary
1413
+ ),
1414
+ };
1415
+ }