@handled-ai/design-system 0.14.8 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,524 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import React from "react";
3
+ import { render, fireEvent } from "@testing-library/react";
4
+ import { VirtualizedDataTable } from "../virtualized-data-table";
5
+ // Verify the barrel re-export compiles (type-only import used below in tests)
6
+ import type { ColumnDef } from "@tanstack/react-table";
7
+ import type { ColumnSizingState } from "../../index";
8
+
9
+ type TestRow = { id: string; name: string; value: number };
10
+
11
+ const testColumns: ColumnDef<TestRow, unknown>[] = [
12
+ { accessorKey: "name", header: "Name", size: 200, minSize: 100 },
13
+ { accessorKey: "value", header: "Value", size: 150, minSize: 80 },
14
+ ];
15
+
16
+ const testData: TestRow[] = [
17
+ { id: "1", name: "Alpha", value: 10 },
18
+ { id: "2", name: "Beta", value: 20 },
19
+ ];
20
+
21
+ // ─── Group 1: Feature disabled by default ─────────────────────────────────────
22
+
23
+ describe("VirtualizedDataTable — resize disabled by default", () => {
24
+ it("does not render resize handles when enableColumnResizing is omitted", () => {
25
+ const { container } = render(
26
+ <VirtualizedDataTable columns={testColumns} data={testData} height={300} />,
27
+ );
28
+ expect(container.querySelectorAll('[role="separator"]').length).toBe(0);
29
+ });
30
+
31
+ it("does not render resize handles when enableColumnResizing={false} explicitly", () => {
32
+ const { container } = render(
33
+ <VirtualizedDataTable
34
+ columns={testColumns}
35
+ data={testData}
36
+ height={300}
37
+ enableColumnResizing={false}
38
+ />,
39
+ );
40
+ expect(container.querySelectorAll('[role="separator"]').length).toBe(0);
41
+ });
42
+ });
43
+
44
+ // ─── Group 2: Handles appear and are correctly structured ─────────────────────
45
+
46
+ describe("VirtualizedDataTable — resize handles render when enabled", () => {
47
+ it("renders one resize handle per resizable column", () => {
48
+ const { container } = render(
49
+ <VirtualizedDataTable
50
+ columns={testColumns}
51
+ data={testData}
52
+ height={300}
53
+ enableColumnResizing
54
+ />,
55
+ );
56
+ expect(container.querySelectorAll('[role="separator"]').length).toBe(2);
57
+ });
58
+
59
+ it("each handle has aria-orientation='vertical'", () => {
60
+ const { container } = render(
61
+ <VirtualizedDataTable
62
+ columns={testColumns}
63
+ data={testData}
64
+ height={300}
65
+ enableColumnResizing
66
+ />,
67
+ );
68
+ container.querySelectorAll('[role="separator"]').forEach((sep) => {
69
+ expect(sep.getAttribute("aria-orientation")).toBe("vertical");
70
+ });
71
+ });
72
+
73
+ it("each handle has cursor-col-resize class", () => {
74
+ const { container } = render(
75
+ <VirtualizedDataTable
76
+ columns={testColumns}
77
+ data={testData}
78
+ height={300}
79
+ enableColumnResizing
80
+ />,
81
+ );
82
+ container.querySelectorAll('[role="separator"]').forEach((sep) => {
83
+ expect(sep.classList.contains("cursor-col-resize")).toBe(true);
84
+ });
85
+ });
86
+
87
+ it("header cells have 'relative' class when resizing is enabled", () => {
88
+ const { container } = render(
89
+ <VirtualizedDataTable
90
+ columns={testColumns}
91
+ data={testData}
92
+ height={300}
93
+ enableColumnResizing
94
+ />,
95
+ );
96
+ container.querySelectorAll('[role="columnheader"]').forEach((h) => {
97
+ expect((h as HTMLElement).classList.contains("relative")).toBe(true);
98
+ });
99
+ });
100
+
101
+ it("resizable header cells get pr-4 padding to prevent overlap with sort text", () => {
102
+ const { container } = render(
103
+ <VirtualizedDataTable
104
+ columns={testColumns}
105
+ data={testData}
106
+ height={300}
107
+ enableColumnResizing
108
+ />,
109
+ );
110
+ // Every header cell whose column can resize must have pr-4
111
+ container.querySelectorAll('[role="columnheader"]').forEach((h) => {
112
+ expect((h as HTMLElement).classList.contains("pr-4")).toBe(true);
113
+ });
114
+ });
115
+
116
+ it("non-resizable columns do NOT get pr-4 padding", () => {
117
+ const columns: ColumnDef<TestRow, unknown>[] = [
118
+ { accessorKey: "name", header: "Name", size: 200, enableResizing: false },
119
+ { accessorKey: "value", header: "Value", size: 150 },
120
+ ];
121
+ const { container } = render(
122
+ <VirtualizedDataTable
123
+ columns={columns}
124
+ data={testData}
125
+ height={300}
126
+ enableColumnResizing
127
+ />,
128
+ );
129
+ const headers = container.querySelectorAll('[role="columnheader"]');
130
+ // First column: enableResizing=false → no pr-4
131
+ expect((headers[0] as HTMLElement).classList.contains("pr-4")).toBe(false);
132
+ // Second column: resizable → has pr-4
133
+ expect((headers[1] as HTMLElement).classList.contains("pr-4")).toBe(true);
134
+ });
135
+
136
+ it("does not render a handle for a column with enableResizing: false", () => {
137
+ const columns: ColumnDef<TestRow, unknown>[] = [
138
+ { accessorKey: "name", header: "Name", size: 200, enableResizing: false },
139
+ { accessorKey: "value", header: "Value", size: 150 },
140
+ ];
141
+ const { container } = render(
142
+ <VirtualizedDataTable
143
+ columns={columns}
144
+ data={testData}
145
+ height={300}
146
+ enableColumnResizing
147
+ />,
148
+ );
149
+ expect(container.querySelectorAll('[role="separator"]').length).toBe(1);
150
+ });
151
+ });
152
+
153
+ // ─── Group 3: Initial sizing from column defs ─────────────────────────────────
154
+
155
+ describe("VirtualizedDataTable — initial column sizes", () => {
156
+ it("header cells render at their declared size", () => {
157
+ const { container } = render(
158
+ <VirtualizedDataTable
159
+ columns={testColumns}
160
+ data={testData}
161
+ height={300}
162
+ enableColumnResizing
163
+ />,
164
+ );
165
+ const headers = container.querySelectorAll('[role="columnheader"]');
166
+ expect((headers[0] as HTMLElement).style.width).toBe("200px");
167
+ expect((headers[1] as HTMLElement).style.width).toBe("150px");
168
+ });
169
+
170
+ it("column cannot be dragged below its minSize (onChange mode)", () => {
171
+ const { container } = render(
172
+ <VirtualizedDataTable
173
+ columns={testColumns}
174
+ data={testData}
175
+ height={300}
176
+ enableColumnResizing
177
+ columnResizeMode="onChange"
178
+ />,
179
+ );
180
+ const separator = container.querySelector('[role="separator"]')!;
181
+ // Start at x=200, drag far left past minSize=100 → delta of -150 would give 50px
182
+ fireEvent.mouseDown(separator, { clientX: 200 });
183
+ fireEvent.mouseMove(document, { clientX: 50 });
184
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
185
+ expect(parseInt(header.style.width, 10)).toBeGreaterThanOrEqual(100);
186
+ fireEvent.mouseUp(document);
187
+ });
188
+ });
189
+
190
+ // ─── Group 4: columnResizeMode="onEnd" (new default) ─────────────────────────
191
+ //
192
+ // With onEnd, sizing state is committed at mouseUp, not during mousemove.
193
+ // Width in the DOM should be unchanged mid-drag, then reflect the new size
194
+ // after mouseUp.
195
+
196
+ describe("VirtualizedDataTable — columnResizeMode onEnd (default)", () => {
197
+ it("header width does NOT change during mousemove (committed on mouseUp)", () => {
198
+ const { container } = render(
199
+ <VirtualizedDataTable
200
+ columns={testColumns}
201
+ data={testData}
202
+ height={300}
203
+ enableColumnResizing
204
+ // columnResizeMode defaults to "onEnd" — do not pass it explicitly
205
+ />,
206
+ );
207
+ const separator = container.querySelector('[role="separator"]')!;
208
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
209
+ const widthBeforeDrag = header.style.width;
210
+
211
+ fireEvent.mouseDown(separator, { clientX: 200 });
212
+ fireEvent.mouseMove(document, { clientX: 260 }); // mid-drag: should not change yet
213
+ expect(header.style.width).toBe(widthBeforeDrag);
214
+
215
+ fireEvent.mouseUp(document);
216
+ });
217
+
218
+ it("header width changes after mouseUp completes the drag", () => {
219
+ const { container } = render(
220
+ <VirtualizedDataTable
221
+ columns={testColumns}
222
+ data={testData}
223
+ height={300}
224
+ enableColumnResizing
225
+ // "onEnd" default
226
+ />,
227
+ );
228
+ const separator = container.querySelector('[role="separator"]')!;
229
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
230
+ const widthBefore = parseInt(header.style.width, 10);
231
+
232
+ fireEvent.mouseDown(separator, { clientX: 200 });
233
+ fireEvent.mouseMove(document, { clientX: 260 }); // +60px delta
234
+ fireEvent.mouseUp(document); // commits the resize
235
+
236
+ expect(parseInt(header.style.width, 10)).toBeGreaterThan(widthBefore);
237
+ });
238
+
239
+ it("onColumnSizingChange is called on mouseUp (not mousemove) in onEnd mode", () => {
240
+ const onSizingChange = vi.fn();
241
+ const { container } = render(
242
+ <VirtualizedDataTable
243
+ columns={testColumns}
244
+ data={testData}
245
+ height={300}
246
+ enableColumnResizing
247
+ columnSizing={{}}
248
+ onColumnSizingChange={onSizingChange}
249
+ // "onEnd" default
250
+ />,
251
+ );
252
+ const separator = container.querySelector('[role="separator"]')!;
253
+
254
+ fireEvent.mouseDown(separator, { clientX: 200 });
255
+ fireEvent.mouseMove(document, { clientX: 250 });
256
+ // Should NOT have been called yet mid-drag in onEnd mode
257
+ expect(onSizingChange).not.toHaveBeenCalled();
258
+
259
+ fireEvent.mouseUp(document);
260
+ // Now it should fire
261
+ expect(onSizingChange).toHaveBeenCalled();
262
+ });
263
+ });
264
+
265
+ // ─── Group 5: Drag changes column width (onChange mode) ───────────────────────
266
+
267
+ describe("VirtualizedDataTable — drag resizing with columnResizeMode='onChange'", () => {
268
+ it("dragging right increases column width", () => {
269
+ const { container } = render(
270
+ <VirtualizedDataTable
271
+ columns={testColumns}
272
+ data={testData}
273
+ height={300}
274
+ enableColumnResizing
275
+ columnResizeMode="onChange"
276
+ />,
277
+ );
278
+ const separator = container.querySelector('[role="separator"]')!;
279
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
280
+ const widthBefore = parseInt(header.style.width, 10);
281
+
282
+ fireEvent.mouseDown(separator, { clientX: 200 });
283
+ fireEvent.mouseMove(document, { clientX: 260 }); // +60px delta
284
+ fireEvent.mouseUp(document);
285
+
286
+ expect(parseInt(header.style.width, 10)).toBeGreaterThan(widthBefore);
287
+ });
288
+
289
+ it("dragging left decreases column width", () => {
290
+ const { container } = render(
291
+ <VirtualizedDataTable
292
+ columns={testColumns}
293
+ data={testData}
294
+ height={300}
295
+ enableColumnResizing
296
+ columnResizeMode="onChange"
297
+ />,
298
+ );
299
+ const separator = container.querySelector('[role="separator"]')!;
300
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
301
+ const widthBefore = parseInt(header.style.width, 10);
302
+
303
+ fireEvent.mouseDown(separator, { clientX: 200 });
304
+ fireEvent.mouseMove(document, { clientX: 160 }); // -40px delta
305
+ fireEvent.mouseUp(document);
306
+
307
+ expect(parseInt(header.style.width, 10)).toBeLessThan(widthBefore);
308
+ });
309
+
310
+ it("dragging one column does not change the adjacent column's width", () => {
311
+ const { container } = render(
312
+ <VirtualizedDataTable
313
+ columns={testColumns}
314
+ data={testData}
315
+ height={300}
316
+ enableColumnResizing
317
+ columnResizeMode="onChange"
318
+ />,
319
+ );
320
+ const separators = container.querySelectorAll('[role="separator"]');
321
+ const headers = container.querySelectorAll('[role="columnheader"]');
322
+ const secondWidthBefore = parseInt((headers[1] as HTMLElement).style.width, 10);
323
+
324
+ fireEvent.mouseDown(separators[0], { clientX: 200 });
325
+ fireEvent.mouseMove(document, { clientX: 260 });
326
+ fireEvent.mouseUp(document);
327
+
328
+ expect(parseInt((headers[1] as HTMLElement).style.width, 10)).toBe(secondWidthBefore);
329
+ });
330
+
331
+ it("does not throw during resize in uncontrolled mode (no sizing props)", () => {
332
+ const { container } = render(
333
+ <VirtualizedDataTable
334
+ columns={testColumns}
335
+ data={testData}
336
+ height={300}
337
+ enableColumnResizing
338
+ columnResizeMode="onChange"
339
+ />,
340
+ );
341
+ const separator = container.querySelector('[role="separator"]')!;
342
+ expect(() => {
343
+ fireEvent.mouseDown(separator, { clientX: 200 });
344
+ fireEvent.mouseMove(document, { clientX: 250 });
345
+ fireEvent.mouseUp(document);
346
+ }).not.toThrow();
347
+ });
348
+ });
349
+
350
+ // ─── Group 6: Controlled mode ─────────────────────────────────────────────────
351
+
352
+ describe("VirtualizedDataTable — controlled columnSizing", () => {
353
+ it("external columnSizing prop overrides the column def size", () => {
354
+ const sizing: ColumnSizingState = { name: 300 };
355
+ const { container } = render(
356
+ <VirtualizedDataTable
357
+ columns={testColumns}
358
+ data={testData}
359
+ height={300}
360
+ enableColumnResizing
361
+ columnSizing={sizing}
362
+ />,
363
+ );
364
+ const headers = container.querySelectorAll('[role="columnheader"]');
365
+ expect((headers[0] as HTMLElement).style.width).toBe("300px");
366
+ // Column not in sizing map keeps its column-def default
367
+ expect((headers[1] as HTMLElement).style.width).toBe("150px");
368
+ });
369
+
370
+ it("onColumnSizingChange is called when a resize drag occurs (onChange mode)", () => {
371
+ const onSizingChange = vi.fn();
372
+ const { container } = render(
373
+ <VirtualizedDataTable
374
+ columns={testColumns}
375
+ data={testData}
376
+ height={300}
377
+ enableColumnResizing
378
+ columnResizeMode="onChange"
379
+ columnSizing={{}}
380
+ onColumnSizingChange={onSizingChange}
381
+ />,
382
+ );
383
+ const separator = container.querySelector('[role="separator"]')!;
384
+ fireEvent.mouseDown(separator, { clientX: 200 });
385
+ fireEvent.mouseMove(document, { clientX: 250 });
386
+ expect(onSizingChange).toHaveBeenCalled();
387
+ fireEvent.mouseUp(document);
388
+ });
389
+
390
+ it("renders correctly when columnSizing is provided without onColumnSizingChange", () => {
391
+ const sizing: ColumnSizingState = { name: 280 };
392
+ const { container } = render(
393
+ <VirtualizedDataTable
394
+ columns={testColumns}
395
+ data={testData}
396
+ height={300}
397
+ enableColumnResizing
398
+ columnSizing={sizing}
399
+ />,
400
+ );
401
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
402
+ expect(header.style.width).toBe("280px");
403
+ });
404
+ });
405
+
406
+ // ─── Group 7: Cell widths track header widths ─────────────────────────────────
407
+
408
+ describe("VirtualizedDataTable — cell widths track column widths", () => {
409
+ it("row cells match the column width after a resize drag (onChange mode)", () => {
410
+ const { container } = render(
411
+ <VirtualizedDataTable
412
+ columns={testColumns}
413
+ data={testData}
414
+ height={300}
415
+ enableColumnResizing
416
+ columnResizeMode="onChange"
417
+ />,
418
+ );
419
+ const separator = container.querySelector('[role="separator"]')!;
420
+ fireEvent.mouseDown(separator, { clientX: 200 });
421
+ fireEvent.mouseMove(document, { clientX: 260 });
422
+ fireEvent.mouseUp(document);
423
+
424
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
425
+ const newHeaderWidth = header.style.width;
426
+
427
+ // Data rows (aria-rowindex present) are rendered by the virtualizer.
428
+ // In happy-dom (no layout engine) the virtualizer may produce zero virtual
429
+ // items; if rows are present, verify their first cell tracks the header width.
430
+ const rows = container.querySelectorAll('[role="row"]');
431
+ for (let i = 1; i < rows.length; i++) {
432
+ const firstCell = rows[i].querySelector('[role="cell"]') as HTMLElement | null;
433
+ if (firstCell) {
434
+ expect(firstCell.style.width).toBe(newHeaderWidth);
435
+ }
436
+ }
437
+ });
438
+ });
439
+
440
+ // ─── Group 8: No regressions with enableColumnResizing on ────────────────────
441
+
442
+ describe("VirtualizedDataTable — no regressions with enableColumnResizing on", () => {
443
+ it("onSortingChange fires when a sortable header is clicked", () => {
444
+ const columnsWithSort: ColumnDef<TestRow, unknown>[] = [
445
+ { accessorKey: "name", header: "Name", size: 200, enableSorting: true },
446
+ { accessorKey: "value", header: "Value", size: 150, enableSorting: true },
447
+ ];
448
+ const onSortingChange = vi.fn();
449
+ const { container } = render(
450
+ <VirtualizedDataTable
451
+ columns={columnsWithSort}
452
+ data={testData}
453
+ height={300}
454
+ enableColumnResizing
455
+ sorting={[]}
456
+ onSortingChange={onSortingChange}
457
+ />,
458
+ );
459
+ const sortButton = container.querySelector('[role="columnheader"] button')!;
460
+ fireEvent.click(sortButton);
461
+ expect(onSortingChange).toHaveBeenCalled();
462
+ });
463
+
464
+ it("onRowClick prop is accepted without error; scroll container is present", () => {
465
+ // The virtualizer produces no virtual rows in happy-dom (no layout engine),
466
+ // so we verify the component mounts cleanly with onRowClick wired and that
467
+ // the table scroll container is in the DOM.
468
+ const onRowClick = vi.fn();
469
+ const { container } = render(
470
+ <VirtualizedDataTable
471
+ columns={testColumns}
472
+ data={testData}
473
+ height={300}
474
+ enableColumnResizing
475
+ onRowClick={onRowClick}
476
+ />,
477
+ );
478
+ expect(container.querySelector('[role="table"]')).not.toBeNull();
479
+ // If the virtualizer happens to render rows, clicking them calls onRowClick
480
+ const dataRows = Array.from(
481
+ container.querySelectorAll('[role="row"]'),
482
+ ).filter((r) => r.getAttribute("aria-rowindex") !== null);
483
+ if (dataRows.length > 0) {
484
+ fireEvent.click(dataRows[0]);
485
+ expect(onRowClick).toHaveBeenCalled();
486
+ }
487
+ });
488
+
489
+ it("empty state renders correctly when data is empty and resizing is enabled", () => {
490
+ const { container } = render(
491
+ <VirtualizedDataTable
492
+ columns={testColumns}
493
+ data={[]}
494
+ height={300}
495
+ enableColumnResizing
496
+ emptyMessage="Nothing here"
497
+ />,
498
+ );
499
+ expect(container.textContent).toContain("Nothing here");
500
+ });
501
+ });
502
+
503
+ // ─── Group 9: Barrel re-export of ColumnSizingState ──────────────────────────
504
+
505
+ describe("VirtualizedDataTable — ColumnSizingState barrel re-export", () => {
506
+ it("ColumnSizingState from the barrel index is compatible with the columnSizing prop", () => {
507
+ // This test is intentionally type-level: the import at the top of this file
508
+ // uses `import type { ColumnSizingState } from '../../index'`. If the barrel
509
+ // no longer re-exports it, tsc (run via `pnpm typecheck`) will error.
510
+ // At runtime we just verify the prop is accepted with a well-typed value.
511
+ const sizing: ColumnSizingState = { name: 250 };
512
+ const { container } = render(
513
+ <VirtualizedDataTable
514
+ columns={testColumns}
515
+ data={testData}
516
+ height={300}
517
+ enableColumnResizing
518
+ columnSizing={sizing}
519
+ />,
520
+ );
521
+ const header = container.querySelectorAll('[role="columnheader"]')[0] as HTMLElement;
522
+ expect(header.style.width).toBe("250px");
523
+ });
524
+ });