@rovula/ui 0.1.28 → 0.1.29

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