@codemation/ui 0.2.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 (41) hide show
  1. package/.turbo/turbo-build.log +19 -0
  2. package/.turbo/turbo-lint.log +4 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/CHANGELOG.md +25 -0
  5. package/LICENSE +37 -0
  6. package/dist/index.cjs +845 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +417 -0
  9. package/dist/index.d.ts +417 -0
  10. package/dist/index.js +749 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +71 -0
  13. package/src/components/StatusPill.tsx +33 -0
  14. package/src/components/composite/CodemationDialog.tsx +137 -0
  15. package/src/components/composite/JsonMonacoEditor.tsx +75 -0
  16. package/src/components/reui/tree/Tree.tsx +35 -0
  17. package/src/components/reui/tree/TreeContext.ts +21 -0
  18. package/src/components/reui/tree/TreeDragLine.tsx +28 -0
  19. package/src/components/reui/tree/TreeItem.tsx +51 -0
  20. package/src/components/reui/tree/TreeItemLabel.tsx +58 -0
  21. package/src/components/ui/badge.tsx +40 -0
  22. package/src/components/ui/button.tsx +64 -0
  23. package/src/components/ui/collapsible.tsx +26 -0
  24. package/src/components/ui/dialog.tsx +137 -0
  25. package/src/components/ui/dropdown-menu.tsx +239 -0
  26. package/src/components/ui/input.tsx +20 -0
  27. package/src/components/ui/label.tsx +18 -0
  28. package/src/components/ui/select.tsx +171 -0
  29. package/src/components/ui/switch.tsx +29 -0
  30. package/src/components/ui/tabs.tsx +76 -0
  31. package/src/components/ui/textarea.tsx +18 -0
  32. package/src/index.ts +76 -0
  33. package/src/lib/cn.ts +6 -0
  34. package/src/lucide-icons.d.ts +46 -0
  35. package/test/StatusPill.test.tsx +26 -0
  36. package/test/primitives.test.tsx +707 -0
  37. package/test/setup.ts +7 -0
  38. package/test/ui-components.test.tsx +208 -0
  39. package/tsconfig.json +11 -0
  40. package/tsdown.config.ts +10 -0
  41. package/vitest.ui.config.ts +40 -0
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Smoke tests for shadcn/Radix primitive wrappers in @codemation/ui.
3
+ *
4
+ * Strategy: every component is rendered at least once; branches (variant props,
5
+ * showCloseButton, asChild, etc.) are exercised where they produce different
6
+ * DOM output. No behavioural interaction tests — those belong in the packages
7
+ * that consume these primitives in context.
8
+ */
9
+ import { render, screen } from "@testing-library/react";
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { Badge } from "../src/components/ui/badge";
13
+ import { Button } from "../src/components/ui/button";
14
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../src/components/ui/collapsible";
15
+ import {
16
+ Dialog,
17
+ DialogClose,
18
+ DialogContent,
19
+ DialogDescription,
20
+ DialogFooter,
21
+ DialogHeader,
22
+ DialogOverlay,
23
+ DialogPortal,
24
+ DialogTitle,
25
+ DialogTrigger,
26
+ } from "../src/components/ui/dialog";
27
+ import { Input } from "../src/components/ui/input";
28
+ import { Label } from "../src/components/ui/label";
29
+ import {
30
+ Select,
31
+ SelectContent,
32
+ SelectGroup,
33
+ SelectItem,
34
+ SelectLabel,
35
+ SelectSeparator,
36
+ SelectTrigger,
37
+ SelectValue,
38
+ } from "../src/components/ui/select";
39
+ import { Switch } from "../src/components/ui/switch";
40
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../src/components/ui/tabs";
41
+ import { Textarea } from "../src/components/ui/textarea";
42
+ import { CodemationDialog } from "../src/components/composite/CodemationDialog";
43
+ import {
44
+ DropdownMenu,
45
+ DropdownMenuCheckboxItem,
46
+ DropdownMenuContent,
47
+ DropdownMenuLabel,
48
+ DropdownMenuSeparator,
49
+ DropdownMenuTrigger,
50
+ } from "../src/components/ui/dropdown-menu";
51
+ import { Tree } from "../src/components/reui/tree/Tree";
52
+ import { TreeContext } from "../src/components/reui/tree/TreeContext";
53
+ import { TreeItem } from "../src/components/reui/tree/TreeItem";
54
+
55
+ // ── Badge ─────────────────────────────────────────────────────────────────────
56
+
57
+ describe("Badge", () => {
58
+ it("renders with default variant", () => {
59
+ const { container } = render(<Badge>Hello</Badge>);
60
+ const el = container.querySelector("[data-slot='badge']");
61
+ expect(el).not.toBeNull();
62
+ expect(el?.textContent).toBe("Hello");
63
+ });
64
+
65
+ it("renders each named variant without crashing", () => {
66
+ const variants = ["default", "secondary", "destructive", "outline", "ghost", "link"] as const;
67
+ for (const variant of variants) {
68
+ const { container } = render(<Badge variant={variant}>v</Badge>);
69
+ expect(container.querySelector("[data-slot='badge']")).not.toBeNull();
70
+ }
71
+ });
72
+
73
+ it("renders as child slot when asChild=true", () => {
74
+ const { container } = render(
75
+ <Badge asChild>
76
+ <a href="#">link</a>
77
+ </Badge>,
78
+ );
79
+ // Slot.Root merges props onto the child <a>
80
+ expect(container.querySelector("a")).not.toBeNull();
81
+ });
82
+
83
+ it("merges custom className", () => {
84
+ const { container } = render(<Badge className="custom-class">X</Badge>);
85
+ expect(container.querySelector("[data-slot='badge']")?.className).toContain("custom-class");
86
+ });
87
+ });
88
+
89
+ // ── Button ────────────────────────────────────────────────────────────────────
90
+
91
+ describe("Button", () => {
92
+ it("renders as a button element", () => {
93
+ render(<Button>Click me</Button>);
94
+ expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
95
+ });
96
+
97
+ it("renders each variant without crashing", () => {
98
+ const variants = ["default", "outline", "secondary", "ghost", "destructive", "link"] as const;
99
+ for (const variant of variants) {
100
+ render(<Button variant={variant}>v</Button>);
101
+ }
102
+ // All buttons rendered in the same DOM but we just confirm no throw
103
+ expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(variants.length);
104
+ });
105
+
106
+ it("renders each size without crashing", () => {
107
+ const sizes = ["default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg"] as const;
108
+ const { container } = render(
109
+ <>
110
+ {sizes.map((s) => (
111
+ <Button key={s} size={s}>
112
+ {s}
113
+ </Button>
114
+ ))}
115
+ </>,
116
+ );
117
+ expect(container.querySelectorAll("[data-slot='button']").length).toBe(sizes.length);
118
+ });
119
+
120
+ it("renders as child slot when asChild=true", () => {
121
+ const { container } = render(
122
+ <Button asChild>
123
+ <a href="#">link</a>
124
+ </Button>,
125
+ );
126
+ expect(container.querySelector("a")).not.toBeNull();
127
+ });
128
+
129
+ it("applies data-variant and data-size attributes", () => {
130
+ const { container } = render(
131
+ <Button variant="ghost" size="sm">
132
+ b
133
+ </Button>,
134
+ );
135
+ const el = container.querySelector("[data-slot='button']");
136
+ expect(el?.getAttribute("data-variant")).toBe("ghost");
137
+ expect(el?.getAttribute("data-size")).toBe("sm");
138
+ });
139
+ });
140
+
141
+ // ── Input ─────────────────────────────────────────────────────────────────────
142
+
143
+ describe("Input", () => {
144
+ it("renders an input element with data-slot", () => {
145
+ const { container } = render(<Input />);
146
+ expect(container.querySelector("[data-slot='input']")).not.toBeNull();
147
+ });
148
+
149
+ it("passes type prop through", () => {
150
+ const { container } = render(<Input type="email" />);
151
+ expect(container.querySelector("input")?.type).toBe("email");
152
+ });
153
+
154
+ it("passes placeholder through", () => {
155
+ render(<Input placeholder="Enter text" />);
156
+ expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
157
+ });
158
+ });
159
+
160
+ // ── Label ─────────────────────────────────────────────────────────────────────
161
+
162
+ describe("Label", () => {
163
+ it("renders with data-slot", () => {
164
+ const { container } = render(<Label>My label</Label>);
165
+ const el = container.querySelector("[data-slot='label']");
166
+ expect(el).not.toBeNull();
167
+ expect(el?.textContent).toBe("My label");
168
+ });
169
+
170
+ it("merges custom className", () => {
171
+ const { container } = render(<Label className="custom">Text</Label>);
172
+ expect(container.querySelector("[data-slot='label']")?.className).toContain("custom");
173
+ });
174
+ });
175
+
176
+ // ── Switch ────────────────────────────────────────────────────────────────────
177
+
178
+ describe("Switch", () => {
179
+ it("renders with data-slot='switch'", () => {
180
+ const { container } = render(<Switch />);
181
+ expect(container.querySelector("[data-slot='switch']")).not.toBeNull();
182
+ });
183
+
184
+ it("renders the inner thumb", () => {
185
+ const { container } = render(<Switch />);
186
+ expect(container.querySelector("[data-slot='switch-thumb']")).not.toBeNull();
187
+ });
188
+
189
+ it("merges custom className", () => {
190
+ const { container } = render(<Switch className="my-switch" />);
191
+ expect(container.querySelector("[data-slot='switch']")?.className).toContain("my-switch");
192
+ });
193
+ });
194
+
195
+ // ── Textarea ──────────────────────────────────────────────────────────────────
196
+
197
+ describe("Textarea", () => {
198
+ it("renders a textarea with data-slot", () => {
199
+ const { container } = render(<Textarea />);
200
+ expect(container.querySelector("[data-slot='textarea']")).not.toBeNull();
201
+ });
202
+
203
+ it("passes placeholder through", () => {
204
+ render(<Textarea placeholder="Write here" />);
205
+ expect(screen.getByPlaceholderText("Write here")).toBeInTheDocument();
206
+ });
207
+ });
208
+
209
+ // ── Collapsible ───────────────────────────────────────────────────────────────
210
+
211
+ describe("Collapsible", () => {
212
+ it("renders root with data-slot='collapsible'", () => {
213
+ const { container } = render(
214
+ <Collapsible>
215
+ <CollapsibleTrigger>Toggle</CollapsibleTrigger>
216
+ <CollapsibleContent>Content</CollapsibleContent>
217
+ </Collapsible>,
218
+ );
219
+ expect(container.querySelector("[data-slot='collapsible']")).not.toBeNull();
220
+ expect(container.querySelector("[data-slot='collapsible-trigger']")).not.toBeNull();
221
+ expect(container.querySelector("[data-slot='collapsible-content']")).not.toBeNull();
222
+ });
223
+
224
+ it("renders content when defaultOpen=true", () => {
225
+ render(
226
+ <Collapsible defaultOpen>
227
+ <CollapsibleContent>Visible content</CollapsibleContent>
228
+ </Collapsible>,
229
+ );
230
+ expect(screen.getByText("Visible content")).toBeInTheDocument();
231
+ });
232
+ });
233
+
234
+ // ── Tabs ──────────────────────────────────────────────────────────────────────
235
+
236
+ describe("Tabs", () => {
237
+ it("renders with data-slot='tabs'", () => {
238
+ const { container } = render(
239
+ <Tabs defaultValue="a">
240
+ <TabsList>
241
+ <TabsTrigger value="a">Tab A</TabsTrigger>
242
+ </TabsList>
243
+ <TabsContent value="a">Panel A</TabsContent>
244
+ </Tabs>,
245
+ );
246
+ expect(container.querySelector("[data-slot='tabs']")).not.toBeNull();
247
+ expect(container.querySelector("[data-slot='tabs-list']")).not.toBeNull();
248
+ expect(container.querySelector("[data-slot='tabs-trigger']")).not.toBeNull();
249
+ expect(container.querySelector("[data-slot='tabs-content']")).not.toBeNull();
250
+ });
251
+
252
+ it("renders active tab content", () => {
253
+ render(
254
+ <Tabs defaultValue="b">
255
+ <TabsList>
256
+ <TabsTrigger value="a">A</TabsTrigger>
257
+ <TabsTrigger value="b">B</TabsTrigger>
258
+ </TabsList>
259
+ <TabsContent value="a">Panel A</TabsContent>
260
+ <TabsContent value="b">Panel B</TabsContent>
261
+ </Tabs>,
262
+ );
263
+ expect(screen.getByText("Panel B")).toBeInTheDocument();
264
+ });
265
+
266
+ it("renders vertical orientation", () => {
267
+ const { container } = render(
268
+ <Tabs defaultValue="x" orientation="vertical">
269
+ <TabsList>
270
+ <TabsTrigger value="x">X</TabsTrigger>
271
+ </TabsList>
272
+ <TabsContent value="x">X content</TabsContent>
273
+ </Tabs>,
274
+ );
275
+ expect(container.querySelector("[data-orientation='vertical']")).not.toBeNull();
276
+ });
277
+
278
+ it("renders TabsList with line variant", () => {
279
+ const { container } = render(
280
+ <Tabs defaultValue="x">
281
+ <TabsList variant="line">
282
+ <TabsTrigger value="x">X</TabsTrigger>
283
+ </TabsList>
284
+ <TabsContent value="x">X</TabsContent>
285
+ </Tabs>,
286
+ );
287
+ expect(container.querySelector("[data-variant='line']")).not.toBeNull();
288
+ });
289
+ });
290
+
291
+ // ── Select ────────────────────────────────────────────────────────────────────
292
+ // Note: Radix Select calls scrollIntoView() on mount when open, which jsdom doesn't
293
+ // implement. We stub it on Element.prototype before each open-select test.
294
+
295
+ describe("Select", () => {
296
+ it("renders trigger with data-slot", () => {
297
+ const { container } = render(
298
+ <Select>
299
+ <SelectTrigger>
300
+ <SelectValue placeholder="Pick one" />
301
+ </SelectTrigger>
302
+ </Select>,
303
+ );
304
+ expect(container.querySelector("[data-slot='select-trigger']")).not.toBeNull();
305
+ });
306
+
307
+ it("renders small size trigger", () => {
308
+ const { container } = render(
309
+ <Select>
310
+ <SelectTrigger size="sm">
311
+ <SelectValue />
312
+ </SelectTrigger>
313
+ </Select>,
314
+ );
315
+ const trigger = container.querySelector("[data-slot='select-trigger']");
316
+ expect(trigger?.getAttribute("data-size")).toBe("sm");
317
+ });
318
+
319
+ it("renders open select with group, label, item, separator", () => {
320
+ // Radix Select calls scrollIntoView on mount — stub it for jsdom.
321
+ const origScrollIntoView = Element.prototype.scrollIntoView;
322
+ Element.prototype.scrollIntoView = () => {};
323
+ try {
324
+ render(
325
+ <Select open>
326
+ <SelectTrigger>
327
+ <SelectValue />
328
+ </SelectTrigger>
329
+ <SelectContent>
330
+ <SelectGroup>
331
+ <SelectLabel>Colors</SelectLabel>
332
+ <SelectItem value="red">Red</SelectItem>
333
+ <SelectSeparator />
334
+ <SelectItem value="blue">Blue</SelectItem>
335
+ </SelectGroup>
336
+ </SelectContent>
337
+ </Select>,
338
+ );
339
+ expect(screen.getByText("Red")).toBeInTheDocument();
340
+ expect(screen.getByText("Blue")).toBeInTheDocument();
341
+ expect(screen.getByText("Colors")).toBeInTheDocument();
342
+ } finally {
343
+ Element.prototype.scrollIntoView = origScrollIntoView;
344
+ }
345
+ });
346
+
347
+ it("renders popper position content without crashing", () => {
348
+ const origScrollIntoView = Element.prototype.scrollIntoView;
349
+ Element.prototype.scrollIntoView = () => {};
350
+ try {
351
+ render(
352
+ <Select open>
353
+ <SelectTrigger>
354
+ <SelectValue />
355
+ </SelectTrigger>
356
+ <SelectContent position="popper">
357
+ <SelectItem value="x">X</SelectItem>
358
+ </SelectContent>
359
+ </Select>,
360
+ );
361
+ expect(screen.getByText("X")).toBeInTheDocument();
362
+ } finally {
363
+ Element.prototype.scrollIntoView = origScrollIntoView;
364
+ }
365
+ });
366
+ });
367
+
368
+ // ── Dialog ────────────────────────────────────────────────────────────────────
369
+
370
+ describe("Dialog", () => {
371
+ it("renders open dialog with all sub-components", () => {
372
+ render(
373
+ <Dialog open>
374
+ <DialogTrigger>Open</DialogTrigger>
375
+ <DialogContent>
376
+ <DialogHeader>
377
+ <DialogTitle>My title</DialogTitle>
378
+ <DialogDescription>My description</DialogDescription>
379
+ </DialogHeader>
380
+ <p>Body content</p>
381
+ <DialogFooter>
382
+ <button type="button">Cancel</button>
383
+ </DialogFooter>
384
+ </DialogContent>
385
+ </Dialog>,
386
+ );
387
+ expect(screen.getByText("My title")).toBeInTheDocument();
388
+ expect(screen.getByText("My description")).toBeInTheDocument();
389
+ expect(screen.getByText("Body content")).toBeInTheDocument();
390
+ });
391
+
392
+ it("renders DialogContent without close button when showCloseButton=false", () => {
393
+ const { container } = render(
394
+ <Dialog open>
395
+ <DialogContent showCloseButton={false}>
396
+ <DialogTitle>No X</DialogTitle>
397
+ </DialogContent>
398
+ </Dialog>,
399
+ );
400
+ // The ghost close button should not be present
401
+ const closeButtons = container.querySelectorAll("[data-slot='dialog-close']");
402
+ expect(closeButtons.length).toBe(0);
403
+ });
404
+
405
+ it("renders DialogFooter with showCloseButton=true", () => {
406
+ render(
407
+ <Dialog open>
408
+ <DialogContent showCloseButton={false}>
409
+ <DialogTitle>Footer close</DialogTitle>
410
+ <DialogFooter showCloseButton>
411
+ <span>Actions</span>
412
+ </DialogFooter>
413
+ </DialogContent>
414
+ </Dialog>,
415
+ );
416
+ expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
417
+ });
418
+
419
+ it("renders DialogOverlay standalone", () => {
420
+ render(
421
+ <Dialog open>
422
+ <DialogPortal>
423
+ <DialogOverlay />
424
+ </DialogPortal>
425
+ </Dialog>,
426
+ );
427
+ // The overlay should render in the DOM
428
+ const overlay = document.querySelector("[data-slot='dialog-overlay']");
429
+ expect(overlay).not.toBeNull();
430
+ });
431
+ });
432
+
433
+ // ── CodemationDialog ──────────────────────────────────────────────────────────
434
+
435
+ describe("CodemationDialog", () => {
436
+ it("renders compound dialog with title, content and actions (bottom)", () => {
437
+ render(
438
+ <CodemationDialog onClose={() => {}}>
439
+ <CodemationDialog.Title>Title text</CodemationDialog.Title>
440
+ <CodemationDialog.Content>Body text</CodemationDialog.Content>
441
+ <CodemationDialog.Actions>
442
+ <button type="button">OK</button>
443
+ </CodemationDialog.Actions>
444
+ </CodemationDialog>,
445
+ );
446
+ expect(screen.getByText("Title text")).toBeInTheDocument();
447
+ expect(screen.getByText("Body text")).toBeInTheDocument();
448
+ expect(screen.getByRole("button", { name: "OK" })).toBeInTheDocument();
449
+ });
450
+
451
+ it("renders actions at top position", () => {
452
+ render(
453
+ <CodemationDialog onClose={() => {}}>
454
+ <CodemationDialog.Title>T</CodemationDialog.Title>
455
+ <CodemationDialog.Content>C</CodemationDialog.Content>
456
+ <CodemationDialog.Actions position="top" align="start">
457
+ <button type="button">Filter</button>
458
+ </CodemationDialog.Actions>
459
+ </CodemationDialog>,
460
+ );
461
+ expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument();
462
+ });
463
+
464
+ it("renders actions with align=between", () => {
465
+ render(
466
+ <CodemationDialog onClose={() => {}}>
467
+ <CodemationDialog.Title>T</CodemationDialog.Title>
468
+ <CodemationDialog.Content>C</CodemationDialog.Content>
469
+ <CodemationDialog.Actions align="between">
470
+ <button type="button">L</button>
471
+ <button type="button">R</button>
472
+ </CodemationDialog.Actions>
473
+ </CodemationDialog>,
474
+ );
475
+ expect(screen.getByRole("button", { name: "L" })).toBeInTheDocument();
476
+ });
477
+
478
+ it("renders narrow and full size variants without crashing", () => {
479
+ const { unmount } = render(
480
+ <CodemationDialog onClose={() => {}} size="narrow">
481
+ <CodemationDialog.Title>Narrow</CodemationDialog.Title>
482
+ <CodemationDialog.Content>C</CodemationDialog.Content>
483
+ </CodemationDialog>,
484
+ );
485
+ expect(screen.getByText("Narrow")).toBeInTheDocument();
486
+ unmount();
487
+
488
+ render(
489
+ <CodemationDialog onClose={() => {}} size="full">
490
+ <CodemationDialog.Title>Full</CodemationDialog.Title>
491
+ <CodemationDialog.Content>C</CodemationDialog.Content>
492
+ </CodemationDialog>,
493
+ );
494
+ expect(screen.getByText("Full")).toBeInTheDocument();
495
+ });
496
+
497
+ it("renders with showCloseButton=true", () => {
498
+ render(
499
+ <CodemationDialog onClose={() => {}} showCloseButton>
500
+ <CodemationDialog.Title>Closeable</CodemationDialog.Title>
501
+ <CodemationDialog.Content>C</CodemationDialog.Content>
502
+ </CodemationDialog>,
503
+ );
504
+ expect(screen.getByText("Closeable")).toBeInTheDocument();
505
+ });
506
+
507
+ it("renders with alertdialog role", () => {
508
+ render(
509
+ <CodemationDialog onClose={() => {}} role="alertdialog" testId="my-alert">
510
+ <CodemationDialog.Title>Alert</CodemationDialog.Title>
511
+ <CodemationDialog.Content>C</CodemationDialog.Content>
512
+ </CodemationDialog>,
513
+ );
514
+ expect(screen.getByRole("alertdialog")).toBeInTheDocument();
515
+ });
516
+
517
+ it("passes contentClassName to the panel", () => {
518
+ render(
519
+ <CodemationDialog onClose={() => {}} contentClassName="extra-class">
520
+ <CodemationDialog.Title>T</CodemationDialog.Title>
521
+ <CodemationDialog.Content>C</CodemationDialog.Content>
522
+ </CodemationDialog>,
523
+ );
524
+ const panel = document.querySelector("[data-slot='dialog-content']");
525
+ expect(panel?.className).toContain("extra-class");
526
+ });
527
+ });
528
+
529
+ // ── Tree ──────────────────────────────────────────────────────────────────────
530
+
531
+ describe("Tree", () => {
532
+ it("renders with data-slot='tree'", () => {
533
+ const { container } = render(<Tree>content</Tree>);
534
+ expect(container.querySelector("[data-slot='tree']")).not.toBeNull();
535
+ });
536
+
537
+ it("applies custom indent via CSS variable", () => {
538
+ const { container } = render(<Tree indent={30}>content</Tree>);
539
+ const el = container.querySelector("[data-slot='tree']") as HTMLElement | null;
540
+ expect(el?.style.getPropertyValue("--tree-indent")).toBe("30px");
541
+ });
542
+
543
+ it("renders with asChild=true (Slot)", () => {
544
+ const { container } = render(
545
+ <Tree asChild>
546
+ <ul>
547
+ <li>item</li>
548
+ </ul>
549
+ </Tree>,
550
+ );
551
+ // The outer element should be the <ul> with data-slot
552
+ expect(container.querySelector("ul[data-slot='tree']")).not.toBeNull();
553
+ });
554
+
555
+ it("calls getContainerProps on the tree object", () => {
556
+ const tree = {
557
+ getContainerProps: () => ({ "data-custom": "yes" }),
558
+ };
559
+ const { container } = render(<Tree tree={tree}>x</Tree>);
560
+ const el = container.querySelector("[data-slot='tree']");
561
+ expect(el?.getAttribute("data-custom")).toBe("yes");
562
+ });
563
+
564
+ it("exposes plus-minus toggleIconType to context", () => {
565
+ // TreeItemLabel inside a plus-minus tree will render a plus icon for collapsed folder
566
+ render(
567
+ <Tree toggleIconType="plus-minus">
568
+ <TreeContext.Consumer>
569
+ {(ctx) => <span data-testid="icon-type">{ctx.toggleIconType}</span>}
570
+ </TreeContext.Consumer>
571
+ </Tree>,
572
+ );
573
+ expect(screen.getByTestId("icon-type").textContent).toBe("plus-minus");
574
+ });
575
+ });
576
+
577
+ // ── TreeItem ──────────────────────────────────────────────────────────────────
578
+
579
+ describe("TreeItem", () => {
580
+ type ItemOverrides = Partial<{
581
+ getProps: () => Record<string, unknown>;
582
+ getItemMeta: () => { level: number };
583
+ isFocused: () => boolean;
584
+ isFolder: () => boolean;
585
+ isExpanded: () => boolean;
586
+ isSelected: () => boolean;
587
+ isDragTarget: () => boolean;
588
+ isMatchingSearch: () => boolean;
589
+ }>;
590
+
591
+ function makeItem(overrides: ItemOverrides = {}) {
592
+ return {
593
+ getProps: () => ({}),
594
+ getItemMeta: () => ({ level: 0 }),
595
+ isFocused: () => false,
596
+ isFolder: () => false,
597
+ isExpanded: () => false,
598
+ isSelected: () => false,
599
+ isDragTarget: () => false,
600
+ isMatchingSearch: () => false,
601
+ ...overrides,
602
+ };
603
+ }
604
+
605
+ it("renders as a button by default", () => {
606
+ const item = makeItem();
607
+ render(<TreeItem item={item}>file.txt</TreeItem>);
608
+ expect(screen.getByRole("button", { name: "file.txt" })).toBeInTheDocument();
609
+ });
610
+
611
+ it("renders as child slot when asChild=true", () => {
612
+ const item = makeItem();
613
+ const { container } = render(
614
+ <TreeItem item={item} asChild>
615
+ <div>file</div>
616
+ </TreeItem>,
617
+ );
618
+ expect(container.querySelector("div[data-slot='tree-item']")).not.toBeNull();
619
+ });
620
+
621
+ it("sets data-folder=true for folder items", () => {
622
+ const item = makeItem({ isFolder: () => true });
623
+ const { container } = render(<TreeItem item={item}>folder</TreeItem>);
624
+ expect(container.querySelector("[data-folder='true']")).not.toBeNull();
625
+ });
626
+
627
+ it("sets data-focus=true when focused", () => {
628
+ const item = makeItem({ isFocused: () => true });
629
+ const { container } = render(<TreeItem item={item}>x</TreeItem>);
630
+ expect(container.querySelector("[data-focus='true']")).not.toBeNull();
631
+ });
632
+
633
+ it("sets data-selected when isSelected is defined", () => {
634
+ const item = makeItem({ isSelected: () => true });
635
+ const { container } = render(<TreeItem item={item}>x</TreeItem>);
636
+ expect(container.querySelector("[data-selected='true']")).not.toBeNull();
637
+ });
638
+
639
+ it("applies padding based on indent and level", () => {
640
+ const item = makeItem({ getItemMeta: () => ({ level: 2 }) });
641
+ const { container } = render(
642
+ <TreeContext.Provider value={{ indent: 16 }}>
643
+ <TreeItem item={item}>item</TreeItem>
644
+ </TreeContext.Provider>,
645
+ );
646
+ const el = container.querySelector("[data-slot='tree-item']") as HTMLElement | null;
647
+ // Level 2 * indent 16 = 32px
648
+ expect(el?.style.getPropertyValue("--tree-padding")).toBe("32px");
649
+ });
650
+ });
651
+
652
+ // ── Dialog: DialogClose ────────────────────────────────────────────────────────
653
+
654
+ describe("DialogClose", () => {
655
+ it("renders DialogClose with data-slot", () => {
656
+ render(
657
+ <Dialog open>
658
+ <DialogPortal>
659
+ <DialogClose>Dismiss</DialogClose>
660
+ </DialogPortal>
661
+ </Dialog>,
662
+ );
663
+ const el = document.querySelector("[data-slot='dialog-close']");
664
+ expect(el).not.toBeNull();
665
+ });
666
+ });
667
+
668
+ // ── DropdownMenu: remaining components ────────────────────────────────────────
669
+
670
+ describe("DropdownMenu remaining components", () => {
671
+ it("renders DropdownMenuCheckboxItem with checked state", () => {
672
+ render(
673
+ <DropdownMenu open>
674
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
675
+ <DropdownMenuContent>
676
+ <DropdownMenuCheckboxItem checked>Check me</DropdownMenuCheckboxItem>
677
+ </DropdownMenuContent>
678
+ </DropdownMenu>,
679
+ );
680
+ expect(screen.getByText("Check me")).toBeInTheDocument();
681
+ });
682
+
683
+ it("renders DropdownMenuLabel", () => {
684
+ render(
685
+ <DropdownMenu open>
686
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
687
+ <DropdownMenuContent>
688
+ <DropdownMenuLabel>Section heading</DropdownMenuLabel>
689
+ </DropdownMenuContent>
690
+ </DropdownMenu>,
691
+ );
692
+ expect(screen.getByText("Section heading")).toBeInTheDocument();
693
+ });
694
+
695
+ it("renders DropdownMenuSeparator", () => {
696
+ render(
697
+ <DropdownMenu open>
698
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
699
+ <DropdownMenuContent>
700
+ <DropdownMenuSeparator />
701
+ </DropdownMenuContent>
702
+ </DropdownMenu>,
703
+ );
704
+ const sep = document.querySelector("[data-slot='dropdown-menu-separator']");
705
+ expect(sep).not.toBeNull();
706
+ });
707
+ });