@elabs-ai/components-ui 4.0.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -2,9 +2,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { LocaleProvider, useLocale } from "./locale-provider";
3
3
 
4
4
  const meta = {
5
- title: "Providers/LocaleProvider",
5
+ title: "Foundations/Localization",
6
6
  component: LocaleProvider,
7
7
  tags: ["autodocs"],
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "Root i18n provider — one of the two providers every app mounts once, at the root. " +
13
+ 'Wrap it around your app inside `ThemeProvider`: `<ThemeProvider><LocaleProvider locale="en-US">…' +
14
+ "</LocaleProvider></ThemeProvider>`. Every component's microcopy, number formatting and " +
15
+ "date formatting then reads through `useLocale()`. Full resolution chain (`translate` → " +
16
+ "`messages` → shipped English defaults → raw key), cardinal plurals and bridging to your " +
17
+ "own i18n runtime (next-intl, react-intl, i18next, …): `docs/I18N.md`. See " +
18
+ "[Foundations / Theming](?path=/docs/foundations-theming--docs) for the other root provider.",
19
+ },
20
+ },
21
+ },
8
22
  } satisfies Meta<typeof LocaleProvider>;
9
23
 
10
24
  export default meta;
@@ -52,6 +66,24 @@ function LocaleDemo() {
52
66
  );
53
67
  }
54
68
 
69
+ // ---------------------------------------------------------------------------
70
+ // Demo component: cardinal-plural microcopy via `Intl.PluralRules`
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function PluralDemo() {
74
+ const { t } = useLocale();
75
+ return (
76
+ <div className="space-y-1 rounded-md border border-border p-4 text-body">
77
+ {[0, 1, 2, 5].map((count) => (
78
+ <p key={count}>
79
+ <span className="font-medium text-muted-foreground">count={count}: </span>
80
+ {t("itemsSelected", { count })}
81
+ </p>
82
+ ))}
83
+ </div>
84
+ );
85
+ }
86
+
55
87
  // ---------------------------------------------------------------------------
56
88
  // Stories
57
89
  // ---------------------------------------------------------------------------
@@ -81,3 +113,59 @@ export const RtlArabic: Story = {
81
113
  export const NoProvider: Story = {
82
114
  render: () => <LocaleDemo />,
83
115
  };
116
+
117
+ /**
118
+ * The `translate` resolver bridge (#19) — stands in for an app's own i18n
119
+ * runtime (next-intl, react-intl, i18next, …). It takes precedence over both
120
+ * `messages` and the shipped English defaults; returning `undefined` for a
121
+ * key falls through to the normal chain. See `docs/I18N.md` for a worked
122
+ * next-intl example.
123
+ */
124
+ export const ExternalTranslateResolver: Story = {
125
+ render: () => (
126
+ <LocaleProvider
127
+ locale="de-DE"
128
+ messages={{ close: "Schließen (via messages)" }}
129
+ translate={(key) => (key === "close" ? "Schließen (via translate)" : undefined)}
130
+ >
131
+ <LocaleDemo />
132
+ </LocaleProvider>
133
+ ),
134
+ };
135
+
136
+ /**
137
+ * Cardinal-plural microcopy (#19) — `t("itemsSelected", { count })` selects
138
+ * the right English form ("1 item selected" vs "N items selected") from a
139
+ * `PluralMessage` map as `count` changes, via `Intl.PluralRules`.
140
+ */
141
+ export const PluralForms: Story = {
142
+ render: () => (
143
+ <LocaleProvider locale="en-US">
144
+ <PluralDemo />
145
+ </LocaleProvider>
146
+ ),
147
+ };
148
+
149
+ /**
150
+ * The same plural key resolved for Polish (`pl-PL`), which has FOUR cardinal
151
+ * categories (one/few/many/other) with different count boundaries than
152
+ * English's two (one/other) — the case a plural design that only handles
153
+ * English silently gets wrong. Count 1 → "one", 2 → "few", 5 → "many".
154
+ */
155
+ export const PluralFormsPolish: Story = {
156
+ render: () => (
157
+ <LocaleProvider
158
+ locale="pl-PL"
159
+ messages={{
160
+ itemsSelected: {
161
+ one: "{count} plik zaznaczony",
162
+ few: "{count} pliki zaznaczone",
163
+ many: "{count} plików zaznaczonych",
164
+ other: "{count} pliku zaznaczonego",
165
+ },
166
+ }}
167
+ >
168
+ <PluralDemo />
169
+ </LocaleProvider>
170
+ ),
171
+ };
@@ -1,4 +1,4 @@
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
3
  import { createPortal } from "react-dom";
4
4
  import { useDirection } from "@radix-ui/react-direction";
@@ -236,3 +236,259 @@ describe("t() — variable interpolation", () => {
236
236
  expect(screen.getByTestId("result").textContent).toBe("greeting");
237
237
  });
238
238
  });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // t() — pluggable `translate` resolver bridge (#19)
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe("LocaleProvider — translate resolver bridge", () => {
245
+ function TranslateReader({ vars }: { vars?: Record<string, string | number> }) {
246
+ const { t } = useLocale();
247
+ return <span data-testid="result">{t("close", vars)}</span>;
248
+ }
249
+
250
+ it("a translate() result overrides both a messages override and the English default", () => {
251
+ render(
252
+ <LocaleProvider
253
+ messages={{ close: "Fermer" }}
254
+ translate={(key) => (key === "close" ? "Schließen" : undefined)}
255
+ >
256
+ <TranslateReader />
257
+ </LocaleProvider>,
258
+ );
259
+ expect(screen.getByTestId("result").textContent).toBe("Schließen");
260
+ });
261
+
262
+ it("translate() returning undefined falls through to messages, then DEFAULT_MESSAGES, then the key", () => {
263
+ // Rung 1: messages override present -> used.
264
+ const { rerender } = render(
265
+ <LocaleProvider messages={{ close: "Fermer" }} translate={() => undefined}>
266
+ <TranslateReader />
267
+ </LocaleProvider>,
268
+ );
269
+ expect(screen.getByTestId("result").textContent).toBe("Fermer");
270
+
271
+ // Rung 2: no messages override -> DEFAULT_MESSAGES English default.
272
+ rerender(
273
+ <LocaleProvider translate={() => undefined}>
274
+ <TranslateReader />
275
+ </LocaleProvider>,
276
+ );
277
+ expect(screen.getByTestId("result").textContent).toBe("Close");
278
+
279
+ // Rung 3: a key with no default and no override -> the raw key itself.
280
+ function UnknownKeyReader() {
281
+ const { t } = useLocale();
282
+ return <span data-testid="unknown">{t("totallyUnknownKey")}</span>;
283
+ }
284
+ rerender(
285
+ <LocaleProvider translate={() => undefined}>
286
+ <UnknownKeyReader />
287
+ </LocaleProvider>,
288
+ );
289
+ expect(screen.getByTestId("unknown").textContent).toBe("totallyUnknownKey");
290
+ });
291
+
292
+ it("passes the raw key and vars to translate(), not a pre-interpolated string", () => {
293
+ const translate = vi.fn((_key: string, _vars?: Record<string, string | number>) => undefined);
294
+ function CountReader() {
295
+ const { t } = useLocale();
296
+ return (
297
+ <span data-testid="result">
298
+ {t("ui.viewToolbar.countOfTotal", { count: 3, total: 10 })}
299
+ </span>
300
+ );
301
+ }
302
+ render(
303
+ <LocaleProvider translate={translate}>
304
+ <CountReader />
305
+ </LocaleProvider>,
306
+ );
307
+ expect(translate).toHaveBeenCalledWith("ui.viewToolbar.countOfTotal", { count: 3, total: 10 });
308
+ });
309
+
310
+ it("does not double-interpolate a translate() return value that itself contains a literal '{'", () => {
311
+ render(
312
+ <LocaleProvider translate={(key) => (key === "close" ? "raw {not-a-var} text" : undefined)}>
313
+ <TranslateReader />
314
+ </LocaleProvider>,
315
+ );
316
+ // brand-ui's own {name}-replacement must NOT run a second time over the
317
+ // resolver's own output — a real ICU formatter already interpolated it.
318
+ expect(screen.getByTestId("result").textContent).toBe("raw {not-a-var} text");
319
+ });
320
+
321
+ it("with no resolver and no message, t() still falls back to the key exactly as today", () => {
322
+ function UnknownKeyReader() {
323
+ const { t } = useLocale();
324
+ return <span data-testid="result">{t("anotherUnknownKey")}</span>;
325
+ }
326
+ render(
327
+ <LocaleProvider>
328
+ <UnknownKeyReader />
329
+ </LocaleProvider>,
330
+ );
331
+ expect(screen.getByTestId("result").textContent).toBe("anotherUnknownKey");
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // t() — ICU cardinal-plural forms via `Intl.PluralRules` (#19)
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe("LocaleProvider — plural forms (Intl.PluralRules)", () => {
340
+ const ITEM_COUNT_MESSAGE = {
341
+ one: "{count} item selected",
342
+ other: "{count} items selected",
343
+ };
344
+
345
+ function PluralReader({ count }: { count: number }) {
346
+ const { t } = useLocale();
347
+ return <span data-testid="result">{t("itemCount", { count })}</span>;
348
+ }
349
+
350
+ it("selects the English 'one' form for count=1 and 'other' for 0 and many", () => {
351
+ const { rerender } = render(
352
+ <LocaleProvider locale="en-US" messages={{ itemCount: ITEM_COUNT_MESSAGE }}>
353
+ <PluralReader count={1} />
354
+ </LocaleProvider>,
355
+ );
356
+ expect(screen.getByTestId("result").textContent).toBe("1 item selected");
357
+
358
+ rerender(
359
+ <LocaleProvider locale="en-US" messages={{ itemCount: ITEM_COUNT_MESSAGE }}>
360
+ <PluralReader count={0} />
361
+ </LocaleProvider>,
362
+ );
363
+ expect(screen.getByTestId("result").textContent).toBe("0 items selected");
364
+
365
+ rerender(
366
+ <LocaleProvider locale="en-US" messages={{ itemCount: ITEM_COUNT_MESSAGE }}>
367
+ <PluralReader count={5} />
368
+ </LocaleProvider>,
369
+ );
370
+ expect(screen.getByTestId("result").textContent).toBe("5 items selected");
371
+ });
372
+
373
+ // Polish has FOUR cardinal categories (one/few/many/other) with different
374
+ // boundaries than English's two (one/other) — this is the case a
375
+ // design that only handles English silently gets wrong.
376
+ const PL_FILE_MESSAGE = {
377
+ one: "{count} plik",
378
+ few: "{count} pliki",
379
+ many: "{count} plików",
380
+ other: "{count} pliku",
381
+ };
382
+
383
+ function PlPluralReader({ count }: { count: number }) {
384
+ const { t } = useLocale();
385
+ return <span data-testid="result">{t("fileCount", { count })}</span>;
386
+ }
387
+
388
+ it("selects the correct Polish plural category for 1 (one), 2 (few), 5 (many), 12 (many), and 22 (few) — teens boundary", () => {
389
+ const { rerender } = render(
390
+ <LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
391
+ <PlPluralReader count={1} />
392
+ </LocaleProvider>,
393
+ );
394
+ expect(screen.getByTestId("result").textContent).toBe("1 plik");
395
+
396
+ rerender(
397
+ <LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
398
+ <PlPluralReader count={2} />
399
+ </LocaleProvider>,
400
+ );
401
+ expect(screen.getByTestId("result").textContent).toBe("2 pliki");
402
+
403
+ rerender(
404
+ <LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
405
+ <PlPluralReader count={5} />
406
+ </LocaleProvider>,
407
+ );
408
+ expect(screen.getByTestId("result").textContent).toBe("5 plików");
409
+
410
+ // Polish teens (11–21) always use "many" category, breaking the pattern.
411
+ // 12 → "many"; 22 → "few" demonstrates this non-contiguous boundary.
412
+ rerender(
413
+ <LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
414
+ <PlPluralReader count={12} />
415
+ </LocaleProvider>,
416
+ );
417
+ expect(screen.getByTestId("result").textContent).toBe("12 plików");
418
+
419
+ rerender(
420
+ <LocaleProvider locale="pl-PL" messages={{ fileCount: PL_FILE_MESSAGE }}>
421
+ <PlPluralReader count={22} />
422
+ </LocaleProvider>,
423
+ );
424
+ expect(screen.getByTestId("result").textContent).toBe("22 pliki");
425
+ });
426
+
427
+ it("falls back to the 'other' form when a category has no entry", () => {
428
+ // Only "one" and "other" are supplied; Polish "few"/"many" counts must
429
+ // fall back to "other" rather than rendering undefined/blank.
430
+ const PARTIAL = { one: "{count} rzecz", other: "{count} rzeczy" };
431
+ render(
432
+ <LocaleProvider locale="pl-PL" messages={{ fileCount: PARTIAL }}>
433
+ <PlPluralReader count={2} />
434
+ </LocaleProvider>,
435
+ );
436
+ expect(screen.getByTestId("result").textContent).toBe("2 rzeczy");
437
+ });
438
+
439
+ it("handles locales with zero/two categories (Arabic example)", () => {
440
+ // Arabic (ar-EG) has categories: zero, one, two, few, many, other.
441
+ // This test verifies that locales with non-English category sets work
442
+ // and that partial maps (not providing every category) fall back correctly.
443
+ const AR_MESSAGE: Record<string, string> = {
444
+ zero: "{count} ملفات", // zero
445
+ one: "{count} ملف", // one
446
+ two: "{count} ملفان", // two
447
+ few: "{count} ملفات", // few
448
+ many: "{count} ملف", // many
449
+ other: "{count} ملف", // other
450
+ };
451
+
452
+ function ArPluralReader({ count }: { count: number }) {
453
+ const { t } = useLocale();
454
+ return <span data-testid="result">{t("fileCount", { count })}</span>;
455
+ }
456
+
457
+ const { rerender } = render(
458
+ <LocaleProvider locale="ar-EG" messages={{ fileCount: AR_MESSAGE }}>
459
+ <ArPluralReader count={0} />
460
+ </LocaleProvider>,
461
+ );
462
+ expect(screen.getByTestId("result").textContent).toBe("0 ملفات");
463
+
464
+ rerender(
465
+ <LocaleProvider locale="ar-EG" messages={{ fileCount: AR_MESSAGE }}>
466
+ <ArPluralReader count={1} />
467
+ </LocaleProvider>,
468
+ );
469
+ expect(screen.getByTestId("result").textContent).toBe("1 ملف");
470
+
471
+ rerender(
472
+ <LocaleProvider locale="ar-EG" messages={{ fileCount: AR_MESSAGE }}>
473
+ <ArPluralReader count={2} />
474
+ </LocaleProvider>,
475
+ );
476
+ expect(screen.getByTestId("result").textContent).toBe("2 ملفان");
477
+ });
478
+
479
+ it("the no-provider default value also resolves plural forms (en-US)", () => {
480
+ // No provider, so this exercises makeDefaultValue()'s t() directly against
481
+ // the shipped DEFAULT_MESSAGES "itemsSelected" plural entry — proving the
482
+ // singleton default value branches on plural forms too, not only a
483
+ // provider's merged messages.
484
+ function DefaultPluralReader({ count }: { count: number }) {
485
+ const { t } = useLocale();
486
+ return <span data-testid="result">{t("itemsSelected", { count })}</span>;
487
+ }
488
+ const { rerender } = render(<DefaultPluralReader count={1} />);
489
+ expect(screen.getByTestId("result").textContent).toBe("1 item selected");
490
+
491
+ rerender(<DefaultPluralReader count={5} />);
492
+ expect(screen.getByTestId("result").textContent).toBe("5 items selected");
493
+ });
494
+ });
@@ -2,14 +2,19 @@
2
2
 
3
3
  import { createContext, use, useMemo, type ReactNode } from "react";
4
4
  import { DirectionProvider } from "@radix-ui/react-direction";
5
- import { DEFAULT_MESSAGES } from "./messages";
5
+ import { DEFAULT_MESSAGES, type MessageValue, type PluralCategory } from "./messages";
6
6
 
7
7
  // ---------------------------------------------------------------------------
8
8
  // Public types
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- /** A flat map of string keys to their translated values. */
12
- export type Messages = Record<string, string>;
11
+ /**
12
+ * A flat map of string keys to their translated values — a plain string, or a
13
+ * `PluralMessage` (one string per ICU cardinal-plural category, e.g.
14
+ * `{ one: "{count} item", other: "{count} items" }`) for microcopy whose
15
+ * wording changes with a count. See `PluralMessage` in `./messages`.
16
+ */
17
+ export type Messages = Record<string, MessageValue>;
13
18
 
14
19
  export interface LocaleContextValue {
15
20
  /** BCP-47 locale tag, e.g. "en-US", "ar-EG", "de-DE". */
@@ -18,8 +23,13 @@ export interface LocaleContextValue {
18
23
  dir: "ltr" | "rtl";
19
24
  /**
20
25
  * Translate a key.
21
- * Falls back to: consumer messages English default the key itself.
22
- * Supports simple `{name}`-style variable interpolation.
26
+ * Falls back to: an external `translate` resolver (if the provider was
27
+ * given one) → consumer `messages` English default → the key itself.
28
+ * Supports simple `{name}`-style variable interpolation, and ICU
29
+ * cardinal-plural forms (`Intl.PluralRules`) when the resolved message is a
30
+ * `PluralMessage` map and `vars.count` is a number. Note: `count` must be a
31
+ * number, not a numeric string (e.g., `count: 3`, not `count: "3"`) to select
32
+ * the correct plural category.
23
33
  */
24
34
  t: (key: string, vars?: Record<string, string | number>) => string;
25
35
  /** Format a number using the active locale. Memoized per locale+opts hash. */
@@ -45,18 +55,41 @@ function interpolate(template: string, vars?: Record<string, string | number>):
45
55
  );
46
56
  }
47
57
 
58
+ /**
59
+ * Resolve a `MessageValue` (a plain string or a `PluralMessage`) to its final,
60
+ * interpolated string.
61
+ *
62
+ * A `PluralMessage` selects its category via `Intl.PluralRules(locale).select(count)`
63
+ * when `vars.count` is a number; with no `count`, or when the selected
64
+ * category has no entry, it falls back to `"other"` (the one category every
65
+ * locale defines), then to the raw `key` if even `"other"` is missing.
66
+ */
67
+ function resolveMessage(
68
+ key: string,
69
+ template: MessageValue,
70
+ vars: Record<string, string | number> | undefined,
71
+ pluralRules: Intl.PluralRules,
72
+ ): string {
73
+ if (typeof template === "string") return interpolate(template, vars);
74
+ const count = typeof vars?.count === "number" ? vars.count : undefined;
75
+ const category: PluralCategory = count !== undefined ? pluralRules.select(count) : "other";
76
+ const form = template[category] ?? template.other ?? key;
77
+ return interpolate(form, vars);
78
+ }
79
+
48
80
  function makeDefaultValue(): LocaleContextValue {
49
81
  const locale = "en-US";
50
82
  // Simple cache: one Intl object per serialized opts string
51
83
  const numberCache = new Map<string, Intl.NumberFormat>();
52
84
  const dateCache = new Map<string, Intl.DateTimeFormat>();
85
+ const pluralRules = new Intl.PluralRules(locale);
53
86
 
54
87
  return {
55
88
  locale,
56
89
  dir: "ltr",
57
90
  t(key, vars) {
58
91
  const template = DEFAULT_MESSAGES[key] ?? key;
59
- return interpolate(template, vars);
92
+ return resolveMessage(key, template, vars, pluralRules);
60
93
  },
61
94
  formatNumber(n, opts) {
62
95
  const k = JSON.stringify(opts ?? null);
@@ -95,6 +128,21 @@ export interface LocaleProviderProps {
95
128
  * to the English bundle.
96
129
  */
97
130
  messages?: Messages;
131
+ /**
132
+ * Optional external resolver — hand brand-ui's microcopy off to an app's own
133
+ * i18n runtime (next-intl, react-intl, i18next, …) instead of maintaining a
134
+ * second, flattened message catalogue. Called with the raw key and vars
135
+ * (not pre-interpolated), so a real ICU formatter gets real values and can
136
+ * do its own pluralization/number/date formatting.
137
+ *
138
+ * Return a string to use it as-is (brand-ui does NOT re-run its own
139
+ * `{name}` interpolation on it — the app's runtime already formatted it).
140
+ * Return `undefined`/`null` to decline a key; brand-ui then falls back to
141
+ * `messages` → `DEFAULT_MESSAGES` → the raw key, in that order — so a
142
+ * resolver only needs to cover the keys it actually wants to override.
143
+ * See `docs/I18N.md`.
144
+ */
145
+ translate?: (key: string, vars?: Record<string, string | number>) => string | undefined | null;
98
146
  children: ReactNode;
99
147
  }
100
148
 
@@ -118,6 +166,7 @@ export function LocaleProvider({
118
166
  locale = "en-US",
119
167
  dir = "ltr",
120
168
  messages,
169
+ translate,
121
170
  children,
122
171
  }: LocaleProviderProps) {
123
172
  // Merge consumer messages over the shipped English defaults.
@@ -131,14 +180,20 @@ export function LocaleProvider({
131
180
  // Memoized Intl formatter caches — keyed by serialised opts
132
181
  const numberCache = useMemo(() => new Map<string, Intl.NumberFormat>(), [locale]);
133
182
  const dateCache = useMemo(() => new Map<string, Intl.DateTimeFormat>(), [locale]);
183
+ const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
134
184
 
135
185
  const value = useMemo<LocaleContextValue>(
136
186
  () => ({
137
187
  locale,
138
188
  dir,
139
189
  t(key, vars) {
190
+ // An external runtime (next-intl, react-intl, i18next, …) gets first
191
+ // say, and its result is used AS-IS — it already interpolated and
192
+ // pluralized. `undefined`/`null` means "I decline this key".
193
+ const resolved = translate?.(key, vars);
194
+ if (resolved != null) return resolved;
140
195
  const template = merged[key] ?? key;
141
- return interpolate(template, vars);
196
+ return resolveMessage(key, template, vars, pluralRules);
142
197
  },
143
198
  formatNumber(n, opts) {
144
199
  const k = JSON.stringify(opts ?? null);
@@ -151,7 +206,7 @@ export function LocaleProvider({
151
206
  return dateCache.get(k)!.format(d);
152
207
  },
153
208
  }),
154
- [locale, dir, merged, numberCache, dateCache],
209
+ [locale, dir, merged, numberCache, dateCache, pluralRules, translate],
155
210
  );
156
211
 
157
212
  return (