@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
@@ -25,6 +25,7 @@ export const Menubar = forwardRef<
25
25
  return (
26
26
  <MenubarPrimitive.Root
27
27
  ref={ref}
28
+ data-slot="menubar"
28
29
  className={cn("flex h-9 items-center gap-1 rounded-md bg-card p-1 shadow-ring-sm", className)}
29
30
  {...props}
30
31
  />
@@ -38,8 +39,9 @@ export const MenubarTrigger = forwardRef<
38
39
  return (
39
40
  <MenubarPrimitive.Trigger
40
41
  ref={ref}
42
+ data-slot="menubar-trigger"
41
43
  className={cn(
42
- "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
44
+ "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-body font-medium outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
43
45
  className,
44
46
  )}
45
47
  {...props}
@@ -58,6 +60,7 @@ export const MenubarContent = forwardRef<
58
60
  <MenubarPrimitive.Portal>
59
61
  <MenubarPrimitive.Content
60
62
  ref={ref}
63
+ data-slot="menubar-content"
61
64
  align={align}
62
65
  alignOffset={alignOffset}
63
66
  sideOffset={sideOffset}
@@ -80,8 +83,9 @@ export const MenubarItem = forwardRef<
80
83
  return (
81
84
  <MenubarPrimitive.Item
82
85
  ref={ref}
86
+ data-slot="menubar-item"
83
87
  className={cn(
84
- "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
88
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
85
89
  inset && "ps-8",
86
90
  className,
87
91
  )}
@@ -97,8 +101,9 @@ export const MenubarSubTrigger = forwardRef<
97
101
  return (
98
102
  <MenubarPrimitive.SubTrigger
99
103
  ref={ref}
104
+ data-slot="menubar-sub-trigger"
100
105
  className={cn(
101
- "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
106
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
102
107
  inset && "ps-8",
103
108
  className,
104
109
  )}
@@ -117,6 +122,7 @@ export const MenubarSubContent = forwardRef<
117
122
  return (
118
123
  <MenubarPrimitive.SubContent
119
124
  ref={ref}
125
+ data-slot="menubar-sub-content"
120
126
  className={cn(
121
127
  "z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
122
128
  "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",
@@ -135,9 +141,10 @@ export const MenubarCheckboxItem = forwardRef<
135
141
  return (
136
142
  <MenubarPrimitive.CheckboxItem
137
143
  ref={ref}
144
+ data-slot="menubar-checkbox-item"
138
145
  checked={checked}
139
146
  className={cn(
140
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
147
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
141
148
  className,
142
149
  )}
143
150
  {...props}
@@ -159,8 +166,9 @@ export const MenubarRadioItem = forwardRef<
159
166
  return (
160
167
  <MenubarPrimitive.RadioItem
161
168
  ref={ref}
169
+ data-slot="menubar-radio-item"
162
170
  className={cn(
163
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
171
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
164
172
  className,
165
173
  )}
166
174
  {...props}
@@ -182,6 +190,7 @@ export const MenubarSeparator = forwardRef<
182
190
  return (
183
191
  <MenubarPrimitive.Separator
184
192
  ref={ref}
193
+ data-slot="menubar-separator"
185
194
  className={cn("-mx-1 my-1 h-px bg-border", className)}
186
195
  {...props}
187
196
  />
@@ -191,7 +200,8 @@ export const MenubarSeparator = forwardRef<
191
200
  export function MenubarShortcut({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
192
201
  return (
193
202
  <span
194
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
203
+ data-slot="menubar-shortcut"
204
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
195
205
  {...props}
196
206
  />
197
207
  );
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  /**
2
4
  * Shared collapsible sidebar navigation primitive (issue #99).
3
5
  * Promoted from `registry/blocks/sidebar-02/nav-main.tsx`.
@@ -90,7 +92,7 @@ export function NavMain({ routes, className }: NavMainProps) {
90
92
  >
91
93
  {route.icon}
92
94
  {!isCollapsed && (
93
- <span className="ms-2 flex-1 text-sm font-medium">{route.title}</span>
95
+ <span className="ms-2 flex-1 text-body font-medium">{route.title}</span>
94
96
  )}
95
97
  {!isCollapsed && (
96
98
  <span className="ms-auto">
@@ -114,7 +116,7 @@ export function NavMain({ routes, className }: NavMainProps) {
114
116
  <SidebarMenuSubButton asChild>
115
117
  <a
116
118
  href={subRoute.link}
117
- className="flex items-center rounded-md px-4 py-1.5 text-sm font-medium text-sidebar-muted-foreground"
119
+ className="flex items-center rounded-md px-4 py-1.5 text-body font-medium text-sidebar-muted-foreground"
118
120
  >
119
121
  {subRoute.title}
120
122
  </a>
@@ -135,7 +137,9 @@ export function NavMain({ routes, className }: NavMainProps) {
135
137
  )}
136
138
  >
137
139
  {route.icon}
138
- {!isCollapsed && <span className="ms-2 text-sm font-medium">{route.title}</span>}
140
+ {!isCollapsed && (
141
+ <span className="ms-2 text-body font-medium">{route.title}</span>
142
+ )}
139
143
  </a>
140
144
  </SidebarMenuButton>
141
145
  )}
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
3
4
  import { NavNotifications } from "./nav-notifications";
4
5
 
5
6
  describe("NavNotifications", () => {
@@ -12,4 +13,14 @@ describe("NavNotifications", () => {
12
13
  render(<NavNotifications notifications={notifications} />);
13
14
  expect(screen.getByRole("button", { name: "Open notifications" })).toBeInTheDocument();
14
15
  });
16
+
17
+ it("caps the menu width so it never overflows a narrow viewport", async () => {
18
+ const user = userEvent.setup();
19
+ render(<NavNotifications notifications={notifications} />);
20
+ await user.click(screen.getByRole("button", { name: "Open notifications" }));
21
+ const menu = await screen.findByRole("menu");
22
+ // Without a max-width cap, the fixed `w-80` (320px) menu forces horizontal
23
+ // overflow on any viewport narrower than ~336px.
24
+ expect(menu.className).toContain("max-w-[calc(100vw-2rem)]");
25
+ });
15
26
  });
@@ -81,7 +81,7 @@ export function NavNotifications({
81
81
  variant="ghost"
82
82
  size="icon"
83
83
  className={cn("rounded-full", className)}
84
- aria-label="Open notifications"
84
+ aria-label={t("ui.navNotifications.trigger")}
85
85
  >
86
86
  <BellIcon className="size-5" />
87
87
  </Button>
@@ -89,6 +89,9 @@ export function NavNotifications({
89
89
  <DropdownMenuContent
90
90
  side={side}
91
91
  align={align}
92
+ // Keeps the menu clear of the viewport edge instead of sitting flush
93
+ // against it on a narrow phone screen.
94
+ collisionPadding={16}
92
95
  className={cn(
93
96
  // The vertical inset belongs to the SIDE-opening rail placement, where
94
97
  // the menu runs alongside its trigger and needs clearance from it. A
@@ -96,7 +99,10 @@ export function NavNotifications({
96
99
  // and the inset would just hang it 24px off the bar. Derived from
97
100
  // `side` rather than exposed as a second className seam.
98
101
  (side === "left" || side === "right") && "my-6",
99
- "w-80",
102
+ // `w-80` (320px) is wider than some phone viewports once the 16px
103
+ // collision padding on both sides is subtracted — cap it so the menu
104
+ // shrinks instead of forcing horizontal overflow.
105
+ "w-80 max-w-[calc(100vw-2rem)]",
100
106
  )}
101
107
  >
102
108
  <DropdownMenuLabel>{t("ui.navNotifications.label")}</DropdownMenuLabel>
@@ -107,13 +113,13 @@ export function NavNotifications({
107
113
  <AvatarFallback>{fallback}</AvatarFallback>
108
114
  </Avatar>
109
115
  <div className="flex flex-col">
110
- <span className="text-sm font-medium">{text}</span>
111
- <span className="text-xs text-muted-foreground">{time}</span>
116
+ <span className="text-body font-medium">{text}</span>
117
+ <span className="text-meta text-muted-foreground">{time}</span>
112
118
  </div>
113
119
  </DropdownMenuItem>
114
120
  ))}
115
121
  <DropdownMenuSeparator />
116
- <DropdownMenuItem className="justify-center text-sm text-muted-foreground">
122
+ <DropdownMenuItem className="justify-center text-body text-muted-foreground">
117
123
  {viewAllLabel}
118
124
  </DropdownMenuItem>
119
125
  </DropdownMenuContent>
@@ -54,9 +54,9 @@ export function NavUser({ user }: NavUserProps) {
54
54
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
55
55
  <AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
56
56
  </Avatar>
57
- <div className="grid flex-1 text-start text-sm leading-tight">
57
+ <div className="grid flex-1 text-start text-body leading-tight">
58
58
  <span className="truncate font-medium">{user.name}</span>
59
- <span className="truncate text-xs">{user.email}</span>
59
+ <span className="truncate text-meta">{user.email}</span>
60
60
  </div>
61
61
  <ChevronsUpDown className="ms-auto size-4" />
62
62
  </SidebarMenuButton>
@@ -68,14 +68,14 @@ export function NavUser({ user }: NavUserProps) {
68
68
  sideOffset={4}
69
69
  >
70
70
  <DropdownMenuLabel className="p-0 font-normal">
71
- <div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
71
+ <div className="flex items-center gap-2 px-1 py-1.5 text-start text-body">
72
72
  <Avatar className="h-8 w-8 rounded-lg">
73
73
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
74
74
  <AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
75
75
  </Avatar>
76
- <div className="grid flex-1 text-start text-sm leading-tight">
76
+ <div className="grid flex-1 text-start text-body leading-tight">
77
77
  <span className="truncate font-medium">{user.name}</span>
78
- <span className="truncate text-xs">{user.email}</span>
78
+ <span className="truncate text-meta">{user.email}</span>
79
79
  </div>
80
80
  </div>
81
81
  </DropdownMenuLabel>
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -150,6 +152,7 @@ export const NavigationMenu = forwardRef<
150
152
  <NavigationMenuGuardContext.Provider value={guard}>
151
153
  <NavigationMenuPrimitive.Root
152
154
  ref={ref}
155
+ data-slot="navigation-menu"
153
156
  value={currentValue}
154
157
  onValueChange={handleValueChange}
155
158
  className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
@@ -169,6 +172,7 @@ export const NavigationMenuList = forwardRef<
169
172
  return (
170
173
  <NavigationMenuPrimitive.List
171
174
  ref={ref}
175
+ data-slot="navigation-menu-list"
172
176
  className={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
173
177
  {...props}
174
178
  />
@@ -183,13 +187,18 @@ export const NavigationMenuItem = forwardRef<
183
187
  const value = valueProp ?? autoValue;
184
188
  return (
185
189
  <NavigationMenuItemValueContext.Provider value={value}>
186
- <NavigationMenuPrimitive.Item ref={ref} value={value} {...props} />
190
+ <NavigationMenuPrimitive.Item
191
+ ref={ref}
192
+ data-slot="navigation-menu-item"
193
+ value={value}
194
+ {...props}
195
+ />
187
196
  </NavigationMenuItemValueContext.Provider>
188
197
  );
189
198
  });
190
199
 
191
200
  export const navigationMenuTriggerStyle = cva(
192
- "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent",
201
+ "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-body font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent",
193
202
  );
194
203
 
195
204
  export const NavigationMenuTrigger = forwardRef<
@@ -211,6 +220,7 @@ export const NavigationMenuTrigger = forwardRef<
211
220
  return (
212
221
  <NavigationMenuPrimitive.Trigger
213
222
  ref={ref}
223
+ data-slot="navigation-menu-trigger"
214
224
  className={cn(navigationMenuTriggerStyle(), "group", className)}
215
225
  onPointerEnter={(event) => {
216
226
  onPointerEnter?.(event);
@@ -242,8 +252,9 @@ export const NavigationMenuContent = forwardRef<
242
252
  return (
243
253
  <NavigationMenuPrimitive.Content
244
254
  ref={ref}
255
+ data-slot="navigation-menu-content"
245
256
  className={cn(
246
- "left-0 top-0 w-full p-2 md:absolute md:w-auto",
257
+ "start-0 top-0 w-full p-2 md:absolute md:w-auto",
247
258
  "data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out",
248
259
  "data-[motion=from-end]:slide-in-from-right-2 data-[motion=from-start]:slide-in-from-left-2 data-[motion=to-end]:slide-out-to-right-2 data-[motion=to-start]:slide-out-to-left-2",
249
260
  "data-[motion^=from-]:[--tw-ease:var(--ease-entrance)]",
@@ -261,9 +272,10 @@ export const NavigationMenuViewport = forwardRef<
261
272
  ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
262
273
  >(function NavigationMenuViewport({ className, ...props }, ref) {
263
274
  return (
264
- <div className="absolute left-0 top-full flex justify-center">
275
+ <div className="absolute start-0 top-full flex justify-center">
265
276
  <NavigationMenuPrimitive.Viewport
266
277
  ref={ref}
278
+ data-slot="navigation-menu-viewport"
267
279
  className={cn(
268
280
  "relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
269
281
  "origin-top transition-[width,height] duration-base ease-standard data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:zoom-in-90 data-[state=closed]:zoom-out-95",
@@ -76,13 +76,17 @@ type Story = StoryObj<typeof meta>;
76
76
 
77
77
  export const Default: Story = {
78
78
  args: { defaultValue: 5, min: 0, max: 10 },
79
- // Clicks the Increase button and confirms the value increments.
79
+ // Clicks the Increase button and confirms the value increments. The input is a
80
+ // locale-formatted text spinbutton, so the displayed value is a string and the
81
+ // numeric value is announced via aria-valuenow.
80
82
  play: async ({ canvas, userEvent }) => {
81
83
  const input = canvas.getByRole("spinbutton");
82
- await expect(input).toHaveValue(5);
84
+ await expect(input).toHaveValue("5");
85
+ await expect(input).toHaveAttribute("aria-valuenow", "5");
83
86
  const increaseBtn = canvas.getByRole("button", { name: /increase/i });
84
87
  await userEvent.click(increaseBtn);
85
- await expect(input).toHaveValue(6);
88
+ await expect(input).toHaveValue("6");
89
+ await expect(input).toHaveAttribute("aria-valuenow", "6");
86
90
  },
87
91
  };
88
92
 
@@ -1,7 +1,9 @@
1
+ import { createRef } from "react";
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
  import { render, screen } from "@testing-library/react";
3
4
  import userEvent from "@testing-library/user-event";
4
5
  import { NumberInput } from "./number-input";
6
+ import { LocaleProvider } from "../locale-provider";
5
7
 
6
8
  describe("NumberInput", () => {
7
9
  it("renders a spinbutton input", () => {
@@ -93,4 +95,49 @@ describe("NumberInput", () => {
93
95
  await userEvent.keyboard("{ArrowDown}");
94
96
  expect(onValueChange).toHaveBeenLastCalledWith(5);
95
97
  });
98
+
99
+ it("forwards the ref to the underlying <input>, not the wrapper", () => {
100
+ const ref = createRef<HTMLInputElement>();
101
+ render(<NumberInput aria-label="Qty" defaultValue={5} ref={ref} />);
102
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
103
+ expect(ref.current).toBe(screen.getByRole("spinbutton", { name: "Qty" }));
104
+ });
105
+
106
+ it("does not hard-fix a width — the caller's className is not fighting a default", () => {
107
+ render(<NumberInput aria-label="Qty" defaultValue={5} className="w-24" />);
108
+ const group = screen.getByRole("group");
109
+ expect(group.className).not.toMatch(/\bw-36\b/);
110
+ expect(group.className).toMatch(/\bw-24\b/);
111
+ });
112
+
113
+ it("rounds fp-noisy step increments to the step's precision (0.1 + 0.1 + 0.1)", async () => {
114
+ const onValueChange = vi.fn();
115
+ render(
116
+ <NumberInput aria-label="Amount" defaultValue={0} step={0.1} onValueChange={onValueChange} />,
117
+ );
118
+ const increaseBtn = screen.getByRole("button", { name: "Increase" });
119
+ await userEvent.click(increaseBtn);
120
+ await userEvent.click(increaseBtn);
121
+ await userEvent.click(increaseBtn);
122
+ expect(onValueChange).toHaveBeenLastCalledWith(0.3);
123
+ });
124
+
125
+ it("parses a locale-formatted number using the active locale's separators (de-DE: comma decimal)", async () => {
126
+ const onValueChange = vi.fn();
127
+ render(
128
+ <LocaleProvider locale="de-DE">
129
+ <NumberInput
130
+ aria-label="Betrag"
131
+ defaultValue={0}
132
+ step={0.1}
133
+ onValueChange={onValueChange}
134
+ />
135
+ </LocaleProvider>,
136
+ );
137
+ const input = screen.getByRole("spinbutton", { name: "Betrag" });
138
+ await userEvent.clear(input);
139
+ await userEvent.type(input, "1.234,5");
140
+ await userEvent.tab();
141
+ expect(onValueChange).toHaveBeenLastCalledWith(1234.5);
142
+ });
96
143
  });
@@ -1,8 +1,10 @@
1
+ "use client";
2
+
1
3
  // a2ui.exposed: yes
2
4
  import { forwardRef, useCallback, useRef, useState, type HTMLAttributes } from "react";
3
5
  import { Minus, Plus } from "lucide-react";
4
- import { cn } from "../../lib/cn";
5
6
  import { InputGroup, InputGroupButton, InputGroupInput } from "../input-group";
7
+ import { useLocale } from "../locale-provider";
6
8
 
7
9
  export interface NumberInputProps extends Omit<
8
10
  HTMLAttributes<HTMLDivElement>,
@@ -56,11 +58,37 @@ function clampValue(val: number, min?: number, max?: number): number {
56
58
  return result;
57
59
  }
58
60
 
59
- function formatNum(value: number, formatOptions?: Intl.NumberFormatOptions): string {
60
- if (formatOptions) {
61
- return new Intl.NumberFormat(undefined, formatOptions).format(value);
62
- }
63
- return String(value);
61
+ /** Number of decimal places implied by a step (e.g. 0.1 → 1, 1 → 0). */
62
+ function stepDecimals(step: number): number {
63
+ if (!isFinite(step) || step === 0) return 0;
64
+ const s = String(step);
65
+ const i = s.indexOf(".");
66
+ return i === -1 ? 0 : s.length - i - 1;
67
+ }
68
+
69
+ /** Round `value` to the decimal precision implied by `step`, avoiding fp noise (0.1+0.2). */
70
+ function roundToStep(value: number, step: number): number {
71
+ const decimals = stepDecimals(step);
72
+ if (decimals === 0) return Math.round(value);
73
+ const factor = 10 ** decimals;
74
+ return Math.round(value * factor) / factor;
75
+ }
76
+
77
+ /** The active locale's decimal and group separator characters (e.g. "," / "." in de-DE). */
78
+ function getLocaleSeparators(locale: string): { decimal: string; group: string } {
79
+ const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
80
+ const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
81
+ const group = parts.find((p) => p.type === "group")?.value ?? ",";
82
+ return { decimal, group };
83
+ }
84
+
85
+ /** Parse a locale-formatted numeric string (group + decimal separators) into a number. */
86
+ function parseLocaleNumber(raw: string, locale: string): number {
87
+ const { decimal, group } = getLocaleSeparators(locale);
88
+ let normalized = raw.trim();
89
+ if (group) normalized = normalized.split(group).join("");
90
+ if (decimal !== ".") normalized = normalized.split(decimal).join(".");
91
+ return Number(normalized);
64
92
  }
65
93
 
66
94
  /**
@@ -69,7 +97,7 @@ function formatNum(value: number, formatOptions?: Intl.NumberFormatOptions): str
69
97
  * Controlled via `value`/`onValueChange`; uncontrolled via `defaultValue`.
70
98
  * Keyboard ↑/↓ = ±step; Shift+↑/↓ = ±step×10. Clamps on blur (not per keystroke).
71
99
  */
72
- export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function NumberInput(
100
+ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(function NumberInput(
73
101
  {
74
102
  value: valueProp,
75
103
  defaultValue,
@@ -94,14 +122,20 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
94
122
  },
95
123
  ref,
96
124
  ) {
125
+ const { locale, formatNumber, t } = useLocale();
97
126
  const isControlled = valueProp !== undefined;
98
127
 
128
+ const formatValue = useCallback(
129
+ (n: number) => formatNumber(n, formatOptions),
130
+ [formatNumber, formatOptions],
131
+ );
132
+
99
133
  const [internalValue, setInternalValue] = useState<number | null>(
100
134
  defaultValue !== undefined ? defaultValue : null,
101
135
  );
102
136
  const [inputText, setInputText] = useState<string>(() => {
103
137
  const initial = isControlled ? valueProp : defaultValue;
104
- return initial != null ? formatNum(initial, formatOptions) : "";
138
+ return initial != null ? formatValue(initial) : "";
105
139
  });
106
140
  // Track whether the user is actively editing to avoid overwriting their input
107
141
  const isEditing = useRef(false);
@@ -120,12 +154,12 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
120
154
  (delta: number) => {
121
155
  if (disabled || readOnly) return;
122
156
  const base = currentValue ?? 0;
123
- let next = base + delta;
157
+ let next = roundToStep(base + delta, step);
124
158
  if (clamp) next = clampValue(next, min, max);
125
- setInputText(next != null ? formatNum(next, formatOptions) : "");
159
+ setInputText(formatValue(next));
126
160
  commit(next);
127
161
  },
128
- [disabled, readOnly, currentValue, clamp, min, max, formatOptions, commit],
162
+ [disabled, readOnly, currentValue, step, clamp, min, max, formatValue, commit],
129
163
  );
130
164
 
131
165
  const handleKeyDown = useCallback(
@@ -152,22 +186,23 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
152
186
  commit(null);
153
187
  return;
154
188
  }
155
- const parsed = parseFloat(raw);
189
+ const parsed = parseLocaleNumber(raw, locale);
156
190
  if (isNaN(parsed)) {
157
191
  // Revert to last known good value
158
- setInputText(currentValue != null ? formatNum(currentValue, formatOptions) : "");
192
+ setInputText(currentValue != null ? formatValue(currentValue) : "");
159
193
  return;
160
194
  }
161
- const clamped = clamp ? clampValue(parsed, min, max) : parsed;
162
- setInputText(formatNum(clamped, formatOptions));
195
+ const rounded = roundToStep(parsed, step);
196
+ const clamped = clamp ? clampValue(rounded, min, max) : rounded;
197
+ setInputText(formatValue(clamped));
163
198
  commit(clamped);
164
- }, [inputText, currentValue, clamp, min, max, formatOptions, commit]);
199
+ }, [inputText, currentValue, clamp, min, max, step, locale, formatValue, commit]);
165
200
 
166
201
  // Keep display text in sync with controlled value changes (only when not editing)
167
202
  const prevControlledValue = useRef(valueProp);
168
203
  if (isControlled && valueProp !== prevControlledValue.current && !isEditing.current) {
169
204
  prevControlledValue.current = valueProp;
170
- const nextText = valueProp != null ? formatNum(valueProp, formatOptions) : "";
205
+ const nextText = valueProp != null ? formatValue(valueProp) : "";
171
206
  if (nextText !== inputText) setInputText(nextText);
172
207
  }
173
208
 
@@ -177,18 +212,24 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
177
212
  disabled || readOnly || (max !== undefined && (currentValue ?? 0) >= max);
178
213
 
179
214
  return (
180
- <InputGroup ref={ref} className={cn("w-36", className)} {...props}>
215
+ <InputGroup className={className} {...props}>
181
216
  <InputGroupButton
182
- aria-label="Decrease"
217
+ aria-label={t("ui.numberInput.decrease")}
183
218
  tabIndex={-1}
184
219
  disabled={decrementDisabled}
185
220
  onClick={() => applyStep(-step)}
186
- className="rounded-r-none"
221
+ className="rounded-e-none"
187
222
  >
188
223
  <Minus aria-hidden="true" />
189
224
  </InputGroupButton>
190
225
  <InputGroupInput
191
- type="number"
226
+ ref={ref}
227
+ type="text"
228
+ inputMode="decimal"
229
+ role="spinbutton"
230
+ aria-valuenow={currentValue ?? undefined}
231
+ aria-valuemin={min}
232
+ aria-valuemax={max}
192
233
  id={id}
193
234
  name={name}
194
235
  value={inputText}
@@ -200,17 +241,17 @@ export const NumberInput = forwardRef<HTMLDivElement, NumberInputProps>(function
200
241
  aria-describedby={ariaDescribedby}
201
242
  aria-invalid={ariaInvalid}
202
243
  aria-required={ariaRequired}
203
- className="text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
244
+ className="text-center"
204
245
  onChange={handleChange}
205
246
  onKeyDown={handleKeyDown}
206
247
  onBlur={handleBlur}
207
248
  />
208
249
  <InputGroupButton
209
- aria-label="Increase"
250
+ aria-label={t("ui.numberInput.increase")}
210
251
  tabIndex={-1}
211
252
  disabled={incrementDisabled}
212
253
  onClick={() => applyStep(step)}
213
- className="rounded-l-none"
254
+ className="rounded-s-none"
214
255
  >
215
256
  <Plus aria-hidden="true" />
216
257
  </InputGroupButton>
@@ -1,7 +1,26 @@
1
+ import { createRef } from "react";
1
2
  import { describe, expect, it } from "vitest";
2
3
  import { render, screen } from "@testing-library/react";
3
4
  import { LocaleProvider } from "../locale-provider";
4
- import { PaginationEllipsis, PaginationNext, PaginationPrevious } from "./pagination";
5
+ import {
6
+ PaginationEllipsis,
7
+ PaginationLink,
8
+ PaginationNext,
9
+ PaginationPrevious,
10
+ } from "./pagination";
11
+
12
+ describe("PaginationLink — forwardRef", () => {
13
+ it("forwards its ref to the rendered <a>", () => {
14
+ const ref = createRef<HTMLAnchorElement>();
15
+ render(
16
+ <PaginationLink ref={ref} href="#2">
17
+ 2
18
+ </PaginationLink>,
19
+ );
20
+ expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
21
+ expect(ref.current).toBe(screen.getByText("2"));
22
+ });
23
+ });
5
24
 
6
25
  /**
7
26
  * Pagination had no test file before #18 — its three microcopy sites
@@ -5,10 +5,11 @@ import { buttonVariants } from "../button";
5
5
  import { useLocale } from "../locale-provider";
6
6
 
7
7
  export function Pagination({ className, ...props }: ComponentProps<"nav">) {
8
+ const { t } = useLocale();
8
9
  return (
9
10
  <nav
10
11
  role="navigation"
11
- aria-label="pagination"
12
+ aria-label={t("ui.pagination.label")}
12
13
  className={cn("mx-auto flex w-full justify-center", className)}
13
14
  {...props}
14
15
  />
@@ -27,19 +28,22 @@ export const PaginationItem = forwardRef<HTMLLIElement, ComponentProps<"li">>(
27
28
  },
28
29
  );
29
30
  type PaginationLinkProps = { isActive?: boolean } & ComponentProps<"a">;
30
- export function PaginationLink({ className, isActive, ...props }: PaginationLinkProps) {
31
- return (
32
- <a
33
- aria-current={isActive ? "page" : undefined}
34
- className={cn(
35
- buttonVariants({ variant: isActive ? "outline" : "ghost", size: "icon" }),
36
- "cursor-pointer",
37
- className,
38
- )}
39
- {...props}
40
- />
41
- );
42
- }
31
+ export const PaginationLink = forwardRef<HTMLAnchorElement, PaginationLinkProps>(
32
+ function PaginationLink({ className, isActive, ...props }, ref) {
33
+ return (
34
+ <a
35
+ ref={ref}
36
+ aria-current={isActive ? "page" : undefined}
37
+ className={cn(
38
+ buttonVariants({ variant: isActive ? "outline" : "ghost", size: "icon" }),
39
+ "cursor-pointer",
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ );
45
+ },
46
+ );
43
47
  export function PaginationPrevious({ className, ...props }: ComponentProps<"a">) {
44
48
  const { t } = useLocale();
45
49
  return (
@@ -14,6 +14,7 @@ export const PopoverContent = forwardRef<
14
14
  <PopoverPrimitive.Portal>
15
15
  <PopoverPrimitive.Content
16
16
  ref={ref}
17
+ data-slot="popover-content"
17
18
  align={align}
18
19
  sideOffset={sideOffset}
19
20
  className={cn(