@robomous/ui-core 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 (126) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +38 -0
  3. package/components.json +13 -0
  4. package/dist/index.d.ts +44 -0
  5. package/dist/index.js +47 -0
  6. package/dist/lib/button.d.ts +1 -0
  7. package/dist/lib/button.js +4 -0
  8. package/dist/lib/cn.d.ts +18 -0
  9. package/dist/lib/cn.js +21 -0
  10. package/dist/lib/menu.d.ts +1 -0
  11. package/dist/lib/menu.js +16 -0
  12. package/dist/lib/progress.d.ts +3 -0
  13. package/dist/lib/progress.js +6 -0
  14. package/dist/lib/select.d.ts +1 -0
  15. package/dist/lib/select.js +3 -0
  16. package/dist/primitives/alert.d.ts +10 -0
  17. package/dist/primitives/alert.js +27 -0
  18. package/dist/primitives/badge.d.ts +9 -0
  19. package/dist/primitives/badge.js +28 -0
  20. package/dist/primitives/button.d.ts +10 -0
  21. package/dist/primitives/button.js +35 -0
  22. package/dist/primitives/card.d.ts +11 -0
  23. package/dist/primitives/card.js +24 -0
  24. package/dist/primitives/combobox.d.ts +24 -0
  25. package/dist/primitives/combobox.js +58 -0
  26. package/dist/primitives/dialog.d.ts +17 -0
  27. package/dist/primitives/dialog.js +36 -0
  28. package/dist/primitives/dropdown-menu.d.ts +29 -0
  29. package/dist/primitives/dropdown-menu.js +50 -0
  30. package/dist/primitives/field.d.ts +24 -0
  31. package/dist/primitives/field.js +67 -0
  32. package/dist/primitives/input-group.d.ts +16 -0
  33. package/dist/primitives/input-group.js +56 -0
  34. package/dist/primitives/input.d.ts +3 -0
  35. package/dist/primitives/input.js +6 -0
  36. package/dist/primitives/label.d.ts +4 -0
  37. package/dist/primitives/label.js +7 -0
  38. package/dist/primitives/progress.d.ts +4 -0
  39. package/dist/primitives/progress.js +7 -0
  40. package/dist/primitives/select.d.ts +15 -0
  41. package/dist/primitives/select.js +35 -0
  42. package/dist/primitives/separator.d.ts +4 -0
  43. package/dist/primitives/separator.js +8 -0
  44. package/dist/primitives/sheet.d.ts +14 -0
  45. package/dist/primitives/sheet.js +36 -0
  46. package/dist/primitives/skeleton.d.ts +2 -0
  47. package/dist/primitives/skeleton.js +6 -0
  48. package/dist/primitives/sonner.d.ts +4 -0
  49. package/dist/primitives/sonner.js +40 -0
  50. package/dist/primitives/table.d.ts +10 -0
  51. package/dist/primitives/table.js +27 -0
  52. package/dist/primitives/tabs.d.ts +11 -0
  53. package/dist/primitives/tabs.js +28 -0
  54. package/dist/primitives/textarea.d.ts +3 -0
  55. package/dist/primitives/textarea.js +6 -0
  56. package/dist/primitives/tooltip.d.ts +7 -0
  57. package/dist/primitives/tooltip.js +17 -0
  58. package/dist/statusTone.d.ts +37 -0
  59. package/dist/statusTone.js +38 -0
  60. package/dist/tokens.d.ts +26 -0
  61. package/dist/tokens.js +96 -0
  62. package/gates/canonical.test.mjs +71 -0
  63. package/gates/extensions.test.mjs +373 -0
  64. package/gates/index.d.mts +51 -0
  65. package/gates/index.mjs +514 -0
  66. package/gates/tokens.test.mjs +276 -0
  67. package/package.json +60 -0
  68. package/shadcn/README.md +13 -0
  69. package/shadcn/alert.tsx +76 -0
  70. package/shadcn/badge.tsx +49 -0
  71. package/shadcn/button.tsx +67 -0
  72. package/shadcn/card.tsx +103 -0
  73. package/shadcn/combobox.tsx +299 -0
  74. package/shadcn/dialog.tsx +166 -0
  75. package/shadcn/dropdown-menu.tsx +267 -0
  76. package/shadcn/field.tsx +236 -0
  77. package/shadcn/input-group.tsx +154 -0
  78. package/shadcn/input.tsx +19 -0
  79. package/shadcn/label.tsx +22 -0
  80. package/shadcn/progress.tsx +29 -0
  81. package/shadcn/select.tsx +190 -0
  82. package/shadcn/separator.tsx +28 -0
  83. package/shadcn/sheet.tsx +145 -0
  84. package/shadcn/skeleton.tsx +13 -0
  85. package/shadcn/sonner.tsx +49 -0
  86. package/shadcn/table.tsx +114 -0
  87. package/shadcn/tabs.tsx +88 -0
  88. package/shadcn/textarea.tsx +18 -0
  89. package/shadcn/tooltip.tsx +57 -0
  90. package/src/harness.test.tsx +37 -0
  91. package/src/index.ts +119 -0
  92. package/src/lib/button.ts +4 -0
  93. package/src/lib/cn.test.ts +33 -0
  94. package/src/lib/cn.ts +23 -0
  95. package/src/lib/menu.ts +16 -0
  96. package/src/lib/progress.ts +6 -0
  97. package/src/lib/select.ts +4 -0
  98. package/src/primitives/alert.tsx +76 -0
  99. package/src/primitives/badge.tsx +57 -0
  100. package/src/primitives/button.tsx +67 -0
  101. package/src/primitives/card.tsx +103 -0
  102. package/src/primitives/combobox.test.tsx +38 -0
  103. package/src/primitives/combobox.tsx +299 -0
  104. package/src/primitives/dialog.tsx +166 -0
  105. package/src/primitives/dropdown-menu.tsx +267 -0
  106. package/src/primitives/field.tsx +236 -0
  107. package/src/primitives/input-group.tsx +154 -0
  108. package/src/primitives/input.tsx +19 -0
  109. package/src/primitives/label.tsx +22 -0
  110. package/src/primitives/primitives.test.tsx +440 -0
  111. package/src/primitives/progress.tsx +29 -0
  112. package/src/primitives/select.tsx +190 -0
  113. package/src/primitives/separator.tsx +28 -0
  114. package/src/primitives/sheet.tsx +145 -0
  115. package/src/primitives/skeleton.tsx +13 -0
  116. package/src/primitives/sonner.test.tsx +41 -0
  117. package/src/primitives/sonner.tsx +65 -0
  118. package/src/primitives/table.tsx +114 -0
  119. package/src/primitives/tabs.tsx +88 -0
  120. package/src/primitives/textarea.tsx +18 -0
  121. package/src/primitives/tooltip.tsx +57 -0
  122. package/src/statusTone.test.ts +20 -0
  123. package/src/statusTone.ts +52 -0
  124. package/src/styles.css +204 -0
  125. package/src/tokens.test.ts +285 -0
  126. package/src/tokens.ts +102 -0
@@ -0,0 +1,440 @@
1
+ /**
2
+ * The component harness, proved on the primitives that carry a decision.
3
+ *
4
+ * Not a snapshot of every class string — that would pin the design system to
5
+ * whatever it happened to be on the day. What is asserted here is the handful of behaviours a screen
6
+ * would silently lose: the merge that makes `className` a real override — and the
7
+ * two geometry overrides that ride on it, `inlineLink` and `menuSurface`, whose
8
+ * whole job is to beat a canonical utility — the `asChild` that keeps a link a
9
+ * link, and the role an error is announced with.
10
+ *
11
+ * The button no longer defaults `type`, so nothing here stops a "Cancel"
12
+ * submitting a form; that is a call-site property now, and
13
+ * `tests/scripts/form_buttons.test.mjs` is what holds it.
14
+ *
15
+ * This file is also the reason the jsdom harness exists at all: standing the
16
+ * environment up once here is cheaper than the first screen that needs it doing so
17
+ * under deadline.
18
+ */
19
+
20
+ import { render, screen } from "@testing-library/react";
21
+ import userEvent from "@testing-library/user-event";
22
+ import type { JSX } from "react";
23
+ import { describe, expect, it } from "vitest";
24
+
25
+ import { inlineLink } from "../lib/button";
26
+ import { menuSurface } from "../lib/menu";
27
+ import { progressAria } from "../lib/progress";
28
+ import { twoLineTrigger } from "../lib/select";
29
+ import { Alert, AlertDescription, AlertTitle } from "./alert";
30
+ import { Badge } from "./badge";
31
+ import { Button } from "./button";
32
+ import { Card, CardTitle } from "./card";
33
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog";
34
+ import {
35
+ DropdownMenu,
36
+ DropdownMenuContent,
37
+ DropdownMenuItem,
38
+ DropdownMenuTrigger,
39
+ } from "./dropdown-menu";
40
+ import { Progress } from "./progress";
41
+ import { FieldError } from "./field";
42
+ import { Input } from "./input";
43
+ import { Label } from "./label";
44
+ import {
45
+ Select,
46
+ SelectContent,
47
+ SelectItem,
48
+ SelectTrigger,
49
+ SelectValue,
50
+ } from "./select";
51
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet";
52
+ import { Table, TableBody, TableHead, TableHeader, TableRow } from "./table";
53
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
54
+
55
+ describe("Button", () => {
56
+ it("keeps an explicit type", () => {
57
+ render(<Button type="submit">Go</Button>);
58
+ expect(screen.getByRole("button").getAttribute("type")).toBe("submit");
59
+ });
60
+
61
+ it("lets a caller override a conflicting utility rather than emitting both", () => {
62
+ // Without `tailwind-merge` both `px-4` and `px-6` survive and which one wins is
63
+ // decided by the order Tailwind wrote them into the stylesheet — a rule nobody
64
+ // can see from the call site. This is what makes `className` an extension
65
+ // point rather than a suggestion.
66
+ render(<Button className="px-6">Wide</Button>);
67
+ const className = screen.getByRole("button", { name: "Wide" }).className;
68
+ expect(className).toContain("px-6");
69
+ expect(className).not.toContain("px-4");
70
+ });
71
+
72
+ it("renders the child element with asChild, so a link stays a link", () => {
73
+ render(
74
+ <Button asChild variant="default">
75
+ <a href="/projects">Projects</a>
76
+ </Button>,
77
+ );
78
+ // A `role="link"` on a `<button>` would read the same to a test and behave
79
+ // differently to a browser — no middle-click, no "open in new tab".
80
+ const link = screen.getByRole("link", { name: "Projects" });
81
+ expect(link.tagName).toBe("A");
82
+ expect(link.className).toContain("bg-primary");
83
+ });
84
+
85
+ it("styles a link button as an underline-on-hover text link", () => {
86
+ render(<Button variant="link">More</Button>);
87
+
88
+ const classes = screen.getByRole("button").className;
89
+ // The underline arrives on hover. A rule that underlined at rest would look
90
+ // like a link in a screenshot and read as one to `toContain("underline")`,
91
+ // which is why the resting state is asserted as an absence and the utility
92
+ // is matched whole — `hover:underline` and `underline-offset-4` both contain
93
+ // the substring.
94
+ expect(classes).toContain("hover:underline");
95
+ expect(classes).not.toMatch(/(^|\s)underline(\s|$)/);
96
+ });
97
+
98
+ it("hands back the height and padding a link button in prose cannot keep", () => {
99
+ render(
100
+ <Button variant="link" className={inlineLink}>
101
+ More
102
+ </Button>,
103
+ );
104
+
105
+ // `Button` merges `className` over `buttonVariants` with tailwind-merge, so
106
+ // this is the merge itself: canonical's `h-8 px-2.5` is gone from the
107
+ // rendered attribute rather than merely outranked by a later rule.
108
+ const classes = screen.getByRole("button").className.split(" ");
109
+ expect(classes).toContain("h-auto");
110
+ expect(classes).toContain("p-0");
111
+ expect(classes).not.toContain("h-8");
112
+ expect(classes).not.toContain("px-2.5");
113
+ });
114
+ });
115
+
116
+ describe("Alert and Badge", () => {
117
+ it("announces an alert, and composes its title and description", () => {
118
+ render(
119
+ <Alert variant="destructive">
120
+ <AlertTitle>Refused</AlertTitle>
121
+ <AlertDescription>because</AlertDescription>
122
+ </Alert>,
123
+ );
124
+ const alert = screen.getByRole("alert");
125
+ expect(alert.textContent).toContain("Refused");
126
+ expect(alert.textContent).toContain("because");
127
+ });
128
+
129
+ it("marks a badge with its variant, so a style can be keyed on data rather than colour", () => {
130
+ render(<Badge variant="success">done</Badge>);
131
+ expect(screen.getByText("done").getAttribute("data-variant")).toBe("success");
132
+ expect(screen.getByText("done").getAttribute("data-slot")).toBe("badge");
133
+ });
134
+
135
+ it.each(["success", "warning", "info", "quiet"] as const)("%s is a Badge variant keyed on data", (variant) => {
136
+ render(<Badge variant={variant}>x</Badge>);
137
+ const el = screen.getByText("x");
138
+ expect(el.getAttribute("data-variant")).toBe(variant);
139
+ expect(el.className).toContain("border-transparent");
140
+ expect(el.className).not.toMatch(/border-(emerald|amber|sky)/);
141
+ });
142
+ });
143
+
144
+ describe("fields", () => {
145
+ it("associates a label with its control by id", () => {
146
+ render(
147
+ <>
148
+ <Label htmlFor="tag">Tag</Label>
149
+ <Input id="tag" defaultValue="v1" />
150
+ </>,
151
+ );
152
+ expect(screen.getByLabelText("Tag")).toHaveProperty("value", "v1");
153
+ });
154
+
155
+ it("announces a field error", () => {
156
+ render(<FieldError>must not be blank</FieldError>);
157
+ expect(screen.getByRole("alert").textContent).toBe("must not be blank");
158
+ });
159
+ });
160
+
161
+ /**
162
+ * The two-line option — composed at the call site rather than a primitive prop,
163
+ * which is why what is asserted here is the trigger's own behaviour and not a
164
+ * layout this file would otherwise have to keep in step with a call site.
165
+ *
166
+ * The claim worth a test is the one that is easy to lose: the trigger shows the
167
+ * *same* two lines the list does, because Radix renders the selected item's own
168
+ * children into it. A second copy of the layout at the call site would look
169
+ * identical the day it was written and drift the day either half moved.
170
+ */
171
+ describe("Select", () => {
172
+ function pickOne(): JSX.Element {
173
+ return (
174
+ <Select defaultValue="a">
175
+ <SelectTrigger data-testid="model" className={twoLineTrigger}>
176
+ <SelectValue />
177
+ </SelectTrigger>
178
+ <SelectContent>
179
+ <SelectItem value="a">
180
+ <span className="flex flex-col items-start">
181
+ <span>org/model-tiny</span>
182
+ <span className="text-xs text-muted-foreground">311.9 MB · tiny</span>
183
+ </span>
184
+ </SelectItem>
185
+ <SelectItem value="b">org/model-large</SelectItem>
186
+ </SelectContent>
187
+ </Select>
188
+ );
189
+ }
190
+
191
+ it("stacks the identifier and its meta in the closed trigger", () => {
192
+ render(pickOne());
193
+ const trigger = screen.getByTestId("model");
194
+ expect(trigger.textContent).toContain("org/model-tiny");
195
+ expect(trigger.textContent).toContain("311.9 MB · tiny");
196
+ // Two elements, not one line that happens to wrap — the meta carries the
197
+ // muted role and the id does not.
198
+ const meta = trigger.querySelector(".text-muted-foreground");
199
+ expect(meta?.textContent).toBe("311.9 MB · tiny");
200
+ expect(meta?.textContent).not.toContain("org/model-tiny");
201
+ });
202
+
203
+ it("grows rather than clipping, and leaves a one-line option where it was", () => {
204
+ render(pickOne());
205
+ // `h-8` would fix the height and squash the second line; `min-h-8` keeps the
206
+ // one-line control on Nova's contract height and lets a two-line one grow.
207
+ const trigger = screen.getByTestId("model");
208
+ expect(trigger.className).toContain("min-h-8");
209
+ // Nothing truncates: half a model id is not a model id.
210
+ expect(trigger.className).not.toContain("truncate");
211
+ // `twoLineTrigger` names canonical's own modifier chains, so `cn`'s merge
212
+ // replaces the fixed height and the value clamp rather than stacking beside
213
+ // them — a real check of the merge at render, not an echo of the constant.
214
+ expect(trigger.className).toContain("data-[size=default]:h-auto");
215
+ expect(trigger.className).toContain("line-clamp-none");
216
+ expect(trigger.className).not.toContain("data-[size=default]:h-8");
217
+ expect(trigger.className).not.toContain("line-clamp-1");
218
+ });
219
+
220
+ it("floors the open list at the closed control's width", async () => {
221
+ render(pickOne());
222
+ await userEvent.click(screen.getByTestId("model"));
223
+ const viewport = document.querySelector("[data-radix-select-viewport]");
224
+ expect(viewport).not.toBeNull();
225
+ const className = (viewport as HTMLElement).className;
226
+ expect(className).toContain("w-full");
227
+ expect(className).toContain("min-w-(--radix-select-trigger-width)");
228
+ });
229
+ });
230
+
231
+ describe("Card and Table", () => {
232
+ it("marks a card title with its slot, for the styling that reads it", () => {
233
+ render(
234
+ <Card>
235
+ <CardTitle>Classes</CardTitle>
236
+ </Card>,
237
+ );
238
+ expect(screen.getByText("Classes").getAttribute("data-slot")).toBe("card-title");
239
+ });
240
+
241
+ it("keeps the table's header while the body is empty", () => {
242
+ render(
243
+ <Table>
244
+ <TableHeader>
245
+ <TableRow>
246
+ <TableHead>Name</TableHead>
247
+ <TableHead>State</TableHead>
248
+ </TableRow>
249
+ </TableHeader>
250
+ <TableBody />
251
+ </Table>,
252
+ );
253
+ expect(screen.getByRole("columnheader", { name: "Name" })).not.toBeNull();
254
+ expect(screen.getByRole("columnheader", { name: "State" })).not.toBeNull();
255
+ });
256
+ });
257
+
258
+ /**
259
+ * The tab bar, asserted on what it *means* rather than on what it looks like.
260
+ *
261
+ * The distinction between the open section and the other two has to survive a
262
+ * restyling, so nothing here matches a class string — a test that pinned
263
+ * `bg-card` would have failed on the very change it was supposed to protect, and
264
+ * a test that pinned `border-primary` would fail on the next one. What is asserted
265
+ * is the part a screen reader and the keyboard both read: the roles, `aria-selected`,
266
+ * Radix's `data-state`, and that only the open panel is in the tree at all.
267
+ *
268
+ * There is nothing here about a variant cascade: `TabsList` has one shape and no
269
+ * context to hand down.
270
+ */
271
+ describe("Tabs", () => {
272
+ function bar(): JSX.Element {
273
+ return (
274
+ <Tabs defaultValue="schema">
275
+ <TabsList aria-label="Sections">
276
+ <TabsTrigger value="schema">Schema</TabsTrigger>
277
+ <TabsTrigger value="batches">Batches</TabsTrigger>
278
+ </TabsList>
279
+ <TabsContent value="schema">the classes</TabsContent>
280
+ <TabsContent value="batches">the batches</TabsContent>
281
+ </Tabs>
282
+ );
283
+ }
284
+
285
+ it("marks the open section as the selected tab and the others as not", () => {
286
+ render(bar());
287
+
288
+ const [schema, batches] = screen.getAllByRole("tab");
289
+ expect(schema.getAttribute("aria-selected")).toBe("true");
290
+ expect(schema.dataset.state).toBe("active");
291
+ expect(batches.getAttribute("aria-selected")).toBe("false");
292
+ expect(batches.dataset.state).toBe("inactive");
293
+ });
294
+
295
+ it("moves the selection when the tab is clicked, so the state is the source of the styling", async () => {
296
+ render(bar());
297
+
298
+ await userEvent.click(screen.getByRole("tab", { name: "Batches" }));
299
+ expect(screen.getByRole("tab", { name: "Batches" }).getAttribute("aria-selected")).toBe("true");
300
+ expect(screen.getByRole("tab", { name: "Schema" }).getAttribute("aria-selected")).toBe("false");
301
+ });
302
+
303
+ it("keeps only the open panel in the tree, and labels it with its tab", () => {
304
+ render(bar());
305
+
306
+ const panels = screen.getAllByRole("tabpanel");
307
+ expect(panels).toHaveLength(1);
308
+ expect(panels[0]?.textContent).toBe("the classes");
309
+ expect(screen.getByRole("tablist").getAttribute("aria-label")).toBe("Sections");
310
+ });
311
+
312
+ it("is operable from the keyboard, because every trigger is a real button", async () => {
313
+ render(bar());
314
+
315
+ // Radix's roving tabindex: one stop for the whole bar, arrows move within it.
316
+ await userEvent.tab();
317
+ expect(document.activeElement).toBe(screen.getByRole("tab", { name: "Schema" }));
318
+
319
+ await userEvent.keyboard("{ArrowRight}");
320
+ expect(document.activeElement).toBe(screen.getByRole("tab", { name: "Batches" }));
321
+ expect(screen.getByRole("tab", { name: "Batches" }).getAttribute("aria-selected")).toBe("true");
322
+ });
323
+ });
324
+
325
+ describe("Progress", () => {
326
+ it("reports its value to assistive technology, not only as a width", () => {
327
+ // Canonical `Progress` reads `value` only to size the indicator and never
328
+ // forwards it to Radix's `Root`, so `progressAria` says it again — every
329
+ // caller in the product spreads it beside `value` for exactly this reason.
330
+ render(<Progress value={42} {...progressAria(42)} aria-label="Ingest" />);
331
+ expect(screen.getByRole("progressbar", { name: "Ingest" }).getAttribute("aria-valuenow")).toBe(
332
+ "42",
333
+ );
334
+ });
335
+
336
+ it("fills with the functional colour, and carries the data-slot a caller can restyle from", () => {
337
+ // Canonical `Progress` has no `variant` prop and no notion of status — a
338
+ // batch's completion is an amount, not a polarity, so the indicator is
339
+ // always `bg-primary`. A caller who needs to reach it targets the
340
+ // `data-slot` it renders with, from its own `className` on `Root`.
341
+ render(<Progress value={42} aria-label="Ingest" />);
342
+ const fill = screen.getByRole("progressbar").firstElementChild as Element;
343
+ expect(fill.getAttribute("data-slot")).toBe("progress-indicator");
344
+ expect(fill.className).toBe("size-full flex-1 bg-primary transition-all");
345
+ });
346
+
347
+ it("hands its ref to the track element", () => {
348
+ let track: HTMLDivElement | null = null;
349
+ render(
350
+ <Progress
351
+ value={7}
352
+ aria-label="Ingest"
353
+ ref={(node) => {
354
+ track = node;
355
+ }}
356
+ />,
357
+ );
358
+ expect(track).toBe(screen.getByRole("progressbar"));
359
+ });
360
+ });
361
+
362
+ describe("Dialog", () => {
363
+ function describedBy(dialog: HTMLElement): readonly (string | null)[] {
364
+ return (dialog.getAttribute("aria-describedby") ?? "")
365
+ .split(" ")
366
+ .filter(Boolean)
367
+ .map((id) => document.getElementById(id)?.textContent ?? null);
368
+ }
369
+
370
+ it("points aria-describedby at every description, each under its own id, when the caller names them", () => {
371
+ render(
372
+ <Dialog open>
373
+ <DialogContent aria-describedby="d1 d2">
374
+ <DialogTitle>Narrowing</DialogTitle>
375
+ <DialogDescription id="d1">one class narrows</DialogDescription>
376
+ <DialogDescription id="d2">nothing becomes invalid</DialogDescription>
377
+ </DialogContent>
378
+ </Dialog>,
379
+ );
380
+ const dialog = screen.getByRole("dialog");
381
+ expect(dialog.getAttribute("aria-describedby")).toBe("d1 d2");
382
+ expect(describedBy(dialog)).toEqual(["one class narrows", "nothing becomes invalid"]);
383
+ expect(document.getElementById("d1")?.textContent).toBe("one class narrows");
384
+ expect(document.getElementById("d2")?.textContent).toBe("nothing becomes invalid");
385
+ });
386
+
387
+ it("wires a single, unnamed description through Radix's own id by default", () => {
388
+ render(
389
+ <Dialog open>
390
+ <DialogContent>
391
+ <DialogTitle>Narrowing</DialogTitle>
392
+ <DialogDescription>one class narrows</DialogDescription>
393
+ </DialogContent>
394
+ </Dialog>,
395
+ );
396
+ const dialog = screen.getByRole("dialog");
397
+ const describedById = dialog.getAttribute("aria-describedby");
398
+ expect(describedById).toBeTruthy();
399
+ expect(document.getElementById(describedById ?? "")?.textContent).toBe("one class narrows");
400
+ });
401
+ });
402
+
403
+ describe("sheet", () => {
404
+ it("renders a dialog with its title and description wired by Radix", async () => {
405
+ render(<Sheet open><SheetContent><SheetHeader><SheetTitle>Filters</SheetTitle><SheetDescription>Narrow the list</SheetDescription></SheetHeader></SheetContent></Sheet>);
406
+ const dialog = await screen.findByRole("dialog");
407
+ expect(dialog.getAttribute("data-slot")).toBe("sheet-content");
408
+ expect(dialog.getAttribute("data-side")).toBe("right");
409
+ expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
410
+ });
411
+ });
412
+
413
+ describe("DropdownMenu", () => {
414
+ it("sizes its surface to the items, not to the trigger", async () => {
415
+ const user = userEvent.setup();
416
+ render(
417
+ <DropdownMenu>
418
+ <DropdownMenuTrigger asChild>
419
+ <Button variant="ghost" size="icon" aria-label="Actions" />
420
+ </DropdownMenuTrigger>
421
+ <DropdownMenuContent className={menuSurface}>
422
+ <DropdownMenuItem>Check integrity of this connection</DropdownMenuItem>
423
+ </DropdownMenuContent>
424
+ </DropdownMenu>,
425
+ );
426
+ await user.click(screen.getByRole("button", { name: "Actions" }));
427
+ const classes = (await screen.findByRole("menu")).className.split(" ");
428
+ // Canonical pins the surface to the trigger with
429
+ // `w-(--radix-dropdown-menu-trigger-width)`, which behind this icon-sized
430
+ // button is the 128px floor — long items wrap. `menuSurface` is in the same
431
+ // utility group, so tailwind-merge drops canonical's at render and the
432
+ // rendered class list is the assertion: this is the merge, not a hope about
433
+ // cascade order.
434
+ expect(classes).toContain("w-auto");
435
+ expect(classes).not.toContain("w-(--radix-dropdown-menu-trigger-width)");
436
+ // The floor is a different group and survives, which is what keeps a
437
+ // one-word menu from collapsing to its widest item.
438
+ expect(classes).toContain("min-w-32");
439
+ });
440
+ });
@@ -0,0 +1,29 @@
1
+ import * as React from "react"
2
+ import { Progress as ProgressPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "../lib/cn.js"
5
+
6
+ function Progress({
7
+ className,
8
+ value,
9
+ ...props
10
+ }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
11
+ return (
12
+ <ProgressPrimitive.Root
13
+ data-slot="progress"
14
+ className={cn(
15
+ "relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
16
+ className
17
+ )}
18
+ {...props}
19
+ >
20
+ <ProgressPrimitive.Indicator
21
+ data-slot="progress-indicator"
22
+ className="size-full flex-1 bg-primary transition-all"
23
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
24
+ />
25
+ </ProgressPrimitive.Root>
26
+ )
27
+ }
28
+
29
+ export { Progress }
@@ -0,0 +1,190 @@
1
+ import * as React from "react"
2
+ import { Select as SelectPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "../lib/cn.js"
5
+ import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
6
+
7
+ function Select({
8
+ ...props
9
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
10
+ return <SelectPrimitive.Root data-slot="select" {...props} />
11
+ }
12
+
13
+ function SelectGroup({
14
+ className,
15
+ ...props
16
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
17
+ return (
18
+ <SelectPrimitive.Group
19
+ data-slot="select-group"
20
+ className={cn("scroll-my-1 p-1", className)}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ function SelectValue({
27
+ ...props
28
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
29
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
30
+ }
31
+
32
+ function SelectTrigger({
33
+ className,
34
+ size = "default",
35
+ children,
36
+ ...props
37
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
38
+ size?: "sm" | "default"
39
+ }) {
40
+ return (
41
+ <SelectPrimitive.Trigger
42
+ data-slot="select-trigger"
43
+ data-size={size}
44
+ className={cn(
45
+ "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
46
+ className
47
+ )}
48
+ {...props}
49
+ >
50
+ {children}
51
+ <SelectPrimitive.Icon asChild>
52
+ <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
53
+ </SelectPrimitive.Icon>
54
+ </SelectPrimitive.Trigger>
55
+ )
56
+ }
57
+
58
+ function SelectContent({
59
+ className,
60
+ children,
61
+ position = "item-aligned",
62
+ align = "center",
63
+ ...props
64
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
65
+ return (
66
+ <SelectPrimitive.Portal>
67
+ <SelectPrimitive.Content
68
+ data-slot="select-content"
69
+ data-align-trigger={position === "item-aligned"}
70
+ className={cn("dark relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
71
+ position={position}
72
+ align={align}
73
+ {...props}
74
+ >
75
+ <SelectScrollUpButton />
76
+ <SelectPrimitive.Viewport
77
+ data-position={position}
78
+ className={cn(
79
+ "data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
80
+ position === "popper" && ""
81
+ )}
82
+ >
83
+ {children}
84
+ </SelectPrimitive.Viewport>
85
+ <SelectScrollDownButton />
86
+ </SelectPrimitive.Content>
87
+ </SelectPrimitive.Portal>
88
+ )
89
+ }
90
+
91
+ function SelectLabel({
92
+ className,
93
+ ...props
94
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
95
+ return (
96
+ <SelectPrimitive.Label
97
+ data-slot="select-label"
98
+ className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
99
+ {...props}
100
+ />
101
+ )
102
+ }
103
+
104
+ function SelectItem({
105
+ className,
106
+ children,
107
+ ...props
108
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
109
+ return (
110
+ <SelectPrimitive.Item
111
+ data-slot="select-item"
112
+ className={cn(
113
+ "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
114
+ className
115
+ )}
116
+ {...props}
117
+ >
118
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
119
+ <SelectPrimitive.ItemIndicator>
120
+ <CheckIcon className="pointer-events-none" />
121
+ </SelectPrimitive.ItemIndicator>
122
+ </span>
123
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
124
+ </SelectPrimitive.Item>
125
+ )
126
+ }
127
+
128
+ function SelectSeparator({
129
+ className,
130
+ ...props
131
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
132
+ return (
133
+ <SelectPrimitive.Separator
134
+ data-slot="select-separator"
135
+ className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
136
+ {...props}
137
+ />
138
+ )
139
+ }
140
+
141
+ function SelectScrollUpButton({
142
+ className,
143
+ ...props
144
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
145
+ return (
146
+ <SelectPrimitive.ScrollUpButton
147
+ data-slot="select-scroll-up-button"
148
+ className={cn(
149
+ "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
150
+ className
151
+ )}
152
+ {...props}
153
+ >
154
+ <ChevronUpIcon
155
+ />
156
+ </SelectPrimitive.ScrollUpButton>
157
+ )
158
+ }
159
+
160
+ function SelectScrollDownButton({
161
+ className,
162
+ ...props
163
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
164
+ return (
165
+ <SelectPrimitive.ScrollDownButton
166
+ data-slot="select-scroll-down-button"
167
+ className={cn(
168
+ "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
169
+ className
170
+ )}
171
+ {...props}
172
+ >
173
+ <ChevronDownIcon
174
+ />
175
+ </SelectPrimitive.ScrollDownButton>
176
+ )
177
+ }
178
+
179
+ export {
180
+ Select,
181
+ SelectContent,
182
+ SelectGroup,
183
+ SelectItem,
184
+ SelectLabel,
185
+ SelectScrollDownButton,
186
+ SelectScrollUpButton,
187
+ SelectSeparator,
188
+ SelectTrigger,
189
+ SelectValue,
190
+ }
@@ -0,0 +1,28 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Separator as SeparatorPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "../lib/cn.js"
7
+
8
+ function Separator({
9
+ className,
10
+ orientation = "horizontal",
11
+ decorative = true,
12
+ ...props
13
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
14
+ return (
15
+ <SeparatorPrimitive.Root
16
+ data-slot="separator"
17
+ decorative={decorative}
18
+ orientation={orientation}
19
+ className={cn(
20
+ "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ export { Separator }