@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
@@ -52,6 +52,13 @@ export interface ModelPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "
52
52
  open?: boolean;
53
53
  /** Fires whenever the popover would open/close — a trigger click, or a row select. */
54
54
  onOpenChange?: (open: boolean) => void;
55
+ /**
56
+ * Rendered inside the popover BELOW the list — a persistent action row that is
57
+ * not one of the options (a free-text entry, a "browse…" affordance).
58
+ * Outside `CommandList` on purpose: it must not be filtered by the search query
59
+ * and must not participate in cmdk's roving selection.
60
+ */
61
+ footer?: ReactNode;
55
62
  }
56
63
 
57
64
  function Row({
@@ -101,9 +108,10 @@ function Row({
101
108
  * A compact pill that opens a grouped, searchable target list anchored under
102
109
  * itself — sized to sit in a composer footer.
103
110
  *
104
- * The inline sibling of `@elabs-ai/components-ai`'s `ModelSelector`,
105
- * which is the same `Command` internals in a modal `Dialog`. Reach for that one
106
- * for a full command palette; reach for this one for an in-place picker.
111
+ * **Which one to reach for.** An inline pill in a composer footer, anchored
112
+ * under itself this component. A full ⌘K palette compose `CommandDialog` +
113
+ * `Command*` from this package directly; there is no separate "selector"
114
+ * component wrapping them.
107
115
  *
108
116
  * **`Command` lives inside a `Popover`, never a `DropdownMenu`, and that is not
109
117
  * a stylistic choice.** `DropdownMenuContent` owns roving tabindex and its own
@@ -127,6 +135,7 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
127
135
  className,
128
136
  open: openProp,
129
137
  onOpenChange,
138
+ footer,
130
139
  ...props
131
140
  },
132
141
  ref,
@@ -205,37 +214,20 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
205
214
  </div>
206
215
  ) : null}
207
216
 
217
+ {/*
218
+ #121 — `CommandList` IS the ARIA listbox (cmdk hardcodes
219
+ role="listbox" on it); ARIA only lets a listbox own `option`/
220
+ `group`. It stays MOUNTED in every body (never conditionally
221
+ removed — `CommandInput` hardcodes `aria-controls` to its id,
222
+ and axe's aria-valid-attr-value only tolerates a dangling
223
+ reference when aria-expanded="false"), but every non-option
224
+ body — loading, error/empty, and `CommandEmpty` — renders as a
225
+ SIBLING below it, in the same visual slot an empty `CommandList`
226
+ (which collapses to zero height) would otherwise occupy.
227
+ */}
208
228
  <CommandList>
209
- {body === "loading" ? (
210
- <div className="space-y-2 p-3" data-slot="model-picker-loading">
211
- <span className="sr-only" role="status">
212
- {t("loading")}
213
- </span>
214
- <Skeleton className="h-6 w-full" />
215
- <Skeleton className="h-6 w-4/5" />
216
- <Skeleton className="h-6 w-3/5" />
217
- </div>
218
- ) : null}
219
-
220
- {body === "error" || body === "empty" ? (
221
- <div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
222
- <p className="text-body">
223
- {body === "error"
224
- ? t("ui.modelPicker.loadFailed")
225
- : t("ui.modelPicker.nothingYet")}
226
- </p>
227
- {onRetry ? (
228
- <Button variant="outline" size="sm" onClick={onRetry}>
229
- {t("ui.modelPicker.retry")}
230
- </Button>
231
- ) : null}
232
- </div>
233
- ) : null}
234
-
235
- {body === "list" ? (
236
- <>
237
- <CommandEmpty>{t("noResults")}</CommandEmpty>
238
- {groups.map((group) => (
229
+ {body === "list"
230
+ ? groups.map((group) => (
239
231
  <CommandGroup key={group.key} heading={group.label}>
240
232
  {group.items.map((item) => (
241
233
  <Row
@@ -249,10 +241,44 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
249
241
  />
250
242
  ))}
251
243
  </CommandGroup>
252
- ))}
253
- </>
254
- ) : null}
244
+ ))
245
+ : null}
255
246
  </CommandList>
247
+
248
+ {/*
249
+ cmdk's `Command.Empty` reads `filtered.count` off the `Command`
250
+ root store, not off a `CommandList` ancestor — it renders
251
+ correctly as a sibling of the list it used to nest inside.
252
+ */}
253
+ {body === "list" ? <CommandEmpty>{t("noResults")}</CommandEmpty> : null}
254
+
255
+ {body === "loading" ? (
256
+ <div className="space-y-2 p-3" data-slot="model-picker-loading">
257
+ <span className="sr-only" role="status">
258
+ {t("loading")}
259
+ </span>
260
+ <Skeleton className="h-6 w-full" />
261
+ <Skeleton className="h-6 w-4/5" />
262
+ <Skeleton className="h-6 w-3/5" />
263
+ </div>
264
+ ) : null}
265
+
266
+ {body === "error" || body === "empty" ? (
267
+ <div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
268
+ <p className="text-body">
269
+ {body === "error"
270
+ ? t("ui.modelPicker.loadFailed")
271
+ : t("ui.modelPicker.nothingYet")}
272
+ </p>
273
+ {onRetry ? (
274
+ <Button variant="outline" size="sm" onClick={onRetry}>
275
+ {t("ui.modelPicker.retry")}
276
+ </Button>
277
+ ) : null}
278
+ </div>
279
+ ) : null}
280
+
281
+ {footer}
256
282
  </Command>
257
283
  </PopoverContent>
258
284
  </Popover>
@@ -1,6 +1,8 @@
1
+ "use client";
2
+
1
3
  /**
2
4
  * Shared collapsible sidebar navigation primitive (issue #99).
3
- * Promoted from `packages/ui/src/blocks/sidebar-02/nav-main.tsx`.
5
+ * Promoted from `registry/blocks/sidebar-02/nav-main.tsx`.
4
6
  *
5
7
  * Renders a `SidebarMenu` of routes; each route with sub-items uses a
6
8
  * `Collapsible` expand/collapse affordance. Collapses icon-only when the
@@ -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
  )}
@@ -0,0 +1,110 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, waitFor, within } from "storybook/test";
3
+ import { NavNotifications } from "./nav-notifications";
4
+
5
+ const meta = {
6
+ title: "Navigation/NavNotifications",
7
+ component: NavNotifications,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'A bell button that opens a short list of recent notifications. Placement is the caller\'s decision, not the component\'s: the defaults (`side="right"`, the `my-6` inset) suit a bell sitting in a left-hand nav rail, while a bell at the end of a top bar passes `side="bottom" align="end"` so the menu hangs below its trigger instead of being collision-flipped back across the controls beside it.',
14
+ },
15
+ },
16
+ },
17
+ argTypes: {
18
+ notifications: { control: false, table: { category: "Content" } },
19
+ viewAllLabel: { control: "text", table: { category: "Content" } },
20
+ side: { control: "inline-radio", table: { category: "Placement" } },
21
+ align: { control: "inline-radio", table: { category: "Placement" } },
22
+ className: { control: "text", table: { category: "Styling" } },
23
+ },
24
+ } satisfies Meta<typeof NavNotifications>;
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ const NOTIFICATIONS = [
29
+ { id: "n1", fallback: "OR", text: "Order 4821 is waiting on stock.", time: "10m ago" },
30
+ { id: "n2", fallback: "PY", text: "This week’s payout cleared.", time: "1h ago" },
31
+ { id: "n3", fallback: "CS", text: "Three customers asked about delivery.", time: "2h ago" },
32
+ ];
33
+
34
+ /** The rail placement — the shipped defaults, unchanged. */
35
+ export const Default: Story = {
36
+ args: { notifications: NOTIFICATIONS },
37
+ play: async ({ canvasElement }) => {
38
+ const canvas = within(canvasElement);
39
+ await userEvent.click(canvas.getByRole("button", { name: "Open notifications" }));
40
+ // The menu is portalled to <body>, so it is reached through the document,
41
+ // not the canvas.
42
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeVisible());
43
+ await expect(document.querySelector('[role="menu"]')).toHaveAttribute("data-side", "right");
44
+ // Close it before the story settles. While a Radix menu is open its
45
+ // siblings carry `aria-hidden`, and the trigger inside one of them is
46
+ // still focusable — which axe reports as `aria-hidden-focus` against the
47
+ // ANCESTOR, not against anything this component authored. Leaving the menu
48
+ // open would fail the a11y pass on library behaviour the story is not
49
+ // testing.
50
+ await userEvent.keyboard("{Escape}");
51
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull());
52
+ },
53
+ };
54
+
55
+ /**
56
+ * The top-bar placement: `side="bottom" align="end"` puts the menu under the
57
+ * bell and flush with its outer edge. The caller passes NOTHING else — the
58
+ * rail's 24px vertical inset is derived from `side` inside the component, so a
59
+ * bottom-side menu drops it on its own and there is no inset to cancel here.
60
+ * (`className` styles the bell TRIGGER, not the portalled menu, so a `my-0`
61
+ * override would land on the wrong element anyway.)
62
+ *
63
+ * Asserted on Radix's own resolved `data-side`/`data-align`, which report where
64
+ * the menu ACTUALLY landed after collision handling — a class-name assertion
65
+ * would pass on a menu that flipped back across the controls beside it.
66
+ */
67
+ export const TopBarPlacement: Story = {
68
+ args: {
69
+ notifications: NOTIFICATIONS,
70
+ side: "bottom",
71
+ align: "end",
72
+ },
73
+ play: async ({ canvasElement }) => {
74
+ const canvas = within(canvasElement);
75
+ await userEvent.click(canvas.getByRole("button", { name: "Open notifications" }));
76
+ const content = await waitFor(() => {
77
+ const node = document.querySelector('[role="menu"]');
78
+ expect(node).toBeVisible();
79
+ return node;
80
+ });
81
+ await expect(content).toHaveAttribute("data-side", "bottom");
82
+ await expect(content).toHaveAttribute("data-align", "end");
83
+ // Closed for the same reason as `Default` — see the note there.
84
+ await userEvent.keyboard("{Escape}");
85
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull());
86
+ },
87
+ };
88
+
89
+ /**
90
+ * `className` styles the component ROOT — the bell trigger — and merges LAST, so
91
+ * a caller-supplied radius replaces the shipped `rounded-full`.
92
+ */
93
+ export const CustomTriggerClass: Story = {
94
+ args: { notifications: NOTIFICATIONS, className: "rounded-md" },
95
+ play: async ({ canvasElement }) => {
96
+ const canvas = within(canvasElement);
97
+ const trigger = canvas.getByRole("button", { name: "Open notifications" });
98
+ // WHERE it landed: on the trigger, not on the portalled menu.
99
+ await expect(trigger).toHaveClass("rounded-md");
100
+ // …and it WON. `cn`'s tailwind-merge drops the losing radius outright, so
101
+ // the shipped `rounded-full` is gone from the class list rather than merely
102
+ // ordered behind it.
103
+ await expect(trigger).not.toHaveClass("rounded-full");
104
+ // Asserted on the painted result too, because a class list says nothing
105
+ // about what the browser resolved. `rounded-full` computes to a huge radius
106
+ // (the pill); the token radius is single-digit px.
107
+ const radius = Number.parseFloat(getComputedStyle(trigger).borderTopLeftRadius);
108
+ await expect(radius).toBeLessThan(100);
109
+ },
110
+ };
@@ -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
  });
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { ComponentProps } from "react";
3
4
  import { BellIcon } from "lucide-react";
4
5
  import { Avatar, AvatarFallback } from "../avatar";
5
6
  import { Button } from "../button";
@@ -11,6 +12,8 @@ import {
11
12
  DropdownMenuSeparator,
12
13
  DropdownMenuTrigger,
13
14
  } from "../dropdown-menu";
15
+ import { useLocale } from "../locale-provider";
16
+ import { cn } from "../../lib/cn";
14
17
 
15
18
  export interface NavNotification {
16
19
  id: string;
@@ -24,30 +27,85 @@ export interface NavNotificationsProps {
24
27
  notifications: NavNotification[];
25
28
  /** Label for the "view all" footer item. @default "View all notifications" */
26
29
  viewAllLabel?: string;
30
+ /**
31
+ * Which side of the trigger the menu opens on.
32
+ *
33
+ * The default is `"right"`, which is right for the surface this component was
34
+ * extracted from — a bell sitting in a left-hand nav rail. It is WRONG for a
35
+ * trigger at the end of a top bar: Radix collision-handling then flips the
36
+ * menu back across the trigger and it covers the controls beside it. Set
37
+ * `side="bottom"` (usually with `align="end"`) for a top-bar bell.
38
+ *
39
+ * @default "right"
40
+ */
41
+ side?: ComponentProps<typeof DropdownMenuContent>["side"];
42
+ /**
43
+ * How the menu aligns along that side. Radix's own default is `"center"`;
44
+ * pass `"end"` so a top-bar menu hangs from the trigger's outer edge instead
45
+ * of straddling it.
46
+ */
47
+ align?: ComponentProps<typeof DropdownMenuContent>["align"];
48
+ /**
49
+ * Extra classes for the component ROOT — the bell trigger — merged last, so a
50
+ * caller can override the shipped `rounded-full` (`component-api.md`).
51
+ *
52
+ * There is deliberately NO seam for the portalled menu surface: nothing needs
53
+ * one today, and an unused prop is a worse API than a missing one. The one
54
+ * thing a caller used to reach for it (dropping the rail's vertical inset for
55
+ * a top-bar menu) is now derived from `side` instead — see below.
56
+ */
57
+ className?: string;
27
58
  }
28
59
 
29
60
  /**
30
- * Sidebar notifications dropdown button. Shared primitive from sidebar-02 (issue #99).
31
- * Renders a bell-icon ghost button that opens a dropdown listing recent notifications.
61
+ * Notifications dropdown button. Shared primitive from sidebar-02 (issue #99).
62
+ * Renders a bell-icon ghost button that opens a dropdown listing recent
63
+ * notifications.
64
+ *
65
+ * Placement is caller-owned via `side`/`align`; the defaults are the original
66
+ * rail placement, so every existing call site renders unchanged. `className`
67
+ * styles the trigger, not the menu.
32
68
  */
33
69
  export function NavNotifications({
34
70
  notifications,
35
71
  viewAllLabel = "View all notifications",
72
+ side = "right",
73
+ align,
74
+ className,
36
75
  }: NavNotificationsProps) {
76
+ const { t } = useLocale();
37
77
  return (
38
78
  <DropdownMenu>
39
79
  <DropdownMenuTrigger asChild>
40
80
  <Button
41
81
  variant="ghost"
42
82
  size="icon"
43
- className="rounded-full"
44
- aria-label="Open notifications"
83
+ className={cn("rounded-full", className)}
84
+ aria-label={t("ui.navNotifications.trigger")}
45
85
  >
46
86
  <BellIcon className="size-5" />
47
87
  </Button>
48
88
  </DropdownMenuTrigger>
49
- <DropdownMenuContent side="right" className="my-6 w-80">
50
- <DropdownMenuLabel>Notifications</DropdownMenuLabel>
89
+ <DropdownMenuContent
90
+ side={side}
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}
95
+ className={cn(
96
+ // The vertical inset belongs to the SIDE-opening rail placement, where
97
+ // the menu runs alongside its trigger and needs clearance from it. A
98
+ // menu that opens above or below the trigger is already clear of it,
99
+ // and the inset would just hang it 24px off the bar. Derived from
100
+ // `side` rather than exposed as a second className seam.
101
+ (side === "left" || side === "right") && "my-6",
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)]",
106
+ )}
107
+ >
108
+ <DropdownMenuLabel>{t("ui.navNotifications.label")}</DropdownMenuLabel>
51
109
  <DropdownMenuSeparator />
52
110
  {notifications.map(({ id, fallback, text, time }) => (
53
111
  <DropdownMenuItem key={id} className="flex items-start gap-3">
@@ -55,13 +113,13 @@ export function NavNotifications({
55
113
  <AvatarFallback>{fallback}</AvatarFallback>
56
114
  </Avatar>
57
115
  <div className="flex flex-col">
58
- <span className="text-sm font-medium">{text}</span>
59
- <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>
60
118
  </div>
61
119
  </DropdownMenuItem>
62
120
  ))}
63
121
  <DropdownMenuSeparator />
64
- <DropdownMenuItem className="justify-center text-sm text-muted-foreground">
122
+ <DropdownMenuItem className="justify-center text-body text-muted-foreground">
65
123
  {viewAllLabel}
66
124
  </DropdownMenuItem>
67
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>
@@ -86,7 +86,29 @@ export const Default: Story = {
86
86
  const analytics = await canvas.findByText("Analytics");
87
87
  await waitFor(() => expect(analytics).toBeVisible());
88
88
  await waitFor(() => expect(canvas.getByText("Data Integration")).toBeVisible());
89
+ // userEvent.click() synthesizes a real pointer sequence (pointerenter +
90
+ // pointermove before the click), which — independently of the click's own
91
+ // open — arms Radix's hover-intent open timer inside
92
+ // @radix-ui/react-navigation-menu's NavigationMenu root (delayDuration,
93
+ // default 200ms). That timer is cleared only by a subsequent pointer
94
+ // leave/enter, never by the click-open path or by Escape's dismiss path,
95
+ // so left alone it fires ~200ms after this interaction regardless of the
96
+ // explicit Escape below — silently reopening the menu well after play()
97
+ // has returned (confirmed via instrumentation: aria-expanded flips back
98
+ // to "true" at ~t+207ms). That reopen is what axe's afterEach scan was
99
+ // intermittently catching — a real defect in the underlying menu, not a
100
+ // wrong wait condition; filed separately (residual, not fixed here).
101
+ // Unhovering cancels that stray timer through Radix's own cancellation
102
+ // path (onTriggerLeave), the same way a real user moving the pointer away
103
+ // before dismissing would.
104
+ await userEvent.unhover(trigger);
89
105
  // Escape dismisses the open panel
90
106
  await userEvent.keyboard("{Escape}");
107
+ // Wait for the close transition to complete via a real post-condition:
108
+ // aria-expanded flips to "false" in the same React commit that removes
109
+ // the trigger's aria-hidden focus-proxy span (both derive from the same
110
+ // `open` value in NavigationMenuTrigger's render), so this dominates the
111
+ // axe scan that runs immediately after play() resolves.
112
+ await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
91
113
  },
92
114
  };
@@ -0,0 +1,174 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ NavigationMenu,
6
+ NavigationMenuContent,
7
+ NavigationMenuItem,
8
+ NavigationMenuLink,
9
+ NavigationMenuList,
10
+ NavigationMenuTrigger,
11
+ } from "./navigation-menu";
12
+
13
+ afterEach(cleanup);
14
+
15
+ function renderMenu() {
16
+ render(
17
+ <NavigationMenu>
18
+ <NavigationMenuList>
19
+ <NavigationMenuItem>
20
+ <NavigationMenuTrigger>Products</NavigationMenuTrigger>
21
+ <NavigationMenuContent>
22
+ <NavigationMenuLink href="#">Analytics</NavigationMenuLink>
23
+ </NavigationMenuContent>
24
+ </NavigationMenuItem>
25
+ </NavigationMenuList>
26
+ </NavigationMenu>,
27
+ );
28
+ }
29
+
30
+ describe("NavigationMenuTrigger", () => {
31
+ /**
32
+ * Residual of #54 (issue #85). `userEvent.click()` — like a real mouse click
33
+ * — synthesizes a pointer move onto the trigger before the click itself,
34
+ * which arms `@radix-ui/react-navigation-menu`'s root-level hover-intent
35
+ * open timer (`openTimerRef`, default `delayDuration` 200ms). Neither the
36
+ * click-open path (`onItemSelect`) nor any dismiss path (Escape /
37
+ * outside-click / focus-out, all funnelled through one
38
+ * `ROOT_CONTENT_DISMISS` handler) clears that timer, so — left unpatched —
39
+ * it fires ~200ms later and silently reopens the panel even though the user
40
+ * already dismissed it.
41
+ *
42
+ * Fake timers are load-bearing here, not incidental: asserting
43
+ * `aria-expanded` immediately after Escape passes on the UNPATCHED wrapper
44
+ * too, because the stray timer has not fired yet. The lock only means
45
+ * anything once the assertion runs from INSIDE the delay window.
46
+ */
47
+ it("does not spontaneously reopen after Escape dismisses it inside the hover-intent delay window", async () => {
48
+ // `shouldAdvanceTime` keeps the fake clock ticking in step with real time
49
+ // (in addition to responding to explicit `advanceTimersByTime` calls)
50
+ // so React's own scheduler — which falls back to a real `setTimeout` for
51
+ // its internal flush in this jsdom environment — is never left waiting
52
+ // on a fake timer nothing ever advances; plain `vi.useFakeTimers()` here
53
+ // hangs `userEvent.click()` indefinitely for exactly that reason.
54
+ vi.useFakeTimers({ shouldAdvanceTime: true });
55
+ try {
56
+ const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime });
57
+ renderMenu();
58
+ const trigger = screen.getByRole("button", { name: /products/i });
59
+
60
+ // Deliberately the opposite of #54's story-level unhover() workaround:
61
+ // hover-then-click with NO intervening pointer-leave, so the real
62
+ // click-then-dismiss sequence is exercised instead of routed around it.
63
+ await user.click(trigger);
64
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
65
+
66
+ await user.keyboard("{Escape}");
67
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
68
+
69
+ // Advance past delayDuration (200ms) + a margin so the stray timer, if
70
+ // still armed, fires.
71
+ await act(async () => {
72
+ await vi.advanceTimersByTimeAsync(300);
73
+ });
74
+
75
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
76
+ } finally {
77
+ vi.useRealTimers();
78
+ }
79
+ });
80
+
81
+ /**
82
+ * Acceptance criterion from #85: the fix must not regress ordinary
83
+ * click-to-toggle behaviour into the "menu never opens" failure mode hit by
84
+ * an earlier, abandoned `delayDuration={0}` attempt. Several rapid
85
+ * open/close/open cycles, entirely via click (no hover), must all still
86
+ * land the menu in the expected state.
87
+ */
88
+ it("still opens and closes normally across rapid repeated click cycles", async () => {
89
+ const user = userEvent.setup();
90
+ renderMenu();
91
+ const trigger = screen.getByRole("button", { name: /products/i });
92
+
93
+ for (let cycle = 0; cycle < 3; cycle += 1) {
94
+ await user.click(trigger);
95
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
96
+
97
+ await user.click(trigger);
98
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
99
+ }
100
+ });
101
+
102
+ /**
103
+ * Fix-round-1 regression (validator finding): a synthetic pointer-leave
104
+ * dispatched on every click — the first attempt at fixing #85 — runs
105
+ * Radix's real `onTriggerLeave`, which does two things, not one: it clears
106
+ * the stray open timer (wanted) AND unconditionally arms Radix's 150ms
107
+ * close timer (`startCloseTimer`/`closeTimerRef`, unwanted) via
108
+ * `context.onTriggerLeave` (`@radix-ui/react-navigation-menu@1.2.14 and
109
+ * 1.2.22`'s index.mjs, root `onTriggerLeave`). Since a click-opened menu
110
+ * never gets a real pointerenter on its content, nothing ever clears that
111
+ * close timer, so it silently closes itself ~150ms after every click-open
112
+ * unless the mouse happens to move onto the panel — breaking keyboard and
113
+ * touch entry entirely, and any mouse user who pauses before reaching the
114
+ * panel. This test opens the menu via click and asserts it is STILL OPEN
115
+ * well past that delay with no further pointer interaction at all.
116
+ */
117
+ it("stays open past the close-timer delay when opened by click with no further pointer movement", async () => {
118
+ vi.useFakeTimers({ shouldAdvanceTime: true });
119
+ try {
120
+ const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime });
121
+ renderMenu();
122
+ const trigger = screen.getByRole("button", { name: /products/i });
123
+
124
+ await user.click(trigger);
125
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
126
+
127
+ // Advance well past both Radix's 150ms close-timer delay and its
128
+ // 200ms open-timer delay, with no further interaction of any kind.
129
+ await act(async () => {
130
+ await vi.advanceTimersByTimeAsync(400);
131
+ });
132
+
133
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
134
+ } finally {
135
+ vi.useRealTimers();
136
+ }
137
+ });
138
+
139
+ /**
140
+ * Fix-round-1 requirement: the guard against #85's stray reopen must be
141
+ * scoped to genuine, still-hovering pointer dismissals — it must never
142
+ * interfere with a pure keyboard sequence (no mouse involved at all).
143
+ * Opening and dismissing via keyboard alone must behave exactly as an
144
+ * unpatched menu would: the panel closes on Escape, focus returns to the
145
+ * trigger (Radix's own `triggerRef.current?.focus()` on dismiss), and
146
+ * nothing reopens it afterward.
147
+ */
148
+ it("keyboard: Escape dismisses, returns focus to the trigger, and does not reopen", async () => {
149
+ vi.useFakeTimers({ shouldAdvanceTime: true });
150
+ try {
151
+ const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime });
152
+ renderMenu();
153
+ const trigger = screen.getByRole("button", { name: /products/i });
154
+
155
+ act(() => {
156
+ trigger.focus();
157
+ });
158
+ await user.keyboard("{Enter}");
159
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
160
+
161
+ await user.keyboard("{Escape}");
162
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
163
+ expect(trigger).toHaveFocus();
164
+
165
+ await act(async () => {
166
+ await vi.advanceTimersByTimeAsync(300);
167
+ });
168
+
169
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
170
+ } finally {
171
+ vi.useRealTimers();
172
+ }
173
+ });
174
+ });