@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.
- package/dist/index.cjs +3145 -592
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1206 -26
- package/dist/index.d.ts +1206 -26
- package/dist/index.js +3094 -546
- package/dist/index.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/data-card-list.test.tsx +74 -0
- package/src/__tests__/data-table-row-click.test.tsx +113 -0
- package/src/__tests__/filter-chips.test.tsx +237 -0
- package/src/calendar-date-chip.tsx +262 -0
- package/src/column-manager.tsx +590 -0
- package/src/copyable-cell.tsx +253 -0
- package/src/data-card-list.tsx +188 -0
- package/src/data-table-card.tsx +205 -0
- package/src/data-table.tsx +876 -144
- package/src/date-picker.tsx +15 -3
- package/src/filter-chips.tsx +1874 -0
- package/src/format-datetime.ts +170 -0
- package/src/index.ts +95 -2
- package/src/popover.tsx +24 -15
- package/src/rotating-search-input.tsx +164 -0
- package/src/tab-presets.tsx +189 -0
package/src/data-table.tsx
CHANGED
|
@@ -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 {
|
|
5
|
+
import { Shimmer } from "./skeleton";
|
|
6
6
|
import { EmptyState } from "./empty-state";
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
col.align === "
|
|
385
|
-
? "text-
|
|
386
|
-
:
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
<
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
{
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
<
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
1282
|
+
{p}
|
|
676
1283
|
</button>
|
|
677
|
-
|
|
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
|
+
}
|