@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.
@@ -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
+ }