@jinaan/uikit 0.0.0-stage → 0.1.1
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 +91 -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 +100 -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 +1649 -0
- package/dist/index.js.map +1 -0
- package/dist/input.d.ts +21 -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 +749 -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 +195 -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,749 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CaretDown,
|
|
3
|
+
CaretUp,
|
|
4
|
+
CaretUpDown,
|
|
5
|
+
Columns as ColumnsIcon,
|
|
6
|
+
FunnelSimple,
|
|
7
|
+
Lock,
|
|
8
|
+
MagnifyingGlass,
|
|
9
|
+
WarningCircle,
|
|
10
|
+
X,
|
|
11
|
+
} from "@phosphor-icons/react";
|
|
12
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
|
15
|
+
import { Button } from "./button";
|
|
16
|
+
import { Checkbox } from "./checkbox";
|
|
17
|
+
import { Input } from "./input";
|
|
18
|
+
import {
|
|
19
|
+
DropdownMenu,
|
|
20
|
+
DropdownMenuCheckboxItem,
|
|
21
|
+
DropdownMenuContent,
|
|
22
|
+
DropdownMenuItem,
|
|
23
|
+
DropdownMenuLabel,
|
|
24
|
+
DropdownMenuSeparator,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
} from "./dropdown-menu";
|
|
27
|
+
import {
|
|
28
|
+
Select,
|
|
29
|
+
SelectContent,
|
|
30
|
+
SelectItem,
|
|
31
|
+
SelectTrigger,
|
|
32
|
+
SelectValue,
|
|
33
|
+
} from "./select";
|
|
34
|
+
import { TableBody, TableCell, TableRow } from "./table";
|
|
35
|
+
import { TableEmptyState } from "./table-empty-state";
|
|
36
|
+
import {
|
|
37
|
+
TableSurface,
|
|
38
|
+
type TableSurfaceAction,
|
|
39
|
+
type TableSurfaceTone,
|
|
40
|
+
} from "./table-surface";
|
|
41
|
+
import { cn } from "./utils";
|
|
42
|
+
|
|
43
|
+
export interface Column<T> {
|
|
44
|
+
id: string;
|
|
45
|
+
header: string;
|
|
46
|
+
cell: (row: T) => ReactNode;
|
|
47
|
+
sortable?: boolean;
|
|
48
|
+
sortValue?: (row: T) => string | number | null | undefined;
|
|
49
|
+
numeric?: boolean;
|
|
50
|
+
defaultHidden?: boolean;
|
|
51
|
+
align?: "left" | "right";
|
|
52
|
+
className?: string;
|
|
53
|
+
/** Column width for the fixed layout (e.g. "18rem", "20%"). Defaults to auto. */
|
|
54
|
+
width?: string;
|
|
55
|
+
/** Required columns stay visible and cannot be hidden. */
|
|
56
|
+
required?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DataTableFilterOption {
|
|
60
|
+
value: string;
|
|
61
|
+
label: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DataTableFilter<T> {
|
|
65
|
+
id: string;
|
|
66
|
+
label: string;
|
|
67
|
+
options: DataTableFilterOption[];
|
|
68
|
+
value: string[];
|
|
69
|
+
onChange: (value: string[]) => void;
|
|
70
|
+
/** Return true when the row matches the selected filter values. */
|
|
71
|
+
match: (row: T, values: string[]) => boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BulkAction<T> {
|
|
75
|
+
label: string;
|
|
76
|
+
icon?: ReactNode;
|
|
77
|
+
variant?: "primary" | "outline" | "destructive";
|
|
78
|
+
onRun: (rows: T[]) => void | Promise<void>;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TabDef {
|
|
83
|
+
id: string;
|
|
84
|
+
label: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DataTableError {
|
|
88
|
+
title: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
action?: ReactNode;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface DataTableProps<T> {
|
|
94
|
+
rows: T[];
|
|
95
|
+
columns: Column<T>[];
|
|
96
|
+
getRowId: (row: T) => string;
|
|
97
|
+
|
|
98
|
+
loading?: boolean;
|
|
99
|
+
error?: DataTableError | null;
|
|
100
|
+
empty?: {
|
|
101
|
+
icon?: ReactNode;
|
|
102
|
+
title: string;
|
|
103
|
+
description?: ReactNode;
|
|
104
|
+
action?: ReactNode;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
search?: {
|
|
108
|
+
value: string;
|
|
109
|
+
onChange: (value: string) => void;
|
|
110
|
+
placeholder?: string;
|
|
111
|
+
keys: (row: T) => string;
|
|
112
|
+
};
|
|
113
|
+
filters?: DataTableFilter<T>[];
|
|
114
|
+
toolbarStart?: ReactNode;
|
|
115
|
+
toolbarEnd?: ReactNode;
|
|
116
|
+
|
|
117
|
+
tabs?: TabDef[];
|
|
118
|
+
activeTab?: string;
|
|
119
|
+
onTabChange?: (id: string) => void;
|
|
120
|
+
|
|
121
|
+
infoBanner?: ReactNode;
|
|
122
|
+
|
|
123
|
+
selectable?: boolean;
|
|
124
|
+
bulkActions?: BulkAction<T>[];
|
|
125
|
+
|
|
126
|
+
rowActions?: (row: T) => ReactNode;
|
|
127
|
+
onRowClick?: (row: T) => void;
|
|
128
|
+
|
|
129
|
+
defaultSort?: { id: string; dir: "asc" | "desc" };
|
|
130
|
+
pageSize?: number;
|
|
131
|
+
pageSizeOptions?: number[];
|
|
132
|
+
|
|
133
|
+
/** Surface preset for the table card. */
|
|
134
|
+
tone?: TableSurfaceTone;
|
|
135
|
+
/** Optional action pinned to the top-right of the header band. */
|
|
136
|
+
headerAction?: ReactNode | TableSurfaceAction;
|
|
137
|
+
/** Header band color/style overrides. */
|
|
138
|
+
headerClassName?: string;
|
|
139
|
+
/** Inner panel color/style overrides. */
|
|
140
|
+
panelClassName?: string;
|
|
141
|
+
|
|
142
|
+
className?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const HEADER_CELL =
|
|
146
|
+
"h-[var(--table-header-h)] whitespace-nowrap px-[var(--table-pad-x)] text-left align-middle text-[0.8125rem] font-medium";
|
|
147
|
+
|
|
148
|
+
const TOOLBAR_BUTTON =
|
|
149
|
+
"rounded-[6px] bg-surface text-fg shadow-xs ring-1 ring-line hover:bg-elevated hover:text-fg active:bg-elevated";
|
|
150
|
+
|
|
151
|
+
function initials(name: string): string {
|
|
152
|
+
return name
|
|
153
|
+
.split(/\s+/)
|
|
154
|
+
.slice(0, 2)
|
|
155
|
+
.map((p) => p[0]?.toUpperCase() ?? "")
|
|
156
|
+
.join("");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Clerk-style identity cell: avatar + primary name + secondary line. */
|
|
160
|
+
export function DataTableIdentity({
|
|
161
|
+
name,
|
|
162
|
+
secondary,
|
|
163
|
+
image,
|
|
164
|
+
}: {
|
|
165
|
+
name: string;
|
|
166
|
+
secondary?: ReactNode;
|
|
167
|
+
image?: string;
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex items-center gap-3">
|
|
171
|
+
<Avatar className="size-8">
|
|
172
|
+
{image ? <AvatarImage src={image} alt="" /> : null}
|
|
173
|
+
<AvatarFallback>{initials(name)}</AvatarFallback>
|
|
174
|
+
</Avatar>
|
|
175
|
+
<div className="min-w-0">
|
|
176
|
+
<div className="truncate font-medium text-table-header-active">{name}</div>
|
|
177
|
+
{secondary ? <div className="truncate text-xs text-table-muted">{secondary}</div> : null}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function DataTable<T>({
|
|
184
|
+
rows,
|
|
185
|
+
columns,
|
|
186
|
+
getRowId,
|
|
187
|
+
loading = false,
|
|
188
|
+
error,
|
|
189
|
+
empty,
|
|
190
|
+
search,
|
|
191
|
+
filters,
|
|
192
|
+
toolbarStart,
|
|
193
|
+
toolbarEnd,
|
|
194
|
+
tabs,
|
|
195
|
+
activeTab,
|
|
196
|
+
onTabChange,
|
|
197
|
+
infoBanner,
|
|
198
|
+
selectable = false,
|
|
199
|
+
bulkActions,
|
|
200
|
+
rowActions,
|
|
201
|
+
onRowClick,
|
|
202
|
+
defaultSort,
|
|
203
|
+
pageSize: initialPageSize = 25,
|
|
204
|
+
pageSizeOptions = [10, 25, 50, 100],
|
|
205
|
+
tone,
|
|
206
|
+
headerAction,
|
|
207
|
+
headerClassName,
|
|
208
|
+
panelClassName,
|
|
209
|
+
className,
|
|
210
|
+
}: DataTableProps<T>) {
|
|
211
|
+
const [hidden, setHidden] = useState<Record<string, boolean>>(() =>
|
|
212
|
+
Object.fromEntries(
|
|
213
|
+
columns.filter((c) => c.defaultHidden && !c.required).map((c) => [c.id, true]),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
const [sort, setSort] = useState<{ id: string; dir: "asc" | "desc" } | null>(
|
|
217
|
+
defaultSort ?? null,
|
|
218
|
+
);
|
|
219
|
+
const [page, setPage] = useState(0);
|
|
220
|
+
const [pageSize, setPageSize] = useState(initialPageSize);
|
|
221
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
222
|
+
const [activeFilterIds, setActiveFilterIds] = useState<string[]>([]);
|
|
223
|
+
|
|
224
|
+
const visibleColumns = columns.filter((c) => !hidden[c.id]);
|
|
225
|
+
const hiddenColumns = columns.filter((c) => hidden[c.id] && !c.required);
|
|
226
|
+
const activeFilters = (filters ?? []).filter((f) => activeFilterIds.includes(f.id));
|
|
227
|
+
const filterSignature = (filters ?? [])
|
|
228
|
+
.map((f) => `${f.id}:${f.value.join(",")}`)
|
|
229
|
+
.join("|");
|
|
230
|
+
|
|
231
|
+
const filtered = useMemo(() => {
|
|
232
|
+
let next = rows;
|
|
233
|
+
for (const filter of filters ?? []) {
|
|
234
|
+
if (filter.value.length > 0) {
|
|
235
|
+
next = next.filter((row) => filter.match(row, filter.value));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!search || !search.value.trim()) return next;
|
|
239
|
+
const q = search.value.trim().toLowerCase();
|
|
240
|
+
return next.filter((r) => search.keys(r).toLowerCase().includes(q));
|
|
241
|
+
}, [rows, search, filters]);
|
|
242
|
+
|
|
243
|
+
const sorted = useMemo(() => {
|
|
244
|
+
if (!sort) return filtered;
|
|
245
|
+
const col = columns.find((c) => c.id === sort.id);
|
|
246
|
+
if (!col?.sortValue) return filtered;
|
|
247
|
+
const dir = sort.dir === "asc" ? 1 : -1;
|
|
248
|
+
return [...filtered].sort((a, b) => {
|
|
249
|
+
const av = col.sortValue!(a);
|
|
250
|
+
const bv = col.sortValue!(b);
|
|
251
|
+
if (av == null && bv == null) return 0;
|
|
252
|
+
if (av == null) return 1;
|
|
253
|
+
if (bv == null) return -1;
|
|
254
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
|
|
255
|
+
return String(av).localeCompare(String(bv)) * dir;
|
|
256
|
+
});
|
|
257
|
+
}, [filtered, sort, columns]);
|
|
258
|
+
|
|
259
|
+
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
setPage(0);
|
|
263
|
+
}, [search?.value, pageSize, sort?.id, sort?.dir, activeTab, filterSignature]);
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (page > pageCount - 1) setPage(pageCount - 1);
|
|
267
|
+
}, [page, pageCount]);
|
|
268
|
+
|
|
269
|
+
const start = page * pageSize;
|
|
270
|
+
const pageRows = sorted.slice(start, start + pageSize);
|
|
271
|
+
|
|
272
|
+
const toggleSort = (col: Column<T>) => {
|
|
273
|
+
if (!col.sortable) return;
|
|
274
|
+
setSort((prev) =>
|
|
275
|
+
prev?.id === col.id
|
|
276
|
+
? prev.dir === "asc"
|
|
277
|
+
? { id: col.id, dir: "desc" }
|
|
278
|
+
: null
|
|
279
|
+
: { id: col.id, dir: "asc" },
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const allSelected = pageRows.length > 0 && pageRows.every((r) => selected.has(getRowId(r)));
|
|
284
|
+
const someSelected = pageRows.some((r) => selected.has(getRowId(r)));
|
|
285
|
+
const selectedRows = rows.filter((r) => selected.has(getRowId(r)));
|
|
286
|
+
|
|
287
|
+
const toggleAll = () => {
|
|
288
|
+
setSelected((prev) => {
|
|
289
|
+
const next = new Set(prev);
|
|
290
|
+
if (allSelected) pageRows.forEach((r) => next.delete(getRowId(r)));
|
|
291
|
+
else pageRows.forEach((r) => next.add(getRowId(r)));
|
|
292
|
+
return next;
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const toggleOne = (id: string) => {
|
|
297
|
+
setSelected((prev) => {
|
|
298
|
+
const next = new Set(prev);
|
|
299
|
+
if (next.has(id)) next.delete(id);
|
|
300
|
+
else next.add(id);
|
|
301
|
+
return next;
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const hasToolbar = Boolean(search || filters?.length || toolbarStart || toolbarEnd);
|
|
306
|
+
|
|
307
|
+
// Fixed layout keeps header and body columns aligned across the two tables.
|
|
308
|
+
// When no column declares a width, give the first column more room and split
|
|
309
|
+
// the rest evenly so tables don't render as uniform stripes.
|
|
310
|
+
const anyExplicitWidth = visibleColumns.some((c) => c.width);
|
|
311
|
+
const restShare =
|
|
312
|
+
visibleColumns.length > 1 ? (100 - 30) / (visibleColumns.length - 1) : 100;
|
|
313
|
+
const dataWidths = visibleColumns.map((c, i) => {
|
|
314
|
+
if (c.width) return c.width;
|
|
315
|
+
if (anyExplicitWidth) return "auto";
|
|
316
|
+
return `${i === 0 ? 30 : restShare}%`;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const colWidths = [
|
|
320
|
+
...(selectable ? ["2.75rem"] : []),
|
|
321
|
+
...dataWidths,
|
|
322
|
+
...(rowActions ? ["11rem"] : []),
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
const ariaSort = (c: Column<T>) =>
|
|
326
|
+
sort?.id === c.id ? (sort.dir === "asc" ? "ascending" : "descending") : undefined;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div className={cn("space-y-4", className)}>
|
|
330
|
+
{tabs && tabs.length > 0 && (
|
|
331
|
+
<div className="flex items-center gap-1 border-b border-line" role="tablist">
|
|
332
|
+
{tabs.map((t) => {
|
|
333
|
+
const active = t.id === activeTab;
|
|
334
|
+
return (
|
|
335
|
+
<button
|
|
336
|
+
key={t.id}
|
|
337
|
+
type="button"
|
|
338
|
+
role="tab"
|
|
339
|
+
aria-selected={active}
|
|
340
|
+
onClick={() => onTabChange?.(t.id)}
|
|
341
|
+
className={cn(
|
|
342
|
+
"-mb-px border-b-2 px-3 py-2 text-sm transition-colors",
|
|
343
|
+
active
|
|
344
|
+
? "border-fg font-medium text-fg"
|
|
345
|
+
: "border-transparent text-muted hover:text-fg",
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
348
|
+
{t.label}
|
|
349
|
+
</button>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
|
|
355
|
+
{hasToolbar && (
|
|
356
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
357
|
+
{search && (
|
|
358
|
+
<Input
|
|
359
|
+
size="sm"
|
|
360
|
+
type="search"
|
|
361
|
+
aria-label={search.placeholder ?? "Search"}
|
|
362
|
+
placeholder={search.placeholder ?? "Search..."}
|
|
363
|
+
value={search.value}
|
|
364
|
+
onChange={(e) => search.onChange(e.target.value)}
|
|
365
|
+
onClear={() => search.onChange("")}
|
|
366
|
+
leadingIcon={<MagnifyingGlass />}
|
|
367
|
+
containerClassName="w-full sm:w-[280px]"
|
|
368
|
+
className="rounded-[6px] border-0 bg-surface shadow-xs ring-1 ring-line"
|
|
369
|
+
/>
|
|
370
|
+
)}
|
|
371
|
+
{toolbarStart}
|
|
372
|
+
<DropdownMenu>
|
|
373
|
+
<DropdownMenuTrigger asChild>
|
|
374
|
+
<Button
|
|
375
|
+
variant="secondary"
|
|
376
|
+
size="sm"
|
|
377
|
+
leadingIcon={<ColumnsIcon />}
|
|
378
|
+
trailingIcon={<CaretDown />}
|
|
379
|
+
className={TOOLBAR_BUTTON}
|
|
380
|
+
>
|
|
381
|
+
Columns
|
|
382
|
+
</Button>
|
|
383
|
+
</DropdownMenuTrigger>
|
|
384
|
+
<DropdownMenuContent align="end" className="w-54">
|
|
385
|
+
<DropdownMenuLabel className="text-[13px]">Active columns</DropdownMenuLabel>
|
|
386
|
+
{visibleColumns.map((c) => (
|
|
387
|
+
<DropdownMenuCheckboxItem
|
|
388
|
+
key={c.id}
|
|
389
|
+
checked
|
|
390
|
+
disabled={c.required}
|
|
391
|
+
onCheckedChange={() => setHidden((prev) => ({ ...prev, [c.id]: true }))}
|
|
392
|
+
onSelect={(e) => e.preventDefault()}
|
|
393
|
+
className="justify-between"
|
|
394
|
+
>
|
|
395
|
+
<span>{c.header}</span>
|
|
396
|
+
{c.required ? <Lock className="size-3.5 text-subtle" aria-hidden /> : null}
|
|
397
|
+
</DropdownMenuCheckboxItem>
|
|
398
|
+
))}
|
|
399
|
+
{hiddenColumns.length > 0 ? (
|
|
400
|
+
<>
|
|
401
|
+
<DropdownMenuSeparator />
|
|
402
|
+
<DropdownMenuLabel className="text-[13px]">Available columns</DropdownMenuLabel>
|
|
403
|
+
{hiddenColumns.map((c) => (
|
|
404
|
+
<DropdownMenuCheckboxItem
|
|
405
|
+
key={c.id}
|
|
406
|
+
checked={false}
|
|
407
|
+
onCheckedChange={() => setHidden((prev) => ({ ...prev, [c.id]: false }))}
|
|
408
|
+
onSelect={(e) => e.preventDefault()}
|
|
409
|
+
>
|
|
410
|
+
{c.header}
|
|
411
|
+
</DropdownMenuCheckboxItem>
|
|
412
|
+
))}
|
|
413
|
+
</>
|
|
414
|
+
) : null}
|
|
415
|
+
</DropdownMenuContent>
|
|
416
|
+
</DropdownMenu>
|
|
417
|
+
{filters && filters.length > 0 ? (
|
|
418
|
+
<DropdownMenu>
|
|
419
|
+
<DropdownMenuTrigger asChild>
|
|
420
|
+
<Button
|
|
421
|
+
variant="secondary"
|
|
422
|
+
size="sm"
|
|
423
|
+
iconOnly
|
|
424
|
+
aria-label="Add filter"
|
|
425
|
+
className={TOOLBAR_BUTTON}
|
|
426
|
+
>
|
|
427
|
+
<FunnelSimple />
|
|
428
|
+
</Button>
|
|
429
|
+
</DropdownMenuTrigger>
|
|
430
|
+
<DropdownMenuContent align="start" className="w-54">
|
|
431
|
+
{filters.map((filter) => (
|
|
432
|
+
<DropdownMenuItem
|
|
433
|
+
key={filter.id}
|
|
434
|
+
onSelect={() =>
|
|
435
|
+
setActiveFilterIds((prev) =>
|
|
436
|
+
prev.includes(filter.id) ? prev : [...prev, filter.id],
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
>
|
|
440
|
+
{filter.label}
|
|
441
|
+
</DropdownMenuItem>
|
|
442
|
+
))}
|
|
443
|
+
</DropdownMenuContent>
|
|
444
|
+
</DropdownMenu>
|
|
445
|
+
) : null}
|
|
446
|
+
{toolbarEnd ? (
|
|
447
|
+
<div className="flex-0 sm:ml-auto [&_button]:h-8 [&_button]:rounded-[6px]">
|
|
448
|
+
{toolbarEnd}
|
|
449
|
+
</div>
|
|
450
|
+
) : null}
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{activeFilters.length > 0 ? (
|
|
455
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
456
|
+
{activeFilters.map((filter) => (
|
|
457
|
+
<DropdownMenu key={filter.id}>
|
|
458
|
+
<DropdownMenuTrigger asChild>
|
|
459
|
+
<Button
|
|
460
|
+
variant="outline"
|
|
461
|
+
size="sm"
|
|
462
|
+
className="rounded-[6px] bg-surface shadow-xs"
|
|
463
|
+
>
|
|
464
|
+
<FunnelSimple />
|
|
465
|
+
<span className="font-medium">{filter.label}</span>
|
|
466
|
+
<span className="h-4 w-px bg-line" aria-hidden />
|
|
467
|
+
<span className="text-muted">
|
|
468
|
+
{filter.value.length > 0
|
|
469
|
+
? `${filter.value.length} selected`
|
|
470
|
+
: "Enter value"}
|
|
471
|
+
</span>
|
|
472
|
+
<CaretDown className="text-muted" />
|
|
473
|
+
</Button>
|
|
474
|
+
</DropdownMenuTrigger>
|
|
475
|
+
<DropdownMenuContent align="start" className="w-56">
|
|
476
|
+
<DropdownMenuLabel className="text-[13px]">
|
|
477
|
+
Filter by {filter.label}
|
|
478
|
+
</DropdownMenuLabel>
|
|
479
|
+
{filter.options.map((option) => {
|
|
480
|
+
const checked = filter.value.includes(option.value);
|
|
481
|
+
return (
|
|
482
|
+
<DropdownMenuCheckboxItem
|
|
483
|
+
key={option.value}
|
|
484
|
+
checked={checked}
|
|
485
|
+
onCheckedChange={() =>
|
|
486
|
+
filter.onChange(
|
|
487
|
+
checked
|
|
488
|
+
? filter.value.filter((value) => value !== option.value)
|
|
489
|
+
: [...filter.value, option.value],
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
onSelect={(e) => e.preventDefault()}
|
|
493
|
+
>
|
|
494
|
+
{option.label}
|
|
495
|
+
</DropdownMenuCheckboxItem>
|
|
496
|
+
);
|
|
497
|
+
})}
|
|
498
|
+
<DropdownMenuSeparator />
|
|
499
|
+
<DropdownMenuItem
|
|
500
|
+
onSelect={() => {
|
|
501
|
+
filter.onChange([]);
|
|
502
|
+
setActiveFilterIds((prev) => prev.filter((id) => id !== filter.id));
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
Clear filter
|
|
506
|
+
</DropdownMenuItem>
|
|
507
|
+
</DropdownMenuContent>
|
|
508
|
+
</DropdownMenu>
|
|
509
|
+
))}
|
|
510
|
+
<button
|
|
511
|
+
type="button"
|
|
512
|
+
onClick={() => {
|
|
513
|
+
for (const filter of activeFilters) filter.onChange([]);
|
|
514
|
+
setActiveFilterIds([]);
|
|
515
|
+
}}
|
|
516
|
+
className="text-sm text-muted transition-colors hover:text-fg"
|
|
517
|
+
>
|
|
518
|
+
Clear filters
|
|
519
|
+
</button>
|
|
520
|
+
</div>
|
|
521
|
+
) : null}
|
|
522
|
+
|
|
523
|
+
{infoBanner}
|
|
524
|
+
|
|
525
|
+
{selectable && selected.size > 0 && (
|
|
526
|
+
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-line bg-elevated px-3 py-2 text-sm">
|
|
527
|
+
<span className="font-medium text-fg">{selected.size} selected</span>
|
|
528
|
+
<div className="flex items-center gap-2">
|
|
529
|
+
{(bulkActions ?? []).map((a) => (
|
|
530
|
+
<Button
|
|
531
|
+
key={a.label}
|
|
532
|
+
size="sm"
|
|
533
|
+
variant={a.variant ?? "outline"}
|
|
534
|
+
disabled={a.disabled}
|
|
535
|
+
onClick={() => void a.onRun(selectedRows)}
|
|
536
|
+
>
|
|
537
|
+
{a.icon}
|
|
538
|
+
{a.label}
|
|
539
|
+
</Button>
|
|
540
|
+
))}
|
|
541
|
+
</div>
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
onClick={() => setSelected(new Set())}
|
|
545
|
+
className="ml-auto inline-flex items-center gap-1 text-xs text-muted hover:text-fg"
|
|
546
|
+
>
|
|
547
|
+
<X className="size-3.5" />
|
|
548
|
+
Clear
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
<TableSurface
|
|
554
|
+
action={headerAction}
|
|
555
|
+
tone={tone}
|
|
556
|
+
headerClassName={headerClassName}
|
|
557
|
+
panelClassName={panelClassName}
|
|
558
|
+
header={
|
|
559
|
+
// Visible header: presentational so its controls stay focusable while
|
|
560
|
+
// the body table carries the semantic column headers (sr-only).
|
|
561
|
+
<table role="presentation" className="w-full table-fixed border-collapse">
|
|
562
|
+
<colgroup>
|
|
563
|
+
{colWidths.map((w, i) => (
|
|
564
|
+
<col key={i} style={{ width: w }} />
|
|
565
|
+
))}
|
|
566
|
+
</colgroup>
|
|
567
|
+
<thead>
|
|
568
|
+
<tr>
|
|
569
|
+
{selectable && (
|
|
570
|
+
<th className="h-[var(--table-header-h)] w-11 px-4 align-middle">
|
|
571
|
+
<Checkbox
|
|
572
|
+
aria-label="Select all rows"
|
|
573
|
+
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
574
|
+
onCheckedChange={toggleAll}
|
|
575
|
+
/>
|
|
576
|
+
</th>
|
|
577
|
+
)}
|
|
578
|
+
{visibleColumns.map((c) => (
|
|
579
|
+
<th
|
|
580
|
+
key={c.id}
|
|
581
|
+
className={cn(HEADER_CELL, c.align === "right" && "text-right", c.className)}
|
|
582
|
+
>
|
|
583
|
+
{c.sortable ? (
|
|
584
|
+
<button
|
|
585
|
+
type="button"
|
|
586
|
+
aria-label={`Sort by ${c.header}`}
|
|
587
|
+
onClick={() => toggleSort(c)}
|
|
588
|
+
className={cn(
|
|
589
|
+
"inline-flex items-center gap-1.5 transition-colors hover:text-table-header-active",
|
|
590
|
+
sort?.id === c.id && "text-table-header-active",
|
|
591
|
+
)}
|
|
592
|
+
>
|
|
593
|
+
{c.header}
|
|
594
|
+
{sort?.id === c.id ? (
|
|
595
|
+
sort.dir === "asc" ? (
|
|
596
|
+
<CaretUp className="size-3.5" weight="bold" />
|
|
597
|
+
) : (
|
|
598
|
+
<CaretDown className="size-3.5" weight="bold" />
|
|
599
|
+
)
|
|
600
|
+
) : (
|
|
601
|
+
<CaretUpDown className="size-3.5 opacity-50" />
|
|
602
|
+
)}
|
|
603
|
+
</button>
|
|
604
|
+
) : (
|
|
605
|
+
c.header
|
|
606
|
+
)}
|
|
607
|
+
</th>
|
|
608
|
+
))}
|
|
609
|
+
{rowActions && (
|
|
610
|
+
<th className="h-[var(--table-header-h)] w-44 px-4 align-middle">
|
|
611
|
+
<span className="sr-only">Actions</span>
|
|
612
|
+
</th>
|
|
613
|
+
)}
|
|
614
|
+
</tr>
|
|
615
|
+
</thead>
|
|
616
|
+
</table>
|
|
617
|
+
}
|
|
618
|
+
>
|
|
619
|
+
{loading ? (
|
|
620
|
+
<div className="space-y-2 p-4">
|
|
621
|
+
{[0, 1, 2].map((i) => (
|
|
622
|
+
<div key={i} className="h-11 w-full animate-pulse rounded-lg bg-table-row-hover" />
|
|
623
|
+
))}
|
|
624
|
+
</div>
|
|
625
|
+
) : error ? (
|
|
626
|
+
<div className="flex min-h-[22rem] flex-col items-center justify-center gap-4 px-6 py-16 text-center">
|
|
627
|
+
<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">
|
|
628
|
+
<WarningCircle weight="duotone" />
|
|
629
|
+
</div>
|
|
630
|
+
<div className="space-y-2">
|
|
631
|
+
<p className="text-[1.75rem] font-semibold text-table-header-active">
|
|
632
|
+
{error.title}
|
|
633
|
+
</p>
|
|
634
|
+
{error.description ? (
|
|
635
|
+
<p className="mx-auto max-w-md text-base leading-relaxed text-table-muted">
|
|
636
|
+
{error.description}
|
|
637
|
+
</p>
|
|
638
|
+
) : null}
|
|
639
|
+
</div>
|
|
640
|
+
{error.action}
|
|
641
|
+
</div>
|
|
642
|
+
) : pageRows.length === 0 ? (
|
|
643
|
+
<TableEmptyState
|
|
644
|
+
icon={empty?.icon}
|
|
645
|
+
title={empty?.title ?? "No results"}
|
|
646
|
+
description={typeof empty?.description === "string" ? empty.description : undefined}
|
|
647
|
+
action={empty?.action}
|
|
648
|
+
/>
|
|
649
|
+
) : (
|
|
650
|
+
<table className="w-full table-fixed border-collapse">
|
|
651
|
+
<colgroup>
|
|
652
|
+
{colWidths.map((w, i) => (
|
|
653
|
+
<col key={i} style={{ width: w }} />
|
|
654
|
+
))}
|
|
655
|
+
</colgroup>
|
|
656
|
+
{/* Semantic headers for assistive tech (visible header is presentational). */}
|
|
657
|
+
<thead className="sr-only">
|
|
658
|
+
<tr>
|
|
659
|
+
{selectable && <th scope="col">Select</th>}
|
|
660
|
+
{visibleColumns.map((c) => (
|
|
661
|
+
<th key={c.id} scope="col" aria-sort={ariaSort(c)}>
|
|
662
|
+
{c.header}
|
|
663
|
+
</th>
|
|
664
|
+
))}
|
|
665
|
+
{rowActions && <th scope="col">Actions</th>}
|
|
666
|
+
</tr>
|
|
667
|
+
</thead>
|
|
668
|
+
<TableBody>
|
|
669
|
+
{pageRows.map((row) => {
|
|
670
|
+
const id = getRowId(row);
|
|
671
|
+
const isSelected = selected.has(id);
|
|
672
|
+
return (
|
|
673
|
+
<TableRow
|
|
674
|
+
key={id}
|
|
675
|
+
data-state={isSelected ? "selected" : undefined}
|
|
676
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
677
|
+
className={onRowClick ? "cursor-pointer" : undefined}
|
|
678
|
+
>
|
|
679
|
+
{selectable && (
|
|
680
|
+
<TableCell className="w-11 px-4" onClick={(e) => e.stopPropagation()}>
|
|
681
|
+
<Checkbox
|
|
682
|
+
aria-label="Select row"
|
|
683
|
+
checked={isSelected}
|
|
684
|
+
onCheckedChange={() => toggleOne(id)}
|
|
685
|
+
/>
|
|
686
|
+
</TableCell>
|
|
687
|
+
)}
|
|
688
|
+
{visibleColumns.map((c) => (
|
|
689
|
+
<TableCell
|
|
690
|
+
key={c.id}
|
|
691
|
+
data-numeric={c.numeric ? "" : undefined}
|
|
692
|
+
className={cn(c.align === "right" && "text-right", c.className)}
|
|
693
|
+
>
|
|
694
|
+
{c.cell(row)}
|
|
695
|
+
</TableCell>
|
|
696
|
+
))}
|
|
697
|
+
{rowActions && (
|
|
698
|
+
<TableCell className="w-44 px-4 text-right" onClick={(e) => e.stopPropagation()}>
|
|
699
|
+
{rowActions(row)}
|
|
700
|
+
</TableCell>
|
|
701
|
+
)}
|
|
702
|
+
</TableRow>
|
|
703
|
+
);
|
|
704
|
+
})}
|
|
705
|
+
</TableBody>
|
|
706
|
+
</table>
|
|
707
|
+
)}
|
|
708
|
+
</TableSurface>
|
|
709
|
+
|
|
710
|
+
{!loading && !error && sorted.length > 0 && (
|
|
711
|
+
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
|
712
|
+
<span className="text-muted">
|
|
713
|
+
{start + 1}–{Math.min(start + pageSize, sorted.length)} of {sorted.length}
|
|
714
|
+
</span>
|
|
715
|
+
<div className="flex items-center gap-2">
|
|
716
|
+
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
|
|
717
|
+
<SelectTrigger aria-label="Rows per page" className="h-8 w-[110px]">
|
|
718
|
+
<SelectValue />
|
|
719
|
+
</SelectTrigger>
|
|
720
|
+
<SelectContent>
|
|
721
|
+
{pageSizeOptions.map((n) => (
|
|
722
|
+
<SelectItem key={n} value={String(n)}>
|
|
723
|
+
{n} / page
|
|
724
|
+
</SelectItem>
|
|
725
|
+
))}
|
|
726
|
+
</SelectContent>
|
|
727
|
+
</Select>
|
|
728
|
+
<Button
|
|
729
|
+
variant="outline"
|
|
730
|
+
size="sm"
|
|
731
|
+
disabled={page <= 0}
|
|
732
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
733
|
+
>
|
|
734
|
+
Previous
|
|
735
|
+
</Button>
|
|
736
|
+
<Button
|
|
737
|
+
variant="outline"
|
|
738
|
+
size="sm"
|
|
739
|
+
disabled={page >= pageCount - 1}
|
|
740
|
+
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
|
741
|
+
>
|
|
742
|
+
Next
|
|
743
|
+
</Button>
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
)}
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
}
|