@cosmicdrift/kumiko-renderer-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,936 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Default-Primitives für Web-Renderer. Tests pinnen den Vertrag, den die
4
+ // Renderer-Komponenten (RenderEdit, RenderList, KumikoScreen) an die
5
+ // Primitives weiterreichen — vor allem Accessibility-Zusagen
6
+ // (role="alert" bei Error-Varianten), Event-Shape-Mapping (Input
7
+ // liefert pro kind unterschiedliche JS-Typen zurück statt des rohen
8
+ // ChangeEvent) und das testId-Forwarding, von dem die E2E-Tests
9
+ // abhängen werden.
10
+
11
+ import userEvent from "@testing-library/user-event";
12
+ import { describe, expect, test, vi } from "vitest";
13
+ import { defaultPrimitives } from "../primitives";
14
+ import { fireEvent, render, screen } from "./test-utils";
15
+
16
+ const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog } = defaultPrimitives;
17
+
18
+ describe("Button", () => {
19
+ test("disabled: attribute gesetzt + Tailwind-Klassen für pointer-events/opacity", () => {
20
+ render(
21
+ <Button disabled testId="btn">
22
+ Save
23
+ </Button>,
24
+ );
25
+ const btn = screen.getByTestId("btn") as HTMLButtonElement;
26
+ expect(btn.disabled).toBe(true);
27
+ // Visuelles Feedback kommt aus Tailwind-Klassen (shadcn-Pattern).
28
+ expect(btn.className).toContain("disabled:pointer-events-none");
29
+ expect(btn.className).toContain("disabled:opacity-50");
30
+ });
31
+
32
+ test("onClick fires on click", () => {
33
+ const onClick = vi.fn();
34
+ render(
35
+ <Button onClick={onClick} testId="btn">
36
+ Go
37
+ </Button>,
38
+ );
39
+ fireEvent.click(screen.getByTestId("btn"));
40
+ expect(onClick).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ test("loading: rendert Spinner statt Children + ist disabled", () => {
44
+ const onClick = vi.fn();
45
+ render(
46
+ <Button loading onClick={onClick} testId="btn">
47
+ Save
48
+ </Button>,
49
+ );
50
+ const btn = screen.getByTestId("btn") as HTMLButtonElement;
51
+ expect(btn.disabled).toBe(true);
52
+ expect(btn.dataset["loading"]).toBe("true");
53
+ // Children verschwinden während loading; Spinner ist ein <svg>.
54
+ expect(btn.textContent).not.toContain("Save");
55
+ expect(btn.querySelector("svg")).not.toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("Banner", () => {
60
+ test('variant="error" sets role="alert" (a11y)', () => {
61
+ render(
62
+ <Banner variant="error" testId="b">
63
+ Something broke
64
+ </Banner>,
65
+ );
66
+ expect(screen.getByTestId("b").getAttribute("role")).toBe("alert");
67
+ });
68
+
69
+ test('variant="info" has no alert role', () => {
70
+ render(
71
+ <Banner variant="info" testId="b">
72
+ Hi
73
+ </Banner>,
74
+ );
75
+ expect(screen.getByTestId("b").getAttribute("role")).toBeNull();
76
+ });
77
+
78
+ test("actions prop renders in actions slot", () => {
79
+ render(
80
+ <Banner variant="info" testId="b" actions={<span>undo</span>}>
81
+ Saved
82
+ </Banner>,
83
+ );
84
+ const slot = screen.getByTestId("b").querySelector('[data-slot="actions"]');
85
+ expect(slot?.textContent).toBe("undo");
86
+ });
87
+ });
88
+
89
+ describe("Field", () => {
90
+ test("required fügt einen Stern ans Label an", () => {
91
+ render(
92
+ <Field id="f1" label="Name" required testId="f">
93
+ <input />
94
+ </Field>,
95
+ );
96
+ // shadcn-Field rendert den Mark als <span>* mit text-destructive.
97
+ const label = screen.getByTestId("f").querySelector("label");
98
+ expect(label?.textContent).toContain("*");
99
+ });
100
+
101
+ test("issues render inside role=alert with per-testId suffix", () => {
102
+ render(
103
+ <Field
104
+ id="f1"
105
+ label="Email"
106
+ testId="f"
107
+ issues={[{ path: "email", code: "invalid", i18nKey: "Email invalid" }]}
108
+ >
109
+ <input />
110
+ </Field>,
111
+ );
112
+ const alert = screen.getByTestId("f-errors");
113
+ expect(alert.getAttribute("role")).toBe("alert");
114
+ expect(alert.textContent).toContain("Email invalid");
115
+ });
116
+
117
+ test("no issues → no alert element", () => {
118
+ render(
119
+ <Field id="f1" label="Email" testId="f">
120
+ <input />
121
+ </Field>,
122
+ );
123
+ expect(screen.queryByTestId("f-errors")).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("Input kind mapping", () => {
128
+ test('kind="text": onChange receives string', () => {
129
+ const onChange = vi.fn();
130
+ render(<Input id="i" name="i" kind="text" value="" onChange={onChange} />);
131
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "hello" } });
132
+ expect(onChange).toHaveBeenCalledWith("hello");
133
+ });
134
+
135
+ test('kind="number": "" → undefined, numeric → number', () => {
136
+ const onChange = vi.fn();
137
+ render(<Input id="i" name="i" kind="number" value={0} onChange={onChange} />);
138
+ const input = screen.getByRole("spinbutton");
139
+ fireEvent.change(input, { target: { value: "42" } });
140
+ expect(onChange).toHaveBeenLastCalledWith(42);
141
+ fireEvent.change(input, { target: { value: "" } });
142
+ expect(onChange).toHaveBeenLastCalledWith(undefined);
143
+ });
144
+
145
+ test('kind="boolean": onChange receives checked', () => {
146
+ const onChange = vi.fn();
147
+ render(<Input id="i" name="i" kind="boolean" value={false} onChange={onChange} />);
148
+ fireEvent.click(screen.getByRole("checkbox"));
149
+ expect(onChange).toHaveBeenCalledWith(true);
150
+ });
151
+
152
+ test('kind="date": Trigger zeigt formatiertes Datum, kein nativer date-Input', () => {
153
+ // Default-DateInput nutzt Radix-Popover + DayPicker statt native
154
+ // <input type="date">. Trigger ist ein Button mit dem formatierten
155
+ // Datum als sichtbarem Text.
156
+ const onChange = vi.fn();
157
+ render(
158
+ <Input id="i" name="i" kind="date" value="2026-04-23" onChange={onChange} locale="de-DE" />,
159
+ );
160
+ expect(document.querySelector('input[type="date"]')).toBeNull();
161
+ const trigger = screen.getByRole("button");
162
+ expect(trigger.textContent).toContain("23. April 2026");
163
+ });
164
+
165
+ test("hasError=true sets aria-invalid", () => {
166
+ render(<Input id="i" name="i" kind="text" value="" hasError onChange={() => {}} />);
167
+ expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe("true");
168
+ });
169
+ });
170
+
171
+ describe("DataTable", () => {
172
+ test("empty rows render empty-state slot with derived testId", () => {
173
+ render(
174
+ <DataTable
175
+ columns={[{ field: "name", label: "Name", type: "string", sortable: false }]}
176
+ rows={[]}
177
+ testId="t"
178
+ />,
179
+ );
180
+ // getByTestId throws if missing — existence assertion is implicit.
181
+ expect(screen.getByTestId("t-empty")).not.toBeNull();
182
+ });
183
+
184
+ test("rows + cells get individual testIds for E2E hooks", () => {
185
+ render(
186
+ <DataTable
187
+ columns={[
188
+ { field: "name", label: "Name", type: "string", sortable: false },
189
+ { field: "active", label: "Active", type: "boolean", sortable: false },
190
+ ]}
191
+ rows={[{ id: "r1", values: { name: "Alice", active: true } }]}
192
+ testId="t"
193
+ />,
194
+ );
195
+ expect(screen.getByTestId("row-r1")).not.toBeNull();
196
+ expect(screen.getByTestId("cell-r1-name").textContent).toBe("Alice");
197
+ expect(screen.getByTestId("cell-r1-active").textContent).toBe("✓");
198
+ });
199
+
200
+ test("onRowClick fires with the clicked row", () => {
201
+ const onRowClick = vi.fn();
202
+ const row = { id: "r1", values: { name: "Alice" } };
203
+ render(
204
+ <DataTable
205
+ columns={[{ field: "name", label: "Name", type: "string", sortable: false }]}
206
+ rows={[row]}
207
+ onRowClick={onRowClick}
208
+ />,
209
+ );
210
+ fireEvent.click(screen.getByTestId("row-r1"));
211
+ expect(onRowClick).toHaveBeenCalledWith(row);
212
+ });
213
+
214
+ // Sort-Header pinnt das 3-State-Toggle-Verhalten + Visual-Indicator
215
+ // + aria-sort. Renderer-Vertrag mit dem Caller (RenderList): jede
216
+ // sortable-Column liefert beim Click den nächsten Sort-State zurück
217
+ // — Caller setzt damit URL-State und re-fetcht.
218
+ describe("Sort-Header", () => {
219
+ const sortableCols = [
220
+ { field: "name", label: "Name", type: "string", sortable: true },
221
+ { field: "createdAt", label: "Created", type: "timestamp", sortable: true },
222
+ { field: "id", label: "ID", type: "string", sortable: false },
223
+ ] as const;
224
+ // Mindestens eine Row, sonst rendert der DefaultDataTable den
225
+ // Empty-State-Branch und das thead-Markup ist gar nicht im DOM.
226
+ // Sort-Header lebt im thead, das brauchen wir hier.
227
+ const oneRow = [{ id: "r1", values: { name: "A", createdAt: "2026-01-01", id: "r1" } }];
228
+
229
+ test("ohne onSortChange: Header bleibt plain (kein Button, kein cursor)", () => {
230
+ render(<DataTable columns={sortableCols} rows={oneRow} />);
231
+ // Plain th.textContent enthält das Label. KEIN Button drinnen.
232
+ expect(screen.getByTestId("column-name").querySelector("button")).toBeNull();
233
+ });
234
+
235
+ test("mit onSortChange: sortable-Column rendert Button + ArrowUpDown-Icon", () => {
236
+ render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={vi.fn()} />);
237
+ const header = screen.getByTestId("column-name");
238
+ expect(header.querySelector("button")).not.toBeNull();
239
+ // Default-Icon (kein active sort) ist ArrowUpDown — Lucide rendert
240
+ // svg ohne expliziten name; wir prüfen aria-sort='none'.
241
+ expect(header.getAttribute("aria-sort")).toBe("none");
242
+ });
243
+
244
+ test("non-sortable Column rendert KEINEN Button (auch mit onSortChange)", () => {
245
+ render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={vi.fn()} />);
246
+ expect(screen.getByTestId("column-id").querySelector("button")).toBeNull();
247
+ });
248
+
249
+ test("aria-sort=ascending wenn sort.field passt + dir=asc", () => {
250
+ render(
251
+ <DataTable
252
+ columns={sortableCols}
253
+ rows={oneRow}
254
+ sort={{ field: "name", dir: "asc" }}
255
+ onSortChange={vi.fn()}
256
+ />,
257
+ );
258
+ expect(screen.getByTestId("column-name").getAttribute("aria-sort")).toBe("ascending");
259
+ expect(screen.getByTestId("column-createdAt").getAttribute("aria-sort")).toBe("none");
260
+ });
261
+
262
+ test("aria-sort=descending wenn dir=desc", () => {
263
+ render(
264
+ <DataTable
265
+ columns={sortableCols}
266
+ rows={oneRow}
267
+ sort={{ field: "name", dir: "desc" }}
268
+ onSortChange={vi.fn()}
269
+ />,
270
+ );
271
+ expect(screen.getByTestId("column-name").getAttribute("aria-sort")).toBe("descending");
272
+ });
273
+
274
+ test("Click ohne aktiven Sort: onSortChange({field, dir:'asc'})", () => {
275
+ const onSortChange = vi.fn();
276
+ render(<DataTable columns={sortableCols} rows={oneRow} onSortChange={onSortChange} />);
277
+ fireEvent.click(screen.getByTestId("column-name").querySelector("button") as HTMLElement);
278
+ expect(onSortChange).toHaveBeenCalledWith({ field: "name", dir: "asc" });
279
+ });
280
+
281
+ test("Click mit aktivem asc: onSortChange({field, dir:'desc'})", () => {
282
+ const onSortChange = vi.fn();
283
+ render(
284
+ <DataTable
285
+ columns={sortableCols}
286
+ rows={oneRow}
287
+ sort={{ field: "name", dir: "asc" }}
288
+ onSortChange={onSortChange}
289
+ />,
290
+ );
291
+ fireEvent.click(screen.getByTestId("column-name").querySelector("button") as HTMLElement);
292
+ expect(onSortChange).toHaveBeenCalledWith({ field: "name", dir: "desc" });
293
+ });
294
+
295
+ test("Click mit aktivem desc: onSortChange(null) (3-State zurück zu unsorted)", () => {
296
+ const onSortChange = vi.fn();
297
+ render(
298
+ <DataTable
299
+ columns={sortableCols}
300
+ rows={oneRow}
301
+ sort={{ field: "name", dir: "desc" }}
302
+ onSortChange={onSortChange}
303
+ />,
304
+ );
305
+ fireEvent.click(screen.getByTestId("column-name").querySelector("button") as HTMLElement);
306
+ expect(onSortChange).toHaveBeenCalledWith(null);
307
+ });
308
+
309
+ test("Click auf andere Spalte (sort=null für die): startet bei asc", () => {
310
+ const onSortChange = vi.fn();
311
+ render(
312
+ <DataTable
313
+ columns={sortableCols}
314
+ rows={oneRow}
315
+ sort={{ field: "name", dir: "desc" }}
316
+ onSortChange={onSortChange}
317
+ />,
318
+ );
319
+ fireEvent.click(
320
+ screen.getByTestId("column-createdAt").querySelector("button") as HTMLElement,
321
+ );
322
+ // Andere Spalte: dort gibt's keinen aktiven Sort, also asc.
323
+ expect(onSortChange).toHaveBeenCalledWith({ field: "createdAt", dir: "asc" });
324
+ });
325
+ });
326
+
327
+ // Pager: Window-of-7 Logik + 3 State-Pfade (first/middle/last page),
328
+ // disabled-Edges, Click-Callback. Server-Wiring (offset etc.) liegt
329
+ // im KumikoScreen — hier nur das UI.
330
+ describe("Pager", () => {
331
+ const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
332
+ const oneRow = [{ id: "r1", values: { name: "A" } }];
333
+
334
+ test("ohne pager-prop: kein Pager im DOM", () => {
335
+ render(<DataTable columns={cols} rows={oneRow} testId="dt" />);
336
+ expect(screen.queryByTestId("dt-pager")).toBeNull();
337
+ });
338
+
339
+ test("pager mit total=0: kein Pager (nichts zu paginieren)", () => {
340
+ render(
341
+ <DataTable
342
+ columns={cols}
343
+ rows={[]}
344
+ testId="dt"
345
+ pager={{ page: 1, limit: 50, total: 0, onPageChange: vi.fn() }}
346
+ />,
347
+ );
348
+ expect(screen.queryByTestId("dt-pager")).toBeNull();
349
+ });
350
+
351
+ test("page=1: Prev-Button disabled, Next aktiv", () => {
352
+ render(
353
+ <DataTable
354
+ columns={cols}
355
+ rows={oneRow}
356
+ testId="dt"
357
+ pager={{ page: 1, limit: 50, total: 3000, onPageChange: vi.fn() }}
358
+ />,
359
+ );
360
+ expect((screen.getByTestId("dt-pager-prev") as HTMLButtonElement).disabled).toBe(true);
361
+ expect((screen.getByTestId("dt-pager-next") as HTMLButtonElement).disabled).toBe(false);
362
+ });
363
+
364
+ test("page=last: Next-Button disabled", () => {
365
+ render(
366
+ <DataTable
367
+ columns={cols}
368
+ rows={oneRow}
369
+ testId="dt"
370
+ pager={{ page: 60, limit: 50, total: 3000, onPageChange: vi.fn() }}
371
+ />,
372
+ );
373
+ expect((screen.getByTestId("dt-pager-next") as HTMLButtonElement).disabled).toBe(true);
374
+ });
375
+
376
+ test("Click auf Page-Button: onPageChange feuert mit der Seite", () => {
377
+ const onPageChange = vi.fn();
378
+ render(
379
+ <DataTable
380
+ columns={cols}
381
+ rows={oneRow}
382
+ testId="dt"
383
+ pager={{ page: 1, limit: 50, total: 3000, onPageChange }}
384
+ />,
385
+ );
386
+ fireEvent.click(screen.getByTestId("dt-pager-page-2"));
387
+ expect(onPageChange).toHaveBeenCalledWith(2);
388
+ });
389
+
390
+ test("Click auf Prev von page=3: onPageChange(2)", () => {
391
+ const onPageChange = vi.fn();
392
+ render(
393
+ <DataTable
394
+ columns={cols}
395
+ rows={oneRow}
396
+ testId="dt"
397
+ pager={{ page: 3, limit: 50, total: 3000, onPageChange }}
398
+ />,
399
+ );
400
+ fireEvent.click(screen.getByTestId("dt-pager-prev"));
401
+ expect(onPageChange).toHaveBeenCalledWith(2);
402
+ });
403
+
404
+ test("aria-current='page' auf der aktiven Seite", () => {
405
+ render(
406
+ <DataTable
407
+ columns={cols}
408
+ rows={oneRow}
409
+ testId="dt"
410
+ pager={{ page: 5, limit: 50, total: 3000, onPageChange: vi.fn() }}
411
+ />,
412
+ );
413
+ expect(screen.getByTestId("dt-pager-page-5").getAttribute("aria-current")).toBe("page");
414
+ });
415
+
416
+ test("totalPages ≤ 7: alle Seiten ohne Ellipse", () => {
417
+ render(
418
+ <DataTable
419
+ columns={cols}
420
+ rows={oneRow}
421
+ testId="dt"
422
+ pager={{ page: 1, limit: 50, total: 200, onPageChange: vi.fn() }}
423
+ />,
424
+ );
425
+ // total=200, limit=50 → 4 Seiten, kein Window
426
+ expect(screen.queryByTestId("dt-pager-page-1")).not.toBeNull();
427
+ expect(screen.queryByTestId("dt-pager-page-4")).not.toBeNull();
428
+ // Kein Ellipsis-Glyph im DOM
429
+ expect(screen.queryByText("…")).toBeNull();
430
+ });
431
+
432
+ test("totalPages > 7 und page in der Mitte: Ellipsen außen", () => {
433
+ render(
434
+ <DataTable
435
+ columns={cols}
436
+ rows={oneRow}
437
+ testId="dt"
438
+ pager={{ page: 30, limit: 50, total: 3000, onPageChange: vi.fn() }}
439
+ />,
440
+ );
441
+ // Window: 1 ... 28 29 [30] 31 32 ... 60
442
+ expect(screen.getAllByText("…")).toHaveLength(2);
443
+ expect(screen.queryByTestId("dt-pager-page-1")).not.toBeNull();
444
+ expect(screen.queryByTestId("dt-pager-page-60")).not.toBeNull();
445
+ expect(screen.queryByTestId("dt-pager-page-30")).not.toBeNull();
446
+ });
447
+ });
448
+
449
+ // Infinite-Scroll Sentinel: rendert sentinel-div, zeigt Spinner wenn
450
+ // loadingMore, "End of list" wenn !hasMore. IntersectionObserver
451
+ // selbst ist in jsdom unmocked — wir testen nur die Marker, der
452
+ // Observer-Fire-Pfad ist im KumikoScreen.EntityListBody.
453
+ describe("InfiniteSentinel", () => {
454
+ const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
455
+ const oneRow = [{ id: "r1", values: { name: "A" } }];
456
+
457
+ test("ohne onReachEnd: kein Sentinel im DOM", () => {
458
+ render(<DataTable columns={cols} rows={oneRow} testId="dt" />);
459
+ expect(screen.queryByTestId("dt-sentinel")).toBeNull();
460
+ });
461
+
462
+ test("mit onReachEnd + hasMore=true + loadingMore=false: leerer Sentinel", () => {
463
+ render(
464
+ <DataTable
465
+ columns={cols}
466
+ rows={oneRow}
467
+ testId="dt"
468
+ onReachEnd={vi.fn()}
469
+ loadingMore={false}
470
+ hasMore={true}
471
+ />,
472
+ );
473
+ const sentinel = screen.getByTestId("dt-sentinel");
474
+ // Weder End-Marker noch Spinner — der Sentinel wartet auf den
475
+ // Observer-Fire (Pre-Fetch via rootMargin: 200px).
476
+ expect(sentinel.querySelector("svg")).toBeNull();
477
+ expect(screen.queryByTestId("dt-sentinel-end")).toBeNull();
478
+ });
479
+
480
+ test("loadingMore=true: Spinner sichtbar", () => {
481
+ render(
482
+ <DataTable
483
+ columns={cols}
484
+ rows={oneRow}
485
+ testId="dt"
486
+ onReachEnd={vi.fn()}
487
+ loadingMore={true}
488
+ hasMore={true}
489
+ />,
490
+ );
491
+ expect(screen.getByTestId("dt-sentinel").querySelector("svg")).not.toBeNull();
492
+ });
493
+
494
+ test("hasMore=false: 'End of list' Marker statt Sentinel-Wirkung", () => {
495
+ render(
496
+ <DataTable
497
+ columns={cols}
498
+ rows={oneRow}
499
+ testId="dt"
500
+ onReachEnd={vi.fn()}
501
+ loadingMore={false}
502
+ hasMore={false}
503
+ />,
504
+ );
505
+ expect(screen.getByTestId("dt-sentinel-end")).not.toBeNull();
506
+ expect(screen.getByTestId("dt-sentinel-end").textContent).toContain("End of list");
507
+ });
508
+ });
509
+
510
+ // RowActions: pinst die Inline-vs-Kebab-Entscheidung, Confirm-Dialog
511
+ // bei style=danger, Visibility-Filter pro Row und onTrigger-Wiring.
512
+ describe("RowActions", () => {
513
+ const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
514
+ const rows = [
515
+ { id: "r1", values: { id: "r1", name: "Alpha" } },
516
+ { id: "r2", values: { id: "r2", name: "Beta" } },
517
+ ];
518
+
519
+ test("ohne rowActions: keine Actions-Spalte im Header", () => {
520
+ render(<DataTable columns={cols} rows={rows} testId="dt" />);
521
+ expect(screen.queryByTestId("column-actions")).toBeNull();
522
+ expect(screen.queryByTestId("cell-r1-actions")).toBeNull();
523
+ });
524
+
525
+ test("mit rowActions: Actions-Spalte gerendert", () => {
526
+ render(
527
+ <DataTable
528
+ columns={cols}
529
+ rows={rows}
530
+ testId="dt"
531
+ rowActions={[{ id: "edit", label: "Edit", onTrigger: vi.fn() }]}
532
+ />,
533
+ );
534
+ expect(screen.queryByTestId("column-actions")).not.toBeNull();
535
+ expect(screen.queryByTestId("cell-r1-actions")).not.toBeNull();
536
+ });
537
+
538
+ test("≤2 Actions: Inline-Buttons (kein Kebab)", () => {
539
+ render(
540
+ <DataTable
541
+ columns={cols}
542
+ rows={rows}
543
+ testId="dt"
544
+ rowActions={[
545
+ { id: "edit", label: "Edit", onTrigger: vi.fn() },
546
+ { id: "delete", label: "Delete", style: "danger", onTrigger: vi.fn() },
547
+ ]}
548
+ />,
549
+ );
550
+ expect(screen.queryByTestId("row-r1-action-edit")).not.toBeNull();
551
+ expect(screen.queryByTestId("row-r1-actions-menu")).toBeNull();
552
+ });
553
+
554
+ test(">2 Actions: Kebab-Dropdown statt Inline-Buttons", () => {
555
+ render(
556
+ <DataTable
557
+ columns={cols}
558
+ rows={rows}
559
+ testId="dt"
560
+ rowActions={[
561
+ { id: "a", label: "A", onTrigger: vi.fn() },
562
+ { id: "b", label: "B", onTrigger: vi.fn() },
563
+ { id: "c", label: "C", onTrigger: vi.fn() },
564
+ ]}
565
+ />,
566
+ );
567
+ expect(screen.queryByTestId("row-r1-actions-menu")).not.toBeNull();
568
+ // Inline-Buttons der Kebab-Items sind nicht direkt im DOM —
569
+ // Radix portal'd Content ist erst nach Click sichtbar.
570
+ expect(screen.queryByTestId("row-r1-action-a")).toBeNull();
571
+ });
572
+
573
+ test("Kebab: Click auf Trigger öffnet Dropdown mit allen Items", async () => {
574
+ const user = userEvent.setup();
575
+ render(
576
+ <DataTable
577
+ columns={cols}
578
+ rows={rows}
579
+ testId="dt"
580
+ rowActions={[
581
+ { id: "a", label: "Archive", onTrigger: vi.fn() },
582
+ { id: "b", label: "Duplicate", onTrigger: vi.fn() },
583
+ { id: "c", label: "Export", onTrigger: vi.fn() },
584
+ ]}
585
+ />,
586
+ );
587
+ await user.click(screen.getByTestId("row-r1-actions-menu"));
588
+ expect(screen.queryByTestId("row-r1-action-a")).not.toBeNull();
589
+ expect(screen.queryByTestId("row-r1-action-b")).not.toBeNull();
590
+ expect(screen.queryByTestId("row-r1-action-c")).not.toBeNull();
591
+ });
592
+
593
+ test("Kebab: Click auf Item ohne confirm → onTrigger feuert direkt", async () => {
594
+ const user = userEvent.setup();
595
+ const onTrigger = vi.fn();
596
+ render(
597
+ <DataTable
598
+ columns={cols}
599
+ rows={rows}
600
+ testId="dt"
601
+ rowActions={[
602
+ { id: "a", label: "Archive", onTrigger },
603
+ { id: "b", label: "Duplicate", onTrigger: vi.fn() },
604
+ { id: "c", label: "Export", onTrigger: vi.fn() },
605
+ ]}
606
+ />,
607
+ );
608
+ await user.click(screen.getByTestId("row-r1-actions-menu"));
609
+ await user.click(screen.getByTestId("row-r1-action-a"));
610
+ // micro-task warten (onTrigger ist async im Hook)
611
+ await new Promise((r) => setTimeout(r, 0));
612
+ expect(onTrigger).toHaveBeenCalledWith(rows[0]);
613
+ });
614
+
615
+ test("Kebab: Click auf Danger-Item → Confirm-Dialog statt direkt-Trigger", async () => {
616
+ const user = userEvent.setup();
617
+ const onTrigger = vi.fn();
618
+ render(
619
+ <DataTable
620
+ columns={cols}
621
+ rows={rows}
622
+ testId="dt"
623
+ rowActions={[
624
+ { id: "a", label: "Archive", onTrigger: vi.fn() },
625
+ { id: "b", label: "Duplicate", onTrigger: vi.fn() },
626
+ { id: "delete", label: "Delete", style: "danger", onTrigger },
627
+ ]}
628
+ />,
629
+ );
630
+ await user.click(screen.getByTestId("row-r1-actions-menu"));
631
+ await user.click(screen.getByTestId("row-r1-action-delete"));
632
+ // Trigger NICHT direkt — der Dialog muss zuerst öffnen.
633
+ expect(onTrigger).not.toHaveBeenCalled();
634
+ expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
635
+ });
636
+
637
+ test("confirmLabel separat vom label: Dialog-Button zeigt confirmLabel", async () => {
638
+ const user = userEvent.setup();
639
+ render(
640
+ <DataTable
641
+ columns={cols}
642
+ rows={rows}
643
+ testId="dt"
644
+ rowActions={[
645
+ {
646
+ id: "cancel-sub",
647
+ label: "Mark Subscription as Cancelled",
648
+ style: "danger",
649
+ confirmLabel: "Cancel Subscription",
650
+ confirm: "This is permanent.",
651
+ onTrigger: vi.fn(),
652
+ },
653
+ ]}
654
+ />,
655
+ );
656
+ await user.click(screen.getByTestId("row-r1-action-cancel-sub"));
657
+ const dialog = screen.getByTestId("row-r1-action-cancel-sub-dialog");
658
+ // Confirm-Button im Dialog hat confirmLabel, nicht das volle label
659
+ const confirmBtn = dialog.querySelector('[data-testid$="confirm"]');
660
+ expect(confirmBtn?.textContent).toContain("Cancel Subscription");
661
+ expect(confirmBtn?.textContent).not.toContain("Mark Subscription");
662
+ });
663
+
664
+ test("Click auf Action ohne confirm: onTrigger wird mit Row gerufen", async () => {
665
+ const user = userEvent.setup();
666
+ const onTrigger = vi.fn();
667
+ render(
668
+ <DataTable
669
+ columns={cols}
670
+ rows={rows}
671
+ testId="dt"
672
+ rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
673
+ />,
674
+ );
675
+ await user.click(screen.getByTestId("row-r1-action-edit"));
676
+ expect(onTrigger).toHaveBeenCalledWith(rows[0]);
677
+ });
678
+
679
+ test("style=danger: erzwingt Confirm-Dialog vor onTrigger", async () => {
680
+ const onTrigger = vi.fn();
681
+ render(
682
+ <DataTable
683
+ columns={cols}
684
+ rows={rows}
685
+ testId="dt"
686
+ rowActions={[{ id: "delete", label: "Delete", style: "danger", onTrigger }]}
687
+ />,
688
+ );
689
+ fireEvent.click(screen.getByTestId("row-r1-action-delete"));
690
+ // Click triggered den Dialog, NICHT direkt onTrigger.
691
+ expect(onTrigger).not.toHaveBeenCalled();
692
+ // Dialog muss im DOM sein — wir checken den testId-Suffix.
693
+ expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
694
+ });
695
+
696
+ test("isVisible=false: Action erscheint nicht in der Cell", () => {
697
+ render(
698
+ <DataTable
699
+ columns={cols}
700
+ rows={rows}
701
+ testId="dt"
702
+ rowActions={[
703
+ {
704
+ id: "archive",
705
+ label: "Archive",
706
+ onTrigger: vi.fn(),
707
+ // Nur für r1 sichtbar
708
+ isVisible: (row) => row.id === "r1",
709
+ },
710
+ ]}
711
+ />,
712
+ );
713
+ expect(screen.queryByTestId("row-r1-action-archive")).not.toBeNull();
714
+ expect(screen.queryByTestId("row-r2-action-archive")).toBeNull();
715
+ });
716
+
717
+ test("Click auf Action-Cell propagiert NICHT auf onRowClick", async () => {
718
+ const user = userEvent.setup();
719
+ const onRowClick = vi.fn();
720
+ const onTrigger = vi.fn();
721
+ render(
722
+ <DataTable
723
+ columns={cols}
724
+ rows={rows}
725
+ testId="dt"
726
+ onRowClick={onRowClick}
727
+ rowActions={[{ id: "edit", label: "Edit", onTrigger }]}
728
+ />,
729
+ );
730
+ await user.click(screen.getByTestId("row-r1-action-edit"));
731
+ // onTrigger feuert, onRowClick MUSS NICHT — sonst würde der User
732
+ // beim Action-Click gleichzeitig zum Edit-Screen navigieren.
733
+ expect(onRowClick).not.toHaveBeenCalled();
734
+ });
735
+ });
736
+ });
737
+
738
+ describe("Form", () => {
739
+ test("submit calls onSubmit and prevents default navigation", () => {
740
+ const onSubmit = vi.fn();
741
+ render(
742
+ <Form onSubmit={onSubmit} testId="form">
743
+ <button type="submit">Go</button>
744
+ </Form>,
745
+ );
746
+ const form = screen.getByTestId("form") as HTMLFormElement;
747
+ const submitEvent = new Event("submit", { bubbles: true, cancelable: true });
748
+ fireEvent(form, submitEvent);
749
+ expect(onSubmit).toHaveBeenCalledTimes(1);
750
+ expect(submitEvent.defaultPrevented).toBe(true);
751
+ });
752
+
753
+ test("title slot rendert in der Action-Bar links neben den Actions", () => {
754
+ render(
755
+ <Form
756
+ onSubmit={() => undefined}
757
+ title="Eintrag bearbeiten"
758
+ actions={<button type="submit">Save</button>}
759
+ testId="form"
760
+ >
761
+ <div>content</div>
762
+ </Form>,
763
+ );
764
+ const actionsBar = screen.getByTestId("form-actions");
765
+ expect(actionsBar.textContent).toContain("Eintrag bearbeiten");
766
+ expect(actionsBar.textContent).toContain("Save");
767
+ });
768
+
769
+ test("ohne title und actions: keine Action-Bar gerendert", () => {
770
+ render(
771
+ <Form onSubmit={() => undefined} testId="form">
772
+ <div>content</div>
773
+ </Form>,
774
+ );
775
+ expect(screen.queryByTestId("form-actions")).toBeNull();
776
+ });
777
+ });
778
+
779
+ describe("Banner padded", () => {
780
+ test("padded=true wraps in p-6 container für Page-State-Use", () => {
781
+ const { container } = render(
782
+ <Banner padded variant="info" testId="banner">
783
+ Loading…
784
+ </Banner>,
785
+ );
786
+ const banner = screen.getByTestId("banner");
787
+ // Outer wrapper hat p-6, banner innen
788
+ const wrapper = banner.parentElement;
789
+ expect(wrapper?.className).toContain("p-6");
790
+ expect(container.firstChild).toBe(wrapper);
791
+ });
792
+
793
+ test("padded=undefined rendert ohne Wrapper (inline-Use)", () => {
794
+ const { container } = render(
795
+ <Banner variant="info" testId="banner">
796
+ Inline
797
+ </Banner>,
798
+ );
799
+ expect(container.firstChild).toBe(screen.getByTestId("banner"));
800
+ });
801
+ });
802
+
803
+ describe("Heading variants", () => {
804
+ test('variant="page" renders h1', () => {
805
+ render(
806
+ <Heading variant="page" testId="h">
807
+ Items
808
+ </Heading>,
809
+ );
810
+ expect(screen.getByTestId("h").tagName).toBe("H1");
811
+ });
812
+
813
+ test('variant="section" renders h2 mit uppercase styling', () => {
814
+ render(
815
+ <Heading variant="section" testId="h">
816
+ Basics
817
+ </Heading>,
818
+ );
819
+ const h = screen.getByTestId("h");
820
+ expect(h.tagName).toBe("H2");
821
+ expect(h.className).toContain("uppercase");
822
+ });
823
+ });
824
+
825
+ describe("DataTable toolbar slots", () => {
826
+ test("toolbarTitle + toolbarStart + toolbarEnd rendern in einer Zeile", () => {
827
+ render(
828
+ <DataTable
829
+ columns={[]}
830
+ rows={[]}
831
+ toolbarTitle="Items"
832
+ toolbarStart={<input data-testid="search" />}
833
+ toolbarEnd={
834
+ <button type="button" data-testid="create">
835
+ + Neu
836
+ </button>
837
+ }
838
+ testId="dt"
839
+ />,
840
+ );
841
+ const toolbar = screen.getByTestId("dt-toolbar");
842
+ expect(toolbar.textContent).toContain("Items");
843
+ expect(toolbar.querySelector('[data-testid="search"]')).not.toBeNull();
844
+ expect(toolbar.querySelector('[data-testid="create"]')).not.toBeNull();
845
+ });
846
+
847
+ test("ohne toolbar slots wird kein Toolbar-Container gerendert", () => {
848
+ render(<DataTable columns={[]} rows={[]} testId="dt" />);
849
+ expect(screen.queryByTestId("dt-toolbar")).toBeNull();
850
+ });
851
+ });
852
+
853
+ describe("Dialog", () => {
854
+ test("open=true rendert Dialog mit Title und Confirm/Cancel Buttons", () => {
855
+ const onConfirm = vi.fn();
856
+ const onOpenChange = vi.fn();
857
+ render(
858
+ <Dialog
859
+ open
860
+ onOpenChange={onOpenChange}
861
+ title="Wirklich löschen?"
862
+ onConfirm={onConfirm}
863
+ testId="confirm"
864
+ />,
865
+ );
866
+ expect(screen.getByText("Wirklich löschen?")).toBeTruthy();
867
+ expect(screen.getByTestId("confirm-confirm")).toBeTruthy();
868
+ expect(screen.getByTestId("confirm-cancel")).toBeTruthy();
869
+ });
870
+
871
+ test("open=false rendert nichts (Portal leer)", () => {
872
+ render(
873
+ <Dialog
874
+ open={false}
875
+ onOpenChange={() => undefined}
876
+ title="Hidden"
877
+ onConfirm={() => undefined}
878
+ testId="hidden-dialog"
879
+ />,
880
+ );
881
+ expect(screen.queryByTestId("hidden-dialog")).toBeNull();
882
+ });
883
+
884
+ test("Confirm-Button feuert onConfirm und schließt den Dialog", async () => {
885
+ const user = userEvent.setup();
886
+ const onConfirm = vi.fn();
887
+ const onOpenChange = vi.fn();
888
+ render(
889
+ <Dialog
890
+ open
891
+ onOpenChange={onOpenChange}
892
+ title="Bestätigen?"
893
+ onConfirm={onConfirm}
894
+ testId="dlg"
895
+ />,
896
+ );
897
+ // userEvent.click wartet auf React-State-Updates die durch Radix-
898
+ // Lifecycle (Presence/FocusScope/DismissableLayer) ausgelöst werden
899
+ // — fireEvent.click würde dieselben Updates uneingewickelt lassen
900
+ // und mit ~13 act()-Warnings spammen.
901
+ await user.click(screen.getByTestId("dlg-confirm"));
902
+ expect(onConfirm).toHaveBeenCalledTimes(1);
903
+ expect(onOpenChange).toHaveBeenCalledWith(false);
904
+ });
905
+ });
906
+
907
+ describe("Text variants", () => {
908
+ test('variant="code" renders <code>', () => {
909
+ render(
910
+ <Text variant="code" testId="t">
911
+ x
912
+ </Text>,
913
+ );
914
+ expect(screen.getByTestId("t").tagName).toBe("CODE");
915
+ });
916
+
917
+ test('variant="small" renders <small>', () => {
918
+ render(
919
+ <Text variant="small" testId="t">
920
+ x
921
+ </Text>,
922
+ );
923
+ expect(screen.getByTestId("t").tagName).toBe("SMALL");
924
+ });
925
+
926
+ test('variant="required-mark" renders data-required span', () => {
927
+ render(
928
+ <Text variant="required-mark" testId="t">
929
+ *
930
+ </Text>,
931
+ );
932
+ const el = screen.getByTestId("t");
933
+ expect(el.tagName).toBe("SPAN");
934
+ expect(el.hasAttribute("data-required")).toBe(true);
935
+ });
936
+ });