@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
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useId, useMemo, useState, type ComponentProps } from "react";
4
+ import { ChevronDown, Search } from "lucide-react";
5
+ import { cn } from "../../lib/cn";
6
+ import { Badge } from "../badge";
7
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible";
8
+ import { Input } from "../input";
9
+ import { Kbd } from "../kbd";
10
+ import { useLocale } from "../locale-provider";
11
+ import { StatePanel } from "../state-panel";
12
+
13
+ /** One keyboard shortcut: the action it performs and its ordered key tokens. */
14
+ export interface Shortcut {
15
+ action: string;
16
+ /** Ordered key tokens, e.g. `["⌘", "K"]`. Rendered via {@link Kbd}. */
17
+ keys: string[];
18
+ }
19
+
20
+ /**
21
+ * A named group of shortcuts. There is deliberately no `count` field — the
22
+ * displayed count is always derived from `items.length`, never supplied by
23
+ * the caller (see issue #113: brainless, the upstream this was adapted from,
24
+ * shipped a `count` with no `items`, an honestly-unverifiable number).
25
+ */
26
+ export interface ShortcutGroup {
27
+ id: string;
28
+ label: string;
29
+ items: Shortcut[];
30
+ /** Whether this group starts expanded when search is inactive. @default false */
31
+ defaultOpen?: boolean;
32
+ }
33
+
34
+ export interface KeyboardShortcutsProps extends Omit<ComponentProps<"div">, "onChange"> {
35
+ groups: ShortcutGroup[];
36
+ /** Show the built-in search field. @default true */
37
+ searchable?: boolean;
38
+ /** Controlled search query, if the app owns it. */
39
+ query?: string;
40
+ onQueryChange?: (query: string) => void;
41
+ }
42
+
43
+ function matchesQuery(shortcut: Shortcut, normalizedQuery: string): boolean {
44
+ if (shortcut.action.toLowerCase().includes(normalizedQuery)) return true;
45
+ return shortcut.keys.some((key) => key.toLowerCase().includes(normalizedQuery));
46
+ }
47
+
48
+ interface FilteredGroup {
49
+ group: ShortcutGroup;
50
+ items: Shortcut[];
51
+ }
52
+
53
+ /**
54
+ * A grouped, searchable presentation of a shortcut set. Renders **content
55
+ * only** — the app supplies the `Dialog`/`Sheet` shell, so the same
56
+ * component works in a modal, a settings page or a sidebar.
57
+ *
58
+ * Group counts are always derived from `items.length` (or the number of
59
+ * matching items while a search is active) — never a value passed in.
60
+ */
61
+ export const KeyboardShortcuts = forwardRef<HTMLDivElement, KeyboardShortcutsProps>(
62
+ function KeyboardShortcuts(
63
+ { groups, searchable = true, query: queryProp, onQueryChange, className, ...props },
64
+ ref,
65
+ ) {
66
+ const isControlled = queryProp !== undefined;
67
+ const [internalQuery, setInternalQuery] = useState("");
68
+ const query = isControlled ? queryProp : internalQuery;
69
+ const normalizedQuery = query.trim().toLowerCase();
70
+ const isFiltering = normalizedQuery.length > 0;
71
+ const { t } = useLocale();
72
+ const searchId = useId();
73
+
74
+ const setQuery = (next: string) => {
75
+ if (!isControlled) setInternalQuery(next);
76
+ onQueryChange?.(next);
77
+ };
78
+
79
+ const filteredGroups = useMemo<FilteredGroup[]>(() => {
80
+ return groups
81
+ .map((group) => ({
82
+ group,
83
+ items: isFiltering
84
+ ? group.items.filter((item) => matchesQuery(item, normalizedQuery))
85
+ : group.items,
86
+ }))
87
+ .filter(({ items }) => items.length > 0);
88
+ }, [groups, isFiltering, normalizedQuery]);
89
+
90
+ return (
91
+ <div
92
+ ref={ref}
93
+ data-slot="keyboard-shortcuts"
94
+ className={cn("flex flex-col gap-4", className)}
95
+ {...props}
96
+ >
97
+ {searchable && (
98
+ <div data-slot="keyboard-shortcuts-search" className="relative">
99
+ <label htmlFor={searchId} className="sr-only">
100
+ Search shortcuts
101
+ </label>
102
+ <Search
103
+ aria-hidden="true"
104
+ className="pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
105
+ />
106
+ <Input
107
+ id={searchId}
108
+ type="search"
109
+ autoComplete="off"
110
+ placeholder={t("ui.keyboardShortcuts.searchPlaceholder")}
111
+ value={query}
112
+ onChange={(event) => setQuery(event.target.value)}
113
+ className="ps-8"
114
+ />
115
+ </div>
116
+ )}
117
+
118
+ {filteredGroups.length === 0 ? (
119
+ <div data-slot="keyboard-shortcuts-empty">
120
+ <StatePanel
121
+ kind="empty"
122
+ title={t("ui.keyboardShortcuts.emptyTitle")}
123
+ description={
124
+ isFiltering
125
+ ? t("ui.keyboardShortcuts.emptyFiltered", { query: query.trim() })
126
+ : t("ui.keyboardShortcuts.empty")
127
+ }
128
+ />
129
+ </div>
130
+ ) : (
131
+ <div data-slot="keyboard-shortcuts-groups" className="flex flex-col gap-2">
132
+ {filteredGroups.map(({ group, items }) => (
133
+ <ShortcutGroupItem
134
+ key={group.id}
135
+ group={group}
136
+ items={items}
137
+ forceOpen={isFiltering}
138
+ />
139
+ ))}
140
+ </div>
141
+ )}
142
+ </div>
143
+ );
144
+ },
145
+ );
146
+
147
+ interface ShortcutGroupItemProps {
148
+ group: ShortcutGroup;
149
+ /** Items to display — the full group, or the subset matching the active search. */
150
+ items: Shortcut[];
151
+ /** Force the group open (search is active) without discarding the user's manual toggle. */
152
+ forceOpen: boolean;
153
+ }
154
+
155
+ function ShortcutGroupItem({ group, items, forceOpen }: ShortcutGroupItemProps) {
156
+ const [open, setOpen] = useState(group.defaultOpen ?? false);
157
+
158
+ return (
159
+ <Collapsible
160
+ data-slot="keyboard-shortcuts-group"
161
+ open={forceOpen || open}
162
+ onOpenChange={setOpen}
163
+ className="rounded-md border"
164
+ >
165
+ <CollapsibleTrigger
166
+ data-slot="keyboard-shortcuts-group-trigger"
167
+ className={cn(
168
+ "flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-start",
169
+ "focus-ring",
170
+ "[&[data-state=open]>svg]:rotate-180",
171
+ )}
172
+ >
173
+ <span className="flex items-center gap-2">
174
+ <span className="text-body font-medium text-foreground">{group.label}</span>
175
+ {/* Derived from items.length — never a caller-supplied count (see #113). */}
176
+ <Badge variant="secondary">{items.length}</Badge>
177
+ </span>
178
+ <ChevronDown
179
+ aria-hidden="true"
180
+ className="size-4 shrink-0 text-muted-foreground transition-transform duration-base ease-standard"
181
+ />
182
+ </CollapsibleTrigger>
183
+ <CollapsibleContent data-slot="keyboard-shortcuts-group-content">
184
+ <ul className="flex flex-col gap-1 px-3 pb-3">
185
+ {items.map((item, index) => (
186
+ <li
187
+ key={`${item.action}-${index}`}
188
+ data-slot="keyboard-shortcuts-item"
189
+ className="flex items-center justify-between gap-4 py-1"
190
+ >
191
+ <span className="text-body text-muted-foreground">{item.action}</span>
192
+ <span className="flex items-center gap-1">
193
+ {item.keys.map((key, keyIndex) => (
194
+ <Kbd key={`${key}-${keyIndex}`}>{key}</Kbd>
195
+ ))}
196
+ </span>
197
+ </li>
198
+ ))}
199
+ </ul>
200
+ </CollapsibleContent>
201
+ </Collapsible>
202
+ );
203
+ }
@@ -10,7 +10,7 @@ export const Label = forwardRef<
10
10
  <LabelPrimitive.Root
11
11
  ref={ref}
12
12
  className={cn(
13
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
13
+ "text-body font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
14
14
  className,
15
15
  )}
16
16
  {...props}
@@ -115,7 +115,7 @@ export const LinkPreviewCard = forwardRef<HTMLDivElement, LinkPreviewCardProps>(
115
115
  // When `href` is present: stretched-link focus ring (via focus-within on
116
116
  // the container, not the <a>) + hover lift + motion-tokened transition.
117
117
  href &&
118
- "cursor-pointer transition-[translate,box-shadow,border-color] duration-fast ease-standard focus-within:outline-none focus-within:ring-2 focus-within:ring-ring hover:-translate-y-0.5 hover:border-ring/40 hover:shadow-md motion-reduce:hover:translate-y-0",
118
+ "cursor-pointer transition-[translate,box-shadow,border-color] duration-fast ease-standard focus-ring-within hover:-translate-y-0.5 hover:border-ring/40 hover:shadow-md motion-reduce:hover:translate-y-0",
119
119
  className,
120
120
  )}
121
121
  {...props}
@@ -69,6 +69,30 @@ describe("ListEditor", () => {
69
69
  expect(onValueChange).toHaveBeenCalledWith(["b", "a"]);
70
70
  });
71
71
 
72
+ it("keeps keyboard focus on the moved row's own move-down button after a non-boundary move", async () => {
73
+ render(<ListEditor defaultValue={["a", "b", "c", "d"]} />);
74
+ const moveDown = screen.getByRole("button", { name: "Move item 2 down" });
75
+ moveDown.focus();
76
+ await userEvent.keyboard("{Enter}");
77
+ // Row "b" moved from position 2 to position 3 — index-keyed rows would
78
+ // leave focus sitting on whatever the DOM slot at the old position now
79
+ // renders (the row that got swapped INTO it), not on the row the user
80
+ // actually moved.
81
+ expect(screen.getByRole("button", { name: "Move item 3 down" })).toHaveFocus();
82
+ });
83
+
84
+ it("redirects focus to the complementary button when a move disables the one just pressed", async () => {
85
+ render(<ListEditor defaultValue={["a", "b", "c"]} />);
86
+ const moveUp = screen.getByRole("button", { name: "Move item 2 up" });
87
+ moveUp.focus();
88
+ await userEvent.keyboard("{Enter}");
89
+ // "b" is now row 1 (the top) — its own "move up" is now disabled, and a
90
+ // browser blurs a focused element the moment it goes disabled. Focus
91
+ // must land on that same row's still-enabled "move down" button instead
92
+ // of falling through to <body>.
93
+ expect(screen.getByRole("button", { name: "Move item 1 down" })).toHaveFocus();
94
+ });
95
+
72
96
  it("hides reorder buttons when reorderable=false", () => {
73
97
  render(<ListEditor defaultValue={["a", "b"]} reorderable={false} />);
74
98
  // Anchored at the start — "Remove item 1" contains the substring "move
@@ -3,7 +3,9 @@
3
3
  import {
4
4
  forwardRef,
5
5
  useCallback,
6
+ useEffect,
6
7
  useMemo,
8
+ useRef,
7
9
  useState,
8
10
  type HTMLAttributes,
9
11
  type ReactNode,
@@ -77,10 +79,48 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
77
79
  [isControlled, onValueChange],
78
80
  );
79
81
 
82
+ // `rows` is a bare `string[]` with no identity of its own — keying each
83
+ // row's DOM by its ARRAY INDEX means a reorder swaps which row's data a
84
+ // given DOM node (and its focused "Move up" button) renders, not which
85
+ // node holds the moved row's data. A stable per-row id, generated once and
86
+ // carried through add/remove/move in lockstep with `rows`, lets React
87
+ // (and thus keyboard focus) follow the actual row instead of the slot.
88
+ const idCounterRef = useRef(0);
89
+ const makeId = useCallback(() => `list-editor-row-${idCounterRef.current++}`, []);
90
+ const [rowIds, setRowIds] = useState<string[]>(() => rows.map(() => makeId()));
91
+ const lastRowsRef = useRef(rows);
92
+ const upButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
93
+ const downButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
94
+ const pendingFocusRef = useRef<{ id: string; want: "up" | "down" } | null>(null);
95
+
96
+ // Safety net for a `rows` change that did NOT come from add/remove/move
97
+ // below (e.g. a controlled `value` reset by the app) — keeps `rowIds` the
98
+ // same length as `rows` without guessing at a diff.
99
+ useEffect(() => {
100
+ if (rows === lastRowsRef.current) return;
101
+ lastRowsRef.current = rows;
102
+ setRowIds((prev) => {
103
+ if (prev.length === rows.length) return prev;
104
+ if (rows.length > prev.length) {
105
+ return [...prev, ...Array.from({ length: rows.length - prev.length }, () => makeId())];
106
+ }
107
+ return prev.slice(0, rows.length);
108
+ });
109
+ }, [rows, makeId]);
110
+
111
+ useEffect(() => {
112
+ const pending = pendingFocusRef.current;
113
+ if (!pending) return;
114
+ pendingFocusRef.current = null;
115
+ const map = pending.want === "up" ? upButtonRefs.current : downButtonRefs.current;
116
+ map.get(pending.id)?.focus();
117
+ }, [rowIds]);
118
+
80
119
  const addRow = useCallback(() => {
81
120
  if (max !== undefined && rows.length >= max) return;
82
121
  commit([...rows, ""]);
83
- }, [rows, max, commit]);
122
+ setRowIds((ids) => [...ids, makeId()]);
123
+ }, [rows, max, commit, makeId]);
84
124
 
85
125
  const updateRow = useCallback(
86
126
  (index: number, next: string) => {
@@ -92,6 +132,7 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
92
132
  const removeRow = useCallback(
93
133
  (index: number) => {
94
134
  commit(rows.filter((_, i) => i !== index));
135
+ setRowIds((ids) => ids.filter((_, i) => i !== index));
95
136
  },
96
137
  [rows, commit],
97
138
  );
@@ -103,9 +144,31 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
103
144
  const next = [...rows];
104
145
  const [moved] = next.splice(index, 1);
105
146
  next.splice(target, 0, moved!);
147
+ const movedId = rowIds[index]!;
106
148
  commit(next);
149
+ setRowIds((ids) => {
150
+ const nextIds = [...ids];
151
+ const [id] = nextIds.splice(index, 1);
152
+ nextIds.splice(target, 0, id!);
153
+ return nextIds;
154
+ });
155
+
156
+ // The button the user just pressed keeps focus by default (same id ⇒
157
+ // same DOM node, just repositioned). The one case that needs help: the
158
+ // row landed at the top/bottom, so THIS direction's button is now
159
+ // `disabled` — a browser blurs a focused element the instant it goes
160
+ // disabled, dropping focus to <body>. Redirect to the row's other
161
+ // reorder button instead, which stays enabled.
162
+ const activated: "up" | "down" = direction === -1 ? "up" : "down";
163
+ const willDisable =
164
+ (activated === "up" && target === 0) ||
165
+ (activated === "down" && target === rows.length - 1);
166
+ pendingFocusRef.current = {
167
+ id: movedId,
168
+ want: willDisable ? (activated === "up" ? "down" : "up") : activated,
169
+ };
107
170
  },
108
- [rows, commit],
171
+ [rows, rowIds, commit],
109
172
  );
110
173
 
111
174
  const atMax = max !== undefined && rows.length >= max;
@@ -123,56 +186,67 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
123
186
  </p>
124
187
  ) : (
125
188
  <div className="flex flex-col gap-2">
126
- {rows.map((row, index) => (
127
- <div key={index} data-slot="list-editor-row" className="flex items-center gap-1">
128
- <Input
129
- data-slot="list-editor-item"
130
- value={row}
131
- placeholder={placeholder}
132
- disabled={disabled}
133
- aria-label={t("ui.listEditor.itemLabel", { n: index + 1 })}
134
- onChange={(event) => updateRow(index, event.target.value)}
135
- className="flex-1"
136
- />
137
- {reorderable && (
138
- <>
139
- <Button
140
- data-slot="list-editor-move-up"
141
- type="button"
142
- variant="ghost"
143
- size="icon-sm"
144
- disabled={disabled || index === 0}
145
- aria-label={t("ui.listEditor.moveUp", { n: index + 1 })}
146
- onClick={() => moveRow(index, -1)}
147
- >
148
- <ChevronUp aria-hidden="true" />
149
- </Button>
150
- <Button
151
- data-slot="list-editor-move-down"
152
- type="button"
153
- variant="ghost"
154
- size="icon-sm"
155
- disabled={disabled || index === rows.length - 1}
156
- aria-label={t("ui.listEditor.moveDown", { n: index + 1 })}
157
- onClick={() => moveRow(index, 1)}
158
- >
159
- <ChevronDown aria-hidden="true" />
160
- </Button>
161
- </>
162
- )}
163
- <Button
164
- data-slot="list-editor-remove"
165
- type="button"
166
- variant="ghost"
167
- size="icon-sm"
168
- disabled={disabled}
169
- aria-label={t("ui.listEditor.removeItem", { n: index + 1 })}
170
- onClick={() => removeRow(index)}
171
- >
172
- <X aria-hidden="true" />
173
- </Button>
174
- </div>
175
- ))}
189
+ {rows.map((row, index) => {
190
+ const rowId = rowIds[index] ?? String(index);
191
+ return (
192
+ <div key={rowId} data-slot="list-editor-row" className="flex items-center gap-1">
193
+ <Input
194
+ data-slot="list-editor-item"
195
+ value={row}
196
+ placeholder={placeholder}
197
+ disabled={disabled}
198
+ aria-label={t("ui.listEditor.itemLabel", { n: index + 1 })}
199
+ onChange={(event) => updateRow(index, event.target.value)}
200
+ className="flex-1"
201
+ />
202
+ {reorderable && (
203
+ <>
204
+ <Button
205
+ ref={(el) => {
206
+ if (el) upButtonRefs.current.set(rowId, el);
207
+ else upButtonRefs.current.delete(rowId);
208
+ }}
209
+ data-slot="list-editor-move-up"
210
+ type="button"
211
+ variant="ghost"
212
+ size="icon-sm"
213
+ disabled={disabled || index === 0}
214
+ aria-label={t("ui.listEditor.moveUp", { n: index + 1 })}
215
+ onClick={() => moveRow(index, -1)}
216
+ >
217
+ <ChevronUp aria-hidden="true" />
218
+ </Button>
219
+ <Button
220
+ ref={(el) => {
221
+ if (el) downButtonRefs.current.set(rowId, el);
222
+ else downButtonRefs.current.delete(rowId);
223
+ }}
224
+ data-slot="list-editor-move-down"
225
+ type="button"
226
+ variant="ghost"
227
+ size="icon-sm"
228
+ disabled={disabled || index === rows.length - 1}
229
+ aria-label={t("ui.listEditor.moveDown", { n: index + 1 })}
230
+ onClick={() => moveRow(index, 1)}
231
+ >
232
+ <ChevronDown aria-hidden="true" />
233
+ </Button>
234
+ </>
235
+ )}
236
+ <Button
237
+ data-slot="list-editor-remove"
238
+ type="button"
239
+ variant="ghost"
240
+ size="icon-sm"
241
+ disabled={disabled}
242
+ aria-label={t("ui.listEditor.removeItem", { n: index + 1 })}
243
+ onClick={() => removeRow(index)}
244
+ >
245
+ <X aria-hidden="true" />
246
+ </Button>
247
+ </div>
248
+ );
249
+ })}
176
250
  </div>
177
251
  )}
178
252
  <Button
@@ -5,4 +5,9 @@ export {
5
5
  type LocaleProviderProps,
6
6
  type Messages,
7
7
  } from "./locale-provider";
8
- export { DEFAULT_MESSAGES } from "./messages";
8
+ export {
9
+ DEFAULT_MESSAGES,
10
+ type MessageValue,
11
+ type PluralCategory,
12
+ type PluralMessage,
13
+ } from "./messages";
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { DEFAULT_MESSAGES } from "./messages";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Guards `t("some.key")` call sites across every package against the shipped
9
+ // English dictionary — a key used in source but never registered in
10
+ // `DEFAULT_MESSAGES` silently renders the RAW KEY to end users (t()'s last
11
+ // fallback rung), which is exactly what shipped when a i18n sweep added
12
+ // `t()` call sites but the matching messages.ts entries lagged behind
13
+ // (#4 i18n review). Source is the one true list of "keys a component can
14
+ // render" (see docs/I18N.md); this test keeps that list honest without
15
+ // hand-maintaining a duplicate inventory.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+ // packages/ui/src/components/locale-provider -> repo root
20
+ const REPO_ROOT = join(HERE, "../../../../..");
21
+
22
+ /** Source `.tsx`/`.ts` files under a package's `src/`, excluding tests/stories. */
23
+ function sourceFiles(dir: string, acc: string[] = []): string[] {
24
+ let entries;
25
+ try {
26
+ entries = readdirSync(dir, { withFileTypes: true });
27
+ } catch {
28
+ return acc;
29
+ }
30
+ for (const entry of entries) {
31
+ const full = join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
34
+ sourceFiles(full, acc);
35
+ } else if (/\.tsx?$/.test(entry.name) && !/\.(test|spec|stories)\.tsx?$/.test(entry.name)) {
36
+ acc.push(full);
37
+ }
38
+ }
39
+ return acc;
40
+ }
41
+
42
+ /** Strip `//` and `/* *‍/` comments so example code in comments never counts as a call site. */
43
+ function stripComments(source: string): string {
44
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
45
+ }
46
+
47
+ /**
48
+ * Extract every string-literal key passed as (or inside a ternary forming)
49
+ * the FIRST argument of a `t(...)` call in `source`. Handles the shapes this
50
+ * codebase actually uses:
51
+ * t("ui.foo.bar")
52
+ * t("ui.foo.bar", { count })
53
+ * t(cond ? "ui.foo.a" : "ui.foo.b", { name })
54
+ * t(SOME_MAP[status]) — dynamic; no literal to extract, skipped
55
+ *
56
+ * A literal only counts as a candidate KEY when it stands as a whole ternary
57
+ * branch or the whole argument — i.e. bounded (modulo whitespace) by the
58
+ * start of the argument, `?`, `:`, or the argument's end — so a literal used
59
+ * mid-condition (`zoom === "fit-width" ? a : b`) is correctly NOT treated as
60
+ * a translation key.
61
+ */
62
+ export function extractTranslationKeys(source: string): string[] {
63
+ const clean = stripComments(source);
64
+ const keys: string[] = [];
65
+ const callRe = /(?<![A-Za-z0-9_.])t\(/g;
66
+ let match: RegExpExecArray | null;
67
+
68
+ while ((match = callRe.exec(clean))) {
69
+ const start = match.index + match[0].length;
70
+ let depth = 0;
71
+ let end = start;
72
+ // Walk to the first top-level comma or closing paren — the boundary of
73
+ // the first argument expression.
74
+ for (; end < clean.length; end++) {
75
+ const ch = clean[end];
76
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
77
+ else if (ch === ")" || ch === "}" || ch === "]") {
78
+ if (depth === 0) break;
79
+ depth--;
80
+ } else if (ch === "," && depth === 0) {
81
+ break;
82
+ }
83
+ }
84
+ const firstArg = clean.slice(start, end);
85
+ const literalRe = /(?:^|[?:(,])\s*(["'])((?:(?!\1).)*)\1\s*(?=$|[):,])/g;
86
+ for (const lit of firstArg.matchAll(literalRe)) {
87
+ if (lit[2]) keys.push(lit[2]);
88
+ }
89
+ }
90
+ return keys;
91
+ }
92
+
93
+ interface Finding {
94
+ file: string;
95
+ key: string;
96
+ }
97
+
98
+ function collectMissingKeys(): Finding[] {
99
+ const missing: Finding[] = [];
100
+ const pkgsDir = join(REPO_ROOT, "packages");
101
+ for (const pkg of readdirSync(pkgsDir, { withFileTypes: true })) {
102
+ if (!pkg.isDirectory()) continue;
103
+ const src = join(pkgsDir, pkg.name, "src");
104
+ try {
105
+ if (!statSync(src).isDirectory()) continue;
106
+ } catch {
107
+ continue;
108
+ }
109
+ for (const file of sourceFiles(src)) {
110
+ const source = readFileSync(file, "utf8");
111
+ for (const key of extractTranslationKeys(source)) {
112
+ if (!(key in DEFAULT_MESSAGES)) {
113
+ missing.push({ file: relative(REPO_ROOT, file), key });
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return missing;
119
+ }
120
+
121
+ describe("t() call sites vs. DEFAULT_MESSAGES", () => {
122
+ it("every literal key passed to t() across every package exists in the shipped dictionary", () => {
123
+ const missing = collectMissingKeys();
124
+ if (missing.length > 0) {
125
+ const report = missing.map((m) => ` ${m.file}: t("${m.key}")`).join("\n");
126
+ throw new Error(
127
+ `Found ${missing.length} t() call(s) whose key has no entry in ` +
128
+ `packages/ui/src/components/locale-provider/messages.ts — this key would ` +
129
+ `render as the raw key string to end users instead of English copy:\n${report}`,
130
+ );
131
+ }
132
+ expect(missing).toEqual([]);
133
+ });
134
+ });