@elabs-ai/components-ui 4.1.0 → 4.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 (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. package/dist/chunk-JKPPTL63.js.map +0 -1
@@ -34,12 +34,12 @@ export function SectionHeader({
34
34
  <div className={cn("flex flex-wrap items-end justify-between gap-4", className)}>
35
35
  <div className="space-y-1">
36
36
  {eyebrow ? (
37
- <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
37
+ <div className="text-meta font-medium uppercase tracking-wide text-muted-foreground">
38
38
  {eyebrow}
39
39
  </div>
40
40
  ) : null}
41
41
  <TitleTag className="text-title text-foreground">{title}</TitleTag>
42
- {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
42
+ {description ? <p className="text-body text-muted-foreground">{description}</p> : null}
43
43
  </div>
44
44
  {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
45
45
  </div>
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  forwardRef,
3
5
  useEffect,
@@ -121,8 +123,8 @@ export const SelectTrigger = forwardRef<
121
123
  data-size={size}
122
124
  title={title}
123
125
  className={cn(
124
- "flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm",
125
- size === "sm" ? "h-8" : "h-9",
126
+ "flex w-full items-center justify-between gap-2 rounded-control border border-input bg-input-background px-3 py-2 text-body shadow-input",
127
+ size === "sm" ? "h-control-sm" : "h-control",
126
128
  "placeholder:text-muted-foreground focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
127
129
  // No `disabled:cursor-*` override — disabled controls keep the arrow
128
130
  // automatically (interaction-guidelines.md), matching Combobox's
@@ -154,7 +156,7 @@ export const SelectContent = forwardRef<
154
156
  ref={ref}
155
157
  position={position}
156
158
  className={cn(
157
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md bg-popover text-popover-foreground shadow-ring-md",
159
+ "relative z-50 max-h-96 min-w-[12rem] overflow-hidden rounded-md bg-popover text-popover-foreground shadow-ring-md",
158
160
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
159
161
  "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
160
162
  position === "popper" &&
@@ -190,7 +192,7 @@ export const SelectLabel = forwardRef<
190
192
  return (
191
193
  <SelectPrimitive.Label
192
194
  ref={ref}
193
- className={cn("px-2 py-1.5 text-xs font-semibold text-muted-foreground", className)}
195
+ className={cn("px-2 py-1.5 text-meta font-semibold text-muted-foreground", className)}
194
196
  {...props}
195
197
  />
196
198
  );
@@ -204,7 +206,7 @@ export const SelectItem = forwardRef<
204
206
  <SelectPrimitive.Item
205
207
  ref={ref}
206
208
  className={cn(
207
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-2 pe-8 text-sm outline-none transition-colors duration-fast",
209
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-2 pe-8 text-body outline-none transition-colors duration-fast",
208
210
  "focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
209
211
  className,
210
212
  )}
@@ -37,6 +37,7 @@ export const Separator = forwardRef<ElementRef<typeof SeparatorPrimitive.Root>,
37
37
  return (
38
38
  <SeparatorPrimitive.Root
39
39
  ref={ref}
40
+ data-slot="separator"
40
41
  decorative={decorative}
41
42
  orientation={orientation}
42
43
  className={cn(
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet";
4
+
5
+ describe("SheetContent overflow", () => {
6
+ it("scrolls tall content instead of overflowing the fixed panel", () => {
7
+ render(
8
+ <Sheet open>
9
+ <SheetContent>
10
+ <SheetHeader>
11
+ <SheetTitle>Title</SheetTitle>
12
+ <SheetDescription>Description</SheetDescription>
13
+ </SheetHeader>
14
+ Body
15
+ </SheetContent>
16
+ </Sheet>,
17
+ );
18
+ const content = screen.getByRole("dialog");
19
+ // Without `overflow-y-auto`, content taller than the panel overflows the
20
+ // fixed-position box and its bottom half is unreachable.
21
+ expect(content.className).toContain("overflow-y-auto");
22
+ });
23
+
24
+ it("caps top/bottom sheets at a viewport-relative height so the scroll rule engages", () => {
25
+ render(
26
+ <Sheet open>
27
+ <SheetContent side="bottom">
28
+ <SheetHeader>
29
+ <SheetTitle>Title</SheetTitle>
30
+ <SheetDescription>Description</SheetDescription>
31
+ </SheetHeader>
32
+ Body
33
+ </SheetContent>
34
+ </Sheet>,
35
+ );
36
+ const content = screen.getByRole("dialog");
37
+ expect(content.className).toContain("max-h-[90dvh]");
38
+ });
39
+ });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  forwardRef,
3
5
  type ComponentPropsWithoutRef,
@@ -8,6 +10,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog";
8
10
  import { cva, type VariantProps } from "class-variance-authority";
9
11
  import { X } from "lucide-react";
10
12
  import { cn } from "../../lib/cn";
13
+ import { useLocale } from "../locale-provider";
11
14
 
12
15
  export const Sheet = SheetPrimitive.Root;
13
16
  export const SheetTrigger = SheetPrimitive.Trigger;
@@ -21,8 +24,9 @@ const SheetOverlay = forwardRef<
21
24
  return (
22
25
  <SheetPrimitive.Overlay
23
26
  ref={ref}
27
+ data-slot="sheet-overlay"
24
28
  className={cn(
25
- "fixed inset-0 z-50 bg-foreground/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
29
+ "fixed inset-0 z-50 bg-overlay backdrop-blur-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
26
30
  className,
27
31
  )}
28
32
  {...props}
@@ -31,13 +35,23 @@ const SheetOverlay = forwardRef<
31
35
  });
32
36
 
33
37
  const sheetVariants = cva(
34
- "fixed z-50 gap-4 overscroll-contain bg-card p-6 text-card-foreground shadow-ring-lg transition duration-slow ease-standard data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)]",
38
+ // `overflow-y-auto` so content taller than the sheet scrolls INSIDE it
39
+ // instead of overflowing the fixed-position box (unreachable below the
40
+ // viewport edge).
41
+ "fixed z-50 gap-4 overflow-y-auto overscroll-contain bg-card p-6 text-card-foreground shadow-ring-lg transition duration-slow ease-standard data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)]",
35
42
  {
36
43
  variants: {
37
44
  side: {
38
- top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
45
+ // `top`/`bottom` have no inherent height ceiling, so long content grows
46
+ // past the viewport; cap it at 90dvh (matching Dialog's dvh unit — ADR
47
+ // on mobile browser chrome) so the scroll rule above actually engages.
48
+ top: "inset-x-0 top-0 max-h-[90dvh] border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39
49
  bottom:
40
- "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
50
+ "inset-x-0 bottom-0 max-h-[90dvh] border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
51
+ // `side` is an explicit physical edge of the screen (like Radix's own
52
+ // Popper `side`), not a logical start/end — a caller who picks `"left"`
53
+ // wants the panel on the physical left even in an RTL layout, so these
54
+ // stay physical `left-0`/`right-0` and `border-r`/`border-l` on purpose.
41
55
  left: "inset-y-0 left-0 h-full w-3/4 max-w-sm border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
42
56
  right:
43
57
  "inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
@@ -56,18 +70,24 @@ export const SheetContent = forwardRef<
56
70
  ElementRef<typeof SheetPrimitive.Content>,
57
71
  SheetContentProps
58
72
  >(function SheetContent({ side = "right", className, children, ...props }, ref) {
73
+ const { t } = useLocale();
59
74
  return (
60
75
  <SheetPortal>
61
76
  <SheetOverlay />
62
77
  <SheetPrimitive.Content
63
78
  ref={ref}
79
+ data-slot="sheet-content"
64
80
  className={cn(sheetVariants({ side }), className)}
65
81
  {...props}
66
82
  >
67
83
  {children}
84
+ {/* Matches Dialog's close button exactly (radius, padding, ink,
85
+ opacity choreography) — a 24px target (`p-1` + the 16px glyph)
86
+ instead of the previous bare 16px hit area (#286). */}
68
87
  <SheetPrimitive.Close
69
- className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-ring"
70
- aria-label="Close"
88
+ data-slot="sheet-close"
89
+ className="absolute end-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
90
+ aria-label={t("close")}
71
91
  >
72
92
  <X className="size-4" />
73
93
  </SheetPrimitive.Close>
@@ -77,11 +97,14 @@ export const SheetContent = forwardRef<
77
97
  });
78
98
 
79
99
  export function SheetHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
80
- return <div className={cn("flex flex-col gap-1.5", className)} {...props} />;
100
+ return (
101
+ <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
102
+ );
81
103
  }
82
104
  export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
83
105
  return (
84
106
  <div
107
+ data-slot="sheet-footer"
85
108
  className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
86
109
  {...props}
87
110
  />
@@ -92,7 +115,14 @@ export const SheetTitle = forwardRef<
92
115
  ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
93
116
  >(function SheetTitle({ className, ...props }, ref) {
94
117
  return (
95
- <SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
118
+ // `text-title`, matching `DialogTitle` (#286) — was `text-lg font-semibold`,
119
+ // one rung below Dialog's for the same anatomical role (a modal title).
120
+ <SheetPrimitive.Title
121
+ ref={ref}
122
+ data-slot="sheet-title"
123
+ className={cn("text-title leading-none", className)}
124
+ {...props}
125
+ />
96
126
  );
97
127
  });
98
128
  export const SheetDescription = forwardRef<
@@ -102,7 +132,8 @@ export const SheetDescription = forwardRef<
102
132
  return (
103
133
  <SheetPrimitive.Description
104
134
  ref={ref}
105
- className={cn("text-sm text-muted-foreground", className)}
135
+ data-slot="sheet-description"
136
+ className={cn("text-body text-muted-foreground", className)}
106
137
  {...props}
107
138
  />
108
139
  );
@@ -270,4 +270,30 @@ describe("SideDock", () => {
270
270
  expect(document.activeElement).not.toBe(document.body);
271
271
  expect(document.activeElement).toBe(trigger);
272
272
  });
273
+
274
+ it("coalesces a burst of resize events into one rAF-scheduled update", () => {
275
+ const rafCallbacks: FrameRequestCallback[] = [];
276
+ const rafSpy = vi
277
+ .spyOn(window, "requestAnimationFrame")
278
+ .mockImplementation((cb: FrameRequestCallback) => {
279
+ rafCallbacks.push(cb);
280
+ return rafCallbacks.length;
281
+ });
282
+
283
+ render(
284
+ <SideDock title="Assistant" open overlayBreakpoint={COLUMN_BREAKPOINT}>
285
+ Body
286
+ </SideDock>,
287
+ );
288
+ rafCallbacks.length = 0; // clear anything scheduled at mount
289
+
290
+ // A window drag fires `resize` many times per frame — every one of these
291
+ // must coalesce into a SINGLE scheduled frame, not one per event.
292
+ fireEvent(window, new Event("resize"));
293
+ fireEvent(window, new Event("resize"));
294
+ fireEvent(window, new Event("resize"));
295
+ expect(rafCallbacks).toHaveLength(1);
296
+
297
+ rafSpy.mockRestore();
298
+ });
273
299
  });
@@ -216,9 +216,22 @@ export const SideDock = forwardRef<HTMLElement, SideDockProps>(function SideDock
216
216
  typeof window !== "undefined" ? window.innerWidth : FALLBACK_VIEWPORT_WIDTH,
217
217
  );
218
218
  useEffect(() => {
219
- const onResize = () => setViewportWidth(window.innerWidth);
219
+ // `resize` can fire dozens of times per second while a window is being
220
+ // dragged — coalesce every event inside one frame into a single state
221
+ // update via rAF, instead of re-rendering on each raw event.
222
+ let rafId: number | null = null;
223
+ const onResize = () => {
224
+ if (rafId !== null) return;
225
+ rafId = requestAnimationFrame(() => {
226
+ rafId = null;
227
+ setViewportWidth(window.innerWidth);
228
+ });
229
+ };
220
230
  window.addEventListener("resize", onResize);
221
- return () => window.removeEventListener("resize", onResize);
231
+ return () => {
232
+ window.removeEventListener("resize", onResize);
233
+ if (rafId !== null) cancelAnimationFrame(rafId);
234
+ };
222
235
  }, []);
223
236
 
224
237
  const isWidthControlled = width !== undefined;
@@ -1,5 +1,6 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
3
4
 
4
5
  import {
5
6
  Sidebar,
@@ -9,6 +10,7 @@ import {
9
10
  SidebarMenuButton,
10
11
  SidebarMenuItem,
11
12
  SidebarProvider,
13
+ SidebarTrigger,
12
14
  } from "./sidebar";
13
15
 
14
16
  function renderFrame() {
@@ -75,4 +77,36 @@ describe("SidebarProvider", () => {
75
77
  expect(active.className).toContain("data-[active=true]:font-semibold");
76
78
  expect(resting).toHaveAttribute("data-active", "false");
77
79
  });
80
+
81
+ it("does not re-bind the global keydown listener on every toggle", async () => {
82
+ const user = userEvent.setup();
83
+ const addSpy = vi.spyOn(window, "addEventListener");
84
+ const removeSpy = vi.spyOn(window, "removeEventListener");
85
+ render(
86
+ <SidebarProvider>
87
+ <Sidebar>chrome</Sidebar>
88
+ <SidebarInset>
89
+ <SidebarTrigger />
90
+ </SidebarInset>
91
+ </SidebarProvider>,
92
+ );
93
+ const keydownAddCallsAtMount = addSpy.mock.calls.filter((c) => c[0] === "keydown").length;
94
+ expect(keydownAddCallsAtMount).toBe(1);
95
+
96
+ // Each click flips `open`, which recreates `toggleSidebar` (it closes
97
+ // over `setOpen`, which closes over `open`) — a `useEffect` depending on
98
+ // that callback directly would tear down and re-add the `keydown`
99
+ // listener on every single toggle.
100
+ await user.click(screen.getByRole("button"));
101
+ await user.click(screen.getByRole("button"));
102
+ await user.click(screen.getByRole("button"));
103
+
104
+ const keydownAddCallsAfterToggles = addSpy.mock.calls.filter((c) => c[0] === "keydown").length;
105
+ const keydownRemoveCalls = removeSpy.mock.calls.filter((c) => c[0] === "keydown").length;
106
+ expect(keydownAddCallsAfterToggles).toBe(1);
107
+ expect(keydownRemoveCalls).toBe(0);
108
+
109
+ addSpy.mockRestore();
110
+ removeSpy.mockRestore();
111
+ });
78
112
  });
@@ -7,6 +7,7 @@ import {
7
7
  useContext,
8
8
  useEffect,
9
9
  useMemo,
10
+ useRef,
10
11
  useState,
11
12
  type ComponentProps,
12
13
  type CSSProperties,
@@ -118,6 +119,17 @@ export const SidebarProvider = forwardRef<
118
119
  return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o);
119
120
  }, [isMobile, setOpen]);
120
121
 
122
+ // `toggleSidebar` is a NEW function on every `open` change (it closes over
123
+ // `setOpen`, which itself closes over `open`) — a `useEffect` depending on
124
+ // it directly would tear down and re-add the global `keydown` listener on
125
+ // every single toggle. A ref holding the latest callback keeps the listener
126
+ // registration stable across toggles; only `isNested` (which never changes
127
+ // after mount) re-runs the effect.
128
+ const toggleSidebarRef = useRef(toggleSidebar);
129
+ useEffect(() => {
130
+ toggleSidebarRef.current = toggleSidebar;
131
+ }, [toggleSidebar]);
132
+
121
133
  useEffect(() => {
122
134
  // Same reasoning as the cookie write above: the global keyboard shortcut
123
135
  // belongs to the ONE app frame, not to every nested provider a compound
@@ -126,12 +138,12 @@ export const SidebarProvider = forwardRef<
126
138
  const handleKeyDown = (event: KeyboardEvent) => {
127
139
  if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
128
140
  event.preventDefault();
129
- toggleSidebar();
141
+ toggleSidebarRef.current();
130
142
  }
131
143
  };
132
144
  window.addEventListener("keydown", handleKeyDown);
133
145
  return () => window.removeEventListener("keydown", handleKeyDown);
134
- }, [isNested, toggleSidebar]);
146
+ }, [isNested]);
135
147
 
136
148
  const state = open ? "expanded" : "collapsed";
137
149
 
@@ -273,7 +285,7 @@ export const Sidebar = forwardRef<
273
285
  >
274
286
  <SheetHeader className="sr-only">
275
287
  <SheetTitle>{t("ui.sidebar.title")}</SheetTitle>
276
- <SheetDescription>Displays the mobile sidebar.</SheetDescription>
288
+ <SheetDescription>{t("ui.sidebar.mobileDescription")}</SheetDescription>
277
289
  </SheetHeader>
278
290
  <div className="flex h-full w-full flex-col">{children}</div>
279
291
  </SheetContent>
@@ -335,6 +347,7 @@ export const Sidebar = forwardRef<
335
347
 
336
348
  export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeof Button>>(
337
349
  function SidebarTrigger({ className, onClick, ...props }, ref) {
350
+ const { t } = useLocale();
338
351
  const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
339
352
  // `toggleSidebar` flips `openMobile` below the mobile breakpoint and `open`
340
353
  // above it, so the state this button EXPOSES has to be read the same way —
@@ -368,7 +381,7 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
368
381
  {...props}
369
382
  >
370
383
  <PanelLeftIcon />
371
- <span className="sr-only">Toggle Sidebar</span>
384
+ <span className="sr-only">{t("ui.sidebar.toggle")}</span>
372
385
  </Button>
373
386
  );
374
387
  },
@@ -376,16 +389,17 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
376
389
 
377
390
  export const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<"button">>(
378
391
  function SidebarRail({ className, ...props }, ref) {
392
+ const { t } = useLocale();
379
393
  const { toggleSidebar } = useSidebar();
380
394
  return (
381
395
  <button
382
396
  ref={ref}
383
397
  data-sidebar="rail"
384
398
  data-slot="sidebar-rail"
385
- aria-label="Toggle Sidebar"
399
+ aria-label={t("ui.sidebar.toggle")}
386
400
  tabIndex={-1}
387
401
  onClick={toggleSidebar}
388
- title="Toggle Sidebar"
402
+ title={t("ui.sidebar.toggle")}
389
403
  className={cn(
390
404
  "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
391
405
  "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
@@ -7,6 +7,7 @@ export type SkeletonProps = HTMLAttributes<HTMLDivElement>;
7
7
  export function Skeleton({ className, ...props }: SkeletonProps) {
8
8
  return (
9
9
  <div
10
+ data-slot="skeleton"
10
11
  className={cn("animate-pulse rounded-md bg-muted", className)}
11
12
  aria-hidden="true"
12
13
  {...props}
@@ -109,7 +109,10 @@ export const DragThenReset: Story = {
109
109
  thumb.focus();
110
110
  await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
111
111
  const numberInput = canvas.getByRole("spinbutton", { name: "Temperature" });
112
- await expect(numberInput).toHaveValue(0.3);
112
+ // Text spinbutton (locale-formatted): string display + numeric aria-valuenow.
113
+ await expect(numberInput).toHaveValue("0.3");
114
+ await expect(numberInput).toHaveAttribute("aria-valuenow", "0.3");
115
+ await expect(thumb).toHaveAttribute("aria-valuenow", "0.3");
113
116
 
114
117
  const resetBtn = canvas.getByRole("button", { name: "Reset" });
115
118
  await expect(resetBtn).not.toBeDisabled();
@@ -10,7 +10,7 @@ describe("SliderNumber", () => {
10
10
  "aria-valuenow",
11
11
  "0.5",
12
12
  );
13
- expect(screen.getByRole("spinbutton", { name: "Temperature" })).toHaveValue(0.5);
13
+ expect(screen.getByRole("spinbutton", { name: "Temperature" })).toHaveValue("0.5");
14
14
  });
15
15
 
16
16
  it("rounds slider-driven and typed values identically (lockstep)", async () => {
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { useEffect, useState } from "react";
2
4
  import { Toaster as SonnerToaster, toast } from "sonner";
3
5
  import { resolveThemeIsDark } from "@elabs-ai/components-tokens";
@@ -0,0 +1,18 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import { Spinner } from "./spinner";
5
+
6
+ describe("Spinner", () => {
7
+ it("renders with its default accessible label", () => {
8
+ render(<Spinner />);
9
+ expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
10
+ });
11
+
12
+ it("forwards its ref to the rendered svg", () => {
13
+ const ref = createRef<SVGSVGElement>();
14
+ render(<Spinner ref={ref} />);
15
+ expect(ref.current).toBeInstanceOf(SVGSVGElement);
16
+ expect(ref.current).toBe(screen.getByRole("status", { name: "Loading" }));
17
+ });
18
+ });
@@ -1,19 +1,29 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
1
4
  import { Loader2 } from "lucide-react";
2
5
  import { cn } from "../../lib/cn";
6
+ import { useLocale } from "../locale-provider";
3
7
 
4
8
  export interface SpinnerProps extends React.SVGProps<SVGSVGElement> {
5
- /** Accessible label; defaults to "Loading". */
9
+ /** Accessible label; defaults to the localized "Loading" microcopy. */
6
10
  label?: string;
7
11
  }
8
12
 
9
13
  /** Indeterminate spinner with an accessible label. */
10
- export function Spinner({ className, label = "Loading", ...props }: SpinnerProps) {
14
+ export const Spinner = forwardRef<SVGSVGElement, SpinnerProps>(function Spinner(
15
+ { className, label, ...props },
16
+ ref,
17
+ ) {
18
+ const { t } = useLocale();
11
19
  return (
12
20
  <Loader2
21
+ ref={ref}
22
+ data-slot="spinner"
13
23
  role="status"
14
- aria-label={label}
24
+ aria-label={label ?? t("ui.spinner.label")}
15
25
  className={cn("size-4 animate-spin text-muted-foreground", className)}
16
26
  {...props}
17
27
  />
18
28
  );
19
- }
29
+ });
@@ -122,7 +122,7 @@ describe("StatePanel", () => {
122
122
  expect(wrapper?.className ?? "").not.toContain("size-10");
123
123
  });
124
124
 
125
- it("illustration path renders the title at the subtitle role, distinct from the icon path's text-sm (#47)", () => {
125
+ it("illustration path renders the title at the subtitle role, distinct from the icon path's text-body (#47)", () => {
126
126
  // A 112px+ illustration needs a heavier title to group with — bumping the
127
127
  // title to `text-subtitle` (styling-and-tokens.md's typography-scale
128
128
  // roles) only on the illustration path, so the plain `icon` path (a
@@ -132,13 +132,13 @@ describe("StatePanel", () => {
132
132
  );
133
133
  const titleWithIllustration = withIllustration.querySelector("h3");
134
134
  expect(titleWithIllustration?.className ?? "").toContain("text-subtitle");
135
- expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-sm\b/);
135
+ expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-body\b/);
136
136
 
137
137
  const { container: withIcon } = render(
138
138
  <StatePanel kind="empty" title="No items" icon={<span data-testid="i" />} />,
139
139
  );
140
140
  const titleWithIcon = withIcon.querySelector("h3");
141
- expect(titleWithIcon?.className ?? "").toMatch(/\btext-sm\b/);
141
+ expect(titleWithIcon?.className ?? "").toMatch(/\btext-body\b/);
142
142
  expect(titleWithIcon?.className ?? "").not.toContain("text-subtitle");
143
143
  });
144
144
 
@@ -1,8 +1,11 @@
1
+ "use client";
2
+
1
3
  import { type CSSProperties, type ReactNode } from "react";
2
4
  import { CircleAlert } from "lucide-react";
3
5
  import { cva, type VariantProps } from "class-variance-authority";
4
6
  import { cn } from "../../lib/cn";
5
7
  import { ILLUSTRATION_ACCENT_VAR } from "../../illustrations/illustration-base";
8
+ import { useLocale } from "../locale-provider";
6
9
 
7
10
  // ─── Variants ────────────────────────────────────────────────────────────────
8
11
 
@@ -123,13 +126,15 @@ export function StatePanel({
123
126
  icon,
124
127
  illustration,
125
128
  titleAs: Tag = "h3",
126
- loadingLabel = "Loading…",
129
+ loadingLabel,
127
130
  size = "md",
128
131
  actions,
129
132
  className,
130
133
  }: StatePanelProps) {
134
+ const { t } = useLocale();
131
135
  const isLoading = kind === "loading";
132
136
  const isError = kind === "error";
137
+ const resolvedLoadingLabel = loadingLabel ?? t("loading");
133
138
 
134
139
  // Role: loading → status + live region; error → alert; empty → (none needed).
135
140
  const roleProps = isLoading
@@ -139,9 +144,9 @@ export function StatePanel({
139
144
  : {};
140
145
 
141
146
  // Default title / description fallbacks for error state (mirroring old ErrorState).
142
- const resolvedTitle = title ?? (isError ? "Something went wrong" : undefined);
147
+ const resolvedTitle = title ?? (isError ? t("ui.statePanel.errorTitle") : undefined);
143
148
  const resolvedDescription =
144
- description ?? (isError ? "An unexpected error occurred. Please try again." : undefined);
149
+ description ?? (isError ? t("ui.statePanel.errorDescription") : undefined);
145
150
 
146
151
  // Resolve displayed icon.
147
152
  let resolvedIcon: ReactNode;
@@ -167,7 +172,7 @@ export function StatePanel({
167
172
  correct there — do not "tidy" it onto -text too. */}
168
173
  {isError && (
169
174
  <span className="text-meta font-semibold uppercase tracking-widest text-destructive-text">
170
- Error
175
+ {t("ui.statePanel.errorEyebrow")}
171
176
  </span>
172
177
  )}
173
178
 
@@ -223,21 +228,23 @@ export function StatePanel({
223
228
  <Tag
224
229
  className={cn(
225
230
  "font-semibold text-foreground",
226
- illustration ? "text-subtitle" : "text-sm",
231
+ illustration ? "text-subtitle" : "text-body",
227
232
  )}
228
233
  >
229
234
  {resolvedTitle}
230
235
  </Tag>
231
236
  )}
232
237
  {resolvedDescription && (
233
- <p className="mx-auto max-w-sm text-sm text-muted-foreground">{resolvedDescription}</p>
238
+ <p className="mx-auto max-w-sm text-body text-muted-foreground">
239
+ {resolvedDescription}
240
+ </p>
234
241
  )}
235
242
  </div>
236
243
  )}
237
244
 
238
245
  {/* Loading label (visually shown as supporting text; also the live-region content). */}
239
- {isLoading && loadingLabel && (
240
- <span className="text-sm text-muted-foreground">{loadingLabel}</span>
246
+ {isLoading && resolvedLoadingLabel && (
247
+ <span className="text-body text-muted-foreground">{resolvedLoadingLabel}</span>
241
248
  )}
242
249
 
243
250
  {actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
@@ -9,6 +9,7 @@ export const Switch = forwardRef<
9
9
  return (
10
10
  <SwitchPrimitive.Root
11
11
  ref={ref}
12
+ data-slot="switch"
12
13
  className={cn(
13
14
  "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-fast ease-standard",
14
15
  "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
@@ -19,6 +20,7 @@ export const Switch = forwardRef<
19
20
  {...props}
20
21
  >
21
22
  <SwitchPrimitive.Thumb
23
+ data-slot="switch-thumb"
22
24
  className={cn(
23
25
  "pointer-events-none block size-4 rounded-full bg-background shadow-xs ring-0 transition-transform duration-fast ease-standard",
24
26
  "data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
@@ -104,7 +104,9 @@ export const Scrolling: Story = {
104
104
  });
105
105
  await expect(scrollRegion).toHaveAttribute("tabindex", "0");
106
106
  await expect(scrollRegion).toHaveAccessibleName("Table contents, scrollable");
107
- await expect(scrollRegion).not.toHaveAttribute("role");
107
+ // A named, non-landmark stop: `group`, never `region` (a landmark per
108
+ // overflowing table collides under axe `landmark-unique`).
109
+ await expect(scrollRegion).toHaveAttribute("role", "group");
108
110
 
109
111
  // The region is the only focusable element the story renders, so a
110
112
  // single Tab from nothing focused reaches it — the real WCAG 2.1.1