@payglocal_ui/flux-ui 0.2.5 → 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 +3155 -594
- 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 +3104 -548
- 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 +887 -144
- package/src/date-picker.tsx +15 -3
- package/src/dropdown-menu.tsx +9 -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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
|
4
|
+
import { Check, Copy } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
8
|
+
import { cn } from "./utils";
|
|
9
|
+
|
|
10
|
+
export interface CopyableCellProps {
|
|
11
|
+
/**
|
|
12
|
+
* The full value. This is what reaches the clipboard, the tooltip and the
|
|
13
|
+
* accessible name — always, even when `display` shortens what is on screen.
|
|
14
|
+
* Shortening what is shown must never shorten what the user walks away with.
|
|
15
|
+
*/
|
|
16
|
+
value?: string | null;
|
|
17
|
+
/** What to render instead of `value` — an elided form, typically. */
|
|
18
|
+
display?: string;
|
|
19
|
+
/** The noun in the tooltip and the toast: "Transaction ID copied". */
|
|
20
|
+
label?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Makes the value the handle that opens the row, rendered as a link. Without
|
|
23
|
+
* it the value is plain text that can still be copied.
|
|
24
|
+
*
|
|
25
|
+
* The copy button stops propagation, so an id that opens a row does not also
|
|
26
|
+
* open it when copied.
|
|
27
|
+
*/
|
|
28
|
+
onClick?: () => void;
|
|
29
|
+
/** Render the value in the primary colour. */
|
|
30
|
+
accent?: boolean;
|
|
31
|
+
monospace?: boolean;
|
|
32
|
+
/** What an absent value renders as. Default the em dash every grid uses. */
|
|
33
|
+
fallback?: string;
|
|
34
|
+
/**
|
|
35
|
+
* - `inline` (default) — the value, with its own copy button beside it.
|
|
36
|
+
* - `cell` — the **whole** element is the copy target and the value
|
|
37
|
+
* underlines on hover. For a fixed-width column where a separate button
|
|
38
|
+
* would cost more room than the value it copies.
|
|
39
|
+
*
|
|
40
|
+
* `cell` ignores `onClick`: a cell cannot both copy and open the row on the
|
|
41
|
+
* same click.
|
|
42
|
+
*/
|
|
43
|
+
variant?: "inline" | "cell";
|
|
44
|
+
/**
|
|
45
|
+
* Keep the copy button invisible until the row (or any `group` ancestor) is
|
|
46
|
+
* hovered, or the button itself is focused. Opacity only — it keeps its
|
|
47
|
+
* space, so revealing it never shifts the row.
|
|
48
|
+
*
|
|
49
|
+
* Defaults to `true`, which is what a table wants: twelve permanent copy
|
|
50
|
+
* buttons are twelve pieces of chrome competing with the data. Pass `false`
|
|
51
|
+
* for a detail field, where there is no row to hover and the control would
|
|
52
|
+
* simply never appear. Pointer-coarse devices have no hover to give, so it
|
|
53
|
+
* stays visible there regardless.
|
|
54
|
+
*/
|
|
55
|
+
revealOnHover?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Announce the copy with a toast. Off for a field whose tick and tooltip are
|
|
58
|
+
* feedback enough, and where a toast per copy would be noise.
|
|
59
|
+
*/
|
|
60
|
+
showToast?: boolean;
|
|
61
|
+
/** Extra classes on the value itself, e.g. a muted secondary placement. */
|
|
62
|
+
valueClassName?: string;
|
|
63
|
+
className?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The canonical identifier cell: a value plus a copy button that fades in on
|
|
68
|
+
* the row's hover.
|
|
69
|
+
*
|
|
70
|
+
* Reveal-on-hover is the point. A table of twelve ids with twelve permanent
|
|
71
|
+
* copy buttons is twelve pieces of chrome competing with the data; the row the
|
|
72
|
+
* pointer is on is the only one whose button is useful.
|
|
73
|
+
*
|
|
74
|
+
* It relies on the row's own `group` class, which every `DataTable` `<tr>`
|
|
75
|
+
* already carries. Outside a DataTable row, pass `className="group"` on an
|
|
76
|
+
* ancestor or the button stays hidden.
|
|
77
|
+
*/
|
|
78
|
+
export function CopyableCell({
|
|
79
|
+
value,
|
|
80
|
+
display,
|
|
81
|
+
label = "Value",
|
|
82
|
+
onClick,
|
|
83
|
+
accent = false,
|
|
84
|
+
monospace = false,
|
|
85
|
+
fallback = "—",
|
|
86
|
+
variant = "inline",
|
|
87
|
+
revealOnHover = true,
|
|
88
|
+
showToast = true,
|
|
89
|
+
valueClassName,
|
|
90
|
+
className,
|
|
91
|
+
}: CopyableCellProps) {
|
|
92
|
+
const [copied, setCopied] = useState(false);
|
|
93
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
94
|
+
|
|
95
|
+
// The tick reverts on a timer, so an unmount mid-flight must not leave it
|
|
96
|
+
// running against a gone component.
|
|
97
|
+
useEffect(
|
|
98
|
+
() => () => {
|
|
99
|
+
if (timer.current) clearTimeout(timer.current);
|
|
100
|
+
},
|
|
101
|
+
[]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (!value) return <span className="text-muted-foreground">{fallback}</span>;
|
|
105
|
+
|
|
106
|
+
const copyValue = async () => {
|
|
107
|
+
try {
|
|
108
|
+
await navigator.clipboard.writeText(value);
|
|
109
|
+
setCopied(true);
|
|
110
|
+
if (showToast) toast.success(`${label} copied`);
|
|
111
|
+
if (timer.current) clearTimeout(timer.current);
|
|
112
|
+
timer.current = setTimeout(() => setCopied(false), 1500);
|
|
113
|
+
} catch {
|
|
114
|
+
// Clipboard blocked — an insecure context, or permission denied. Copying
|
|
115
|
+
// an id is not worth an error toast over; the value is still on screen.
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Copying is never also a request to open the record the value belongs to,
|
|
121
|
+
* so the event stops here. DataTable's own `onRowClick` already skips a
|
|
122
|
+
* click on a button; this also covers rows made clickable some other way.
|
|
123
|
+
*/
|
|
124
|
+
const copy = (e: MouseEvent) => {
|
|
125
|
+
e.stopPropagation();
|
|
126
|
+
void copyValue();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const text = cn(
|
|
130
|
+
"min-w-0 truncate font-medium",
|
|
131
|
+
accent ? "text-primary" : "text-foreground",
|
|
132
|
+
monospace && "font-mono",
|
|
133
|
+
valueClassName
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/** Names the full value when the screen only shows part of it. */
|
|
137
|
+
const elided = display !== undefined && display !== value;
|
|
138
|
+
const copyHint = copied ? "Copied" : elided ? `Copy ${value}` : `Copy ${label}`;
|
|
139
|
+
|
|
140
|
+
if (variant === "cell") {
|
|
141
|
+
// The whole box copies, so there is no separate button to reach for — in a
|
|
142
|
+
// narrow column that button costs more width than the value it copies. The
|
|
143
|
+
// value underlines on hover to say the box is the target; the icon is a
|
|
144
|
+
// marker, not a second control, which is why it is not focusable.
|
|
145
|
+
return (
|
|
146
|
+
<TooltipProvider delayDuration={200}>
|
|
147
|
+
<Tooltip>
|
|
148
|
+
<TooltipTrigger asChild>
|
|
149
|
+
<div
|
|
150
|
+
role="button"
|
|
151
|
+
tabIndex={0}
|
|
152
|
+
onClick={copy}
|
|
153
|
+
onKeyDown={(e) => {
|
|
154
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
e.stopPropagation();
|
|
157
|
+
void copyValue();
|
|
158
|
+
}}
|
|
159
|
+
aria-label={copied ? "Copied to clipboard" : `Copy ${value}`}
|
|
160
|
+
className={cn(
|
|
161
|
+
"group/copy flex min-w-0 cursor-pointer items-center gap-1 rounded-md",
|
|
162
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35",
|
|
163
|
+
className
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
<span className={cn(text, "flex-1 group-hover/copy:underline")}>
|
|
167
|
+
{display ?? value}
|
|
168
|
+
</span>
|
|
169
|
+
{copied ? (
|
|
170
|
+
<Check className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
171
|
+
) : (
|
|
172
|
+
<Copy
|
|
173
|
+
className={cn(
|
|
174
|
+
"h-3 w-3 shrink-0 text-muted-foreground",
|
|
175
|
+
revealOnHover &&
|
|
176
|
+
"opacity-0 transition-opacity group-hover/copy:opacity-100 group-focus-visible/copy:opacity-100 [@media(hover:none)]:opacity-100"
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
</TooltipTrigger>
|
|
182
|
+
<TooltipContent side="top" className="text-xs">
|
|
183
|
+
{copied ? "Copied" : "Copy"}
|
|
184
|
+
</TooltipContent>
|
|
185
|
+
</Tooltip>
|
|
186
|
+
</TooltipProvider>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className={cn("group/copy flex min-w-0 items-center gap-1", className)}>
|
|
192
|
+
{onClick ? (
|
|
193
|
+
// A bare <button>, deliberately, where the rest of flux would reach for
|
|
194
|
+
// `Button variant="link"`. That variant hard-codes `text-[15px]`, and
|
|
195
|
+
// the obvious override — `text-[inherit]` — does not beat it: Tailwind
|
|
196
|
+
// and tailwind-merge read `text-[<non-length>]` as a COLOUR, so the
|
|
197
|
+
// size class survives and the cell renders 2px larger than every other
|
|
198
|
+
// cell in the row. Preflight already gives a bare button `font: inherit`,
|
|
199
|
+
// so this simply inherits the cell's size at whatever density the table
|
|
200
|
+
// is using, which is what a cell should do.
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={onClick}
|
|
204
|
+
title={value}
|
|
205
|
+
className={cn(
|
|
206
|
+
"cursor-pointer bg-transparent p-0 text-left underline-offset-4",
|
|
207
|
+
"hover:underline focus-visible:underline focus-visible:outline-none",
|
|
208
|
+
text
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
{display ?? value}
|
|
212
|
+
</button>
|
|
213
|
+
) : (
|
|
214
|
+
<span className={text} title={value}>
|
|
215
|
+
{display ?? value}
|
|
216
|
+
</span>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
<TooltipProvider delayDuration={200}>
|
|
220
|
+
<Tooltip>
|
|
221
|
+
<TooltipTrigger asChild>
|
|
222
|
+
<Button
|
|
223
|
+
type="button"
|
|
224
|
+
variant="ghost"
|
|
225
|
+
onClick={copy}
|
|
226
|
+
aria-label={`Copy ${label}`}
|
|
227
|
+
// `group-hover` is the DataTable row; `group-hover/copy` covers a
|
|
228
|
+
// cell used outside one, where hovering the value itself should
|
|
229
|
+
// still reveal the button.
|
|
230
|
+
className={cn(
|
|
231
|
+
"h-5 w-5 min-h-0 min-w-0 shrink-0 rounded-md p-0 text-muted-foreground",
|
|
232
|
+
revealOnHover && [
|
|
233
|
+
"opacity-0 transition-opacity",
|
|
234
|
+
// `group-hover` is the DataTable row; `group-hover/copy`
|
|
235
|
+
// covers a cell used outside one, where hovering the value
|
|
236
|
+
// itself should still reveal the button.
|
|
237
|
+
"focus-visible:opacity-100 group-hover:opacity-100 group-hover/copy:opacity-100",
|
|
238
|
+
// No hover to wait for on a touch device, so it simply shows.
|
|
239
|
+
"[@media(hover:none)]:opacity-100",
|
|
240
|
+
]
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
244
|
+
</Button>
|
|
245
|
+
</TooltipTrigger>
|
|
246
|
+
<TooltipContent side="top" className="text-xs">
|
|
247
|
+
{copyHint}
|
|
248
|
+
</TooltipContent>
|
|
249
|
+
</Tooltip>
|
|
250
|
+
</TooltipProvider>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { normalizePagination, type DataTablePagination } from "./data-table";
|
|
7
|
+
import { EmptyState } from "./empty-state";
|
|
8
|
+
import { Shimmer } from "./skeleton";
|
|
9
|
+
import { cn } from "./utils";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The narrow-viewport counterpart to {@link DataTableCard}: the same records as
|
|
13
|
+
* a stack of cards.
|
|
14
|
+
*
|
|
15
|
+
* It is a separate component rather than a mode of the table on purpose. A
|
|
16
|
+
* card list is not a table with its columns hidden — it chooses a handful of
|
|
17
|
+
* fields, gives them a hierarchy, and drops the rest. Folding that into
|
|
18
|
+
* `DataTableCard` would mean one component carrying two layouts and a
|
|
19
|
+
* breakpoint, and every table paying for props it does not use.
|
|
20
|
+
*
|
|
21
|
+
* Pair the two with CSS, not a media-query hook:
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <DataTableCard className="hidden lg:block" … />
|
|
25
|
+
* <DataCardList className="lg:hidden" … />
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Both render; CSS shows one. A JS breakpoint would have to start with a guess
|
|
29
|
+
* on the server, so one cohort sees the wrong layout on first paint, and a
|
|
30
|
+
* resize across the breakpoint unmounts the visible half — taking scroll
|
|
31
|
+
* position and any open row with it.
|
|
32
|
+
*
|
|
33
|
+
* What it owns is the surface, not the card: the bordered container, the
|
|
34
|
+
* loading skeletons, the empty state and the pager. Those are the four things
|
|
35
|
+
* every hand-rolled card list in the apps reimplemented, and the four that had
|
|
36
|
+
* drifted. The card itself stays with the feature, via `renderCard` — that is
|
|
37
|
+
* the part that genuinely differs per record.
|
|
38
|
+
*/
|
|
39
|
+
export interface DataCardListProps<T> {
|
|
40
|
+
rows: T[];
|
|
41
|
+
rowKey: (row: T) => string;
|
|
42
|
+
/** One record as a card. The only part a feature has to write. */
|
|
43
|
+
renderCard: (row: T, index: number) => ReactNode;
|
|
44
|
+
isLoading?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The loading placeholder for one card. Omit for a generic card-shaped
|
|
47
|
+
* shimmer — good enough for most lists, and worth replacing only where the
|
|
48
|
+
* real card has a distinctive shape worth pre-announcing.
|
|
49
|
+
*/
|
|
50
|
+
renderSkeleton?: (index: number) => ReactNode;
|
|
51
|
+
skeletonCount?: number;
|
|
52
|
+
emptyTitle?: string;
|
|
53
|
+
emptyDescription?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Replaces the list when there are no rows — an illustrated placeholder,
|
|
56
|
+
* typically. Same split as `DataTableCard`: the title/description pair is the
|
|
57
|
+
* plain "nothing matched" state, this is the drawn first-run one.
|
|
58
|
+
*/
|
|
59
|
+
emptyState?: ReactNode;
|
|
60
|
+
/**
|
|
61
|
+
* Replaces the rows entirely when the request failed.
|
|
62
|
+
*
|
|
63
|
+
* Distinct from an empty result with error-worded copy: that keeps the
|
|
64
|
+
* column headers, which is right for "nothing matched" and wrong for "we
|
|
65
|
+
* could not load this" — headers imply data was fetched and found empty.
|
|
66
|
+
*/
|
|
67
|
+
errorState?: ReactNode;
|
|
68
|
+
/**
|
|
69
|
+
* The same {@link DataTablePagination} the table takes, so a list and the
|
|
70
|
+
* table beside it cannot disagree about which page they are on. Rendered as
|
|
71
|
+
* a compact Prev / Next pager rather than a numbered strip: a row of page
|
|
72
|
+
* numbers is the first thing to go wrong on a phone.
|
|
73
|
+
*/
|
|
74
|
+
pagination?: DataTablePagination;
|
|
75
|
+
/** Wraps the list in the same bordered card the table uses. Default true. */
|
|
76
|
+
bordered?: boolean;
|
|
77
|
+
className?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A generic card-shaped placeholder: a title line, two details, a value. */
|
|
81
|
+
function DefaultCardSkeleton() {
|
|
82
|
+
return (
|
|
83
|
+
<div className="rounded-xl border border-border bg-card p-3.5">
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
<Shimmer className="h-4 w-40" />
|
|
86
|
+
<Shimmer className="ml-auto h-3.5 w-5" rounded="sm" />
|
|
87
|
+
</div>
|
|
88
|
+
<Shimmer className="mt-2 h-3 w-28" />
|
|
89
|
+
<Shimmer className="mt-1.5 h-3 w-44" />
|
|
90
|
+
<Shimmer className="mt-2.5 h-3.5 w-32" />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function DataCardList<T>({
|
|
96
|
+
rows,
|
|
97
|
+
rowKey,
|
|
98
|
+
renderCard,
|
|
99
|
+
isLoading = false,
|
|
100
|
+
renderSkeleton,
|
|
101
|
+
skeletonCount = 6,
|
|
102
|
+
emptyTitle = "Nothing to show",
|
|
103
|
+
emptyDescription,
|
|
104
|
+
emptyState,
|
|
105
|
+
errorState,
|
|
106
|
+
pagination = { mode: "client" },
|
|
107
|
+
bordered = true,
|
|
108
|
+
className,
|
|
109
|
+
}: DataCardListProps<T>) {
|
|
110
|
+
const pager = normalizePagination(pagination, rows.length, 1);
|
|
111
|
+
|
|
112
|
+
// Only `client` mode holds every row, so only `client` mode slices; the
|
|
113
|
+
// others were handed exactly this page. Same rule as the table.
|
|
114
|
+
const page = pager.sliceLocally
|
|
115
|
+
? rows.slice((pager.page - 1) * pager.pageSize, pager.page * pager.pageSize)
|
|
116
|
+
: rows;
|
|
117
|
+
|
|
118
|
+
const isCursor = pager.totalPages === undefined;
|
|
119
|
+
const hasPrev = isCursor ? !!pager.hasPrev : pager.page > 1;
|
|
120
|
+
const hasNext = isCursor ? !!pager.hasNext : pager.page < (pager.totalPages ?? 1);
|
|
121
|
+
|
|
122
|
+
const goPrev = () =>
|
|
123
|
+
isCursor ? pager.onPrev?.() : pagination.mode === "page" && pagination.onPageChange(pager.page - 1);
|
|
124
|
+
const goNext = () =>
|
|
125
|
+
isCursor ? pager.onNext?.() : pagination.mode === "page" && pagination.onPageChange(pager.page + 1);
|
|
126
|
+
|
|
127
|
+
const showPager =
|
|
128
|
+
!errorState && pager.showFooter && !isLoading && page.length > 0 && (hasPrev || hasNext);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
className={cn(
|
|
133
|
+
"flex flex-col gap-3 p-4",
|
|
134
|
+
bordered && "rounded-xl border border-border bg-card",
|
|
135
|
+
className
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{errorState ? (
|
|
139
|
+
errorState
|
|
140
|
+
) : isLoading ? (
|
|
141
|
+
Array.from({ length: skeletonCount }).map((_, i) =>
|
|
142
|
+
renderSkeleton ? (
|
|
143
|
+
<div key={i}>{renderSkeleton(i)}</div>
|
|
144
|
+
) : (
|
|
145
|
+
<DefaultCardSkeleton key={i} />
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
) : page.length === 0 ? (
|
|
149
|
+
(emptyState ?? <EmptyState title={emptyTitle} description={emptyDescription} />)
|
|
150
|
+
) : (
|
|
151
|
+
page.map((row, i) => <div key={rowKey(row)}>{renderCard(row, i)}</div>)
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{showPager && (
|
|
155
|
+
<div className="flex items-center justify-between gap-2 pt-1">
|
|
156
|
+
<Button
|
|
157
|
+
type="button"
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="sm"
|
|
160
|
+
disabled={!hasPrev}
|
|
161
|
+
onClick={goPrev}
|
|
162
|
+
leftIcon={<ChevronLeft className="h-3.5 w-3.5" />}
|
|
163
|
+
>
|
|
164
|
+
Prev
|
|
165
|
+
</Button>
|
|
166
|
+
|
|
167
|
+
{/* No total in cursor mode, so no "of N" — the same honesty the
|
|
168
|
+
table's own footer keeps. */}
|
|
169
|
+
<span className="text-[12px] tabular-nums text-muted-foreground">
|
|
170
|
+
Page {pager.page}
|
|
171
|
+
{pager.totalPages !== undefined ? ` of ${pager.totalPages}` : ""}
|
|
172
|
+
</span>
|
|
173
|
+
|
|
174
|
+
<Button
|
|
175
|
+
type="button"
|
|
176
|
+
variant="ghost"
|
|
177
|
+
size="sm"
|
|
178
|
+
disabled={!hasNext}
|
|
179
|
+
onClick={goNext}
|
|
180
|
+
rightIcon={<ChevronRight className="h-3.5 w-3.5" />}
|
|
181
|
+
>
|
|
182
|
+
Next
|
|
183
|
+
</Button>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
4
|
+
import { DataTable } from "./data-table";
|
|
5
|
+
import type {
|
|
6
|
+
Column,
|
|
7
|
+
DataTableDensity,
|
|
8
|
+
DataTableExpandable,
|
|
9
|
+
DataTablePagination,
|
|
10
|
+
DataTableSorting,
|
|
11
|
+
} from "./data-table";
|
|
12
|
+
import { cn } from "./utils";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The canonical table surface: one bordered card holding a title, tabs, a
|
|
16
|
+
* filter toolbar, the grid, and a footer — in that order, with the same
|
|
17
|
+
* dividers and gutters every time.
|
|
18
|
+
*
|
|
19
|
+
* `DataTable` on its own is the grid. This is everything around it, and it
|
|
20
|
+
* exists because that surrounding chrome is where tables actually drift: one
|
|
21
|
+
* feature puts its filters above the card, another inside it; one draws a
|
|
22
|
+
* divider under the tabs, another does not; one pads the toolbar `py-3` and the
|
|
23
|
+
* next `py-2.5`. None of that is a decision a feature should be making.
|
|
24
|
+
*
|
|
25
|
+
* Pagination goes through `pagination`, in every mode — including cursor APIs
|
|
26
|
+
* that carry no row total. The `footer` slot is for a footer that is genuinely
|
|
27
|
+
* not a pager; passing one hides the table's own.
|
|
28
|
+
*/
|
|
29
|
+
export interface DataTableCardProps<T> {
|
|
30
|
+
columns: Column<T>[];
|
|
31
|
+
data: T[];
|
|
32
|
+
rowKey: (row: T) => string;
|
|
33
|
+
isLoading?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Section title. For a grid that names itself — an analytics section like
|
|
36
|
+
* "Top 10 Merchants by Volume" — rather than a page-level grid, whose name is
|
|
37
|
+
* the page header. Renders above `tabs`.
|
|
38
|
+
*/
|
|
39
|
+
title?: string;
|
|
40
|
+
/** One line under the title. Only meaningful with `title`. */
|
|
41
|
+
description?: string;
|
|
42
|
+
/** Controls on the title row, flush right (a period toggle, Refresh, …). */
|
|
43
|
+
actions?: ReactNode;
|
|
44
|
+
/** Tab bar near the top of the card, above the toolbar. */
|
|
45
|
+
tabs?: ReactNode;
|
|
46
|
+
/** Filters / search / action buttons, as a row inside the card top. */
|
|
47
|
+
toolbar?: ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* A non-pager footer inside the card bottom. Hides the table's own footer, so
|
|
50
|
+
* do NOT use it for pagination — that is what `pagination` is for, in every
|
|
51
|
+
* mode. Hand-rolling a pager here is how two different pagers end up in one
|
|
52
|
+
* app.
|
|
53
|
+
*/
|
|
54
|
+
footer?: ReactNode;
|
|
55
|
+
emptyTitle?: string;
|
|
56
|
+
emptyDescription?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Replaces the grid when there are no rows — an illustrated placeholder,
|
|
59
|
+
* typically.
|
|
60
|
+
*
|
|
61
|
+
* `emptyTitle` / `emptyDescription` give the table's own text-only empty
|
|
62
|
+
* state, which keeps the column headers and is right for "nothing matched
|
|
63
|
+
* your filters". This is for the first-run case, where there is no data yet
|
|
64
|
+
* because none has ever existed, and a drawn state says that better than a
|
|
65
|
+
* header row over nothing.
|
|
66
|
+
*/
|
|
67
|
+
emptyState?: ReactNode;
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the rows entirely when the request failed.
|
|
70
|
+
*
|
|
71
|
+
* Distinct from an empty result with error-worded copy: that keeps the
|
|
72
|
+
* column headers, which is right for "nothing matched" and wrong for "we
|
|
73
|
+
* could not load this" — headers imply data was fetched and found empty.
|
|
74
|
+
*/
|
|
75
|
+
errorState?: ReactNode;
|
|
76
|
+
/** See {@link DataTablePagination}. Omit for client-side paging at 10/page. */
|
|
77
|
+
pagination?: DataTablePagination;
|
|
78
|
+
/** See {@link DataTableSorting}. */
|
|
79
|
+
sorting?: DataTableSorting;
|
|
80
|
+
rowAction?: ReactNode | ((row: T, index: number) => ReactNode);
|
|
81
|
+
/**
|
|
82
|
+
* Makes the whole row a click target, for a grid that drills into a detail
|
|
83
|
+
* view. Passed straight through, so it brings the keyboard affordances with
|
|
84
|
+
* it and does not fire for clicks landing on a button, link or form control
|
|
85
|
+
* inside a cell.
|
|
86
|
+
*/
|
|
87
|
+
onRowClick?: (row: T, index: number) => void;
|
|
88
|
+
/** Defaults to "content"; pass "fixed" for grids with frozen sticky columns. */
|
|
89
|
+
tableLayout?: "auto" | "fixed" | "content";
|
|
90
|
+
/** Per-row disclosure panel rendered beneath the row. */
|
|
91
|
+
expandable?: DataTableExpandable<T>;
|
|
92
|
+
/** Row rhythm. Defaults to `compact`, which is what a data-dense grid wants. */
|
|
93
|
+
density?: DataTableDensity;
|
|
94
|
+
skeletonRows?: number;
|
|
95
|
+
/**
|
|
96
|
+
* CSS max-height for the internally scrolling body, so the toolbar and footer
|
|
97
|
+
* stay put while the rows scroll and the page itself does not grow.
|
|
98
|
+
*
|
|
99
|
+
* The default assumes a page header plus this card's toolbar; a card that
|
|
100
|
+
* also carries a `tabs` row needs a smaller cap, or the page starts scrolling
|
|
101
|
+
* as well. Pass `"none"` to let the card grow with its content instead.
|
|
102
|
+
*/
|
|
103
|
+
maxBodyHeight?: string;
|
|
104
|
+
className?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function DataTableCard<T>({
|
|
108
|
+
columns,
|
|
109
|
+
data,
|
|
110
|
+
rowKey,
|
|
111
|
+
isLoading,
|
|
112
|
+
title,
|
|
113
|
+
description,
|
|
114
|
+
actions,
|
|
115
|
+
tabs,
|
|
116
|
+
toolbar,
|
|
117
|
+
footer,
|
|
118
|
+
emptyTitle,
|
|
119
|
+
emptyDescription,
|
|
120
|
+
emptyState,
|
|
121
|
+
errorState,
|
|
122
|
+
pagination,
|
|
123
|
+
sorting,
|
|
124
|
+
rowAction,
|
|
125
|
+
onRowClick,
|
|
126
|
+
tableLayout = "content",
|
|
127
|
+
expandable,
|
|
128
|
+
density = "compact",
|
|
129
|
+
skeletonRows = 8,
|
|
130
|
+
maxBodyHeight = "calc(100vh - 260px)",
|
|
131
|
+
className,
|
|
132
|
+
}: DataTableCardProps<T>) {
|
|
133
|
+
const capped = maxBodyHeight !== "none";
|
|
134
|
+
/** The illustrated stand-in only applies once loading has settled. */
|
|
135
|
+
const showEmptyState = !!emptyState && !isLoading && data.length === 0;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
className={cn("overflow-hidden rounded-xl border border-border bg-card", className)}
|
|
140
|
+
style={
|
|
141
|
+
capped ? ({ ["--dtc-max-body" as string]: maxBodyHeight } as CSSProperties) : undefined
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
{title && (
|
|
145
|
+
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-4 py-3.5">
|
|
146
|
+
<div className="min-w-0">
|
|
147
|
+
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
|
148
|
+
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
|
149
|
+
</div>
|
|
150
|
+
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{tabs && <div className="border-b border-border px-4 pt-3">{tabs}</div>}
|
|
155
|
+
{toolbar && <div className="border-b border-border px-4 py-3">{toolbar}</div>}
|
|
156
|
+
|
|
157
|
+
{errorState}
|
|
158
|
+
{!errorState && showEmptyState && emptyState}
|
|
159
|
+
|
|
160
|
+
<DataTable<T>
|
|
161
|
+
className={cn(
|
|
162
|
+
(showEmptyState || errorState) && "hidden",
|
|
163
|
+
// The card already draws the border and radius; a second set inside
|
|
164
|
+
// it would read as a table nested in a card.
|
|
165
|
+
"rounded-none border-0",
|
|
166
|
+
footer && "[&>.border-t]:hidden",
|
|
167
|
+
// Cap the scroll container so the body scrolls internally while the
|
|
168
|
+
// toolbar and footer stay fixed. The cap comes from an inline custom
|
|
169
|
+
// property rather than a class, so a caller's `className` override
|
|
170
|
+
// cannot depend on class-merge order.
|
|
171
|
+
capped &&
|
|
172
|
+
"[&>div:first-child]:max-h-[var(--dtc-max-body)] [&>div:first-child]:overflow-y-auto"
|
|
173
|
+
)}
|
|
174
|
+
// The header sticks to the top of that scroll area, which is only
|
|
175
|
+
// meaningful while the body actually scrolls.
|
|
176
|
+
theadClassName={capped ? "sticky top-0 z-20 [&_th]:bg-card" : undefined}
|
|
177
|
+
tableLayout={tableLayout}
|
|
178
|
+
columns={columns}
|
|
179
|
+
data={data}
|
|
180
|
+
isLoading={isLoading}
|
|
181
|
+
rowKey={rowKey}
|
|
182
|
+
skeletonRows={skeletonRows}
|
|
183
|
+
density={density}
|
|
184
|
+
pagination={pagination}
|
|
185
|
+
sorting={sorting}
|
|
186
|
+
rowAction={rowAction}
|
|
187
|
+
onRowClick={onRowClick}
|
|
188
|
+
emptyTitle={emptyTitle}
|
|
189
|
+
emptyDescription={emptyDescription}
|
|
190
|
+
expandable={expandable}
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
{footer && (
|
|
194
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
|
|
195
|
+
{footer}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Right-aligned group for toolbar action buttons. */
|
|
203
|
+
export function TableToolbarActions({ children }: { children: ReactNode }) {
|
|
204
|
+
return <div className="ml-auto flex items-center gap-2">{children}</div>;
|
|
205
|
+
}
|