@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.
Files changed (89) hide show
  1. package/README.md +91 -2
  2. package/dist/agent-prompt.d.ts +16 -0
  3. package/dist/agent-prompt.d.ts.map +1 -0
  4. package/dist/avatar.d.ts +5 -0
  5. package/dist/avatar.d.ts.map +1 -0
  6. package/dist/badge.d.ts +11 -0
  7. package/dist/badge.d.ts.map +1 -0
  8. package/dist/button.d.ts +22 -0
  9. package/dist/button.d.ts.map +1 -0
  10. package/dist/callout.d.ts +12 -0
  11. package/dist/callout.d.ts.map +1 -0
  12. package/dist/card.d.ts +8 -0
  13. package/dist/card.d.ts.map +1 -0
  14. package/dist/checkbox.d.ts +3 -0
  15. package/dist/checkbox.d.ts.map +1 -0
  16. package/dist/choice-dialog.d.ts +34 -0
  17. package/dist/choice-dialog.d.ts.map +1 -0
  18. package/dist/code-block.d.ts +11 -0
  19. package/dist/code-block.d.ts.map +1 -0
  20. package/dist/data-table.d.ts +100 -0
  21. package/dist/data-table.d.ts.map +1 -0
  22. package/dist/dialog.d.ts +19 -0
  23. package/dist/dialog.d.ts.map +1 -0
  24. package/dist/dropdown-menu.d.ts +13 -0
  25. package/dist/dropdown-menu.d.ts.map +1 -0
  26. package/dist/empty-state.d.ts +11 -0
  27. package/dist/empty-state.d.ts.map +1 -0
  28. package/dist/index.d.ts +27 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +1649 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/input.d.ts +21 -0
  33. package/dist/input.d.ts.map +1 -0
  34. package/dist/kbd.d.ts +4 -0
  35. package/dist/kbd.d.ts.map +1 -0
  36. package/dist/label.d.ts +3 -0
  37. package/dist/label.d.ts.map +1 -0
  38. package/dist/radio-group.d.ts +35 -0
  39. package/dist/radio-group.d.ts.map +1 -0
  40. package/dist/segmented.d.ts +22 -0
  41. package/dist/segmented.d.ts.map +1 -0
  42. package/dist/select.d.ts +9 -0
  43. package/dist/select.d.ts.map +1 -0
  44. package/dist/separator.d.ts +3 -0
  45. package/dist/separator.d.ts.map +1 -0
  46. package/dist/skeleton.d.ts +3 -0
  47. package/dist/skeleton.d.ts.map +1 -0
  48. package/dist/spinner.d.ts +4 -0
  49. package/dist/spinner.d.ts.map +1 -0
  50. package/dist/table-empty-state.d.ts +15 -0
  51. package/dist/table-empty-state.d.ts.map +1 -0
  52. package/dist/table-surface.d.ts +38 -0
  53. package/dist/table-surface.d.ts.map +1 -0
  54. package/dist/table.d.ts +15 -0
  55. package/dist/table.d.ts.map +1 -0
  56. package/dist/tooltip.d.ts +6 -0
  57. package/dist/tooltip.d.ts.map +1 -0
  58. package/dist/utils.d.ts +4 -0
  59. package/dist/utils.d.ts.map +1 -0
  60. package/package.json +73 -4
  61. package/src/agent-prompt.tsx +60 -0
  62. package/src/avatar.tsx +46 -0
  63. package/src/badge.tsx +54 -0
  64. package/src/button.tsx +126 -0
  65. package/src/callout.tsx +74 -0
  66. package/src/card.tsx +61 -0
  67. package/src/checkbox.tsx +32 -0
  68. package/src/choice-dialog.tsx +110 -0
  69. package/src/code-block.tsx +53 -0
  70. package/src/data-table.tsx +749 -0
  71. package/src/dialog.tsx +123 -0
  72. package/src/dropdown-menu.tsx +103 -0
  73. package/src/empty-state.tsx +35 -0
  74. package/src/index.ts +26 -0
  75. package/src/input.tsx +195 -0
  76. package/src/kbd.tsx +16 -0
  77. package/src/label.tsx +19 -0
  78. package/src/radio-group.tsx +134 -0
  79. package/src/segmented.tsx +76 -0
  80. package/src/select.tsx +91 -0
  81. package/src/separator.tsx +22 -0
  82. package/src/skeleton.tsx +12 -0
  83. package/src/spinner.tsx +13 -0
  84. package/src/table-empty-state.tsx +51 -0
  85. package/src/table-surface.tsx +122 -0
  86. package/src/table.tsx +91 -0
  87. package/src/tooltip.tsx +32 -0
  88. package/src/utils.ts +7 -0
  89. 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
+ }