@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.
Files changed (89) hide show
  1. package/README.md +63 -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 +84 -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 +1522 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/input.d.ts +19 -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 +589 -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 +171 -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,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
+ }