@rovula/ui 0.1.28 → 0.1.30

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 (65) hide show
  1. package/dist/cjs/bundle.css +522 -67
  2. package/dist/cjs/bundle.js +589 -589
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/DataTable/DataTable.d.ts +195 -4
  5. package/dist/cjs/types/components/DataTable/DataTable.editing.d.ts +20 -0
  6. package/dist/cjs/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
  7. package/dist/cjs/types/components/DataTable/DataTable.stories.d.ts +294 -6
  8. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +22 -0
  9. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
  10. package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +3 -3
  11. package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
  12. package/dist/cjs/types/components/Table/Table.d.ts +33 -3
  13. package/dist/cjs/types/components/Table/Table.stories.d.ts +86 -4
  14. package/dist/cjs/types/components/TextInput/TextInput.stories.d.ts +8 -0
  15. package/dist/cjs/types/components/TextInput/TextInput.styles.d.ts +1 -0
  16. package/dist/components/DataTable/DataTable.editing.js +385 -0
  17. package/dist/components/DataTable/DataTable.editing.types.js +1 -0
  18. package/dist/components/DataTable/DataTable.js +993 -50
  19. package/dist/components/DataTable/DataTable.stories.js +1137 -25
  20. package/dist/components/Dropdown/Dropdown.js +8 -6
  21. package/dist/components/ScrollArea/ScrollArea.js +2 -2
  22. package/dist/components/ScrollArea/ScrollArea.stories.js +68 -2
  23. package/dist/components/Table/Table.js +103 -13
  24. package/dist/components/Table/Table.stories.js +226 -9
  25. package/dist/components/TextInput/TextInput.js +6 -4
  26. package/dist/components/TextInput/TextInput.stories.js +8 -0
  27. package/dist/components/TextInput/TextInput.styles.js +7 -1
  28. package/dist/esm/bundle.css +522 -67
  29. package/dist/esm/bundle.js +1545 -1545
  30. package/dist/esm/bundle.js.map +1 -1
  31. package/dist/esm/types/components/DataTable/DataTable.d.ts +195 -4
  32. package/dist/esm/types/components/DataTable/DataTable.editing.d.ts +20 -0
  33. package/dist/esm/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
  34. package/dist/esm/types/components/DataTable/DataTable.stories.d.ts +294 -6
  35. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +22 -0
  36. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
  37. package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +3 -3
  38. package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
  39. package/dist/esm/types/components/Table/Table.d.ts +33 -3
  40. package/dist/esm/types/components/Table/Table.stories.d.ts +86 -4
  41. package/dist/esm/types/components/TextInput/TextInput.stories.d.ts +8 -0
  42. package/dist/esm/types/components/TextInput/TextInput.styles.d.ts +1 -0
  43. package/dist/index.d.ts +493 -122
  44. package/dist/src/theme/global.css +775 -96
  45. package/package.json +14 -2
  46. package/src/components/DataTable/DataTable.editing.tsx +861 -0
  47. package/src/components/DataTable/DataTable.editing.types.ts +192 -0
  48. package/src/components/DataTable/DataTable.stories.tsx +2310 -31
  49. package/src/components/DataTable/DataTable.test.tsx +696 -0
  50. package/src/components/DataTable/DataTable.tsx +2275 -94
  51. package/src/components/Dropdown/Dropdown.tsx +22 -6
  52. package/src/components/ScrollArea/ScrollArea.stories.tsx +146 -3
  53. package/src/components/ScrollArea/ScrollArea.tsx +6 -6
  54. package/src/components/Table/Table.stories.tsx +789 -44
  55. package/src/components/Table/Table.tsx +306 -28
  56. package/src/components/TextInput/TextInput.stories.tsx +80 -0
  57. package/src/components/TextInput/TextInput.styles.ts +7 -1
  58. package/src/components/TextInput/TextInput.tsx +21 -14
  59. package/src/test/setup.ts +50 -0
  60. package/src/theme/global.css +81 -42
  61. package/src/theme/presets/colors.js +12 -0
  62. package/src/theme/themes/variable.css +27 -28
  63. package/src/theme/tokens/baseline.css +2 -1
  64. package/src/theme/tokens/components/scrollbar.css +9 -4
  65. package/src/theme/tokens/components/table.css +63 -0
@@ -1,22 +1,51 @@
1
1
  import React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
+ import { ColumnDef, Row } from "@tanstack/react-table";
3
4
  import { DataTable } from "./DataTable";
4
- import { ColumnDef } from "@tanstack/react-table";
5
+ import type { EditableColumnDef } from "./DataTable.editing.types";
6
+ import {
7
+ EllipsisVertical,
8
+ ChevronRight,
9
+ FileDown,
10
+ FileUp,
11
+ Trash2,
12
+ } from "lucide-react";
13
+ import ActionButton from "@/components/ActionButton/ActionButton";
14
+ import Button from "@/components/Button/Button";
15
+ import { Badge, type BadgeColor } from "@/components/Badge/Badge";
16
+ import { type Options } from "@/components/Dropdown/Dropdown";
17
+ import { cn } from "@/utils/cn";
5
18
 
6
19
  const meta = {
7
20
  title: "Components/DataTable",
8
21
  component: DataTable,
9
22
  tags: ["autodocs"],
10
- parameters: {
11
- layout: "fullscreen",
23
+ parameters: { layout: "fullscreen" },
24
+ argTypes: {
25
+ bordered: { control: "boolean" },
26
+ surface: {
27
+ control: "radio",
28
+ options: ["default", "panel"],
29
+ },
30
+ divided: { control: "boolean" },
31
+ striped: { control: "boolean" },
32
+ fetchingMore: { control: "boolean" },
33
+ loading: { control: "boolean" },
12
34
  },
13
35
  decorators: [
14
36
  (Story) => (
15
37
  <div
16
- className="p-5 flex flex-1 h-full w-full "
38
+ className="p-5 flex flex-1 h-full w-full min-h-0 bg-page-bg-main items-stretch"
17
39
  style={{ height: "100vh" }}
18
40
  >
19
- <Story />
41
+ {/*
42
+ * Never add data-surface="panel" on this root: it pairs table panel
43
+ * tokens with bg-page-bg-main (wrong surface). Use story "OnPanelSurface"
44
+ * or a wrapper with bg-modal-surface + data-surface="panel".
45
+ */}
46
+ <div className="w-full min-h-0 flex-1">
47
+ <Story />
48
+ </div>
20
49
  </div>
21
50
  ),
22
51
  ],
@@ -24,49 +53,2299 @@ const meta = {
24
53
 
25
54
  export default meta;
26
55
 
27
- const columns: ColumnDef<any>[] = [
56
+ // ---------------------------------------------------------------------------
57
+ // Shared fixtures — Project
58
+ // ---------------------------------------------------------------------------
59
+
60
+ type Project = {
61
+ id: string;
62
+ name: string;
63
+ type: "Drone" | "ROV" | "AUV";
64
+ subtype: string;
65
+ createdDate: string;
66
+ status: "To do" | "In Progress" | "Completed";
67
+ };
68
+
69
+ /* prettier-ignore */
70
+ const projectData: Project[] = [
71
+ { id: "1", name: "Drone inspection", type: "Drone", subtype: "Visual inspection", createdDate: "15 Mar 2026", status: "To do" },
72
+ { id: "2", name: "ROV Structure inspection", type: "ROV", subtype: "Text", createdDate: "01 Jan 2026", status: "In Progress" },
73
+ { id: "3", name: "ROV Structure inspection", type: "ROV", subtype: "Structure Inspection", createdDate: "15 Feb 2026", status: "Completed" },
74
+ { id: "4", name: "AUV Pipeline inspection", type: "AUV", subtype: "Subsea pipeline Inspection", createdDate: "30 Jan 2026", status: "In Progress" },
75
+ { id: "5", name: "ROV Structure inspection", type: "ROV", subtype: "Structure Inspection", createdDate: "01 Jan 2026", status: "Completed" },
76
+ ];
77
+
78
+ /** Same rows repeated for pagination demos — each row needs a unique `id` (React `key` + TanStack row id). */
79
+ const projectDataCopies = (copies: number): Project[] =>
80
+ Array.from({ length: copies }, (_, copy) =>
81
+ projectData.map((r) => ({ ...r, id: `${r.id}-c${copy}` })),
82
+ ).flat();
83
+
84
+ /** Large pool for infinite-scroll load-more demos. */
85
+ const infiniteScrollPool = projectDataCopies(30);
86
+
87
+ /* prettier-ignore */
88
+ const statusCls: Record<Project["status"], string> = {
89
+ "To do": "bg-transparent-grey2-8 text-text-contrast-max",
90
+ "In Progress": "bg-warning-500/20 text-warning-400",
91
+ "Completed": "bg-success-500/20 text-success-400",
92
+ };
93
+
94
+ const StatusBadge = ({ status }: { status: Project["status"] }) => (
95
+ <span
96
+ className={`inline-flex items-center px-3 py-1 rounded-lg typography-body3 ${statusCls[status]}`}
97
+ >
98
+ {status}
99
+ </span>
100
+ );
101
+
102
+ /* prettier-ignore */
103
+ const projectColumns: ColumnDef<Project>[] = [
104
+ { accessorKey: "name", header: "Project name" },
105
+ { accessorKey: "type", header: "Type"},
106
+ { accessorKey: "subtype", header: "Subtype" },
107
+ { accessorKey: "createdDate", header: "Created date" },
108
+ { accessorKey: "status", header: "Status", cell: ({ row }) => <StatusBadge status={row.original.status} /> },
109
+ ];
110
+
111
+ const ProjectRowActions = ({ row }: { row: Row<Project> }) => (
112
+ <>
113
+ <ActionButton
114
+ variant="icon"
115
+ size="sm"
116
+ onClick={() => console.log("menu", row.original)}
117
+ aria-label="More options"
118
+ >
119
+ <EllipsisVertical />
120
+ </ActionButton>
121
+ <ActionButton
122
+ variant="icon"
123
+ size="sm"
124
+ onClick={() => console.log("open", row.original)}
125
+ aria-label="Open"
126
+ >
127
+ <ChevronRight />
128
+ </ActionButton>
129
+ </>
130
+ );
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Shared fixtures — Inspection (column-management / resize stories)
134
+ // ---------------------------------------------------------------------------
135
+
136
+ type InspectionRow = {
137
+ id: string;
138
+ code: string;
139
+ assetName: string;
140
+ type: string;
141
+ subtype: string;
142
+ severity: "Highest" | "High" | "Medium" | "Low" | "Lowest";
143
+ inspector: string;
144
+ location: string;
145
+ capturedAt: string;
146
+ status: "To do" | "In Progress" | "Completed";
147
+ kpi: number;
148
+ notes: string;
149
+ };
150
+
151
+ /* prettier-ignore */
152
+ const inspectionData: InspectionRow[] = [
153
+ { id: "1", code: "INS-001", assetName: "Pipeline A", type: "ROV", subtype: "Structural", severity: "Highest", inspector: "John D.", location: "Block A", capturedAt: "01 Jan 2026", status: "Completed", kpi: 98, notes: "Crack detected" },
154
+ { id: "2", code: "INS-002", assetName: "Pipeline B", type: "AUV", subtype: "Corrosion", severity: "High", inspector: "Sarah K.", location: "Block B", capturedAt: "05 Jan 2026", status: "In Progress", kpi: 72, notes: "Corrosion spreading"},
155
+ { id: "3", code: "INS-003", assetName: "Riser C", type: "Drone", subtype: "Visual", severity: "Medium", inspector: "Mike L.", location: "Block C", capturedAt: "10 Jan 2026", status: "To do", kpi: 0, notes: "-" },
156
+ { id: "4", code: "INS-004", assetName: "Wellhead D", type: "ROV", subtype: "Structural", severity: "Low", inspector: "Anna T.", location: "Block A", capturedAt: "15 Jan 2026", status: "Completed", kpi: 100, notes: "No issues found" },
157
+ { id: "5", code: "INS-005", assetName: "Flowline E", type: "AUV", subtype: "Leak detect", severity: "Lowest", inspector: "John D.", location: "Block D", capturedAt: "20 Jan 2026", status: "In Progress", kpi: 55, notes: "Minor seepage" },
158
+ { id: "6", code: "INS-006", assetName: "Manifold F", type: "Drone", subtype: "Visual", severity: "High", inspector: "Sarah K.", location: "Block B", capturedAt: "22 Jan 2026", status: "To do", kpi: 0, notes: "-" },
159
+ { id: "7", code: "INS-007", assetName: "Subsea G", type: "ROV", subtype: "Structural", severity: "Medium", inspector: "Mike L.", location: "Block E", capturedAt: "25 Jan 2026", status: "In Progress", kpi: 44, notes: "Under review" },
160
+ ];
161
+
162
+ /* prettier-ignore */
163
+ const severityCls: Record<InspectionRow["severity"], string> = {
164
+ Highest: "bg-error-500/20 text-error-400",
165
+ High: "bg-orange-500/20 text-orange-400",
166
+ Medium: "bg-warning-500/20 text-warning-400",
167
+ Low: "bg-success-500/20 text-success-400",
168
+ Lowest: "bg-info-500/20 text-info-400",
169
+ };
170
+
171
+ /* prettier-ignore */
172
+ const inspectionStatusCls: Record<InspectionRow["status"], string> = {
173
+ "To do": "bg-transparent text-text-contrast-max border border-white/20",
174
+ "In Progress": "bg-warning-500/20 text-warning-400",
175
+ Completed: "bg-success-500/20 text-success-400",
176
+ };
177
+
178
+ const inspectionColumns: ColumnDef<InspectionRow>[] = [
179
+ { accessorKey: "code", header: "Code", size: 100 },
180
+ { accessorKey: "assetName", header: "Asset name" },
181
+ { accessorKey: "type", header: "Type", size: 90 },
182
+ { accessorKey: "subtype", header: "Subtype" },
28
183
  {
29
- accessorKey: "amount",
184
+ accessorKey: "severity",
185
+ header: "Severity",
186
+ size: 120,
187
+ cell: ({ row }) => (
188
+ <span
189
+ className={`inline-flex items-center px-2 py-0.5 rounded-lg typography-small2 ${
190
+ severityCls[row.original.severity]
191
+ }`}
192
+ >
193
+ {row.original.severity}
194
+ </span>
195
+ ),
30
196
  },
197
+ { accessorKey: "inspector", header: "Inspector" },
198
+ { accessorKey: "location", header: "Location", size: 100 },
199
+ { accessorKey: "capturedAt", header: "Captured date", size: 140 },
31
200
  {
32
201
  accessorKey: "status",
202
+ header: "Status",
203
+ size: 130,
204
+ cell: ({ row }) => (
205
+ <span
206
+ className={`inline-flex items-center px-3 py-1 rounded-lg typography-body3 ${
207
+ inspectionStatusCls[row.original.status]
208
+ }`}
209
+ >
210
+ {row.original.status}
211
+ </span>
212
+ ),
213
+ },
214
+ { accessorKey: "kpi", header: "KPI (%)", size: 90 },
215
+ { accessorKey: "notes", header: "Notes" },
216
+ ];
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Shared fixtures — Event (expandable rows)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ type EventRow = {
223
+ id: string;
224
+ code: string;
225
+ name: string;
226
+ severity: "Highest" | "High" | "Medium" | "Low" | "Lowest" | null;
227
+ children?: EventRow[];
228
+ };
229
+
230
+ /* prettier-ignore */
231
+ const eventSeverityCls: Record<NonNullable<EventRow["severity"]>, string> = {
232
+ Highest: "bg-error-500 text-white",
233
+ High: "bg-orange-500 text-white",
234
+ Medium: "bg-warning-500 text-black",
235
+ Low: "bg-success-500 text-white",
236
+ Lowest: "bg-info-500 text-white",
237
+ };
238
+
239
+ const eventData: EventRow[] = [
240
+ {
241
+ id: "1",
242
+ code: "EV-001",
243
+ name: "Structural anomaly",
244
+ severity: "Highest",
245
+ children: [
246
+ {
247
+ id: "1-1",
248
+ code: "EV-001-A",
249
+ name: "Crack detected",
250
+ severity: "Highest",
251
+ children: [
252
+ {
253
+ id: "1-1-1",
254
+ code: "EV-001-A-1",
255
+ name: "Sub-crack detected",
256
+ severity: "Highest",
257
+ },
258
+ ],
259
+ },
260
+ {
261
+ id: "1-2",
262
+ code: "EV-001-B",
263
+ name: "Corrosion detected",
264
+ severity: "High",
265
+ },
266
+ ],
267
+ },
268
+ {
269
+ id: "2",
270
+ code: "EV-002",
271
+ name: "Pipeline leak",
272
+ severity: "Low",
273
+ children: [
274
+ {
275
+ id: "2-1",
276
+ code: "EV-002-A",
277
+ name: "Minor seepage",
278
+ severity: "Lowest",
279
+ },
280
+ ],
33
281
  },
282
+ { id: "3", code: "EV-003", name: "Obstruction", severity: "Medium" },
283
+ ];
284
+
285
+ const eventColumns: ColumnDef<EventRow>[] = [
286
+ { accessorKey: "code", header: "Code" },
287
+ { accessorKey: "name", header: "Name" },
34
288
  {
35
- accessorKey: "email",
289
+ accessorKey: "severity",
290
+ header: "Severity level",
291
+ cell: ({ row }) =>
292
+ row.original.severity ? (
293
+ <span
294
+ className={`inline-flex items-center px-2 py-0.5 rounded typography-small3 ${
295
+ eventSeverityCls[row.original.severity]
296
+ }`}
297
+ >
298
+ {row.original.severity}
299
+ </span>
300
+ ) : (
301
+ <span className="text-text-g-contrast-medium">–</span>
302
+ ),
36
303
  },
37
304
  ];
38
305
 
39
- export const Default = {
40
- args: {},
41
- render: (args) => {
42
- console.log("args ", args);
43
- const props: typeof args = {
44
- ...args,
306
+ // ---------------------------------------------------------------------------
307
+ // 1. Basics
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /** Default striped rows + column dividers, all columns sortable. */
311
+ export const Default: StoryObj = {
312
+ render: () => (
313
+ <DataTable
314
+ columns={projectColumns}
315
+ data={projectData}
316
+ striped
317
+ divided
318
+ onSorting={(s) => console.log("sort", s)}
319
+ />
320
+ ),
321
+ };
322
+
323
+ /**
324
+ * Matches the Figma "Projects" page design — striped alternating rows,
325
+ * column dividers, row actions (more + navigate), client pagination,
326
+ * and a rounded bordered frame.
327
+ *
328
+ * Design ref: Xspector-New / node 11786-14865
329
+ */
330
+ export const FigmaProjectsPage: StoryObj = {
331
+ render: () => {
332
+ const figmaProjectColumns: ColumnDef<Project>[] = [
333
+ { accessorKey: "name", header: "Project name" },
334
+ { accessorKey: "type", header: "Type", size: 120 },
335
+ { accessorKey: "subtype", header: "Subtype" },
336
+ { accessorKey: "createdDate", header: "Created date", size: 180 },
337
+ {
338
+ accessorKey: "status",
339
+ header: "Status",
340
+ size: 200,
341
+ cell: ({ row }) => <StatusBadge status={row.original.status} />,
342
+ },
343
+ ];
344
+
345
+ return (
346
+ <DataTable
347
+ columns={figmaProjectColumns}
348
+ data={projectData}
349
+ bordered
350
+ striped
351
+ divided
352
+ tableLayout="fixed"
353
+ paginationMode="client"
354
+ pageSizeOptions={[5, 10, 20]}
355
+ rowActions={(row) => <ProjectRowActions row={row} />}
356
+ onSorting={(s) => console.log("sort", s)}
357
+ />
358
+ );
359
+ },
360
+ };
361
+
362
+ /** Empty state — displayed when `data` is an empty array. */
363
+ export const Empty: StoryObj = {
364
+ render: () => (
365
+ <DataTable columns={projectColumns} data={[]} striped divided />
366
+ ),
367
+ };
368
+
369
+ /** Non-striped with column dividers only. */
370
+ export const Divided: StoryObj = {
371
+ render: () => (
372
+ <DataTable columns={projectColumns} data={projectData} divided />
373
+ ),
374
+ };
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Panel surface (Modal / Drawer / Panel)
378
+ // ---------------------------------------------------------------------------
379
+ //
380
+ // Wrapper uses data-surface="panel" so --table-* tokens from table.css apply
381
+ // (same pattern as Table stories). You can instead set surface="panel" on
382
+ // DataTable when the panel root is not a DOM ancestor of the table.
383
+
384
+ /** Simulates a Modal / Drawer / Panel chrome around the table. */
385
+ const DataTablePanelDecorator = ({
386
+ children,
387
+ className,
388
+ }: {
389
+ children: React.ReactNode;
390
+ className?: string;
391
+ }) => (
392
+ <div
393
+ data-surface="panel"
394
+ className={cn(
395
+ "rounded-lg bg-modal-surface p-6 w-full max-w-4xl mx-auto shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)]",
396
+ className,
397
+ )}
398
+ >
399
+ <p className="typography-subtitle4 text-text-g-contrast-medium mb-4">
400
+ Modal / Panel surface
401
+ </p>
402
+ {children}
403
+ </div>
404
+ );
405
+
406
+ /**
407
+ * DataTable inside a panel — matches modal spec: horizontal row separators only
408
+ * (no vertical column rules), compact height without a stretched empty body.
409
+ */
410
+ export const OnPanelSurface: StoryObj = {
411
+ render: () => (
412
+ <div className="flex w-full justify-center items-start">
413
+ <DataTablePanelDecorator className="self-start">
414
+ <DataTable
415
+ className="h-auto"
416
+ columns={projectColumns}
417
+ data={projectData}
418
+ striped={false}
419
+ divided={false}
420
+ onSorting={(s) => console.log("sort", s)}
421
+ />
422
+ </DataTablePanelDecorator>
423
+ </div>
424
+ ),
425
+ };
426
+
427
+ /** Panel + client pagination — footer sits in the same surface scope as the table body. */
428
+ export const OnPanelSurfaceWithPagination: StoryObj = {
429
+ render: () => (
430
+ <div className="flex w-full justify-center items-start">
431
+ <DataTablePanelDecorator className="self-start">
432
+ <DataTable
433
+ className="h-auto max-h-[min(520px,70vh)]"
434
+ columns={projectColumns}
435
+ data={projectDataCopies(4)}
436
+ paginationMode="client"
437
+ pageSizeOptions={[5, 10, 20]}
438
+ striped={true}
439
+ divided={true}
440
+ columnManagement
441
+ />
442
+ </DataTablePanelDecorator>
443
+ </div>
444
+ ),
445
+ };
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // 2. Pagination
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /** Client-side pagination — DataTable manages page index & size internally. */
452
+ export const WithClientPagination: StoryObj = {
453
+ render: () => (
454
+ <DataTable
455
+ columns={projectColumns}
456
+ data={projectDataCopies(4)}
457
+ paginationMode="client"
458
+ pageSizeOptions={[5, 10, 20]}
459
+ striped
460
+ divided
461
+ />
462
+ ),
463
+ };
464
+
465
+ /**
466
+ * Server-side pagination — the caller controls `pageIndex` / `pageSize` and
467
+ * slices data accordingly. `totalCount` drives the page-count calculation.
468
+ */
469
+ export const WithServerPagination: StoryObj = {
470
+ render: () => {
471
+ const [pageIndex, setPageIndex] = React.useState(0);
472
+ const [pageSize, setPageSize] = React.useState(3);
473
+ const pagedData = projectData.slice(
474
+ pageIndex * pageSize,
475
+ (pageIndex + 1) * pageSize,
476
+ );
477
+ return (
478
+ <DataTable
479
+ columns={projectColumns}
480
+ data={pagedData}
481
+ paginationMode="server"
482
+ totalCount={projectData.length}
483
+ pageIndex={pageIndex}
484
+ pageSize={pageSize}
485
+ onPaginationChange={({ pageIndex: pi, pageSize: ps }) => {
486
+ setPageIndex(pi);
487
+ setPageSize(ps);
488
+ }}
489
+ pageSizeOptions={[3, 5]}
490
+ striped
491
+ divided
492
+ />
493
+ );
494
+ },
495
+ };
496
+
497
+ /**
498
+ * Infinite mode (default) — no pagination bar; the table body scrolls inside a
499
+ * fixed height. Use when the caller already passes the full (or windowed) dataset.
500
+ */
501
+ export const WithInfiniteScrollStatic: StoryObj = {
502
+ render: () => (
503
+ <div className="flex w-full flex-col gap-3">
504
+ <p className="typography-body2 text-text-g-contrast-medium">
505
+ <code className="typography-mono">
506
+ paginationMode=&quot;infinite&quot;
507
+ </code>{" "}
508
+ (default) — no footer; scroll the body. Height capped via{" "}
509
+ <code className="typography-mono">className</code>.
510
+ </p>
511
+ <DataTable
512
+ className="h-[min(420px,55vh)] w-full"
513
+ columns={projectColumns}
514
+ data={projectDataCopies(8)}
515
+ paginationMode="infinite"
516
+ striped
517
+ divided
518
+ />
519
+ </div>
520
+ ),
521
+ };
522
+
523
+ /**
524
+ * Infinite scroll + load more — DataTable calls{" "}
525
+ * <code className="typography-mono">fetchMoreData</code> when the scroll position
526
+ * is within 10px of the bottom (see <code className="typography-mono">DataTable</code>{" "}
527
+ * <code className="typography-mono">useEffect</code> on <code className="typography-mono">scrollRef</code>).
528
+ * This story simulates an async page append.
529
+ */
530
+ export const WithInfiniteScrollLoadMore: StoryObj = {
531
+ render: () => {
532
+ const pageSize = 10;
533
+ const [rows, setRows] = React.useState<Project[]>(() =>
534
+ infiniteScrollPool.slice(0, pageSize),
535
+ );
536
+ const [loading, setLoading] = React.useState(false);
537
+ const loadingRef = React.useRef(false);
538
+ const hasMore = rows.length < infiniteScrollPool.length;
539
+
540
+ const fetchMoreData = React.useCallback(() => {
541
+ if (loadingRef.current) return;
542
+ setRows((prev) => {
543
+ if (prev.length >= infiniteScrollPool.length) return prev;
544
+ const startLen = prev.length;
545
+ loadingRef.current = true;
546
+ queueMicrotask(() => setLoading(true));
547
+ window.setTimeout(() => {
548
+ setRows(
549
+ infiniteScrollPool.slice(
550
+ 0,
551
+ Math.min(startLen + pageSize, infiniteScrollPool.length),
552
+ ),
553
+ );
554
+ loadingRef.current = false;
555
+ setLoading(false);
556
+ }, 650);
557
+ return prev;
558
+ });
559
+ }, []);
560
+
561
+ return (
562
+ <div className="flex w-full flex-col gap-3">
563
+ <p className="typography-body2 text-text-g-contrast-medium">
564
+ Scroll to the bottom to trigger{" "}
565
+ <code className="typography-mono">fetchMoreData</code>. Built-in{" "}
566
+ <code className="typography-mono">fetchingMore</code> shows a spinner
567
+ row inside the table. Loaded {rows.length} /{" "}
568
+ {infiniteScrollPool.length} rows.
569
+ {!hasMore ? " (end of list)" : ""}
570
+ </p>
571
+ <DataTable
572
+ className="h-[min(380px,50vh)] w-full"
573
+ columns={projectColumns}
574
+ data={rows}
575
+ paginationMode="infinite"
576
+ fetchMoreData={fetchMoreData}
577
+ fetchingMore={loading}
578
+ striped
579
+ divided
580
+ />
581
+ </div>
582
+ );
583
+ },
584
+ };
585
+
586
+ /**
587
+ * Initial load — pass `loading={true}` with `data={[]}` until the first page
588
+ * arrives; then set `loading={false}` and pass rows. Infinite `fetchMoreData`
589
+ * does not run while `loading` is true.
590
+ */
591
+ export const WithInfiniteScrollInitialLoading: StoryObj = {
592
+ render: () => {
593
+ const pageSize = 10;
594
+ const [rows, setRows] = React.useState<Project[]>([]);
595
+ const [loading, setLoading] = React.useState(true);
596
+
597
+ React.useEffect(() => {
598
+ const t = window.setTimeout(() => {
599
+ setRows(infiniteScrollPool.slice(0, pageSize));
600
+ setLoading(false);
601
+ }, 1200);
602
+ return () => window.clearTimeout(t);
603
+ }, []);
604
+
605
+ const [fetchingMore, setFetchingMore] = React.useState(false);
606
+ const loadingMoreRef = React.useRef(false);
607
+ const [highlightId, setHighlightId] = React.useState<string | undefined>();
608
+
609
+ const fetchMoreData = React.useCallback(() => {
610
+ if (loadingMoreRef.current) return;
611
+ setRows((prev) => {
612
+ if (prev.length >= infiniteScrollPool.length) return prev;
613
+ loadingMoreRef.current = true;
614
+ queueMicrotask(() => setFetchingMore(true));
615
+ const startLen = prev.length;
616
+ window.setTimeout(() => {
617
+ setRows(
618
+ infiniteScrollPool.slice(
619
+ 0,
620
+ Math.min(startLen + pageSize, infiniteScrollPool.length),
621
+ ),
622
+ );
623
+ loadingMoreRef.current = false;
624
+ setFetchingMore(false);
625
+ }, 550);
626
+ return prev;
627
+ });
628
+ }, []);
629
+
630
+ return (
631
+ <div className="flex w-full flex-col gap-3">
632
+ <div className="flex items-center justify-between gap-4">
633
+ <p className="typography-body2 text-text-g-contrast-medium">
634
+ Simulated first fetch (~1.2s) with{" "}
635
+ <code className="typography-mono">loading</code> + empty{" "}
636
+ <code className="typography-mono">data</code>, then infinite scroll
637
+ loads more with{" "}
638
+ <code className="typography-mono">fetchingMore</code>. Click{" "}
639
+ <span className="typography-mono">Scroll to last row</span> to jump
640
+ + highlight.
641
+ </p>
642
+ <Button
643
+ size="sm"
644
+ variant="outline"
645
+ onClick={() => {
646
+ if (!rows.length) return;
647
+ setHighlightId(rows[rows.length - 1]!.id);
648
+ }}
649
+ disabled={!rows.length}
650
+ >
651
+ Scroll to last row
652
+ </Button>
653
+ </div>
654
+ <DataTable
655
+ className="h-[min(380px,50vh)] w-full"
656
+ columns={projectColumns}
657
+ data={rows}
658
+ paginationMode="infinite"
659
+ loading={loading}
660
+ fetchMoreData={fetchMoreData}
661
+ fetchingMore={fetchingMore}
662
+ highlightRowId={highlightId}
663
+ striped
664
+ divided
665
+ />
666
+ </div>
667
+ );
668
+ },
669
+ };
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Performance (expectations + stress demo)
673
+ // ---------------------------------------------------------------------------
674
+
675
+ /**
676
+ * **What this library does *not* do:** `DataTable` has **no built-in row
677
+ * virtualization** — every row in `data` is rendered in the DOM (TanStack
678
+ * `getRowModel`). Very large `data.length` will cost more paint/layout work.
679
+ *
680
+ * **What to use instead:**
681
+ * - `paginationMode="client"` or `"server"` to cap rows per page
682
+ * - `paginationMode="infinite"` + `fetchMoreData` and **slice/window data in the parent**
683
+ * - Or wrap your own virtualizer (e.g. `@tanstack/react-virtual`) if you need
684
+ * millions of rows in one scroll view
685
+ *
686
+ * This story mounts **800** synthetic rows so you can feel scroll/render cost
687
+ * in Storybook (dev builds are slower than production).
688
+ */
689
+ export const PerformanceLargeDataset: StoryObj = {
690
+ render: () => {
691
+ const bigData = React.useMemo((): Project[] => {
692
+ const rowCount = 800;
693
+ const statuses: Project["status"][] = [
694
+ "To do",
695
+ "In Progress",
696
+ "Completed",
697
+ ];
698
+ return Array.from({ length: rowCount }, (_, i) => ({
699
+ id: `perf-${i}`,
700
+ name: `Project ${i + 1}`,
701
+ type: (i % 3 === 0
702
+ ? "Drone"
703
+ : i % 3 === 1
704
+ ? "ROV"
705
+ : "AUV") as Project["type"],
706
+ subtype: "Visual inspection",
707
+ createdDate: "15 Mar 2026",
708
+ status: statuses[i % 3]!,
709
+ }));
710
+ }, []);
711
+
712
+ return (
713
+ <div className="flex w-full flex-col gap-3">
714
+ <p className="typography-body2 text-text-g-contrast-medium max-w-4xl">
715
+ <strong>{bigData.length}</strong> rows in{" "}
716
+ <code className="typography-mono">data</code> — no virtualization.
717
+ Prefer pagination or infinite + parent-side windowing for production
718
+ lists.
719
+ </p>
720
+ <DataTable
721
+ className="h-[min(520px,70vh)] w-full"
722
+ columns={projectColumns}
723
+ data={bigData}
724
+ paginationMode="infinite"
725
+ striped
726
+ divided
727
+ />
728
+ </div>
729
+ );
730
+ },
731
+ };
732
+
733
+ /**
734
+ * Built-in **`virtualized`** — only a vertical window of rows is mounted; the
735
+ * rest are represented by spacer rows. Tune **`virtualRowEstimate`** (px) to
736
+ * match your row height so scroll position stays aligned.
737
+ *
738
+ * Limitations: fixed-height assumption; incompatible with **`reorderable`** in
739
+ * the virtualized path (use non-virtualized mode for drag-reorder).
740
+ */
741
+ export const WithVirtualizedRows: StoryObj = {
742
+ render: () => {
743
+ const bigData = React.useMemo((): Project[] => {
744
+ const rowCount = 5000;
745
+ const statuses: Project["status"][] = [
746
+ "To do",
747
+ "In Progress",
748
+ "Completed",
749
+ ];
750
+ return Array.from({ length: rowCount }, (_, i) => ({
751
+ id: `virt-${i}`,
752
+ name: `Project ${i + 1}`,
753
+ type: (i % 3 === 0
754
+ ? "Drone"
755
+ : i % 3 === 1
756
+ ? "ROV"
757
+ : "AUV") as Project["type"],
758
+ subtype: "Visual inspection",
759
+ createdDate: "15 Mar 2026",
760
+ status: statuses[i % 3]!,
761
+ }));
762
+ }, []);
763
+
764
+ return (
765
+ <div className="flex w-full flex-col gap-3">
766
+ <p className="typography-body2 text-text-g-contrast-medium max-w-4xl">
767
+ <strong>{bigData.length}</strong> rows with{" "}
768
+ <code className="typography-mono">virtualized</code> — compare scroll
769
+ smoothness vs{" "}
770
+ <code className="typography-mono">PerformanceLargeDataset</code> (800
771
+ rows, no virtualization).
772
+ </p>
773
+ <DataTable
774
+ className="h-[min(520px,70vh)] w-full"
775
+ columns={projectColumns}
776
+ data={bigData}
777
+ paginationMode="infinite"
778
+ virtualized
779
+ virtualRowEstimate={52}
780
+ striped
781
+ divided
782
+ />
783
+ </div>
784
+ );
785
+ },
786
+ };
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // 3. Selection
790
+ // ---------------------------------------------------------------------------
791
+
792
+ /** Checkbox selection — header checkbox selects all; row checkboxes select individually. */
793
+ export const WithSelection: StoryObj = {
794
+ render: () => {
795
+ const [selected, setSelected] = React.useState<Record<string, boolean>>({});
796
+ return (
797
+ <div className="flex flex-col gap-3 w-full h-full">
798
+ <p className="typography-small2 text-text-g-contrast-high px-1">
799
+ Selected IDs:{" "}
800
+ {Object.keys(selected)
801
+ .filter((k) => selected[k])
802
+ .join(", ") || "–"}
803
+ </p>
804
+ <DataTable
805
+ columns={projectColumns}
806
+ data={projectData}
807
+ selectable
808
+ onRowSelectionChange={setSelected}
809
+ striped
810
+ divided
811
+ />
812
+ </div>
813
+ );
814
+ },
815
+ };
816
+
817
+ /**
818
+ * Row highlight + scroll — use `highlightRowId` to visually mark important rows
819
+ * (uses the same token as selected rows) and automatically scroll them into view.
820
+ */
821
+ export const WithRowHighlightAndScroll: StoryObj = {
822
+ render: () => {
823
+ const rowsForDemo = React.useMemo(() => projectDataCopies(4), []);
824
+ const [highlightIds, setHighlightIds] = React.useState<string[]>([]);
825
+
826
+ const toggleId = (id: string) => {
827
+ setHighlightIds((prev) =>
828
+ prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
829
+ );
830
+ };
831
+
832
+ const scrollToFirst = () => {
833
+ if (!highlightIds.length) return;
834
+ // Re-setting the same array instance won't re-run the effect; nudge it.
835
+ setHighlightIds((prev) => [...prev]);
45
836
  };
46
837
 
47
- const data = new Array(20).fill(0).map((__, i) => ({
48
- id: "m5gr84i9",
49
- amount: i + 1,
50
- status: "success",
51
- email: "ken99@yahoo.com",
52
- email1: "ken99@yahoo.com",
53
- email2: "ken99@yahoo.com",
54
- email3: "ken99@yahoo.com",
55
- }));
838
+ return (
839
+ <div className="flex w-full flex-col gap-4">
840
+ <div className="flex flex-wrap items-center gap-3">
841
+ <p className="typography-body2 text-text-g-contrast-medium">
842
+ Click IDs to highlight rows (uses{" "}
843
+ <code className="typography-mono">highlightRowId</code> +
844
+ scroll-into-view). Highlight shares the selected token but with a
845
+ softer overlay + outline.
846
+ </p>
847
+ <div className="flex flex-wrap items-center gap-2">
848
+ {rowsForDemo.map((row) => {
849
+ const active = highlightIds.includes(row.id);
850
+ return (
851
+ <Button
852
+ key={row.id}
853
+ size="sm"
854
+ variant={active ? "solid" : "outline"}
855
+ color={active ? "primary" : "secondary"}
856
+ onClick={() => toggleId(row.id)}
857
+ >
858
+ #{row.id}
859
+ </Button>
860
+ );
861
+ })}
862
+ <Button
863
+ size="sm"
864
+ variant="text"
865
+ onClick={() => setHighlightIds([])}
866
+ >
867
+ Clear
868
+ </Button>
869
+ <Button
870
+ size="sm"
871
+ variant="outline"
872
+ onClick={scrollToFirst}
873
+ disabled={!highlightIds.length}
874
+ >
875
+ Scroll to first
876
+ </Button>
877
+ </div>
878
+ </div>
879
+ <DataTable
880
+ className="h-[320px] w-full"
881
+ columns={[{ accessorKey: "id", header: "ID" }, ...projectColumns]}
882
+ data={rowsForDemo}
883
+ highlightRowId={highlightIds}
884
+ striped
885
+ divided
886
+ scrollToHighlightOnMouseLeave
887
+ />
888
+ </div>
889
+ );
890
+ },
891
+ };
892
+
893
+ /**
894
+ * Censor-style list — large, virtualized table where `highlightRowId` moves
895
+ * automatically over time (e.g. matching the latest event / time).
896
+ */
897
+ export const WithVirtualizedCensorTimeline: StoryObj = {
898
+ render: () => {
899
+ type CensorRow = Project & { time: string };
900
+
901
+ const rows: CensorRow[] = React.useMemo(() => {
902
+ const base: CensorRow[] = [];
903
+ const now = Date.now();
904
+ for (let i = 0; i < 2000; i += 1) {
905
+ const t = new Date(now + i * 60_000);
906
+ const hh = t.getHours().toString().padStart(2, "0");
907
+ const mm = t.getMinutes().toString().padStart(2, "0");
908
+ const status: Project["status"] =
909
+ i % 3 === 0 ? "To do" : i % 3 === 1 ? "In Progress" : "Completed";
910
+ base.push({
911
+ id: `censor-${i}`,
912
+ name: `Censor task #${i + 1}`,
913
+ type: (i % 3 === 0
914
+ ? "Drone"
915
+ : i % 3 === 1
916
+ ? "ROV"
917
+ : "AUV") as Project["type"],
918
+ subtype: "Automated check",
919
+ createdDate: `${hh}:${mm}`,
920
+ status,
921
+ time: `${hh}:${mm}`,
922
+ });
923
+ }
924
+ return base;
925
+ }, []);
926
+
927
+ const [activeId, setActiveId] = React.useState<string | null>(
928
+ rows[0]?.id ?? null,
929
+ );
930
+
931
+ React.useEffect(() => {
932
+ if (!rows.length) return undefined;
933
+ let index = 0;
934
+ const handle = setInterval(() => {
935
+ index = (index + 1) % rows.length;
936
+ setActiveId(rows[index]!.id);
937
+ }, 500);
938
+ return () => clearInterval(handle);
939
+ }, [rows]);
940
+
941
+ const censorColumns: ColumnDef<CensorRow>[] = React.useMemo(
942
+ () =>
943
+ [
944
+ { accessorKey: "time", header: "Time" },
945
+ ...projectColumns,
946
+ ] as ColumnDef<CensorRow>[],
947
+ [],
948
+ );
949
+
950
+ return (
951
+ <div className="flex w-full flex-col gap-3">
952
+ <p className="typography-body2 text-text-g-contrast-medium max-w-4xl">
953
+ Virtualized censor list (~{rows.length} rows). The active row moves
954
+ every 0.5s via <code className="typography-mono">highlightRowId</code>{" "}
955
+ to simulate matching incoming events over time.
956
+ </p>
957
+ <DataTable
958
+ className="h-[min(480px,70vh)] w-full"
959
+ columns={censorColumns}
960
+ data={rows}
961
+ paginationMode="infinite"
962
+ virtualized
963
+ virtualRowEstimate={52}
964
+ highlightRowId={activeId ?? undefined}
965
+ striped
966
+ divided
967
+ scrollToHighlightOnMouseLeave
968
+ />
969
+ </div>
970
+ );
971
+ },
972
+ };
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // 4. Row actions
976
+ // ---------------------------------------------------------------------------
977
+
978
+ /** Fixed actions column at the far right — rendered via `rowActions` prop. */
979
+ export const WithRowActions: StoryObj = {
980
+ render: () => (
981
+ <DataTable
982
+ columns={projectColumns}
983
+ data={projectData}
984
+ striped
985
+ divided
986
+ rowActions={(row) => <ProjectRowActions row={row} />}
987
+ />
988
+ ),
989
+ };
990
+
991
+ // ---------------------------------------------------------------------------
992
+ // 5. Expandable rows
993
+ // ---------------------------------------------------------------------------
994
+
995
+ /**
996
+ * Uncontrolled tree rows — `defaultExpanded={true}` opens all nodes on mount.
997
+ * The user can collapse/expand rows freely; state is managed internally.
998
+ */
999
+ export const WithExpandableRows: StoryObj = {
1000
+ render: () => (
1001
+ <DataTable
1002
+ columns={eventColumns}
1003
+ data={eventData}
1004
+ getSubRows={(row) => row.children}
1005
+ striped
1006
+ divided
1007
+ defaultExpanded
1008
+ rowActions={(row: Row<EventRow>) => (
1009
+ <ActionButton
1010
+ variant="icon"
1011
+ size="sm"
1012
+ onClick={() => console.log("menu", row.original)}
1013
+ aria-label="More options"
1014
+ >
1015
+ <EllipsisVertical />
1016
+ </ActionButton>
1017
+ )}
1018
+ />
1019
+ ),
1020
+ };
1021
+
1022
+ /**
1023
+ * Controlled expand — the caller owns `expanded` state via `expanded` +
1024
+ * `onExpandedChange`. Collapsing a row in the table calls `onExpandedChange`;
1025
+ * if the parent doesn't update the state, the row "springs back" open.
1026
+ */
1027
+ export const WithControlledExpand: StoryObj = {
1028
+ render: () => {
1029
+ const allIds = React.useMemo(
1030
+ () =>
1031
+ eventData.reduce<Record<string, boolean>>((acc, row) => {
1032
+ acc[row.id] = true;
1033
+ row.children?.forEach((c) => {
1034
+ acc[c.id] = true;
1035
+ });
1036
+ return acc;
1037
+ }, {}),
1038
+ [],
1039
+ );
1040
+ const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
1041
+ const allExpanded = Object.keys(allIds).every((id) => expanded[id]);
1042
+ return (
1043
+ <div className="flex flex-col gap-3 w-full h-full">
1044
+ <div className="flex items-center gap-2 px-1">
1045
+ <button
1046
+ type="button"
1047
+ className="px-3 py-1 rounded border border-white/20 typography-body3 text-text-contrast-max hover:bg-white/5"
1048
+ onClick={() => setExpanded(allExpanded ? {} : { ...allIds })}
1049
+ >
1050
+ {allExpanded ? "Collapse all" : "Expand all"}
1051
+ </button>
1052
+ <span className="typography-small2 text-text-g-contrast-medium">
1053
+ Expanded: {Object.keys(expanded).join(", ") || "–"}
1054
+ </span>
1055
+ </div>
1056
+ <DataTable
1057
+ columns={eventColumns}
1058
+ data={eventData}
1059
+ getSubRows={(row) => row.children}
1060
+ striped
1061
+ divided
1062
+ expanded={expanded}
1063
+ onExpandedChange={(next) =>
1064
+ setExpanded(next as Record<string, boolean>)
1065
+ }
1066
+ />
1067
+ </div>
1068
+ );
1069
+ },
1070
+ };
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // 6. Column management
1074
+ // ---------------------------------------------------------------------------
1075
+
1076
+ /**
1077
+ * Column management panel — click ⋮ on any column header.
1078
+ * - Toggle per-column visibility with the Switch
1079
+ * - "Hide all" hides everything except one column
1080
+ * - "Show all" restores all columns
1081
+ * - Drag the ⠿ grip to reorder columns
1082
+ */
1083
+ export const WithColumnManagement: StoryObj = {
1084
+ render: () => (
1085
+ <DataTable
1086
+ columns={inspectionColumns}
1087
+ data={inspectionData}
1088
+ striped
1089
+ divided
1090
+ columnManagement
1091
+ paginationMode="client"
1092
+ pageSizeOptions={[5, 10]}
1093
+ />
1094
+ ),
1095
+ };
1096
+
1097
+ /**
1098
+ * Column management with restricted options:
1099
+ * - `reorder: false` — drag handle hidden, column order is fixed
1100
+ * - `hideAll: false` — "Hide all" button removed
1101
+ *
1102
+ * Pass any subset of `ColumnManagementOptions` to tailor the panel.
1103
+ */
1104
+ export const WithColumnManagementOptions: StoryObj = {
1105
+ render: () => (
1106
+ <DataTable
1107
+ columns={inspectionColumns}
1108
+ data={inspectionData}
1109
+ striped
1110
+ divided
1111
+ columnManagement={{ reorder: false, hideAll: false }}
1112
+ paginationMode="client"
1113
+ pageSizeOptions={[5, 10]}
1114
+ />
1115
+ ),
1116
+ };
1117
+
1118
+ // ---------------------------------------------------------------------------
1119
+ // 7. Column resize
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ /**
1123
+ * Resizable columns — drag the right edge of any column header.
1124
+ *
1125
+ * Global bounds via `columnMinSize` / `columnMaxSize`.
1126
+ * Per-column overrides in the column def (`minSize`, `maxSize`):
1127
+ * - "Asset name": `minSize: 120`
1128
+ * - "KPI (%)": `maxSize: 160`
1129
+ */
1130
+ export const WithColumnResize: StoryObj = {
1131
+ render: () => (
1132
+ <DataTable
1133
+ columns={
1134
+ [
1135
+ { accessorKey: "code", header: "Code", size: 100 },
1136
+ { accessorKey: "assetName", header: "Asset name", minSize: 120 },
1137
+ { accessorKey: "type", header: "Type", size: 90 },
1138
+ { accessorKey: "subtype", header: "Subtype" },
1139
+ {
1140
+ accessorKey: "severity",
1141
+ header: "Severity",
1142
+ size: 120,
1143
+ cell: ({ row }: { row: Row<InspectionRow> }) => (
1144
+ <span
1145
+ className={`inline-flex items-center px-2 py-0.5 rounded-lg typography-small2 ${
1146
+ severityCls[row.original.severity]
1147
+ }`}
1148
+ >
1149
+ {row.original.severity}
1150
+ </span>
1151
+ ),
1152
+ },
1153
+ { accessorKey: "inspector", header: "Inspector" },
1154
+ { accessorKey: "location", header: "Location", size: 100 },
1155
+ { accessorKey: "capturedAt", header: "Captured date", size: 140 },
1156
+ {
1157
+ accessorKey: "status",
1158
+ header: "Status",
1159
+ size: 130,
1160
+ cell: ({ row }: { row: Row<InspectionRow> }) => (
1161
+ <span
1162
+ className={`inline-flex items-center px-3 py-1 rounded-lg typography-body3 ${
1163
+ inspectionStatusCls[row.original.status]
1164
+ }`}
1165
+ >
1166
+ {row.original.status}
1167
+ </span>
1168
+ ),
1169
+ },
1170
+ { accessorKey: "kpi", header: "KPI (%)", size: 90, maxSize: 160 },
1171
+ { accessorKey: "notes", header: "Notes" },
1172
+ ] as ColumnDef<InspectionRow>[]
1173
+ }
1174
+ data={inspectionData}
1175
+ striped
1176
+ divided
1177
+ resizable
1178
+ columnMinSize={60}
1179
+ columnMaxSize={400}
1180
+ paginationMode="client"
1181
+ pageSizeOptions={[5, 10]}
1182
+ />
1183
+ ),
1184
+ };
1185
+
1186
+ // ---------------------------------------------------------------------------
1187
+ // 8. Kitchen Sink
1188
+ // ---------------------------------------------------------------------------
1189
+
1190
+ /**
1191
+ * All features combined:
1192
+ * - Checkbox selection
1193
+ * - Sortable columns
1194
+ * - Client-side pagination
1195
+ * - Column management (visibility + reorder)
1196
+ * - Column resize
1197
+ * - Row actions
1198
+ */
1199
+ export const KitchenSink: StoryObj = {
1200
+ render: () => {
1201
+ const [selected, setSelected] = React.useState<Record<string, boolean>>({});
1202
+ return (
1203
+ <DataTable
1204
+ columns={inspectionColumns}
1205
+ data={[...inspectionData, ...inspectionData]}
1206
+ paginationMode="client"
1207
+ pageSizeOptions={[5, 10]}
1208
+ selectable
1209
+ onRowSelectionChange={setSelected}
1210
+ striped
1211
+ divided
1212
+ resizable
1213
+ columnMinSize={60}
1214
+ columnManagement
1215
+ onSorting={(s) => console.log("sort", s)}
1216
+ rowActions={(row: Row<InspectionRow>) => (
1217
+ <>
1218
+ <ActionButton
1219
+ variant="icon"
1220
+ size="sm"
1221
+ onClick={() => console.log("menu", row.original)}
1222
+ aria-label="More options"
1223
+ >
1224
+ <EllipsisVertical />
1225
+ </ActionButton>
1226
+ <ActionButton
1227
+ variant="icon"
1228
+ size="sm"
1229
+ onClick={() => console.log("open", row.original)}
1230
+ aria-label="Open"
1231
+ >
1232
+ <ChevronRight />
1233
+ </ActionButton>
1234
+ </>
1235
+ )}
1236
+ />
1237
+ );
1238
+ },
1239
+ };
1240
+
1241
+ // ---------------------------------------------------------------------------
1242
+ // 9. Row reorder
1243
+ // ---------------------------------------------------------------------------
1244
+
1245
+ /**
1246
+ * Drag-to-reorder rows — a grip handle is prepended automatically.
1247
+ * `onRowReorder` fires with the new data array after a drop.
1248
+ */
1249
+ export const WithRowReorder: StoryObj = {
1250
+ render: () => {
1251
+ const [rows, setRows] = React.useState(projectData);
1252
+ return (
1253
+ <div className="flex flex-col gap-3 w-full h-full">
1254
+ <p className="typography-small2 text-text-g-contrast-high px-1">
1255
+ Order:{" "}
1256
+ <span className="text-text-contrast-max font-medium">
1257
+ {rows.map((r) => r.name.split(" ")[0]).join(", ")}
1258
+ </span>
1259
+ </p>
1260
+ <DataTable
1261
+ tableLayout="fixed"
1262
+ columns={projectColumns}
1263
+ data={rows}
1264
+ divided
1265
+ reorderable
1266
+ onRowReorder={setRows}
1267
+ />
1268
+ </div>
1269
+ );
1270
+ },
1271
+ };
1272
+
1273
+ // ---------------------------------------------------------------------------
1274
+ // 10. Row & cell click
1275
+ // ---------------------------------------------------------------------------
1276
+
1277
+ /** Row click — clicking any row fires `onRowClick`. Rows show `cursor-pointer`. */
1278
+ export const WithRowClick: StoryObj = {
1279
+ render: () => {
1280
+ const [clicked, setClicked] = React.useState<string | null>(null);
1281
+ return (
1282
+ <div className="flex flex-col gap-3 w-full h-full">
1283
+ <p className="typography-small2 text-text-g-contrast-high px-1">
1284
+ Last clicked row:{" "}
1285
+ <span className="text-text-contrast-max font-medium">
1286
+ {clicked ?? "–"}
1287
+ </span>
1288
+ </p>
1289
+ <DataTable
1290
+ columns={projectColumns}
1291
+ data={projectData}
1292
+ striped
1293
+ divided
1294
+ onRowClick={(row) =>
1295
+ setClicked(`${row.original.name} (id: ${row.original.id})`)
1296
+ }
1297
+ />
1298
+ </div>
1299
+ );
1300
+ },
1301
+ };
1302
+
1303
+ /**
1304
+ * Cell click — `onCellClick` fires for individual cells.
1305
+ * Use `event.stopPropagation()` to prevent `onRowClick` from also firing.
1306
+ */
1307
+ export const WithCellClick: StoryObj = {
1308
+ render: () => {
1309
+ const [info, setInfo] = React.useState<{
1310
+ column: string;
1311
+ value: string;
1312
+ } | null>(null);
1313
+ return (
1314
+ <div className="flex flex-col gap-3 w-full h-full">
1315
+ <p className="typography-small2 text-text-g-contrast-high px-1">
1316
+ Last clicked cell:{" "}
1317
+ <span className="text-text-contrast-max font-medium">
1318
+ {info ? `[${info.column}] ${info.value}` : "–"}
1319
+ </span>
1320
+ </p>
1321
+ <DataTable
1322
+ columns={projectColumns}
1323
+ data={projectData}
1324
+ striped
1325
+ divided
1326
+ onRowClick={(row) => console.log("row", row.original.id)}
1327
+ onCellClick={(cell, _row, e) => {
1328
+ e.stopPropagation();
1329
+ setInfo({
1330
+ column: cell.column.id,
1331
+ value: String(cell.getValue() ?? ""),
1332
+ });
1333
+ }}
1334
+ />
1335
+ </div>
1336
+ );
1337
+ },
1338
+ };
1339
+
1340
+ // ---------------------------------------------------------------------------
1341
+ // 11. Sort indicator visibility
1342
+ // ---------------------------------------------------------------------------
1343
+
1344
+ /**
1345
+ * Sort indicator always visible — `sortIndicatorVisibility="always"` shows the
1346
+ * sort icon on every sortable column, not just on hover.
1347
+ */
1348
+ export const SortIndicatorAlwaysVisible: StoryObj = {
1349
+ render: () => (
1350
+ <DataTable
1351
+ columns={projectColumns}
1352
+ data={projectData}
1353
+ striped
1354
+ divided
1355
+ sortIndicatorVisibility="always"
1356
+ onSorting={(s) => console.log("sort", s)}
1357
+ />
1358
+ ),
1359
+ };
1360
+
1361
+ // ---------------------------------------------------------------------------
1362
+ // 12. Table layout comparison
1363
+ // ---------------------------------------------------------------------------
1364
+
1365
+ /* prettier-ignore */
1366
+ const layoutCompareColumns: ColumnDef<Project>[] = [
1367
+ { accessorKey: "name", header: "Project name", size: 280 },
1368
+ { accessorKey: "type", header: "Type", size: 100 },
1369
+ { accessorKey: "subtype", header: "Subtype" },
1370
+ { accessorKey: "createdDate", header: "Created date", size: 140 },
1371
+ { accessorKey: "status", header: "Status", size: 130, cell: ({ row }) => <StatusBadge status={row.original.status} /> },
1372
+ ];
1373
+
1374
+ /**
1375
+ * Side-by-side comparison of `tableLayout="auto"` vs `"fixed"` vs `"equal"`.
1376
+ *
1377
+ * - **auto** — browser distributes width based on cell content; `size` is ignored.
1378
+ * - **fixed** — `size` is used as px per column; the last non-exact column grows
1379
+ * so the table stays full-width (here: Status absorbs the remainder).
1380
+ * - **equal** — all columns without `meta.exactWidth` share the remaining width equally.
1381
+ * With `resizable`, drag handles are off for this row so equal widths are preserved.
1382
+ */
1383
+ export const TableLayoutComparison: StoryObj = {
1384
+ render: () => (
1385
+ <div className="flex flex-col gap-8 w-full h-full">
1386
+ <div className="flex flex-col gap-2 flex-1 min-h-0">
1387
+ <p className="typography-subtitle1 text-text-contrast-max px-1">
1388
+ tableLayout=&quot;auto&quot;{" "}
1389
+ <span className="typography-small2 text-text-g-contrast-medium">
1390
+ — browser decides widths from content (size ignored)
1391
+ </span>
1392
+ </p>
1393
+ <DataTable
1394
+ columns={layoutCompareColumns}
1395
+ data={projectData}
1396
+ striped
1397
+ divided
1398
+ selectable
1399
+ resizable={false}
1400
+ tableLayout="auto"
1401
+ />
1402
+ </div>
1403
+
1404
+ <div className="flex flex-col gap-2 flex-1 min-h-0">
1405
+ <p className="typography-subtitle1 text-text-contrast-max px-1">
1406
+ tableLayout=&quot;fixed&quot;{" "}
1407
+ <span className="typography-small2 text-text-g-contrast-medium">
1408
+ — columns honour size (280 / 100 / default / 140 / 130)
1409
+ </span>
1410
+ </p>
1411
+ <DataTable
1412
+ columns={layoutCompareColumns}
1413
+ data={projectData}
1414
+ striped
1415
+ divided
1416
+ selectable
1417
+ resizable={false}
1418
+ tableLayout="fixed"
1419
+ />
1420
+ </div>
1421
+
1422
+ <div className="flex flex-col gap-2 flex-1 min-h-0">
1423
+ <p className="typography-subtitle1 text-text-contrast-max px-1">
1424
+ tableLayout=&quot;equal&quot;{" "}
1425
+ <span className="typography-small2 text-text-g-contrast-medium">
1426
+ — non-exactWidth columns share remaining space equally
1427
+ </span>
1428
+ </p>
1429
+ <DataTable
1430
+ columns={layoutCompareColumns}
1431
+ data={projectData}
1432
+ striped
1433
+ divided
1434
+ selectable
1435
+ resizable={false}
1436
+ tableLayout="equal"
1437
+ />
1438
+ </div>
1439
+ </div>
1440
+ ),
1441
+ };
1442
+
1443
+ // ---------------------------------------------------------------------------
1444
+ // 13. Custom row & cell className
1445
+ // ---------------------------------------------------------------------------
1446
+
1447
+ /**
1448
+ * `rowClassName` and `cellClassName` let you style individual rows and cells
1449
+ * based on their data.
1450
+ *
1451
+ * - Rows with status **"Completed"** get a green-tinted background.
1452
+ * - Rows with status **"To do"** get a subtle white/opacity background.
1453
+ * - The **"Status"** column cells are bold.
1454
+ * - The **"Type"** cells for ROV are highlighted with an info tint.
1455
+ */
1456
+ export const WithCustomRowAndCellClassName: StoryObj = {
1457
+ render: () => (
1458
+ <DataTable
1459
+ columns={projectColumns}
1460
+ data={projectData}
1461
+ divided
1462
+ rowClassName={(row) => {
1463
+ if (row.original.status === "Completed") return "!bg-success-500/10";
1464
+ if (row.original.status === "To do") return "!bg-white/5";
1465
+ return undefined;
1466
+ }}
1467
+ cellClassName={(cell) => {
1468
+ if (cell.column.id === "status") return "font-semibold";
1469
+ if (cell.column.id === "type" && cell.getValue() === "ROV")
1470
+ return "!bg-info-500/10 text-info-400";
1471
+ return undefined;
1472
+ }}
1473
+ />
1474
+ ),
1475
+ };
1476
+
1477
+ // ---------------------------------------------------------------------------
1478
+ // 13b. Custom body row size and header size
1479
+ // ---------------------------------------------------------------------------
1480
+
1481
+ /**
1482
+ * Showcase overriding header and body row heights via className props.
1483
+ *
1484
+ * The base `TableHead` uses `h-[44px] typography-body2` and `TableCell`
1485
+ * uses `h-[42px] typography-body3`.
1486
+ *
1487
+ * `cn()` is configured with a custom `typography` class-group so
1488
+ * `tailwind-merge` correctly deduplicates conflicting `typography-*`
1489
+ * utilities (e.g. `typography-small2` replaces `typography-body2`).
1490
+ *
1491
+ * **Note:** `<th>` / `<td>` elements treat `height` as a minimum —
1492
+ * to go smaller than the default, internal content (sort icon, badges)
1493
+ * must also fit. Compact swaps to `typography-small2` (12px) text and
1494
+ * reduces padding to reclaim space.
1495
+ *
1496
+ * Three sizes: **Compact**, **Default**, **Comfortable**.
1497
+ */
1498
+ export const CustomRowAndHeaderSize: StoryObj = {
1499
+ render: () => {
1500
+ const compactColumns: ColumnDef<Project>[] = [
1501
+ { accessorKey: "name", header: "Project name" },
1502
+ { accessorKey: "type", header: "Type" },
1503
+ { accessorKey: "subtype", header: "Subtype" },
1504
+ { accessorKey: "createdDate", header: "Created date" },
1505
+ {
1506
+ accessorKey: "status",
1507
+ header: "Status",
1508
+ cell: ({ row }) => (
1509
+ <span
1510
+ className={`inline-flex items-center px-2 py-0.5 rounded typography-small2 ${
1511
+ statusCls[row.original.status]
1512
+ }`}
1513
+ >
1514
+ {row.original.status}
1515
+ </span>
1516
+ ),
1517
+ },
1518
+ ];
1519
+
1520
+ return (
1521
+ <div className="flex flex-col gap-8 h-full">
1522
+ <div className="flex flex-col gap-2 flex-1 min-h-0">
1523
+ <p className="typography-subtitle1 text-text-contrast-max px-1">
1524
+ Compact{" "}
1525
+ <span className="typography-small2 text-text-g-contrast-medium">
1526
+ — Header 32px · Row 28px · typography-small2 (12px)
1527
+ </span>
1528
+ </p>
1529
+ <DataTable
1530
+ columns={compactColumns}
1531
+ data={projectData}
1532
+ striped
1533
+ divided
1534
+ headerCellClassName="h-[32px] typography-small2"
1535
+ cellClassName="h-[28px] typography-small2"
1536
+ onSorting={(s) => console.log("sort", s)}
1537
+ />
1538
+ </div>
1539
+
1540
+ <div className="flex flex-col gap-2 flex-1 min-h-0">
1541
+ <p className="typography-subtitle1 text-text-contrast-max px-1">
1542
+ Default{" "}
1543
+ <span className="typography-small2 text-text-g-contrast-medium">
1544
+ — Header 44px · Row 42px · typography-body2 / body3
1545
+ </span>
1546
+ </p>
1547
+ <DataTable
1548
+ columns={projectColumns}
1549
+ data={projectData}
1550
+ striped
1551
+ divided
1552
+ onSorting={(s) => console.log("sort", s)}
1553
+ />
1554
+ </div>
1555
+
1556
+ <div className="flex flex-col gap-2 flex-1 min-h-0">
1557
+ <p className="typography-subtitle1 text-text-contrast-max px-1">
1558
+ Comfortable{" "}
1559
+ <span className="typography-small2 text-text-g-contrast-medium">
1560
+ — Header 56px · Row 50px · typography-subtitle2 / subtitle4 (16px
1561
+ / 14px)
1562
+ </span>
1563
+ </p>
1564
+ <DataTable
1565
+ columns={projectColumns}
1566
+ data={projectData}
1567
+ striped
1568
+ divided
1569
+ headerCellClassName="h-[56px] typography-subtitle2"
1570
+ cellClassName="h-[50px] typography-subtitle4"
1571
+ onSorting={(s) => console.log("sort", s)}
1572
+ />
1573
+ </div>
1574
+ </div>
1575
+ );
1576
+ },
1577
+ };
1578
+
1579
+ // ---------------------------------------------------------------------------
1580
+ // 14. Real-world example — Data Management (Figma: Xspector-New → Data management)
1581
+ // ---------------------------------------------------------------------------
1582
+ //
1583
+ // Matches the “add row” pattern: `meta.colSpan` merges “Default value” + action
1584
+ // into one `<td>` with “Add option” + primary Add (see DataTable `ColumnMeta.colSpan`).
1585
+ //
1586
+
1587
+ type DataManagementRow = {
1588
+ id: string;
1589
+ /** Footer row — inputs + merged default/actions cell; not draggable. */
1590
+ kind?: "add";
1591
+ columnName: string;
1592
+ dataCategory: string;
1593
+ dataType: string;
1594
+ format: string;
1595
+ source: string;
1596
+ defaultValue: string;
1597
+ };
1598
+
1599
+ type DmEditTrigger = "click" | "doubleClick";
1600
+ type DmEditScope = "row" | "cell";
1601
+
1602
+ const DM_DATA_CATEGORY_LABELS = [
1603
+ "Sequence",
1604
+ "Date",
1605
+ "Multiple choice",
1606
+ "KP",
1607
+ "Data",
1608
+ ] as const;
1609
+
1610
+ const DM_DATA_TYPES_BY_CATEGORY: Record<string, string[]> = {
1611
+ Sequence: ["Integer", "Text"],
1612
+ Date: ["Text"],
1613
+ "Multiple choice": ["Text"],
1614
+ KP: ["Decimals", "Text"],
1615
+ Data: ["Text", "Integer", "Decimals"],
1616
+ };
1617
+
1618
+ const DM_FORMAT_BY_DATA_TYPE: Record<string, string[]> = {
1619
+ Integer: ["-", "0", "Grouped"],
1620
+ Text: ["-", "dd/mm/yyyy"],
1621
+ Decimals: ["-", "5", "2"],
1622
+ Date: ["-", "ISO"],
1623
+ };
1624
+
1625
+ const DM_SOURCE_LABELS = ["-", "Q6", "Manual"];
1626
+
1627
+ function dmDepsParentsReady(o: DataManagementRow) {
1628
+ return o.columnName.trim().length > 0 && o.dataCategory.trim().length > 0;
1629
+ }
1630
+
1631
+ function dmDepsDataTypePicked(o: DataManagementRow) {
1632
+ return o.dataType.trim().length > 0;
1633
+ }
1634
+
1635
+ function dmDepsFormatPicked(o: DataManagementRow) {
1636
+ return o.format.trim().length > 0;
1637
+ }
1638
+
1639
+ function dmOptionsFromLabels(
1640
+ placeholderLabel: string,
1641
+ labels: readonly string[] | string[],
1642
+ ): Options[] {
1643
+ return [
1644
+ { value: `__ph__:${placeholderLabel}`, label: placeholderLabel },
1645
+ ...Array.from(labels).map((l) => ({ value: l, label: l })),
1646
+ ];
1647
+ }
1648
+
1649
+ const dmAddRow: DataManagementRow = {
1650
+ id: "__dm_add__",
1651
+ kind: "add",
1652
+ columnName: "",
1653
+ dataCategory: "",
1654
+ dataType: "",
1655
+ format: "",
1656
+ source: "",
1657
+ defaultValue: "",
1658
+ };
1659
+
1660
+ function isDmAddRow(row: Row<DataManagementRow>) {
1661
+ return row.original.kind === "add" || row.id === "__dm_add__";
1662
+ }
1663
+
1664
+ /* prettier-ignore */
1665
+ const dmData: DataManagementRow[] = [
1666
+ { id: "1", columnName: "Sequence", dataCategory: "Sequence", dataType: "Integer", format: "-", source: "-", defaultValue: "-" },
1667
+ { id: "2", columnName: "Date", dataCategory: "Date", dataType: "Text", format: "dd/mm/yyyy", source: "Q6", defaultValue: "-" },
1668
+ { id: "3", columnName: "Anomaly", dataCategory: "Multiple choice", dataType: "Text", format: "-", source: "-", defaultValue: "2 options" },
1669
+ { id: "4", columnName: "Data", dataCategory: "KP", dataType: "Decimals", format: "5", source: "Q6", defaultValue: "-" },
1670
+ { id: "5", columnName: "Data", dataCategory: "Data", dataType: "Text", format: "-", source: "-", defaultValue: "-" },
1671
+ { id: "6", columnName: "Data", dataCategory: "Data", dataType: "Text", format: "-", source: "-", defaultValue: "-" },
1672
+ { id: "7", columnName: "Data", dataCategory: "Data", dataType: "Text", format: "-", source: "-", defaultValue: "-" },
1673
+ { id: "8", columnName: "Data", dataCategory: "Data", dataType: "Text", format: "-", source: "-", defaultValue: "-" },
1674
+ { id: "9", columnName: "Data", dataCategory: "Data", dataType: "Text", format: "-", source: "-", defaultValue: "-" },
1675
+ ];
1676
+
1677
+ const DmToolbar = ({ count }: { count: number }) => (
1678
+ <div className="flex flex-wrap items-center gap-3">
1679
+ <div className="flex min-w-0 flex-1 items-center gap-2">
1680
+ <span className="typography-h6 text-text-contrast-max">
1681
+ Data management
1682
+ </span>
1683
+ <span className="typography-subtitle1 text-text-contrast-max">
1684
+ ({count})
1685
+ </span>
1686
+ </div>
1687
+ <button
1688
+ type="button"
1689
+ className="flex items-center gap-1.5 rounded-md border border-state-primary-stroke px-3 py-1 typography-button-ms text-state-primary-text-outline"
1690
+ >
1691
+ <FileDown className="size-4 shrink-0 opacity-90" aria-hidden />
1692
+ Import file
1693
+ </button>
1694
+ <button
1695
+ type="button"
1696
+ className="flex items-center gap-1.5 rounded-md border border-state-primary-stroke px-3 py-1 typography-button-ms text-state-primary-text-outline"
1697
+ >
1698
+ <FileUp className="size-4 shrink-0 opacity-90" aria-hidden />
1699
+ Export file
1700
+ </button>
1701
+ <button
1702
+ type="button"
1703
+ className="flex items-center gap-1.5 rounded-md bg-state-primary-default px-3 py-1 typography-button-ms text-state-primary-text-solid"
1704
+ >
1705
+ Save changes
1706
+ </button>
1707
+ </div>
1708
+ );
1709
+
1710
+ const dashPreview = (value: string) =>
1711
+ !value || value === "-" ? (
1712
+ <span className="text-text-g-contrast-medium">-</span>
1713
+ ) : (
1714
+ <span>{value}</span>
1715
+ );
1716
+
1717
+ const dmColumns: EditableColumnDef<DataManagementRow>[] = [
1718
+ {
1719
+ id: "#",
1720
+ header: () => null,
1721
+ cell: ({ row }) => (
1722
+ <span className="typography-small2 text-text-g-contrast-medium">
1723
+ {row.index + 1}
1724
+ </span>
1725
+ ),
1726
+ enableSorting: false,
1727
+ enableHiding: false,
1728
+ enableResizing: false,
1729
+ size: 50,
1730
+ maxSize: 50,
1731
+ minSize: 50,
1732
+ meta: { align: "center", exactWidth: 50 },
1733
+ },
1734
+ {
1735
+ accessorKey: "columnName",
1736
+ header: "Column name",
1737
+ size: 200,
1738
+ enableEditing: true,
1739
+ editVariant: "text",
1740
+ editTextProps: { placeholder: "Column name" },
1741
+ onCommit: (_row, v) => ({
1742
+ columnName: v,
1743
+ dataType: "",
1744
+ format: "",
1745
+ source: "",
1746
+ }),
1747
+ },
1748
+ {
1749
+ accessorKey: "dataCategory",
1750
+ header: "Data Category",
1751
+ size: 200,
1752
+ enableEditing: true,
1753
+ editVariant: "select",
1754
+ editSelectProps: {
1755
+ options: () =>
1756
+ dmOptionsFromLabels("Data Category", DM_DATA_CATEGORY_LABELS),
1757
+ placeholder: "Data Category",
1758
+ },
1759
+ onCommit: (_row, v) => ({
1760
+ dataCategory: v,
1761
+ dataType: "",
1762
+ format: "",
1763
+ source: "",
1764
+ }),
1765
+ },
1766
+ {
1767
+ accessorKey: "dataType",
1768
+ header: "Data type",
1769
+ size: 150,
1770
+ enableEditing: (row) => dmDepsParentsReady(row.original),
1771
+ editVariant: "select",
1772
+ // editShowDisabledField: true,
1773
+ editSelectProps: {
1774
+ options: (row) =>
1775
+ dmOptionsFromLabels(
1776
+ "Data type",
1777
+ DM_DATA_TYPES_BY_CATEGORY[row.original.dataCategory] ?? ["Text"],
1778
+ ),
1779
+ placeholder: "Data type",
1780
+ },
1781
+ onCommit: (_row, v) => ({
1782
+ dataType: v,
1783
+ format: "",
1784
+ source: "",
1785
+ }),
1786
+ displayCell: (row) => dashPreview(row.original.dataType),
1787
+ },
1788
+ {
1789
+ accessorKey: "format",
1790
+ header: "Format",
1791
+ size: 150,
1792
+ enableEditing: (row) =>
1793
+ dmDepsParentsReady(row.original) && dmDepsDataTypePicked(row.original),
1794
+ editVariant: "select",
1795
+ // editShowDisabledField: true,
1796
+ editSelectProps: {
1797
+ options: (row) =>
1798
+ dmOptionsFromLabels(
1799
+ "Format",
1800
+ DM_FORMAT_BY_DATA_TYPE[row.original.dataType] ?? ["-"],
1801
+ ),
1802
+ placeholder: "Format",
1803
+ },
1804
+ onCommit: (_row, v) => ({ format: v, source: "" }),
1805
+ displayCell: (row) => dashPreview(row.original.format),
1806
+ },
1807
+ {
1808
+ accessorKey: "source",
1809
+ header: "Source",
1810
+ size: 150,
1811
+ enableEditing: (row) =>
1812
+ dmDepsParentsReady(row.original) &&
1813
+ dmDepsDataTypePicked(row.original) &&
1814
+ dmDepsFormatPicked(row.original),
1815
+ editVariant: "select",
1816
+ // editShowDisabledField: true,
1817
+ editSelectProps: {
1818
+ options: () => dmOptionsFromLabels("Source", DM_SOURCE_LABELS),
1819
+ placeholder: "Source",
1820
+ },
1821
+ displayCell: (row) => dashPreview(row.original.source),
1822
+ },
1823
+ {
1824
+ accessorKey: "defaultValue",
1825
+ header: "Default value",
1826
+ size: 200,
1827
+ enableSorting: false,
1828
+ enableEditing: true,
1829
+ editVariant: "text",
1830
+ editTextProps: { placeholder: "Default value" },
1831
+ onCommit: (_row, v) => ({ defaultValue: v || "-" }),
1832
+ displayCell: (row) => {
1833
+ const v = row.original.defaultValue;
1834
+ return v === "-" ? (
1835
+ <span className="text-text-g-contrast-medium">-</span>
1836
+ ) : (
1837
+ <button
1838
+ type="button"
1839
+ className="typography-button-ms text-state-secondary-text-outline"
1840
+ >
1841
+ {v}
1842
+ </button>
1843
+ );
1844
+ },
1845
+ },
1846
+ {
1847
+ id: "__actions__",
1848
+ accessorFn: (row) => row.id,
1849
+ header: () => null,
1850
+ cell: ({ row }) =>
1851
+ isDmAddRow(row) ? (
1852
+ <Button size="sm" variant="solid" color="primary">
1853
+ Add
1854
+ </Button>
1855
+ ) : (
1856
+ <ActionButton
1857
+ variant="icon"
1858
+ size="sm"
1859
+ aria-label="Delete row"
1860
+ onClick={() => console.log("delete", row.original.id)}
1861
+ >
1862
+ <Trash2 className="size-4" />
1863
+ </ActionButton>
1864
+ ),
1865
+ enableSorting: false,
1866
+ enableHiding: false,
1867
+ enableResizing: false,
1868
+ size: 100,
1869
+ maxSize: 100,
1870
+ minSize: 100,
1871
+ meta: { exactWidth: 100, align: "center" },
1872
+ },
1873
+ ];
1874
+
1875
+ /**
1876
+ * Data management table — Figma **Xspector-New**:
1877
+ * [full frame](https://www.figma.com/design/99rq6FbfPx6hgPS0VCvHJh/Xspector-New?node-id=11965-17125),
1878
+ * [row / field spec](https://www.figma.com/design/99rq6FbfPx6hgPS0VCvHJh/Xspector-New?node-id=11965-17883).
1879
+ *
1880
+ * Uses the **DataTable editing API** (`enableEditing` + `editDisplayMode` +
1881
+ * `editVariant` per column). Edit scope and trigger are toggleable via the
1882
+ * toolbar at the top of the story.
1883
+ */
1884
+ export const DataManagement: StoryObj = {
1885
+ render: () => {
1886
+ const [rows, setRows] = React.useState<DataManagementRow[]>(() => [
1887
+ ...dmData,
1888
+ dmAddRow,
1889
+ ]);
1890
+ const [editScope, setEditScope] = React.useState<DmEditScope>("row");
1891
+ const [editTrigger, setEditTrigger] =
1892
+ React.useState<DmEditTrigger>("click");
1893
+
1894
+ const dataRowCount = rows.filter(
1895
+ (r) => r.kind !== "add" && r.id !== "__dm_add__",
1896
+ ).length;
56
1897
 
57
1898
  return (
58
- <div className="flex flex-1 h-full flex-row gap-4 w-full">
1899
+ <div
1900
+ data-surface="panel"
1901
+ className="flex h-full min-h-0 w-full max-w-7xl flex-col gap-6 self-center rounded-xl bg-modal-surface p-8 shadow-[0px_12px_24px_-4px_rgba(0,0,0,0.12)]"
1902
+ >
1903
+ <DmToolbar count={dataRowCount} />
1904
+ <div className="flex flex-wrap items-center gap-4 rounded-lg border border-table-c-col-line bg-table-c-header-bg px-4 py-3">
1905
+ <span className="typography-small2 text-text-g-contrast-medium">
1906
+ Cell edit demo
1907
+ </span>
1908
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
1909
+ <input
1910
+ type="radio"
1911
+ name="dm-scope"
1912
+ checked={editScope === "row"}
1913
+ onChange={() => setEditScope("row")}
1914
+ />
1915
+ Whole row
1916
+ </label>
1917
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
1918
+ <input
1919
+ type="radio"
1920
+ name="dm-scope"
1921
+ checked={editScope === "cell"}
1922
+ onChange={() => setEditScope("cell")}
1923
+ />
1924
+ Single cell (blur → preview, Tab → next field in row)
1925
+ </label>
1926
+ <span className="mx-2 hidden h-4 w-px bg-table-c-col-line sm:inline-block" />
1927
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
1928
+ <input
1929
+ type="radio"
1930
+ name="dm-trig"
1931
+ checked={editTrigger === "click"}
1932
+ onChange={() => setEditTrigger("click")}
1933
+ />
1934
+ Activate on click
1935
+ </label>
1936
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
1937
+ <input
1938
+ type="radio"
1939
+ name="dm-trig"
1940
+ checked={editTrigger === "doubleClick"}
1941
+ onChange={() => setEditTrigger("doubleClick")}
1942
+ />
1943
+ Activate on double-click
1944
+ </label>
1945
+ </div>
59
1946
  <DataTable
60
- columns={columns}
61
- data={data}
62
- onSorting={(sorting) => {
63
- console.log("sorting ", sorting);
1947
+ className="min-h-0"
1948
+ columns={dmColumns}
1949
+ data={rows}
1950
+ divided
1951
+ striped={false}
1952
+ surface="panel"
1953
+ tableLayout="equal"
1954
+ reorderable
1955
+ getRowId={(r) => r.id}
1956
+ isRowReorderLocked={(row) => isDmAddRow(row)}
1957
+ onRowReorder={(reordered) => {
1958
+ const add =
1959
+ reordered.find(
1960
+ (r) => r.kind === "add" || r.id === "__dm_add__",
1961
+ ) ?? dmAddRow;
1962
+ const rest = reordered.filter(
1963
+ (r) => r.kind !== "add" && r.id !== "__dm_add__",
1964
+ );
1965
+ setRows([...rest, add]);
64
1966
  }}
65
- fetchMoreData={() => {
66
- console.log("fetchMoreData");
1967
+ enableEditing
1968
+ editDisplayMode={editScope}
1969
+ editTrigger={editTrigger}
1970
+ alwaysEditing={(row) => isDmAddRow(row)}
1971
+ onCellCommit={(rowId, _columnId, patch) => {
1972
+ setRows((prev) =>
1973
+ prev.map((r) => (r.id === rowId ? { ...r, ...patch } : r)),
1974
+ );
1975
+ }}
1976
+ headerCellClassName={(header) =>
1977
+ header.column.id === "__reorder__" ? "!border-r-0" : undefined
1978
+ }
1979
+ cellClassName={(cell, row) =>
1980
+ cn(
1981
+ isDmAddRow(row) && "!py-2",
1982
+ cell.column.id === "__reorder__" && "!border-r-0",
1983
+ )
1984
+ }
1985
+ />
1986
+ </div>
1987
+ );
1988
+ },
1989
+ };
1990
+
1991
+ /** Data Management — empty state when no columns have been configured yet. */
1992
+ export const DataManagementEmpty: StoryObj = {
1993
+ render: () => (
1994
+ <div className="flex flex-col gap-6 w-full h-full">
1995
+ <DmToolbar count={0} />
1996
+ <DataTable columns={dmColumns} data={[]} divided />
1997
+ </div>
1998
+ ),
1999
+ };
2000
+
2001
+ // ---------------------------------------------------------------------------
2002
+ // 15. Editing Field Showcase — all variants, error, testId
2003
+ // ---------------------------------------------------------------------------
2004
+
2005
+ type EditShowcaseRow = {
2006
+ id: string;
2007
+ name: string;
2008
+ category: string;
2009
+ quantity: number;
2010
+ price: number;
2011
+ active: boolean;
2012
+ priority: "critical" | "high" | "medium" | "low" | "none";
2013
+ notes: string;
2014
+ };
2015
+
2016
+ const CATEGORY_OPTIONS: Options[] = [
2017
+ { value: "electronics", label: "Electronics" },
2018
+ { value: "clothing", label: "Clothing" },
2019
+ { value: "food", label: "Food & Beverage" },
2020
+ { value: "furniture", label: "Furniture" },
2021
+ { value: "other", label: "Other" },
2022
+ ];
2023
+
2024
+ const PRIORITY_OPTIONS: Options[] = [
2025
+ { value: "critical", label: "Critical" },
2026
+ { value: "high", label: "High" },
2027
+ { value: "medium", label: "Medium" },
2028
+ { value: "low", label: "Low" },
2029
+ { value: "none", label: "None" },
2030
+ ];
2031
+
2032
+ const PRIORITY_BADGE_COLOR: Record<string, BadgeColor> = {
2033
+ critical: "error",
2034
+ high: "warning",
2035
+ medium: "info",
2036
+ low: "success",
2037
+ none: "default",
2038
+ };
2039
+
2040
+ /* prettier-ignore */
2041
+ const editShowcaseData: EditShowcaseRow[] = [
2042
+ { id: "1", name: "Laptop Pro 16", category: "Electronics", quantity: 25, price: 1299.99, active: true, priority: "critical", notes: "Flagship model" },
2043
+ { id: "2", name: "Winter Jacket", category: "Clothing", quantity: 100, price: 89.50, active: true, priority: "medium", notes: "" },
2044
+ { id: "3", name: "Organic Coffee", category: "Food & Beverage",quantity: 500, price: 12.99, active: false, priority: "low", notes: "Fair trade certified" },
2045
+ { id: "4", name: "Standing Desk", category: "Furniture", quantity: 0, price: 449.00, active: true, priority: "high", notes: "Adjustable height" },
2046
+ { id: "5", name: "Wireless Mouse", category: "Electronics", quantity: 200, price: 29.99, active: true, priority: "none", notes: "" },
2047
+ { id: "6", name: "", category: "", quantity: 0, price: 0, active: false, priority: "none", notes: "Draft item" },
2048
+ ];
2049
+
2050
+ const editShowcaseColumns: EditableColumnDef<EditShowcaseRow>[] = [
2051
+ {
2052
+ id: "#",
2053
+ header: "#",
2054
+ cell: ({ row }) => (
2055
+ <span className="typography-small2 text-text-g-contrast-medium">
2056
+ {row.index + 1}
2057
+ </span>
2058
+ ),
2059
+ enableSorting: false,
2060
+ size: 50,
2061
+ meta: { exactWidth: 50, align: "center" },
2062
+ },
2063
+ {
2064
+ accessorKey: "name",
2065
+ header: "Product name",
2066
+ size: 200,
2067
+ enableEditing: true,
2068
+ editVariant: "text",
2069
+ editTextProps: { placeholder: "Product name" },
2070
+ editError: (row) => {
2071
+ if (!row.original.name.trim()) return "Name is required";
2072
+ if (row.original.name.length < 3) return "Min 3 characters";
2073
+ return undefined;
2074
+ },
2075
+ },
2076
+ {
2077
+ accessorKey: "category",
2078
+ header: "Category",
2079
+ size: 180,
2080
+ enableEditing: true,
2081
+ editVariant: "select",
2082
+ editSelectProps: {
2083
+ options: CATEGORY_OPTIONS,
2084
+ placeholder: "Select category",
2085
+ },
2086
+ editError: (row) => {
2087
+ if (!row.original.category.trim()) return "Category is required";
2088
+ return undefined;
2089
+ },
2090
+ },
2091
+ {
2092
+ accessorKey: "quantity",
2093
+ header: "Quantity",
2094
+ size: 130,
2095
+ enableEditing: true,
2096
+ editVariant: "number",
2097
+ editNumberProps: {
2098
+ placeholder: "Qty",
2099
+ min: 0,
2100
+ max: 99999,
2101
+ step: 1,
2102
+ allowDecimal: false,
2103
+ allowNegative: false,
2104
+ },
2105
+ onCommit: (_row, v) => ({ quantity: Number(v) || 0 }),
2106
+ editError: (row) => {
2107
+ if (row.original.quantity < 0) return "Cannot be negative";
2108
+ return undefined;
2109
+ },
2110
+ },
2111
+ {
2112
+ accessorKey: "price",
2113
+ header: "Price ($)",
2114
+ size: 130,
2115
+ enableEditing: true,
2116
+ editVariant: "number",
2117
+ editNumberProps: {
2118
+ placeholder: "0.00",
2119
+ min: 0,
2120
+ step: 0.01,
2121
+ precision: 2,
2122
+ allowDecimal: true,
2123
+ allowNegative: false,
2124
+ },
2125
+ onCommit: (_row, v) => ({ price: parseFloat(v) || 0 }),
2126
+ displayCell: (row) => (
2127
+ <span className="typography-small2 tabular-nums">
2128
+ ${Number(row.original.price).toFixed(2)}
2129
+ </span>
2130
+ ),
2131
+ editError: (row) => {
2132
+ if (Number(row.original.price) <= 0) return "Price must be > 0";
2133
+ return undefined;
2134
+ },
2135
+ },
2136
+ {
2137
+ accessorKey: "active",
2138
+ header: "Active",
2139
+ size: 100,
2140
+ enableEditing: true,
2141
+ editVariant: "checkbox",
2142
+ editCheckboxProps: { label: "Active" },
2143
+ onCommit: (_row, v) => ({ active: v === "true" }),
2144
+ displayCell: (row) => (
2145
+ <span
2146
+ className={cn(
2147
+ "inline-flex items-center px-2 py-0.5 rounded-lg typography-small2",
2148
+ row.original.active
2149
+ ? "bg-success-500/20 text-success-400"
2150
+ : "bg-transparent-grey2-8 text-text-g-contrast-medium",
2151
+ )}
2152
+ >
2153
+ {row.original.active ? "Yes" : "No"}
2154
+ </span>
2155
+ ),
2156
+ },
2157
+ {
2158
+ accessorKey: "priority",
2159
+ header: "Priority",
2160
+ size: 200,
2161
+ enableEditing: true,
2162
+ editVariant: "custom",
2163
+ editCustomCell: (row, { commit, blur, onKeyDown }) => {
2164
+ const current = row.original.priority;
2165
+ const handleKey = (e: React.KeyboardEvent<HTMLElement>) => {
2166
+ if (e.key === "Escape") {
2167
+ e.preventDefault();
2168
+ blur();
2169
+ return;
2170
+ }
2171
+ onKeyDown?.(e);
2172
+ };
2173
+ return (
2174
+ <div className="flex flex-wrap items-center gap-1">
2175
+ {PRIORITY_OPTIONS.map((opt) => (
2176
+ <button
2177
+ key={opt.value}
2178
+ type="button"
2179
+ className={cn(
2180
+ "rounded-md px-2 py-0.5 typography-small2 border transition-colors",
2181
+ current === opt.value
2182
+ ? "border-primary-500 bg-primary-500/15 text-primary-400"
2183
+ : "border-transparent bg-transparent-grey2-8 text-text-g-contrast-medium hover:text-text-contrast-high hover:bg-transparent-grey2-12",
2184
+ )}
2185
+ onClick={() => {
2186
+ commit(opt.value);
2187
+ blur();
2188
+ }}
2189
+ onKeyDown={handleKey}
2190
+ >
2191
+ {opt.label}
2192
+ </button>
2193
+ ))}
2194
+ </div>
2195
+ );
2196
+ },
2197
+ onCommit: (_row, value) => ({
2198
+ priority: (PRIORITY_OPTIONS.find((o) => o.value === value)
2199
+ ? value
2200
+ : "none") as EditShowcaseRow["priority"],
2201
+ }),
2202
+ displayCell: (row) => {
2203
+ const p = row.original.priority;
2204
+ const label = PRIORITY_OPTIONS.find((o) => o.value === p)?.label ?? p;
2205
+ return (
2206
+ <Badge label={label} color={PRIORITY_BADGE_COLOR[p] ?? "default"} />
2207
+ );
2208
+ },
2209
+ },
2210
+ {
2211
+ accessorKey: "notes",
2212
+ header: "Notes",
2213
+ size: 200,
2214
+ enableEditing: true,
2215
+ editVariant: "text",
2216
+ editTextProps: { placeholder: "Add notes…" },
2217
+ displayCell: (row) =>
2218
+ row.original.notes ? (
2219
+ <span>{row.original.notes}</span>
2220
+ ) : (
2221
+ <span className="text-text-g-contrast-medium italic">—</span>
2222
+ ),
2223
+ },
2224
+ ];
2225
+
2226
+ /**
2227
+ * **Editing Field Showcase** — demonstrates every editing feature:
2228
+ *
2229
+ * | Feature | Where |
2230
+ * |---|---|
2231
+ * | `editVariant: "text"` | Product name, Notes |
2232
+ * | `editVariant: "select"` | Category |
2233
+ * | `editVariant: "number"` | Quantity, Price |
2234
+ * | `editVariant: "checkbox"` | Active |
2235
+ * | `editVariant: "custom"` + `editCustomCell` | Priority (inline button picker → Badge display) |
2236
+ * | `editError` | Name (required + min length), Category (required), Price (> 0) |
2237
+ * | `displayCell` | Price (formatted), Active (badge), Notes (italic placeholder) |
2238
+ * | `testId` | Root container has `data-testid="edit-showcase"` |
2239
+ * | Row / Cell mode toggle | Toolbar radio buttons |
2240
+ * | Click / Double-click trigger | Toolbar radio buttons |
2241
+ * | Tab traversal | Tab/Shift+Tab moves between editable fields |
2242
+ */
2243
+ export const EditingFieldShowcase: StoryObj = {
2244
+ render: () => {
2245
+ const [rows, setRows] = React.useState<EditShowcaseRow[]>(
2246
+ () => editShowcaseData,
2247
+ );
2248
+ const [editScope, setEditScope] = React.useState<"row" | "cell">("row");
2249
+ const [editTrigger, setEditTrigger] = React.useState<
2250
+ "click" | "doubleClick"
2251
+ >("click");
2252
+
2253
+ return (
2254
+ <div className="flex h-full min-h-0 w-full flex-col gap-4">
2255
+ <div className="flex flex-col gap-2">
2256
+ <h2 className="typography-h6 text-text-contrast-max">
2257
+ Editing Field Showcase
2258
+ </h2>
2259
+ <p className="typography-body3 text-text-g-contrast-medium max-w-3xl">
2260
+ All built-in edit variants: <strong>text</strong>,{" "}
2261
+ <strong>select</strong>, <strong>number</strong>,{" "}
2262
+ <strong>checkbox</strong>, and a <strong>custom</strong> field
2263
+ (Priority — inline button picker). Inline validation errors appear
2264
+ on Name, Category, and Price. Uses{" "}
2265
+ <code className="typography-mono">
2266
+ testId=&quot;edit-showcase&quot;
2267
+ </code>{" "}
2268
+ for automated testing.
2269
+ </p>
2270
+ </div>
2271
+
2272
+ <div className="flex flex-wrap items-center gap-4 rounded-lg border border-table-c-col-line bg-table-c-header-bg px-4 py-3">
2273
+ <span className="typography-small2 text-text-g-contrast-medium font-medium">
2274
+ Edit mode
2275
+ </span>
2276
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
2277
+ <input
2278
+ type="radio"
2279
+ name="showcase-scope"
2280
+ checked={editScope === "row"}
2281
+ onChange={() => setEditScope("row")}
2282
+ />
2283
+ Row
2284
+ </label>
2285
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
2286
+ <input
2287
+ type="radio"
2288
+ name="showcase-scope"
2289
+ checked={editScope === "cell"}
2290
+ onChange={() => setEditScope("cell")}
2291
+ />
2292
+ Cell
2293
+ </label>
2294
+ <span className="mx-2 hidden h-4 w-px bg-table-c-col-line sm:inline-block" />
2295
+ <span className="typography-small2 text-text-g-contrast-medium font-medium">
2296
+ Trigger
2297
+ </span>
2298
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
2299
+ <input
2300
+ type="radio"
2301
+ name="showcase-trig"
2302
+ checked={editTrigger === "click"}
2303
+ onChange={() => setEditTrigger("click")}
2304
+ />
2305
+ Click
2306
+ </label>
2307
+ <label className="flex cursor-pointer items-center gap-2 typography-small2 text-text-contrast-high">
2308
+ <input
2309
+ type="radio"
2310
+ name="showcase-trig"
2311
+ checked={editTrigger === "doubleClick"}
2312
+ onChange={() => setEditTrigger("doubleClick")}
2313
+ />
2314
+ Double-click
2315
+ </label>
2316
+ </div>
2317
+
2318
+ <DataTable
2319
+ testId="edit-showcase"
2320
+ className="min-h-0"
2321
+ columns={editShowcaseColumns}
2322
+ data={rows}
2323
+ divided
2324
+ striped={false}
2325
+ bordered
2326
+ tableLayout="equal"
2327
+ enableEditing
2328
+ editDisplayMode={editScope}
2329
+ editTrigger={editTrigger}
2330
+ onCellCommit={(rowId, _colId, patch) => {
2331
+ setRows((prev) =>
2332
+ prev.map((r) => (r.id === rowId ? { ...r, ...patch } : r)),
2333
+ );
67
2334
  }}
2335
+ rowActions={(row: Row<EditShowcaseRow>) => (
2336
+ <ActionButton
2337
+ variant="icon"
2338
+ size="sm"
2339
+ aria-label="Delete row"
2340
+ onClick={() =>
2341
+ setRows((prev) => prev.filter((r) => r.id !== row.original.id))
2342
+ }
2343
+ >
2344
+ <Trash2 className="size-4" />
2345
+ </ActionButton>
2346
+ )}
68
2347
  />
69
2348
  </div>
70
2349
  );
71
2350
  },
72
- } satisfies StoryObj;
2351
+ };