@jinaan/uikit 0.0.0-stage → 0.1.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/README.md +63 -2
- package/dist/agent-prompt.d.ts +16 -0
- package/dist/agent-prompt.d.ts.map +1 -0
- package/dist/avatar.d.ts +5 -0
- package/dist/avatar.d.ts.map +1 -0
- package/dist/badge.d.ts +11 -0
- package/dist/badge.d.ts.map +1 -0
- package/dist/button.d.ts +22 -0
- package/dist/button.d.ts.map +1 -0
- package/dist/callout.d.ts +12 -0
- package/dist/callout.d.ts.map +1 -0
- package/dist/card.d.ts +8 -0
- package/dist/card.d.ts.map +1 -0
- package/dist/checkbox.d.ts +3 -0
- package/dist/checkbox.d.ts.map +1 -0
- package/dist/choice-dialog.d.ts +34 -0
- package/dist/choice-dialog.d.ts.map +1 -0
- package/dist/code-block.d.ts +11 -0
- package/dist/code-block.d.ts.map +1 -0
- package/dist/data-table.d.ts +84 -0
- package/dist/data-table.d.ts.map +1 -0
- package/dist/dialog.d.ts +19 -0
- package/dist/dialog.d.ts.map +1 -0
- package/dist/dropdown-menu.d.ts +13 -0
- package/dist/dropdown-menu.d.ts.map +1 -0
- package/dist/empty-state.d.ts +11 -0
- package/dist/empty-state.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1522 -0
- package/dist/index.js.map +1 -0
- package/dist/input.d.ts +19 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/kbd.d.ts +4 -0
- package/dist/kbd.d.ts.map +1 -0
- package/dist/label.d.ts +3 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/radio-group.d.ts +35 -0
- package/dist/radio-group.d.ts.map +1 -0
- package/dist/segmented.d.ts +22 -0
- package/dist/segmented.d.ts.map +1 -0
- package/dist/select.d.ts +9 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/separator.d.ts +3 -0
- package/dist/separator.d.ts.map +1 -0
- package/dist/skeleton.d.ts +3 -0
- package/dist/skeleton.d.ts.map +1 -0
- package/dist/spinner.d.ts +4 -0
- package/dist/spinner.d.ts.map +1 -0
- package/dist/table-empty-state.d.ts +15 -0
- package/dist/table-empty-state.d.ts.map +1 -0
- package/dist/table-surface.d.ts +38 -0
- package/dist/table-surface.d.ts.map +1 -0
- package/dist/table.d.ts +15 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/tooltip.d.ts +6 -0
- package/dist/tooltip.d.ts.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +73 -4
- package/src/agent-prompt.tsx +60 -0
- package/src/avatar.tsx +46 -0
- package/src/badge.tsx +54 -0
- package/src/button.tsx +126 -0
- package/src/callout.tsx +74 -0
- package/src/card.tsx +61 -0
- package/src/checkbox.tsx +32 -0
- package/src/choice-dialog.tsx +110 -0
- package/src/code-block.tsx +53 -0
- package/src/data-table.tsx +589 -0
- package/src/dialog.tsx +123 -0
- package/src/dropdown-menu.tsx +103 -0
- package/src/empty-state.tsx +35 -0
- package/src/index.ts +26 -0
- package/src/input.tsx +171 -0
- package/src/kbd.tsx +16 -0
- package/src/label.tsx +19 -0
- package/src/radio-group.tsx +134 -0
- package/src/segmented.tsx +76 -0
- package/src/select.tsx +91 -0
- package/src/separator.tsx +22 -0
- package/src/skeleton.tsx +12 -0
- package/src/spinner.tsx +13 -0
- package/src/table-empty-state.tsx +51 -0
- package/src/table-surface.tsx +122 -0
- package/src/table.tsx +91 -0
- package/src/tooltip.tsx +32 -0
- package/src/utils.ts +7 -0
- package/styles.css +371 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Check, Copy } from "@phosphor-icons/react";
|
|
2
|
+
import { useState, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { cn } from "./utils";
|
|
6
|
+
|
|
7
|
+
interface CodeBlockProps {
|
|
8
|
+
code: string;
|
|
9
|
+
/** Short label shown in the header (e.g. a filename or request line). */
|
|
10
|
+
label?: ReactNode;
|
|
11
|
+
language?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CodeBlock({ code, label, className }: CodeBlockProps) {
|
|
16
|
+
const [copied, setCopied] = useState(false);
|
|
17
|
+
|
|
18
|
+
const copy = async () => {
|
|
19
|
+
try {
|
|
20
|
+
await navigator.clipboard.writeText(code);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
23
|
+
} catch {
|
|
24
|
+
/* clipboard unavailable */
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cn(
|
|
31
|
+
"overflow-hidden rounded-lg border border-line bg-canvas",
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
<div className="flex items-center justify-between gap-2 border-b border-line px-3 py-2">
|
|
36
|
+
<span className="truncate text-xs font-medium text-subtle">{label}</span>
|
|
37
|
+
<Button
|
|
38
|
+
variant="ghost"
|
|
39
|
+
size="sm"
|
|
40
|
+
leadingIcon={
|
|
41
|
+
copied ? <Check className="text-success" weight="bold" /> : <Copy />
|
|
42
|
+
}
|
|
43
|
+
onClick={copy}
|
|
44
|
+
>
|
|
45
|
+
{copied ? "Copied" : "Copy"}
|
|
46
|
+
</Button>
|
|
47
|
+
</div>
|
|
48
|
+
<pre className="overflow-x-auto p-4 font-mono text-xs leading-relaxed text-fg">
|
|
49
|
+
<code>{code}</code>
|
|
50
|
+
</pre>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CaretDown,
|
|
3
|
+
CaretUp,
|
|
4
|
+
CaretUpDown,
|
|
5
|
+
Columns as ColumnsIcon,
|
|
6
|
+
MagnifyingGlass,
|
|
7
|
+
WarningCircle,
|
|
8
|
+
X,
|
|
9
|
+
} from "@phosphor-icons/react";
|
|
10
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
|
13
|
+
import { Button } from "./button";
|
|
14
|
+
import { Checkbox } from "./checkbox";
|
|
15
|
+
import { Input } from "./input";
|
|
16
|
+
import {
|
|
17
|
+
DropdownMenu,
|
|
18
|
+
DropdownMenuCheckboxItem,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuLabel,
|
|
21
|
+
DropdownMenuSeparator,
|
|
22
|
+
DropdownMenuTrigger,
|
|
23
|
+
} from "./dropdown-menu";
|
|
24
|
+
import {
|
|
25
|
+
Select,
|
|
26
|
+
SelectContent,
|
|
27
|
+
SelectItem,
|
|
28
|
+
SelectTrigger,
|
|
29
|
+
SelectValue,
|
|
30
|
+
} from "./select";
|
|
31
|
+
import { TableBody, TableCell, TableRow } from "./table";
|
|
32
|
+
import { TableEmptyState } from "./table-empty-state";
|
|
33
|
+
import {
|
|
34
|
+
TableSurface,
|
|
35
|
+
type TableSurfaceAction,
|
|
36
|
+
type TableSurfaceTone,
|
|
37
|
+
} from "./table-surface";
|
|
38
|
+
import { cn } from "./utils";
|
|
39
|
+
|
|
40
|
+
export interface Column<T> {
|
|
41
|
+
id: string;
|
|
42
|
+
header: string;
|
|
43
|
+
cell: (row: T) => ReactNode;
|
|
44
|
+
sortable?: boolean;
|
|
45
|
+
sortValue?: (row: T) => string | number | null | undefined;
|
|
46
|
+
numeric?: boolean;
|
|
47
|
+
defaultHidden?: boolean;
|
|
48
|
+
align?: "left" | "right";
|
|
49
|
+
className?: string;
|
|
50
|
+
/** Column width for the fixed layout (e.g. "18rem", "20%"). Defaults to auto. */
|
|
51
|
+
width?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BulkAction<T> {
|
|
55
|
+
label: string;
|
|
56
|
+
icon?: ReactNode;
|
|
57
|
+
variant?: "primary" | "outline" | "destructive";
|
|
58
|
+
onRun: (rows: T[]) => void | Promise<void>;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TabDef {
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DataTableError {
|
|
68
|
+
title: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
action?: ReactNode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface DataTableProps<T> {
|
|
74
|
+
rows: T[];
|
|
75
|
+
columns: Column<T>[];
|
|
76
|
+
getRowId: (row: T) => string;
|
|
77
|
+
|
|
78
|
+
loading?: boolean;
|
|
79
|
+
error?: DataTableError | null;
|
|
80
|
+
empty?: {
|
|
81
|
+
icon?: ReactNode;
|
|
82
|
+
title: string;
|
|
83
|
+
description?: ReactNode;
|
|
84
|
+
action?: ReactNode;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
search?: {
|
|
88
|
+
value: string;
|
|
89
|
+
onChange: (value: string) => void;
|
|
90
|
+
placeholder?: string;
|
|
91
|
+
keys: (row: T) => string;
|
|
92
|
+
};
|
|
93
|
+
toolbarStart?: ReactNode;
|
|
94
|
+
toolbarEnd?: ReactNode;
|
|
95
|
+
|
|
96
|
+
tabs?: TabDef[];
|
|
97
|
+
activeTab?: string;
|
|
98
|
+
onTabChange?: (id: string) => void;
|
|
99
|
+
|
|
100
|
+
infoBanner?: ReactNode;
|
|
101
|
+
|
|
102
|
+
selectable?: boolean;
|
|
103
|
+
bulkActions?: BulkAction<T>[];
|
|
104
|
+
|
|
105
|
+
rowActions?: (row: T) => ReactNode;
|
|
106
|
+
onRowClick?: (row: T) => void;
|
|
107
|
+
|
|
108
|
+
defaultSort?: { id: string; dir: "asc" | "desc" };
|
|
109
|
+
pageSize?: number;
|
|
110
|
+
pageSizeOptions?: number[];
|
|
111
|
+
|
|
112
|
+
/** Surface preset for the table card. */
|
|
113
|
+
tone?: TableSurfaceTone;
|
|
114
|
+
/** Optional action pinned to the top-right of the header band. */
|
|
115
|
+
headerAction?: ReactNode | TableSurfaceAction;
|
|
116
|
+
/** Header band color/style overrides. */
|
|
117
|
+
headerClassName?: string;
|
|
118
|
+
/** Inner panel color/style overrides. */
|
|
119
|
+
panelClassName?: string;
|
|
120
|
+
|
|
121
|
+
className?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const HEADER_CELL =
|
|
125
|
+
"h-[var(--table-header-h)] whitespace-nowrap px-[var(--table-pad-x)] text-left align-middle text-[0.8125rem] font-medium";
|
|
126
|
+
|
|
127
|
+
function initials(name: string): string {
|
|
128
|
+
return name
|
|
129
|
+
.split(/\s+/)
|
|
130
|
+
.slice(0, 2)
|
|
131
|
+
.map((p) => p[0]?.toUpperCase() ?? "")
|
|
132
|
+
.join("");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Clerk-style identity cell: avatar + primary name + secondary line. */
|
|
136
|
+
export function DataTableIdentity({
|
|
137
|
+
name,
|
|
138
|
+
secondary,
|
|
139
|
+
image,
|
|
140
|
+
}: {
|
|
141
|
+
name: string;
|
|
142
|
+
secondary?: ReactNode;
|
|
143
|
+
image?: string;
|
|
144
|
+
}) {
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex items-center gap-3">
|
|
147
|
+
<Avatar className="size-8">
|
|
148
|
+
{image ? <AvatarImage src={image} alt="" /> : null}
|
|
149
|
+
<AvatarFallback>{initials(name)}</AvatarFallback>
|
|
150
|
+
</Avatar>
|
|
151
|
+
<div className="min-w-0">
|
|
152
|
+
<div className="truncate font-medium text-table-header-active">{name}</div>
|
|
153
|
+
{secondary ? <div className="truncate text-xs text-table-muted">{secondary}</div> : null}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function DataTable<T>({
|
|
160
|
+
rows,
|
|
161
|
+
columns,
|
|
162
|
+
getRowId,
|
|
163
|
+
loading = false,
|
|
164
|
+
error,
|
|
165
|
+
empty,
|
|
166
|
+
search,
|
|
167
|
+
toolbarStart,
|
|
168
|
+
toolbarEnd,
|
|
169
|
+
tabs,
|
|
170
|
+
activeTab,
|
|
171
|
+
onTabChange,
|
|
172
|
+
infoBanner,
|
|
173
|
+
selectable = false,
|
|
174
|
+
bulkActions,
|
|
175
|
+
rowActions,
|
|
176
|
+
onRowClick,
|
|
177
|
+
defaultSort,
|
|
178
|
+
pageSize: initialPageSize = 25,
|
|
179
|
+
pageSizeOptions = [10, 25, 50, 100],
|
|
180
|
+
tone,
|
|
181
|
+
headerAction,
|
|
182
|
+
headerClassName,
|
|
183
|
+
panelClassName,
|
|
184
|
+
className,
|
|
185
|
+
}: DataTableProps<T>) {
|
|
186
|
+
const [hidden, setHidden] = useState<Record<string, boolean>>(() =>
|
|
187
|
+
Object.fromEntries(columns.filter((c) => c.defaultHidden).map((c) => [c.id, true])),
|
|
188
|
+
);
|
|
189
|
+
const [sort, setSort] = useState<{ id: string; dir: "asc" | "desc" } | null>(
|
|
190
|
+
defaultSort ?? null,
|
|
191
|
+
);
|
|
192
|
+
const [page, setPage] = useState(0);
|
|
193
|
+
const [pageSize, setPageSize] = useState(initialPageSize);
|
|
194
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
195
|
+
|
|
196
|
+
const visibleColumns = columns.filter((c) => !hidden[c.id]);
|
|
197
|
+
|
|
198
|
+
const filtered = useMemo(() => {
|
|
199
|
+
if (!search || !search.value.trim()) return rows;
|
|
200
|
+
const q = search.value.trim().toLowerCase();
|
|
201
|
+
return rows.filter((r) => search.keys(r).toLowerCase().includes(q));
|
|
202
|
+
}, [rows, search]);
|
|
203
|
+
|
|
204
|
+
const sorted = useMemo(() => {
|
|
205
|
+
if (!sort) return filtered;
|
|
206
|
+
const col = columns.find((c) => c.id === sort.id);
|
|
207
|
+
if (!col?.sortValue) return filtered;
|
|
208
|
+
const dir = sort.dir === "asc" ? 1 : -1;
|
|
209
|
+
return [...filtered].sort((a, b) => {
|
|
210
|
+
const av = col.sortValue!(a);
|
|
211
|
+
const bv = col.sortValue!(b);
|
|
212
|
+
if (av == null && bv == null) return 0;
|
|
213
|
+
if (av == null) return 1;
|
|
214
|
+
if (bv == null) return -1;
|
|
215
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
|
|
216
|
+
return String(av).localeCompare(String(bv)) * dir;
|
|
217
|
+
});
|
|
218
|
+
}, [filtered, sort, columns]);
|
|
219
|
+
|
|
220
|
+
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
|
221
|
+
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
setPage(0);
|
|
224
|
+
}, [search?.value, pageSize, sort?.id, sort?.dir, activeTab]);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (page > pageCount - 1) setPage(pageCount - 1);
|
|
228
|
+
}, [page, pageCount]);
|
|
229
|
+
|
|
230
|
+
const start = page * pageSize;
|
|
231
|
+
const pageRows = sorted.slice(start, start + pageSize);
|
|
232
|
+
|
|
233
|
+
const toggleSort = (col: Column<T>) => {
|
|
234
|
+
if (!col.sortable) return;
|
|
235
|
+
setSort((prev) =>
|
|
236
|
+
prev?.id === col.id
|
|
237
|
+
? prev.dir === "asc"
|
|
238
|
+
? { id: col.id, dir: "desc" }
|
|
239
|
+
: null
|
|
240
|
+
: { id: col.id, dir: "asc" },
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const allSelected = pageRows.length > 0 && pageRows.every((r) => selected.has(getRowId(r)));
|
|
245
|
+
const someSelected = pageRows.some((r) => selected.has(getRowId(r)));
|
|
246
|
+
const selectedRows = rows.filter((r) => selected.has(getRowId(r)));
|
|
247
|
+
|
|
248
|
+
const toggleAll = () => {
|
|
249
|
+
setSelected((prev) => {
|
|
250
|
+
const next = new Set(prev);
|
|
251
|
+
if (allSelected) pageRows.forEach((r) => next.delete(getRowId(r)));
|
|
252
|
+
else pageRows.forEach((r) => next.add(getRowId(r)));
|
|
253
|
+
return next;
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const toggleOne = (id: string) => {
|
|
258
|
+
setSelected((prev) => {
|
|
259
|
+
const next = new Set(prev);
|
|
260
|
+
if (next.has(id)) next.delete(id);
|
|
261
|
+
else next.add(id);
|
|
262
|
+
return next;
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const hasToolbar = Boolean(search || toolbarStart || toolbarEnd);
|
|
267
|
+
|
|
268
|
+
// Fixed layout keeps header and body columns aligned across the two tables.
|
|
269
|
+
// When no column declares a width, give the first column more room and split
|
|
270
|
+
// the rest evenly so tables don't render as uniform stripes.
|
|
271
|
+
const anyExplicitWidth = visibleColumns.some((c) => c.width);
|
|
272
|
+
const restShare =
|
|
273
|
+
visibleColumns.length > 1 ? (100 - 30) / (visibleColumns.length - 1) : 100;
|
|
274
|
+
const dataWidths = visibleColumns.map((c, i) => {
|
|
275
|
+
if (c.width) return c.width;
|
|
276
|
+
if (anyExplicitWidth) return "auto";
|
|
277
|
+
return `${i === 0 ? 30 : restShare}%`;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const colWidths = [
|
|
281
|
+
...(selectable ? ["2.75rem"] : []),
|
|
282
|
+
...dataWidths,
|
|
283
|
+
...(rowActions ? ["11rem"] : []),
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const ariaSort = (c: Column<T>) =>
|
|
287
|
+
sort?.id === c.id ? (sort.dir === "asc" ? "ascending" : "descending") : undefined;
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className={cn("space-y-4", className)}>
|
|
291
|
+
{tabs && tabs.length > 0 && (
|
|
292
|
+
<div className="flex items-center gap-1 border-b border-line" role="tablist">
|
|
293
|
+
{tabs.map((t) => {
|
|
294
|
+
const active = t.id === activeTab;
|
|
295
|
+
return (
|
|
296
|
+
<button
|
|
297
|
+
key={t.id}
|
|
298
|
+
type="button"
|
|
299
|
+
role="tab"
|
|
300
|
+
aria-selected={active}
|
|
301
|
+
onClick={() => onTabChange?.(t.id)}
|
|
302
|
+
className={cn(
|
|
303
|
+
"-mb-px border-b-2 px-3 py-2 text-sm transition-colors",
|
|
304
|
+
active
|
|
305
|
+
? "border-fg font-medium text-fg"
|
|
306
|
+
: "border-transparent text-muted hover:text-fg",
|
|
307
|
+
)}
|
|
308
|
+
>
|
|
309
|
+
{t.label}
|
|
310
|
+
</button>
|
|
311
|
+
);
|
|
312
|
+
})}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{hasToolbar && (
|
|
317
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
318
|
+
{search && (
|
|
319
|
+
<Input
|
|
320
|
+
size="md"
|
|
321
|
+
type="search"
|
|
322
|
+
aria-label={search.placeholder ?? "Search"}
|
|
323
|
+
placeholder={search.placeholder ?? "Search…"}
|
|
324
|
+
value={search.value}
|
|
325
|
+
onChange={(e) => search.onChange(e.target.value)}
|
|
326
|
+
leadingIcon={<MagnifyingGlass />}
|
|
327
|
+
containerClassName="w-full sm:w-72"
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
{toolbarStart}
|
|
331
|
+
<div className="ml-auto flex items-center gap-2">
|
|
332
|
+
<DropdownMenu>
|
|
333
|
+
<DropdownMenuTrigger asChild>
|
|
334
|
+
<Button
|
|
335
|
+
variant="secondary"
|
|
336
|
+
size="xs"
|
|
337
|
+
leadingIcon={<ColumnsIcon />}
|
|
338
|
+
trailingIcon={<CaretDown />}
|
|
339
|
+
>
|
|
340
|
+
Columns
|
|
341
|
+
</Button>
|
|
342
|
+
</DropdownMenuTrigger>
|
|
343
|
+
<DropdownMenuContent align="end" className="w-44">
|
|
344
|
+
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
|
345
|
+
<DropdownMenuSeparator />
|
|
346
|
+
{columns.map((c) => (
|
|
347
|
+
<DropdownMenuCheckboxItem
|
|
348
|
+
key={c.id}
|
|
349
|
+
checked={!hidden[c.id]}
|
|
350
|
+
onCheckedChange={(v) => setHidden((prev) => ({ ...prev, [c.id]: !v }))}
|
|
351
|
+
onSelect={(e) => e.preventDefault()}
|
|
352
|
+
>
|
|
353
|
+
{c.header}
|
|
354
|
+
</DropdownMenuCheckboxItem>
|
|
355
|
+
))}
|
|
356
|
+
</DropdownMenuContent>
|
|
357
|
+
</DropdownMenu>
|
|
358
|
+
{toolbarEnd}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{infoBanner}
|
|
364
|
+
|
|
365
|
+
{selectable && selected.size > 0 && (
|
|
366
|
+
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-line bg-elevated px-3 py-2 text-sm">
|
|
367
|
+
<span className="font-medium text-fg">{selected.size} selected</span>
|
|
368
|
+
<div className="flex items-center gap-2">
|
|
369
|
+
{(bulkActions ?? []).map((a) => (
|
|
370
|
+
<Button
|
|
371
|
+
key={a.label}
|
|
372
|
+
size="sm"
|
|
373
|
+
variant={a.variant ?? "outline"}
|
|
374
|
+
disabled={a.disabled}
|
|
375
|
+
onClick={() => void a.onRun(selectedRows)}
|
|
376
|
+
>
|
|
377
|
+
{a.icon}
|
|
378
|
+
{a.label}
|
|
379
|
+
</Button>
|
|
380
|
+
))}
|
|
381
|
+
</div>
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
onClick={() => setSelected(new Set())}
|
|
385
|
+
className="ml-auto inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
|
|
386
|
+
>
|
|
387
|
+
<X className="size-3.5" />
|
|
388
|
+
Clear
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
|
|
393
|
+
<TableSurface
|
|
394
|
+
action={headerAction}
|
|
395
|
+
tone={tone}
|
|
396
|
+
headerClassName={headerClassName}
|
|
397
|
+
panelClassName={panelClassName}
|
|
398
|
+
header={
|
|
399
|
+
// Visible header: presentational so its controls stay focusable while
|
|
400
|
+
// the body table carries the semantic column headers (sr-only).
|
|
401
|
+
<table role="presentation" className="w-full table-fixed border-collapse">
|
|
402
|
+
<colgroup>
|
|
403
|
+
{colWidths.map((w, i) => (
|
|
404
|
+
<col key={i} style={{ width: w }} />
|
|
405
|
+
))}
|
|
406
|
+
</colgroup>
|
|
407
|
+
<thead>
|
|
408
|
+
<tr>
|
|
409
|
+
{selectable && (
|
|
410
|
+
<th className="h-[var(--table-header-h)] w-11 px-4 align-middle">
|
|
411
|
+
<Checkbox
|
|
412
|
+
aria-label="Select all rows"
|
|
413
|
+
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
414
|
+
onCheckedChange={toggleAll}
|
|
415
|
+
/>
|
|
416
|
+
</th>
|
|
417
|
+
)}
|
|
418
|
+
{visibleColumns.map((c) => (
|
|
419
|
+
<th
|
|
420
|
+
key={c.id}
|
|
421
|
+
className={cn(HEADER_CELL, c.align === "right" && "text-right", c.className)}
|
|
422
|
+
>
|
|
423
|
+
{c.sortable ? (
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
aria-label={`Sort by ${c.header}`}
|
|
427
|
+
onClick={() => toggleSort(c)}
|
|
428
|
+
className={cn(
|
|
429
|
+
"inline-flex items-center gap-1.5 transition-colors hover:text-table-header-active",
|
|
430
|
+
sort?.id === c.id && "text-table-header-active",
|
|
431
|
+
)}
|
|
432
|
+
>
|
|
433
|
+
{c.header}
|
|
434
|
+
{sort?.id === c.id ? (
|
|
435
|
+
sort.dir === "asc" ? (
|
|
436
|
+
<CaretUp className="size-3.5" weight="bold" />
|
|
437
|
+
) : (
|
|
438
|
+
<CaretDown className="size-3.5" weight="bold" />
|
|
439
|
+
)
|
|
440
|
+
) : (
|
|
441
|
+
<CaretUpDown className="size-3.5 opacity-50" />
|
|
442
|
+
)}
|
|
443
|
+
</button>
|
|
444
|
+
) : (
|
|
445
|
+
c.header
|
|
446
|
+
)}
|
|
447
|
+
</th>
|
|
448
|
+
))}
|
|
449
|
+
{rowActions && (
|
|
450
|
+
<th className="h-[var(--table-header-h)] w-44 px-4 align-middle">
|
|
451
|
+
<span className="sr-only">Actions</span>
|
|
452
|
+
</th>
|
|
453
|
+
)}
|
|
454
|
+
</tr>
|
|
455
|
+
</thead>
|
|
456
|
+
</table>
|
|
457
|
+
}
|
|
458
|
+
>
|
|
459
|
+
{loading ? (
|
|
460
|
+
<div className="space-y-2 p-4">
|
|
461
|
+
{[0, 1, 2].map((i) => (
|
|
462
|
+
<div key={i} className="h-11 w-full animate-pulse rounded-lg bg-table-row-hover" />
|
|
463
|
+
))}
|
|
464
|
+
</div>
|
|
465
|
+
) : error ? (
|
|
466
|
+
<div className="flex min-h-[22rem] flex-col items-center justify-center gap-4 px-6 py-16 text-center">
|
|
467
|
+
<div className="flex size-[var(--table-empty-icon)] items-center justify-center rounded-2xl border border-table-empty-icon-border bg-table-empty-icon text-error [&_svg]:size-8">
|
|
468
|
+
<WarningCircle weight="duotone" />
|
|
469
|
+
</div>
|
|
470
|
+
<div className="space-y-2">
|
|
471
|
+
<p className="text-[1.75rem] font-semibold text-table-header-active">
|
|
472
|
+
{error.title}
|
|
473
|
+
</p>
|
|
474
|
+
{error.description ? (
|
|
475
|
+
<p className="mx-auto max-w-md text-base leading-relaxed text-table-muted">
|
|
476
|
+
{error.description}
|
|
477
|
+
</p>
|
|
478
|
+
) : null}
|
|
479
|
+
</div>
|
|
480
|
+
{error.action}
|
|
481
|
+
</div>
|
|
482
|
+
) : pageRows.length === 0 ? (
|
|
483
|
+
<TableEmptyState
|
|
484
|
+
icon={empty?.icon}
|
|
485
|
+
title={empty?.title ?? "No results"}
|
|
486
|
+
description={typeof empty?.description === "string" ? empty.description : undefined}
|
|
487
|
+
action={empty?.action}
|
|
488
|
+
/>
|
|
489
|
+
) : (
|
|
490
|
+
<table className="w-full table-fixed border-collapse">
|
|
491
|
+
<colgroup>
|
|
492
|
+
{colWidths.map((w, i) => (
|
|
493
|
+
<col key={i} style={{ width: w }} />
|
|
494
|
+
))}
|
|
495
|
+
</colgroup>
|
|
496
|
+
{/* Semantic headers for assistive tech (visible header is presentational). */}
|
|
497
|
+
<thead className="sr-only">
|
|
498
|
+
<tr>
|
|
499
|
+
{selectable && <th scope="col">Select</th>}
|
|
500
|
+
{visibleColumns.map((c) => (
|
|
501
|
+
<th key={c.id} scope="col" aria-sort={ariaSort(c)}>
|
|
502
|
+
{c.header}
|
|
503
|
+
</th>
|
|
504
|
+
))}
|
|
505
|
+
{rowActions && <th scope="col">Actions</th>}
|
|
506
|
+
</tr>
|
|
507
|
+
</thead>
|
|
508
|
+
<TableBody>
|
|
509
|
+
{pageRows.map((row) => {
|
|
510
|
+
const id = getRowId(row);
|
|
511
|
+
const isSelected = selected.has(id);
|
|
512
|
+
return (
|
|
513
|
+
<TableRow
|
|
514
|
+
key={id}
|
|
515
|
+
data-state={isSelected ? "selected" : undefined}
|
|
516
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
517
|
+
className={onRowClick ? "cursor-pointer" : undefined}
|
|
518
|
+
>
|
|
519
|
+
{selectable && (
|
|
520
|
+
<TableCell className="w-11 px-4" onClick={(e) => e.stopPropagation()}>
|
|
521
|
+
<Checkbox
|
|
522
|
+
aria-label="Select row"
|
|
523
|
+
checked={isSelected}
|
|
524
|
+
onCheckedChange={() => toggleOne(id)}
|
|
525
|
+
/>
|
|
526
|
+
</TableCell>
|
|
527
|
+
)}
|
|
528
|
+
{visibleColumns.map((c) => (
|
|
529
|
+
<TableCell
|
|
530
|
+
key={c.id}
|
|
531
|
+
data-numeric={c.numeric ? "" : undefined}
|
|
532
|
+
className={cn(c.align === "right" && "text-right", c.className)}
|
|
533
|
+
>
|
|
534
|
+
{c.cell(row)}
|
|
535
|
+
</TableCell>
|
|
536
|
+
))}
|
|
537
|
+
{rowActions && (
|
|
538
|
+
<TableCell className="w-44 px-4 text-right" onClick={(e) => e.stopPropagation()}>
|
|
539
|
+
{rowActions(row)}
|
|
540
|
+
</TableCell>
|
|
541
|
+
)}
|
|
542
|
+
</TableRow>
|
|
543
|
+
);
|
|
544
|
+
})}
|
|
545
|
+
</TableBody>
|
|
546
|
+
</table>
|
|
547
|
+
)}
|
|
548
|
+
</TableSurface>
|
|
549
|
+
|
|
550
|
+
{!loading && !error && sorted.length > 0 && (
|
|
551
|
+
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
|
552
|
+
<span className="text-muted">
|
|
553
|
+
{start + 1}–{Math.min(start + pageSize, sorted.length)} of {sorted.length}
|
|
554
|
+
</span>
|
|
555
|
+
<div className="flex items-center gap-2">
|
|
556
|
+
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
|
|
557
|
+
<SelectTrigger aria-label="Rows per page" className="h-8 w-[110px]">
|
|
558
|
+
<SelectValue />
|
|
559
|
+
</SelectTrigger>
|
|
560
|
+
<SelectContent>
|
|
561
|
+
{pageSizeOptions.map((n) => (
|
|
562
|
+
<SelectItem key={n} value={String(n)}>
|
|
563
|
+
{n} / page
|
|
564
|
+
</SelectItem>
|
|
565
|
+
))}
|
|
566
|
+
</SelectContent>
|
|
567
|
+
</Select>
|
|
568
|
+
<Button
|
|
569
|
+
variant="outline"
|
|
570
|
+
size="sm"
|
|
571
|
+
disabled={page <= 0}
|
|
572
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
573
|
+
>
|
|
574
|
+
Previous
|
|
575
|
+
</Button>
|
|
576
|
+
<Button
|
|
577
|
+
variant="outline"
|
|
578
|
+
size="sm"
|
|
579
|
+
disabled={page >= pageCount - 1}
|
|
580
|
+
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
|
581
|
+
>
|
|
582
|
+
Next
|
|
583
|
+
</Button>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
);
|
|
589
|
+
}
|