@primitiv-ui/react 0.1.28 → 0.1.29

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 (307) hide show
  1. package/README.md +4 -0
  2. package/package.json +6 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
  4. package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
  5. package/src/AccessibleIcon/types.ts +15 -2
  6. package/src/Accordion/Accordion.tsx +25 -4
  7. package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
  8. package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
  9. package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
  10. package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
  11. package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
  12. package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
  13. package/src/Accordion/types.ts +72 -21
  14. package/src/Alert/Alert.tsx +36 -7
  15. package/src/Alert/__tests__/Alert.test.tsx +5 -0
  16. package/src/Alert/types.ts +18 -2
  17. package/src/Avatar/Avatar.tsx +99 -15
  18. package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
  19. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
  20. package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
  21. package/src/Avatar/hooks/useAvatarImage.ts +16 -0
  22. package/src/Avatar/types.ts +15 -3
  23. package/src/Breadcrumb/Breadcrumb.tsx +136 -18
  24. package/src/Breadcrumb/README.md +47 -0
  25. package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
  26. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
  27. package/src/Breadcrumb/types.ts +73 -7
  28. package/src/Button/Button.tsx +2 -0
  29. package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
  30. package/src/Button/types.ts +21 -0
  31. package/src/Carousel/Carousel.tsx +263 -15
  32. package/src/Carousel/README.md +749 -72
  33. package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
  34. package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
  35. package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
  36. package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
  37. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
  38. package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
  39. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
  40. package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
  41. package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
  42. package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
  43. package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
  44. package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
  45. package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
  46. package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
  47. package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
  48. package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
  49. package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
  50. package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
  51. package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
  52. package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
  53. package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
  54. package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
  55. package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
  56. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
  57. package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
  58. package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
  59. package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
  60. package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
  61. package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
  62. package/src/Carousel/hooks/index.ts +1 -0
  63. package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
  64. package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
  65. package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
  66. package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
  67. package/src/Carousel/loopEngine.ts +54 -0
  68. package/src/Carousel/types.ts +457 -35
  69. package/src/Checkbox/Checkbox.tsx +9 -1
  70. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
  71. package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
  72. package/src/Checkbox/types.ts +19 -0
  73. package/src/CheckboxCard/CheckboxCard.tsx +13 -4
  74. package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
  75. package/src/CheckboxCard/types.ts +65 -17
  76. package/src/Collapsible/Collapsible.tsx +49 -3
  77. package/src/Collapsible/README.md +40 -0
  78. package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
  79. package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
  80. package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
  81. package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
  82. package/src/Collapsible/types.ts +89 -22
  83. package/src/ContextMenu/ContextMenu.tsx +270 -4
  84. package/src/ContextMenu/types.ts +148 -6
  85. package/src/DirectionProvider/DirectionProvider.tsx +41 -9
  86. package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
  87. package/src/DirectionProvider/types.ts +28 -3
  88. package/src/Divider/Divider.tsx +24 -10
  89. package/src/Divider/__tests__/Divider.test.tsx +5 -0
  90. package/src/Divider/types.ts +10 -3
  91. package/src/Drawer/Drawer.tsx +315 -0
  92. package/src/Drawer/README.md +124 -0
  93. package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
  94. package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
  95. package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
  96. package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
  97. package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
  98. package/src/Drawer/index.ts +2 -0
  99. package/src/Drawer/types.ts +66 -0
  100. package/src/Dropdown/Dropdown.tsx +117 -0
  101. package/src/Dropdown/types.ts +241 -11
  102. package/src/EmptyState/EmptyState.tsx +74 -4
  103. package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
  104. package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
  105. package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
  106. package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
  107. package/src/EmptyState/types.ts +49 -6
  108. package/src/Field/Field.tsx +23 -0
  109. package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
  110. package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
  111. package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
  112. package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
  113. package/src/Field/types.ts +60 -12
  114. package/src/Fieldset/Fieldset.tsx +54 -18
  115. package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
  116. package/src/Fieldset/types.ts +20 -2
  117. package/src/Input/Input.tsx +14 -9
  118. package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
  119. package/src/Input/types.ts +19 -2
  120. package/src/InputGroup/InputGroup.tsx +11 -3
  121. package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
  122. package/src/InputGroup/types.ts +26 -5
  123. package/src/MillerColumns/MillerColumns.tsx +86 -19
  124. package/src/MillerColumns/types.ts +83 -27
  125. package/src/Modal/Modal.tsx +31 -4
  126. package/src/Modal/README.md +19 -10
  127. package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
  128. package/src/Modal/hooks/useModalContent.ts +32 -0
  129. package/src/Modal/types.ts +110 -29
  130. package/src/NavigationMenu/NavigationMenu.tsx +890 -0
  131. package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
  132. package/src/NavigationMenu/README.md +355 -0
  133. package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
  134. package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
  135. package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
  136. package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
  137. package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
  138. package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
  139. package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
  140. package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
  141. package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
  142. package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
  143. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
  144. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
  145. package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
  146. package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
  147. package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
  148. package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
  149. package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
  150. package/src/NavigationMenu/constants.ts +5 -0
  151. package/src/NavigationMenu/hooks/index.ts +7 -0
  152. package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
  153. package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
  154. package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
  155. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
  156. package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
  157. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
  158. package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
  159. package/src/NavigationMenu/index.ts +3 -0
  160. package/src/NavigationMenu/types.ts +259 -0
  161. package/src/NavigationMenu/utils.ts +89 -0
  162. package/src/Popover/Popover.tsx +427 -0
  163. package/src/Popover/PopoverContext.ts +13 -0
  164. package/src/Popover/README.md +106 -0
  165. package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
  166. package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
  167. package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
  168. package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
  169. package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
  170. package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
  171. package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
  172. package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
  173. package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
  174. package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
  175. package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
  176. package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
  177. package/src/Popover/hooks/index.ts +3 -0
  178. package/src/Popover/hooks/usePopoverContent.ts +98 -0
  179. package/src/Popover/hooks/usePopoverRoot.ts +84 -0
  180. package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
  181. package/src/Popover/index.ts +2 -0
  182. package/src/Popover/types.ts +197 -0
  183. package/src/Portal/Portal.tsx +50 -10
  184. package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
  185. package/src/Portal/types.ts +5 -0
  186. package/src/Progress/Progress.tsx +33 -1
  187. package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
  188. package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
  189. package/src/Progress/types.ts +20 -4
  190. package/src/Radio/Radio.tsx +9 -1
  191. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
  192. package/src/Radio/hooks/useRadioRoot.ts +3 -0
  193. package/src/Radio/types.ts +20 -0
  194. package/src/RadioCard/RadioCard.tsx +11 -0
  195. package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
  196. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
  197. package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
  198. package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
  199. package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
  200. package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
  201. package/src/RadioCard/types.ts +95 -22
  202. package/src/RadioGroup/RadioGroup.tsx +11 -0
  203. package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
  204. package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
  205. package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
  206. package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
  207. package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
  208. package/src/RadioGroup/types.ts +73 -9
  209. package/src/SegmentedControl/README.md +154 -0
  210. package/src/SegmentedControl/SegmentedControl.tsx +309 -0
  211. package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
  212. package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
  213. package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
  214. package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
  215. package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
  216. package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
  217. package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
  218. package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
  219. package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
  220. package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
  221. package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
  222. package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
  223. package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
  224. package/src/SegmentedControl/hooks/index.ts +2 -0
  225. package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
  226. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
  227. package/src/SegmentedControl/index.ts +2 -0
  228. package/src/SegmentedControl/types.ts +138 -0
  229. package/src/Select/README.md +122 -157
  230. package/src/Select/Select.tsx +595 -68
  231. package/src/Select/SelectContext.ts +38 -0
  232. package/src/Select/SelectItemIndicatorContext.ts +29 -0
  233. package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
  234. package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
  235. package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
  236. package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
  237. package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
  238. package/src/Select/__tests__/Select.form.test.tsx +38 -0
  239. package/src/Select/__tests__/Select.group.test.tsx +6 -6
  240. package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
  241. package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
  242. package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
  243. package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
  244. package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
  245. package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
  246. package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
  247. package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
  248. package/src/Select/__tests__/Select.selection.test.tsx +104 -0
  249. package/src/Select/__tests__/Select.separator.test.tsx +71 -0
  250. package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
  251. package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
  252. package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
  253. package/src/Select/constants.ts +8 -0
  254. package/src/Select/hooks/index.ts +2 -0
  255. package/src/Select/hooks/useSelectContent.ts +149 -0
  256. package/src/Select/hooks/useSelectRoot.ts +149 -0
  257. package/src/Select/types.ts +199 -31
  258. package/src/SkipNav/SkipNav.tsx +69 -29
  259. package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
  260. package/src/SkipNav/types.ts +17 -9
  261. package/src/Slider/Slider.tsx +33 -1
  262. package/src/Slider/SliderContext.ts +28 -1
  263. package/src/Slider/types.ts +157 -9
  264. package/src/Slot/Slot.tsx +94 -19
  265. package/src/Slot/__tests__/Slot.test.tsx +110 -0
  266. package/src/Slot/composeEventHandlers.ts +26 -2
  267. package/src/Slot/types.ts +16 -0
  268. package/src/Status/Status.tsx +36 -15
  269. package/src/Status/__tests__/Status.test.tsx +5 -0
  270. package/src/Status/types.ts +12 -1
  271. package/src/Switch/Switch.tsx +15 -2
  272. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
  273. package/src/Switch/hooks/useSwitchRoot.ts +3 -0
  274. package/src/Switch/types.ts +49 -22
  275. package/src/Table/Table.tsx +44 -10
  276. package/src/Table/types.ts +66 -17
  277. package/src/Tabs/Tabs.tsx +8 -0
  278. package/src/Tabs/types.ts +29 -4
  279. package/src/Textarea/Textarea.tsx +9 -1
  280. package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
  281. package/src/Textarea/types.ts +20 -3
  282. package/src/Toggle/Toggle.tsx +51 -13
  283. package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
  284. package/src/Toggle/types.ts +54 -4
  285. package/src/ToggleGroup/ToggleGroup.tsx +14 -1
  286. package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
  287. package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
  288. package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
  289. package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
  290. package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
  291. package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
  292. package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
  293. package/src/ToggleGroup/types.ts +55 -2
  294. package/src/Tooltip/Tooltip.tsx +98 -20
  295. package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
  296. package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
  297. package/src/Tooltip/types.ts +89 -8
  298. package/src/Tree/Tree.tsx +187 -33
  299. package/src/Tree/types.ts +54 -12
  300. package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
  301. package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
  302. package/src/VisuallyHidden/types.ts +11 -1
  303. package/src/index.ts +4 -0
  304. package/src/test/intersectionObserverPolyfill.ts +7 -0
  305. package/src/test/resizeObserverPolyfill.ts +80 -0
  306. package/src/utils/focusable.ts +15 -0
  307. package/src/utils/index.ts +1 -0
@@ -0,0 +1,67 @@
1
+ import { createContext } from "react";
2
+ import type { Context, Provider } from "react";
3
+
4
+ import { createStrictContext } from "../utils/index.ts";
5
+
6
+ import type {
7
+ NavigationMenuContextValue,
8
+ NavigationMenuItemContextValue,
9
+ NavigationMenuPanelContextValue,
10
+ } from "./types";
11
+
12
+ const navigationMenuContextPair = createStrictContext<NavigationMenuContextValue>(
13
+ "Component must be rendered as a child of NavigationMenu.Root",
14
+ "NavigationMenuContext",
15
+ );
16
+
17
+ /** Strict React context carrying the {@link NavigationMenuContextValue} from
18
+ * `NavigationMenu.Root` to its descendants. `null` outside a Root. */
19
+ export const NavigationMenuContext: Context<NavigationMenuContextValue | null> =
20
+ navigationMenuContextPair[0];
21
+ /** Reads the {@link NavigationMenuContextValue}; throws outside a
22
+ * `NavigationMenu.Root`. */
23
+ export const useNavigationMenuContext: () => NavigationMenuContextValue =
24
+ navigationMenuContextPair[1];
25
+
26
+ /** Provider for {@link NavigationMenuContext}, used by `NavigationMenu.Root`. */
27
+ const NavigationMenuProvider: Provider<NavigationMenuContextValue | null> =
28
+ NavigationMenuContext.Provider;
29
+
30
+ export { NavigationMenuProvider };
31
+
32
+ const navigationMenuItemContextPair =
33
+ createStrictContext<NavigationMenuItemContextValue>(
34
+ "Component must be rendered as a child of NavigationMenu.Item",
35
+ "NavigationMenuItemContext",
36
+ );
37
+
38
+ /** Strict React context carrying the {@link NavigationMenuItemContextValue}
39
+ * from `NavigationMenu.Item` to its Trigger and Content. `null` outside an
40
+ * Item. */
41
+ export const NavigationMenuItemContext: Context<NavigationMenuItemContextValue | null> =
42
+ navigationMenuItemContextPair[0];
43
+ /** Reads the {@link NavigationMenuItemContextValue}; throws outside a
44
+ * `NavigationMenu.Item`. */
45
+ export const useNavigationMenuItemContext: () => NavigationMenuItemContextValue =
46
+ navigationMenuItemContextPair[1];
47
+
48
+ /** Provider for {@link NavigationMenuItemContext}, used by
49
+ * `NavigationMenu.Item`. */
50
+ const NavigationMenuItemProvider: Provider<NavigationMenuItemContextValue | null> =
51
+ NavigationMenuItemContext.Provider;
52
+
53
+ export { NavigationMenuItemProvider };
54
+
55
+ /** Marks its subtree as being inside a `NavigationMenu.Content` panel.
56
+ * Deliberately **not** strict: the default `false` is the meaningful "I am a
57
+ * top-level entry" answer, so a Link outside any panel must read it without
58
+ * throwing. */
59
+ export const NavigationMenuPanelContext: Context<NavigationMenuPanelContextValue> =
60
+ createContext<NavigationMenuPanelContextValue>(false);
61
+
62
+ /** Provider for {@link NavigationMenuPanelContext}, used by
63
+ * `NavigationMenu.Content`. */
64
+ const NavigationMenuPanelProvider: Provider<NavigationMenuPanelContextValue> =
65
+ NavigationMenuPanelContext.Provider;
66
+
67
+ export { NavigationMenuPanelProvider };
@@ -0,0 +1,355 @@
1
+ # NavigationMenu
2
+
3
+ The **desktop dropdown site nav**: a `<nav>` landmark wrapping a list of
4
+ top-level entries, each either a plain link or a trigger that discloses a
5
+ panel. One panel is open at a time.
6
+
7
+ Implements the [ARIA APG Disclosure Navigation
8
+ Menu](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/)
9
+ pattern — **not** a menubar. These are links to pages, so the semantics are
10
+ list-and-link and every top-level entry stays in the tab order.
11
+
12
+ ```tsx
13
+ import { NavigationMenu } from "@primitiv-ui/react";
14
+
15
+ <NavigationMenu.Root>
16
+ <NavigationMenu.List>
17
+ <NavigationMenu.Item value="concepts">
18
+ <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
19
+ <NavigationMenu.Content>
20
+ <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
21
+ </NavigationMenu.Content>
22
+ </NavigationMenu.Item>
23
+ <NavigationMenu.Item>
24
+ <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
25
+ </NavigationMenu.Item>
26
+ </NavigationMenu.List>
27
+ </NavigationMenu.Root>;
28
+ ```
29
+
30
+ ## Scope: desktop only, by design
31
+
32
+ This component is the **desktop** presentation. The mobile nav is deliberately
33
+ not a mode of it: several sections expand *in place* on mobile, versus one
34
+ panel at a time on desktop, and pushing both state models into one primitive
35
+ makes it larger without making the consumer's job smaller.
36
+
37
+ Mobile is instead a composition of [`Drawer`](../Drawer/README.md) +
38
+ [`Collapsible`](../Collapsible/README.md), reusing this component's
39
+ `NavigationMenu.Link`. What stays single-sourced across both:
40
+
41
+ | Concern | Where it lives | Duplicated? |
42
+ | --- | --- | --- |
43
+ | Nav data (sections → children) | one module in your app, `.map()`ed by both | **No** |
44
+ | Active state / `aria-current` | `NavigationMenu.Link`, used verbatim in both | **No** |
45
+ | Panel/row wrapper elements | `List`/`Item`/`Trigger`/`Content` vs `Collapsible.*` | Yes — ~15 lines each |
46
+
47
+ There is also an accessibility argument for the split: rendering one shared
48
+ tree and hiding half of it by breakpoint puts duplicate landmarks and
49
+ duplicate `id`s in the accessibility tree. See RFC 0019 §3–4a.
50
+
51
+ ## Sub-components
52
+
53
+ | Export | Role | Notes |
54
+ | --- | --- | --- |
55
+ | `NavigationMenu.Root` | `<nav>` + state owner | `aria-label="Main"` by default; uncontrolled (`defaultValue`) or controlled (`value` + `onValueChange`), where `""` means closed |
56
+ | `NavigationMenu.List` | `<ul>` | Top-level entries; carries `data-orientation` |
57
+ | `NavigationMenu.Item` | `<li>` | Its `value` is what makes the entry a disclosure — omit it for a plain link entry |
58
+ | `NavigationMenu.Trigger` | `<button>` | `aria-expanded`/`aria-controls`, hover-intent, keyboard; supports `asChild` and ref composition |
59
+ | `NavigationMenu.Content` | The panel | `hidden` while closed; `forceMount` for CSS animation; portals into a `Viewport` when one exists |
60
+ | `NavigationMenu.Viewport` | Shared panel host | **Optional.** All panels render into this one box so the open one morphs into the next |
61
+ | `NavigationMenu.Indicator` | Marker | **Optional.** Publishes the open trigger's measured geometry as custom properties; supports `asChild` so the marker can be an icon |
62
+ | `NavigationMenu.Link` | `<a>` | `active` → `aria-current="page"` + `data-active`; closes the menu on click; supports `asChild` |
63
+
64
+ ## `Item` value: disclosure vs plain link
65
+
66
+ `value` on `NavigationMenu.Item` is the whole distinction:
67
+
68
+ ```tsx
69
+ {/* A disclosure — has a panel */}
70
+ <NavigationMenu.Item value="concepts">
71
+ <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
72
+ <NavigationMenu.Content>…</NavigationMenu.Content>
73
+ </NavigationMenu.Item>
74
+
75
+ {/* A plain link — no panel, no value */}
76
+ <NavigationMenu.Item>
77
+ <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
78
+ </NavigationMenu.Item>
79
+ ```
80
+
81
+ A `Trigger` or `Content` inside a value-less `Item` **throws** with the fix in
82
+ the message, rather than silently deriving an id that could never be opened.
83
+
84
+ Note `value=""` is *not* the same as omitting it: the empty string is the
85
+ "nothing is open" sentinel, so an `Item` that adopts it gets a `Trigger` and
86
+ `Content` that can never report open. Give every disclosure entry a real value.
87
+
88
+ ## State modes
89
+
90
+ - **Uncontrolled** — pass `defaultValue` to start with one panel open, or omit
91
+ it to start closed.
92
+ - **Controlled** — pass `value` and `onValueChange` together. Closing calls
93
+ `onValueChange("")`.
94
+
95
+ The two shapes are discriminated at the type level: passing `defaultValue`
96
+ alongside `value` is a type error.
97
+
98
+ A common controlled use is closing the menu on every route change:
99
+
100
+ ```tsx
101
+ const [open, setOpen] = useState("");
102
+ const { pathname } = useLocation();
103
+ useEffect(() => setOpen(""), [pathname]);
104
+
105
+ <NavigationMenu.Root value={open} onValueChange={setOpen}>…</NavigationMenu.Root>;
106
+ ```
107
+
108
+ ## Hover intent
109
+
110
+ | Prop | Default | Behaviour |
111
+ | --- | --- | --- |
112
+ | `openOnHover` | `true` | Whether hover opens a panel at all |
113
+ | `delayDuration` | `200` | ms of hover before the **first** panel opens; `0` opens immediately |
114
+ | `closeDelay` | `150` | ms the panel survives after the pointer leaves the `<nav>` |
115
+
116
+ Two details worth knowing:
117
+
118
+ - **Switching is instant.** `delayDuration` guards only the first open. Once a
119
+ panel is open, crossing to a sibling trigger swaps panels with no delay —
120
+ waiting again feels broken.
121
+ - **The close intent lives on the `<nav>`, not the trigger.** That is what lets
122
+ the pointer travel from a trigger down into its own panel without dismissing
123
+ it. Returning to the nav within `closeDelay` cancels the pending close.
124
+
125
+ Set `openOnHover={false}` for a click-only nav. Hover-to-open has no touch
126
+ equivalent, so this is the right choice if the same markup serves touch.
127
+
128
+ ### Click after hover
129
+
130
+ A pointer that arrives to click fires `pointerenter` first, which with
131
+ hover-to-open opens the panel *before* the click lands. The trigger therefore
132
+ toggles against what was open when the pointer **arrived**, so a click never
133
+ undoes the user's own hover. A second click on a still-hovered trigger closes,
134
+ as expected.
135
+
136
+ ## Keyboard interaction
137
+
138
+ | Key | Behaviour |
139
+ | --- | --- |
140
+ | `Enter` / `Space` | Toggle the focused trigger's panel |
141
+ | `ArrowRight` / `ArrowLeft` | Move between top-level entries (horizontal), mirrored under `dir="rtl"` |
142
+ | `ArrowUp` / `ArrowDown` | Move between top-level entries (vertical) |
143
+ | `Home` / `End` | First / last top-level entry |
144
+ | `ArrowDown` (horizontal), `ArrowRight`/`ArrowLeft` (vertical) | Open the panel and move focus to its first link |
145
+ | `Escape` | Close the open panel and return focus to its trigger |
146
+
147
+ Movement **wraps** at the ends.
148
+
149
+ **Not a roving tabindex.** Every top-level entry keeps `tabIndex` untouched and
150
+ stays in the tab order — a keyboard user must be able to Tab through page links
151
+ without discovering that arrow keys are required. `useRovingTabindex` is used
152
+ internally for its orientation/RTL-aware keymap only.
153
+
154
+ **Panel links are not top-level entries.** A `Link` inside a `Content` is
155
+ reached by Tab and is excluded from the top-level arrow order, so `Home`/`End`
156
+ inside a panel don't jump out of it.
157
+
158
+ `Escape` is handled once on the `<nav>` so it works from anywhere inside the
159
+ menu, including a link deep in an open panel, and returns focus to the trigger
160
+ so the user is never stranded on an element that just became `hidden`.
161
+
162
+ ## Viewport — the shared panel box
163
+
164
+ Mount a `Viewport` when you want the desktop "one panel morphs into the next"
165
+ behaviour. Every `Content` portals into it, so a CSS transition on its size
166
+ animates between panels instead of each entry expanding separately:
167
+
168
+ ```tsx
169
+ <NavigationMenu.Root>
170
+ <NavigationMenu.List>…</NavigationMenu.List>
171
+ <NavigationMenu.Viewport className="viewport" />
172
+ </NavigationMenu.Root>
173
+ ```
174
+
175
+ ```css
176
+ .viewport { transition: height 200ms; }
177
+ .viewport[data-value="registry"] { height: 12rem; }
178
+ ```
179
+
180
+ Authoring does not change: always nest each `Content` inside its `Item`, next
181
+ to its `Trigger`. Omit the `Viewport` entirely and panels render in place.
182
+
183
+ ## Anchoring the Viewport to the open trigger
184
+
185
+ Every `Trigger` publishes its own `anchor-name` (a plain CSS property, set
186
+ inline), and `Root` publishes whichever one is currently open as
187
+ `--primitiv-navigation-menu-active-trigger-anchor` on the `<nav>` — unset
188
+ while nothing is open. Together they let a stylesheet position `Viewport`
189
+ relative to whichever trigger opened it, via CSS anchor positioning, with
190
+ **no consumer wiring**: unlike a single-instance overlay (`Dropdown`,
191
+ `Popover`), where the consumer hand-authors a matching `anchor-name` /
192
+ `position-anchor` pair, here the library already knows which of several
193
+ triggers is the right one and publishes the pairing itself.
194
+
195
+ ```css
196
+ .viewport {
197
+ position: absolute;
198
+ position-anchor: var(--primitiv-navigation-menu-active-trigger-anchor);
199
+ position-area: block-end; /* centers under the anchor by default */
200
+ }
201
+ ```
202
+
203
+ This is purely a positioning *building block* — the library ships no styles,
204
+ so nothing above is applied for you. It only does anything in a browser that
205
+ supports CSS anchor positioning; write a plain fallback (e.g. insets pinned to
206
+ the nav's own edge) alongside it for browsers that don't.
207
+
208
+ ## Indicator — geometry, not styling
209
+
210
+ No styles ship with the library, so the `Indicator` cannot position itself —
211
+ but it is the only part that can know *where* the open trigger is. It measures
212
+ that trigger and publishes the result as two inline custom properties:
213
+
214
+ | Custom property | Horizontal | Vertical |
215
+ | --- | --- | --- |
216
+ | `--primitiv-navigation-menu-indicator-position` | trigger `offsetLeft` | trigger `offsetTop` |
217
+ | `--primitiv-navigation-menu-indicator-size` | trigger `offsetWidth` | trigger `offsetHeight` |
218
+
219
+ ```css
220
+ .indicator {
221
+ position: absolute;
222
+ inset-block-end: 0;
223
+ block-size: 2px;
224
+ inline-size: var(--primitiv-navigation-menu-indicator-size);
225
+ translate: var(--primitiv-navigation-menu-indicator-position) 0;
226
+ transition: translate 200ms, inline-size 200ms;
227
+ }
228
+ ```
229
+
230
+ Both are re-measured when the open entry changes and on window `resize`.
231
+ When the open value names an entry with no rendered trigger, neither property
232
+ is set — better an unpositioned marker than one parked at `0`.
233
+
234
+ `Indicator` also supports `asChild`, so the marker can be an icon rather than a
235
+ styled box — the `data-*` hooks and the geometry properties are merged onto your
236
+ element either way:
237
+
238
+ ```tsx
239
+ <NavigationMenu.Indicator asChild>
240
+ <ChevronUpIcon aria-hidden />
241
+ </NavigationMenu.Indicator>
242
+ ```
243
+
244
+ ## Panel motion — which way the pointer travelled
245
+
246
+ When the open entry changes, the two panels involved report the direction of
247
+ travel as `data-motion`, so a stylesheet can slide them instead of cross-fading
248
+ every switch:
249
+
250
+ | Value | Meaning |
251
+ | --- | --- |
252
+ | `from-end` | Entering, and the previous entry was earlier in the list |
253
+ | `from-start` | Entering, and the previous entry was later |
254
+ | `to-start` | Leaving, and the newly-open entry is later |
255
+ | `to-end` | Leaving, and the newly-open entry is earlier |
256
+
257
+ The attribute is **absent** for the two transitions with no direction — the first
258
+ open (nothing to travel from) and the full close (nothing to travel to) — and for
259
+ an entry with no registered trigger, since a direction derived from an unknown
260
+ position would be a guess. Those cases just fade.
261
+
262
+ ## Viewport size — the one-box morph
263
+
264
+ `Viewport` measures the open panel and publishes it, the same split the
265
+ `Indicator` uses:
266
+
267
+ | Custom property | Value |
268
+ | --- | --- |
269
+ | `--primitiv-navigation-menu-viewport-width` | the open panel's `offsetWidth` |
270
+ | `--primitiv-navigation-menu-viewport-height` | its `offsetHeight` |
271
+
272
+ ```css
273
+ .viewport {
274
+ inline-size: var(--primitiv-navigation-menu-viewport-width, auto);
275
+ block-size: var(--primitiv-navigation-menu-viewport-height, auto);
276
+ transition: inline-size 200ms, block-size 200ms;
277
+ }
278
+ ```
279
+
280
+ The measurement is **kept through the close** rather than cleared: clearing it
281
+ would collapse the box at the very moment the exit needs its size. Re-measured on
282
+ window `resize`, like the Indicator.
283
+
284
+ ## Link active state
285
+
286
+ `NavigationMenu.Link` does **no route matching**. You own the router, so you
287
+ own the comparison:
288
+
289
+ ```tsx
290
+ <NavigationMenu.Link href="/tokens" active={pathname === "/tokens"}>
291
+ Tokens
292
+ </NavigationMenu.Link>
293
+ ```
294
+
295
+ **A top-level link also closes on hover** (when `openOnHover` is on), so
296
+ travelling along the bar past a plain entry behaves like travelling onto another
297
+ trigger — without it, moving from an open trigger onto a link beside it leaves
298
+ that panel hanging over the page. Links *inside* a panel are exempt, for the
299
+ obvious reason.
300
+
301
+ Clicking any `Link` closes the open panel, whether the link sits in a panel or
302
+ beside one — leaving a panel hanging open over the page the user just navigated
303
+ to is the bug this avoids. Veto it by calling `event.preventDefault()` in your
304
+ own `onClick`.
305
+
306
+ ## `asChild`
307
+
308
+ `Trigger`, `Link` and `Indicator` all support `asChild`, merging their props onto a
309
+ consumer element via [`Slot`](../Slot/README.md) — handlers compose, `style`
310
+ shallow-merges with the child winning, `className` concatenates, refs compose.
311
+
312
+ ```tsx
313
+ <NavigationMenu.Link asChild active={isActive}>
314
+ <RouterLink to="/tokens">Tokens</RouterLink>
315
+ </NavigationMenu.Link>
316
+ ```
317
+
318
+ `Trigger`'s internal registry ref composes with a consumer `ref`, so arrow-key
319
+ travel keeps working through either escape hatch.
320
+
321
+ ## Styling hooks
322
+
323
+ | Element | Attributes |
324
+ | --- | --- |
325
+ | `Root` (`<nav>`) | `data-orientation`, `--primitiv-navigation-menu-active-trigger-anchor` |
326
+ | `List` (`<ul>`) | `data-orientation` |
327
+ | `Trigger` | `data-state="open" \| "closed"`, `anchor-name` (inline style) |
328
+ | `Content` | `data-state="open" \| "closed"`, `data-motion` |
329
+ | `Viewport` | `data-state`, `data-orientation`, `data-value`, measured-size custom properties |
330
+ | `Indicator` | `data-state`, `data-orientation`, `data-value`, geometry custom properties |
331
+ | `Link` | `data-active=""` when active (omitted otherwise) |
332
+
333
+ ## Animation
334
+
335
+ `Content`, `Viewport` and `Indicator` each take `forceMount`, which swaps the
336
+ `hidden` attribute for `aria-hidden` while closed. `hidden` cannot be animated
337
+ away from; `aria-hidden` keeps the element out of the accessibility tree while
338
+ leaving CSS free to transition it:
339
+
340
+ ```tsx
341
+ <NavigationMenu.Content forceMount className="panel" />
342
+ ```
343
+
344
+ ```css
345
+ .panel { opacity: 0; transition: opacity 150ms; }
346
+ .panel[data-state="open"] { opacity: 1; }
347
+ ```
348
+
349
+ ## Reading direction
350
+
351
+ `dir` (`"ltr"` / `"rtl"`) sets the horizontal arrow-key direction and the
352
+ `<nav>`'s `dir` attribute. When omitted it is inherited from the nearest
353
+ [`DirectionProvider`](../DirectionProvider/README.md), falling back to `"ltr"`.
354
+ Only the horizontal arrow pair mirrors; `Home`/`End` stay anchored to the start
355
+ and end of the list.
@@ -0,0 +1,132 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { getTriggerAnchorName, toAnchorIdentFragment } from "../utils";
5
+
6
+ import { ThreeEntryNav } from "./NavigationMenu.fixtures";
7
+
8
+ describe("toAnchorIdentFragment", () => {
9
+ it("replaces characters invalid in a CSS custom-ident with a hyphen", () => {
10
+ expect(toAnchorIdentFragment(":r0:-trigger-concepts")).toBe(
11
+ "-r0--trigger-concepts",
12
+ );
13
+ });
14
+
15
+ it("leaves an already-safe id untouched", () => {
16
+ expect(toAnchorIdentFragment("nav1-trigger-concepts")).toBe(
17
+ "nav1-trigger-concepts",
18
+ );
19
+ });
20
+ });
21
+
22
+ describe("getTriggerAnchorName", () => {
23
+ it("builds a namespaced anchor-name from the trigger id", () => {
24
+ expect(getTriggerAnchorName(":r0:-trigger-concepts")).toBe(
25
+ "--primitiv-navigation-menu-trigger--r0--trigger-concepts",
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("NavigationMenu anchor positioning", () => {
31
+ it("gives each trigger its own anchor-name, derived from its own id", () => {
32
+ render(<ThreeEntryNav />);
33
+
34
+ const concepts = screen.getByRole("button", { name: "Concepts" });
35
+ const registry = screen.getByRole("button", { name: "Registry & CLI" });
36
+
37
+ expect(concepts.style.getPropertyValue("anchor-name")).toBe(
38
+ getTriggerAnchorName(concepts.id),
39
+ );
40
+ expect(registry.style.getPropertyValue("anchor-name")).toBe(
41
+ getTriggerAnchorName(registry.id),
42
+ );
43
+ expect(concepts.style.getPropertyValue("anchor-name")).not.toBe(
44
+ registry.style.getPropertyValue("anchor-name"),
45
+ );
46
+ });
47
+
48
+ it("publishes the open trigger's anchor-name as the nav's active-trigger-anchor", async () => {
49
+ const user = userEvent.setup();
50
+ render(<ThreeEntryNav />);
51
+
52
+ const concepts = screen.getByRole("button", { name: "Concepts" });
53
+ await user.click(concepts);
54
+
55
+ const nav = screen.getByRole("navigation");
56
+ expect(
57
+ nav.style.getPropertyValue(
58
+ "--primitiv-navigation-menu-active-trigger-anchor",
59
+ ),
60
+ ).toBe(getTriggerAnchorName(concepts.id));
61
+ });
62
+
63
+ it("switches the active-trigger-anchor when a different trigger opens", async () => {
64
+ const user = userEvent.setup();
65
+ render(<ThreeEntryNav />);
66
+
67
+ await user.click(screen.getByRole("button", { name: "Concepts" }));
68
+ const registry = screen.getByRole("button", { name: "Registry & CLI" });
69
+ await user.click(registry);
70
+
71
+ const nav = screen.getByRole("navigation");
72
+ expect(
73
+ nav.style.getPropertyValue(
74
+ "--primitiv-navigation-menu-active-trigger-anchor",
75
+ ),
76
+ ).toBe(getTriggerAnchorName(registry.id));
77
+ });
78
+
79
+ it("clears the active-trigger-anchor once the panel closes", async () => {
80
+ const user = userEvent.setup();
81
+ render(<ThreeEntryNav />);
82
+
83
+ const concepts = screen.getByRole("button", { name: "Concepts" });
84
+ await user.click(concepts);
85
+ await user.click(concepts);
86
+
87
+ const nav = screen.getByRole("navigation");
88
+ expect(
89
+ nav.style.getPropertyValue(
90
+ "--primitiv-navigation-menu-active-trigger-anchor",
91
+ ),
92
+ ).toBe("");
93
+ });
94
+
95
+ it("keeps anchor names distinct across two independent Root instances", async () => {
96
+ const user = userEvent.setup();
97
+ render(
98
+ <>
99
+ <ThreeEntryNav aria-label="First" />
100
+ <ThreeEntryNav aria-label="Second" />
101
+ </>,
102
+ );
103
+
104
+ const [firstConcepts, secondConcepts] = screen.getAllByRole("button", {
105
+ name: "Concepts",
106
+ });
107
+
108
+ expect(firstConcepts.style.getPropertyValue("anchor-name")).not.toBe(
109
+ secondConcepts.style.getPropertyValue("anchor-name"),
110
+ );
111
+
112
+ const [firstNav, secondNav] = screen.getAllByRole("navigation");
113
+
114
+ // Opened one at a time: clicking the second nav's trigger moves focus out
115
+ // of the first nav's subtree, which correctly closes its own panel — so
116
+ // each nav's active-trigger-anchor is checked right after its own open,
117
+ // rather than asserting both stay open together.
118
+ await user.click(firstConcepts);
119
+ expect(
120
+ firstNav.style.getPropertyValue(
121
+ "--primitiv-navigation-menu-active-trigger-anchor",
122
+ ),
123
+ ).toBe(getTriggerAnchorName(firstConcepts.id));
124
+
125
+ await user.click(secondConcepts);
126
+ expect(
127
+ secondNav.style.getPropertyValue(
128
+ "--primitiv-navigation-menu-active-trigger-anchor",
129
+ ),
130
+ ).toBe(getTriggerAnchorName(secondConcepts.id));
131
+ });
132
+ });
@@ -0,0 +1,73 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useRef } from "react";
4
+
5
+ import { NavigationMenu } from "../NavigationMenu";
6
+
7
+ describe("NavigationMenu — asChild", () => {
8
+ it("renders a consumer element as the trigger, keeping the disclosure ARIA", async () => {
9
+ const user = userEvent.setup();
10
+ render(
11
+ <NavigationMenu.Root>
12
+ <NavigationMenu.List>
13
+ <NavigationMenu.Item value="concepts">
14
+ <NavigationMenu.Trigger asChild>
15
+ <span role="button" tabIndex={0}>
16
+ Concepts
17
+ </span>
18
+ </NavigationMenu.Trigger>
19
+ <NavigationMenu.Content data-testid="panel">
20
+ <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
21
+ </NavigationMenu.Content>
22
+ </NavigationMenu.Item>
23
+ </NavigationMenu.List>
24
+ </NavigationMenu.Root>,
25
+ );
26
+
27
+ const trigger = screen.getByRole("button", { name: "Concepts" });
28
+
29
+ expect(trigger.tagName).toBe("SPAN");
30
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
31
+
32
+ await user.click(trigger);
33
+
34
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
35
+ expect(screen.getByTestId("panel")).toBeVisible();
36
+ });
37
+
38
+ it("composes an external trigger ref with the internal one", async () => {
39
+ const user = userEvent.setup();
40
+
41
+ function WithRef() {
42
+ const ref = useRef<HTMLButtonElement>(null);
43
+ return (
44
+ <NavigationMenu.Root>
45
+ <NavigationMenu.List>
46
+ <NavigationMenu.Item value="concepts">
47
+ <NavigationMenu.Trigger ref={ref}>Concepts</NavigationMenu.Trigger>
48
+ <NavigationMenu.Content data-testid="panel" />
49
+ </NavigationMenu.Item>
50
+ <NavigationMenu.Item>
51
+ <NavigationMenu.Link href="/changelog">
52
+ Changelog
53
+ </NavigationMenu.Link>
54
+ </NavigationMenu.Item>
55
+ </NavigationMenu.List>
56
+ <button type="button" onClick={() => ref.current?.focus()}>
57
+ Focus it
58
+ </button>
59
+ </NavigationMenu.Root>
60
+ );
61
+ }
62
+
63
+ render(<WithRef />);
64
+ await user.click(screen.getByRole("button", { name: "Focus it" }));
65
+
66
+ // The external ref reaches the DOM node...
67
+ expect(screen.getByRole("button", { name: "Concepts" })).toHaveFocus();
68
+
69
+ // ...and the internal one still drives arrow-key travel.
70
+ await user.keyboard("{ArrowRight}");
71
+ expect(screen.getByRole("link", { name: "Changelog" })).toHaveFocus();
72
+ });
73
+ });