@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.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 +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -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,79 @@ 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"
83
+ className={cn("rounded-full", className)}
44
84
  aria-label="Open notifications"
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
+ className={cn(
93
+ // The vertical inset belongs to the SIDE-opening rail placement, where
94
+ // the menu runs alongside its trigger and needs clearance from it. A
95
+ // menu that opens above or below the trigger is already clear of it,
96
+ // and the inset would just hang it 24px off the bar. Derived from
97
+ // `side` rather than exposed as a second className seam.
98
+ (side === "left" || side === "right") && "my-6",
99
+ "w-80",
100
+ )}
101
+ >
102
+ <DropdownMenuLabel>{t("ui.navNotifications.label")}</DropdownMenuLabel>
51
103
  <DropdownMenuSeparator />
52
104
  {notifications.map(({ id, fallback, text, time }) => (
53
105
  <DropdownMenuItem key={id} className="flex items-start gap-3">
@@ -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
+ });
@@ -1,22 +1,164 @@
1
- import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
1
+ import {
2
+ createContext,
3
+ forwardRef,
4
+ useContext,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ComponentPropsWithoutRef,
10
+ type ElementRef,
11
+ type PointerEvent as ReactPointerEvent,
12
+ } from "react";
2
13
  import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
3
14
  import { cva } from "class-variance-authority";
4
15
  import { ChevronDown } from "lucide-react";
5
16
  import { cn } from "../../lib/cn";
6
17
 
18
+ /**
19
+ * Residual of #54 (issue #85, fix round 1 → round 2): a pointer move onto a
20
+ * trigger arms `@radix-ui/react-navigation-menu`'s root-level hover-intent
21
+ * open timer (`openTimerRef`, default `delayDuration` 200ms — confirmed
22
+ * unchanged through `@radix-ui/react-navigation-menu@1.2.22`, the current
23
+ * latest stable). Neither the click-open path (root `onItemSelect`) nor any
24
+ * dismiss path (Escape, outside-click, focus-out — all funnelled through one
25
+ * `ROOT_CONTENT_DISMISS` handler → `onItemDismiss`) clears that timer, so it
26
+ * fires later and silently reopens the panel even after an explicit
27
+ * dismissal.
28
+ *
29
+ * Round 1 ran Radix's own `onTriggerLeave` cancellation path (a synthetic
30
+ * `pointerout`) on every click. That clears the stray open timer, but
31
+ * `onTriggerLeave` does two things, not one — it ALSO unconditionally arms
32
+ * Radix's 150ms close timer (`startCloseTimer`/`closeTimerRef`), which
33
+ * nothing then cancels for a click-opened (or keyboard/touch-opened) menu,
34
+ * since it never gets a real pointerenter on its content. Every click-opened
35
+ * menu silently closed itself ~150ms later. No supported, finer-grained Radix
36
+ * API exists to clear only the open timer — the 6 context callbacks
37
+ * (`onTriggerEnter`/`onTriggerLeave`/`onContentEnter`/`onContentLeave`/
38
+ * `onItemSelect`/`onItemDismiss`) are the only reachable surface, and none of
39
+ * the internal refs (`openTimerRef`, `closeTimerRef`, `wasClickCloseRef`,
40
+ * `wasEscapeCloseRef`) are exported.
41
+ *
42
+ * Round 2 fix: stop reaching into Radix's internal timers via synthetic
43
+ * events entirely. `NavigationMenu` takes over `value`/`onValueChange` as a
44
+ * controlled proxy (transparent to a consumer's own controlled/uncontrolled
45
+ * usage) and rejects, ONCE, a reopen request for an item that was just
46
+ * dismissed (value → "") while a REAL mouse pointer was still resting on its
47
+ * trigger — exactly the #85 scenario (dismissed inside the hover-intent
48
+ * window, pointer never left). The guard is a one-shot, pointer-scoped
49
+ * suppression per item (`suppressReopenForRef` in `NavigationMenu`, armed by
50
+ * `NavigationMenuItemValueContext` + a small guard context), cleared the
51
+ * instant ANY of the following happens, so it can never block a genuine
52
+ * interaction: (a) the real pointer leaves that trigger (a fresh hover-intent
53
+ * cycle is legitimate — Radix's own `wasEscapeCloseRef`/`wasClickCloseRef`
54
+ * guards reset on the same real pointerenter), or (b) any click on that
55
+ * trigger (`noteExplicitActivation`, which runs synchronously before Radix's
56
+ * own `onItemSelect` in the same click dispatch, per
57
+ * `composeEventHandlers(props.onClick, () => context.onItemSelect(...))` in
58
+ * `@radix-ui/react-navigation-menu`'s Trigger). A pure keyboard sequence never
59
+ * touches this guard at all (no pointer event ever marks the trigger as
60
+ * "resting"), so it cannot regress keyboard or touch entry — see the 3
61
+ * scenarios locked in `navigation-menu.test.tsx` (pointer dismiss stays
62
+ * dismissed, click-open survives past the close-timer delay with no pointer
63
+ * movement, keyboard open/Escape/no-reopen).
64
+ *
65
+ * `NavigationMenuItem` is wrapped (was a bare re-export) purely so its
66
+ * resolved item value — the same value Radix's OWN auto-`useId()` fallback
67
+ * would otherwise compute internally and never expose — is available to
68
+ * `NavigationMenuTrigger` without inventing a new public prop or reaching
69
+ * into Radix's non-exported item context.
70
+ *
71
+ * Tracked upstream: https://github.com/radix-ui/primitives/issues (file and
72
+ * link the specific issue here once opened — non-blocking, parallel to this
73
+ * interim fix; confirmed still present in 1.2.22, the latest stable release
74
+ * as of this fix).
75
+ */
76
+ type NavigationMenuGuard = {
77
+ notePointerPresence(itemValue: string, present: boolean): void;
78
+ noteExplicitActivation(itemValue: string): void;
79
+ /** One-shot: returns true (and clears the flag) iff this reopen should be swallowed. */
80
+ consumeSuppressionFlag(itemValue: string): boolean;
81
+ };
82
+
83
+ const NavigationMenuGuardContext = createContext<NavigationMenuGuard | null>(null);
84
+ const NavigationMenuItemValueContext = createContext<string | undefined>(undefined);
85
+
7
86
  export const NavigationMenu = forwardRef<
8
87
  ElementRef<typeof NavigationMenuPrimitive.Root>,
9
88
  ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
10
- >(function NavigationMenu({ className, children, ...props }, ref) {
89
+ >(function NavigationMenu(
90
+ { className, children, value: valueProp, defaultValue, onValueChange, ...props },
91
+ ref,
92
+ ) {
93
+ const isControlled = valueProp !== undefined;
94
+ const [internalValue, setInternalValue] = useState(defaultValue ?? "");
95
+ const currentValue = isControlled ? (valueProp as string) : internalValue;
96
+ const currentValueRef = useRef(currentValue);
97
+ currentValueRef.current = currentValue;
98
+
99
+ // itemValue currently under a resting, real (mouse) pointer — at most one
100
+ // at a time in practice, since a real pointer can only be inside one
101
+ // trigger's bounds.
102
+ const pointerPresentForRef = useRef<string | null>(null);
103
+ // One-shot: the itemValue whose next reopen request should be swallowed.
104
+ const suppressReopenForRef = useRef<string | null>(null);
105
+
106
+ const guard = useMemo<NavigationMenuGuard>(
107
+ () => ({
108
+ notePointerPresence(itemValue, present) {
109
+ if (present) {
110
+ pointerPresentForRef.current = itemValue;
111
+ return;
112
+ }
113
+ if (pointerPresentForRef.current === itemValue) pointerPresentForRef.current = null;
114
+ // A genuine leave ends the suspicious window this guard exists for —
115
+ // any future reopen must come from a fresh interaction (a new
116
+ // hover-intent cycle after re-entering, or a click), both legitimate.
117
+ if (suppressReopenForRef.current === itemValue) suppressReopenForRef.current = null;
118
+ },
119
+ noteExplicitActivation(itemValue) {
120
+ if (suppressReopenForRef.current === itemValue) suppressReopenForRef.current = null;
121
+ },
122
+ consumeSuppressionFlag(itemValue) {
123
+ if (suppressReopenForRef.current === itemValue) {
124
+ suppressReopenForRef.current = null;
125
+ return true;
126
+ }
127
+ return false;
128
+ },
129
+ }),
130
+ [],
131
+ );
132
+
133
+ const handleValueChange = (next: string) => {
134
+ const previous = currentValueRef.current;
135
+ if (next === "") {
136
+ if (previous !== "" && pointerPresentForRef.current === previous) {
137
+ // Dismissed while a real pointer still rests on its trigger, inside
138
+ // the hover-intent window — the #85 defect. Arm the one-shot guard
139
+ // against exactly this item's next reopen request.
140
+ suppressReopenForRef.current = previous;
141
+ }
142
+ } else if (guard.consumeSuppressionFlag(next)) {
143
+ return; // Swallow the stray reopen — stay on `previous` (closed).
144
+ }
145
+ if (!isControlled) setInternalValue(next);
146
+ onValueChange?.(next);
147
+ };
148
+
11
149
  return (
12
- <NavigationMenuPrimitive.Root
13
- ref={ref}
14
- className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
15
- {...props}
16
- >
17
- {children}
18
- <NavigationMenuViewport />
19
- </NavigationMenuPrimitive.Root>
150
+ <NavigationMenuGuardContext.Provider value={guard}>
151
+ <NavigationMenuPrimitive.Root
152
+ ref={ref}
153
+ value={currentValue}
154
+ onValueChange={handleValueChange}
155
+ className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
156
+ {...props}
157
+ >
158
+ {children}
159
+ <NavigationMenuViewport />
160
+ </NavigationMenuPrimitive.Root>
161
+ </NavigationMenuGuardContext.Provider>
20
162
  );
21
163
  });
22
164
 
@@ -33,7 +175,18 @@ export const NavigationMenuList = forwardRef<
33
175
  );
34
176
  });
35
177
 
36
- export const NavigationMenuItem = NavigationMenuPrimitive.Item;
178
+ export const NavigationMenuItem = forwardRef<
179
+ ElementRef<typeof NavigationMenuPrimitive.Item>,
180
+ ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Item>
181
+ >(function NavigationMenuItem({ value: valueProp, ...props }, ref) {
182
+ const autoValue = useId();
183
+ const value = valueProp ?? autoValue;
184
+ return (
185
+ <NavigationMenuItemValueContext.Provider value={value}>
186
+ <NavigationMenuPrimitive.Item ref={ref} value={value} {...props} />
187
+ </NavigationMenuItemValueContext.Provider>
188
+ );
189
+ });
37
190
 
38
191
  export const navigationMenuTriggerStyle = cva(
39
192
  "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent",
@@ -42,11 +195,35 @@ export const navigationMenuTriggerStyle = cva(
42
195
  export const NavigationMenuTrigger = forwardRef<
43
196
  ElementRef<typeof NavigationMenuPrimitive.Trigger>,
44
197
  ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
45
- >(function NavigationMenuTrigger({ className, children, ...props }, ref) {
198
+ >(function NavigationMenuTrigger(
199
+ { className, children, onClick, onPointerEnter, onPointerLeave, ...props },
200
+ ref,
201
+ ) {
202
+ const itemValue = useContext(NavigationMenuItemValueContext);
203
+ const guard = useContext(NavigationMenuGuardContext);
204
+
205
+ const notePointer = (event: ReactPointerEvent<HTMLButtonElement>, present: boolean) => {
206
+ if (itemValue !== undefined && guard && event.pointerType === "mouse") {
207
+ guard.notePointerPresence(itemValue, present);
208
+ }
209
+ };
210
+
46
211
  return (
47
212
  <NavigationMenuPrimitive.Trigger
48
213
  ref={ref}
49
214
  className={cn(navigationMenuTriggerStyle(), "group", className)}
215
+ onPointerEnter={(event) => {
216
+ onPointerEnter?.(event);
217
+ notePointer(event, true);
218
+ }}
219
+ onPointerLeave={(event) => {
220
+ onPointerLeave?.(event);
221
+ notePointer(event, false);
222
+ }}
223
+ onClick={(event) => {
224
+ if (itemValue !== undefined) guard?.noteExplicitActivation(itemValue);
225
+ onClick?.(event);
226
+ }}
50
227
  {...props}
51
228
  >
52
229
  {children}