@primitiv-ui/react 0.1.27 → 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,113 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { NavigationMenu } from "../NavigationMenu";
5
+ import type { NavigationMenuViewportProps } from "../types";
6
+
7
+ function NavWithViewport(props: NavigationMenuViewportProps) {
8
+ return (
9
+ <NavigationMenu.Root>
10
+ <NavigationMenu.List>
11
+ <NavigationMenu.Item value="concepts">
12
+ <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
13
+ <NavigationMenu.Content data-testid="concepts-panel">
14
+ <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
15
+ </NavigationMenu.Content>
16
+ </NavigationMenu.Item>
17
+ <NavigationMenu.Item value="registry">
18
+ <NavigationMenu.Trigger>Registry</NavigationMenu.Trigger>
19
+ <NavigationMenu.Content data-testid="registry-panel">
20
+ <NavigationMenu.Link href="/cli">CLI</NavigationMenu.Link>
21
+ </NavigationMenu.Content>
22
+ </NavigationMenu.Item>
23
+ </NavigationMenu.List>
24
+ <NavigationMenu.Viewport data-testid="viewport" {...props} />
25
+ </NavigationMenu.Root>
26
+ );
27
+ }
28
+
29
+ describe("NavigationMenu.Viewport", () => {
30
+ it("hosts every panel so they share one box", async () => {
31
+ render(<NavWithViewport />);
32
+
33
+ const viewport = screen.getByTestId("viewport");
34
+
35
+ // Panels portal into an inner clip element, not the outer box itself — the
36
+ // split that lets the outer box host the hover-forgiveness collar (which
37
+ // must extend past its own edges) while the inner one clips a sliding
38
+ // panel to the visible box. See the registry stylesheet.
39
+ await waitFor(() => {
40
+ const clip = screen.getByTestId("concepts-panel").parentElement;
41
+ expect(clip).toHaveAttribute("data-navigation-menu-viewport-clip");
42
+ expect(clip?.parentElement).toBe(viewport);
43
+ });
44
+ expect(
45
+ screen.getByTestId("registry-panel").parentElement?.parentElement,
46
+ ).toBe(viewport);
47
+ });
48
+
49
+ it("leaves panels in place when there is no Viewport", () => {
50
+ render(
51
+ <NavigationMenu.Root>
52
+ <NavigationMenu.List>
53
+ <NavigationMenu.Item value="concepts">
54
+ <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
55
+ <NavigationMenu.Content data-testid="concepts-panel" />
56
+ </NavigationMenu.Item>
57
+ </NavigationMenu.List>
58
+ </NavigationMenu.Root>,
59
+ );
60
+
61
+ expect(screen.getByTestId("concepts-panel").parentElement?.tagName).toBe(
62
+ "LI",
63
+ );
64
+ });
65
+
66
+ it("reports its own open state and hides itself while nothing is open", async () => {
67
+ const user = userEvent.setup();
68
+ render(<NavWithViewport />);
69
+
70
+ const viewport = screen.getByTestId("viewport");
71
+
72
+ expect(viewport).toHaveAttribute("data-state", "closed");
73
+ expect(viewport).toHaveAttribute("hidden");
74
+
75
+ await user.click(screen.getByRole("button", { name: "Concepts" }));
76
+
77
+ expect(viewport).toHaveAttribute("data-state", "open");
78
+ expect(viewport).not.toHaveAttribute("hidden");
79
+ });
80
+
81
+ it("carries the open entry's value so the box can size per panel", async () => {
82
+ const user = userEvent.setup();
83
+ render(<NavWithViewport />);
84
+
85
+ await user.click(screen.getByRole("button", { name: "Registry" }));
86
+
87
+ expect(screen.getByTestId("viewport")).toHaveAttribute(
88
+ "data-value",
89
+ "registry",
90
+ );
91
+ });
92
+
93
+ it("stays mounted and unhidden with forceMount", () => {
94
+ render(<NavWithViewport forceMount />);
95
+
96
+ const viewport = screen.getByTestId("viewport");
97
+
98
+ expect(viewport).not.toHaveAttribute("hidden");
99
+ expect(viewport).toHaveAttribute("data-state", "closed");
100
+ });
101
+
102
+ it("still lands keyboard focus inside a hosted panel", async () => {
103
+ const user = userEvent.setup();
104
+ render(<NavWithViewport />);
105
+
106
+ screen.getByRole("button", { name: "Concepts" }).focus();
107
+ await user.keyboard("{ArrowDown}");
108
+
109
+ await waitFor(() =>
110
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,5 @@
1
+ /** Focusable descendants a panel can hand focus to when the keyboard opens it
2
+ * with the enter-panel arrow. Deliberately narrow: nav panels hold links and
3
+ * the occasional button, and the first of those is the useful landing spot. */
4
+ export const PANEL_FOCUSABLE_SELECTOR =
5
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -0,0 +1,7 @@
1
+ export * from "./useNavigationMenuEntry";
2
+ export * from "./useNavigationMenuIndicator";
3
+ export * from "./useNavigationMenuLink";
4
+ export * from "./useNavigationMenuRoot";
5
+ export * from "./useNavigationMenuTopLevelEntry";
6
+ export * from "./useNavigationMenuTrigger";
7
+ export * from "./useNavigationMenuViewport";
@@ -0,0 +1,33 @@
1
+ import {
2
+ useNavigationMenuContext,
3
+ useNavigationMenuItemContext,
4
+ } from "../NavigationMenuContext";
5
+ import { getTriggerAndPanelIds } from "../utils";
6
+
7
+ /**
8
+ * Resolves everything a Trigger and its Content both need: the entry's value
9
+ * (from the enclosing Item), the paired ids, and whether this entry is the
10
+ * open one. Keeping it in one place is what guarantees the two sides agree on
11
+ * the ids they cross-reference.
12
+ */
13
+ export function useNavigationMenuEntry(): {
14
+ value: string;
15
+ triggerId: string;
16
+ panelId: string;
17
+ open: boolean;
18
+ state: "open" | "closed";
19
+ } {
20
+ const { navigationMenuId, openValue } = useNavigationMenuContext();
21
+ const { value: itemValue } = useNavigationMenuItemContext();
22
+ if (itemValue === undefined) {
23
+ throw new Error(
24
+ "NavigationMenu.Trigger and NavigationMenu.Content require a `value` on their NavigationMenu.Item. " +
25
+ "Omit the value only for a plain link entry, which has no panel to open.",
26
+ );
27
+ }
28
+ const value = itemValue;
29
+ const { triggerId, panelId } = getTriggerAndPanelIds(navigationMenuId, value);
30
+ const open = openValue !== "" && openValue === value;
31
+
32
+ return { value, triggerId, panelId, open, state: open ? "open" : "closed" };
33
+ }
@@ -0,0 +1,70 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type { CSSProperties } from "react";
3
+
4
+ import { useNavigationMenuContext } from "../NavigationMenuContext";
5
+ import { getTriggerAndPanelIds } from "../utils";
6
+
7
+ type Geometry = { position: number; size: number } | null;
8
+
9
+ /**
10
+ * Measures the open trigger and hands back the geometry as custom properties.
11
+ *
12
+ * The library ships no styles, so the Indicator can't position itself — but
13
+ * only it can know *where* the open trigger is. Publishing the measurement as
14
+ * `--primitiv-navigation-menu-indicator-position` / `-size` splits that
15
+ * correctly: the component measures, the stylesheet decides whether that means
16
+ * `translate`, `left`, a border, or nothing at all.
17
+ */
18
+ export function useNavigationMenuIndicator(): {
19
+ open: boolean;
20
+ openValue: string;
21
+ orientation: "horizontal" | "vertical";
22
+ style: CSSProperties;
23
+ } {
24
+ const { orientation, openValue, navigationMenuId } =
25
+ useNavigationMenuContext();
26
+ const open = openValue !== "";
27
+ const [geometry, setGeometry] = useState<Geometry>(null);
28
+
29
+ const measure = useCallback(() => {
30
+ // Closed and open-but-unrendered are the same measurement problem, so they
31
+ // share one path: `openValue === ""` derives a trigger id no element
32
+ // carries, and the missing-element branch below clears the geometry.
33
+ const { triggerId } = getTriggerAndPanelIds(navigationMenuId, openValue);
34
+ const trigger = document.getElementById(triggerId);
35
+ if (!trigger) {
36
+ setGeometry(null);
37
+ return;
38
+ }
39
+ setGeometry(
40
+ orientation === "horizontal"
41
+ ? { position: trigger.offsetLeft, size: trigger.offsetWidth }
42
+ : { position: trigger.offsetTop, size: trigger.offsetHeight },
43
+ );
44
+ }, [navigationMenuId, openValue, orientation]);
45
+
46
+ useEffect(() => {
47
+ measure();
48
+ }, [measure]);
49
+
50
+ // Offsets are layout-dependent, so a reflow invalidates them. Re-measuring on
51
+ // resize is what stops the indicator drifting off its trigger after the nav
52
+ // rewraps.
53
+ useEffect(() => {
54
+ window.addEventListener("resize", measure);
55
+ return () => window.removeEventListener("resize", measure);
56
+ }, [measure]);
57
+
58
+ // An `undefined` value is dropped by React, so a closed indicator simply
59
+ // carries no geometry rather than a stale or zeroed one.
60
+ const style = {
61
+ "--primitiv-navigation-menu-indicator-position": geometry
62
+ ? `${geometry.position}px`
63
+ : undefined,
64
+ "--primitiv-navigation-menu-indicator-size": geometry
65
+ ? `${geometry.size}px`
66
+ : undefined,
67
+ } as CSSProperties;
68
+
69
+ return { open, openValue, orientation, style };
70
+ }
@@ -0,0 +1,72 @@
1
+ import { useContext } from "react";
2
+ import type {
3
+ KeyboardEvent,
4
+ MouseEvent,
5
+ PointerEvent,
6
+ RefObject,
7
+ } from "react";
8
+
9
+ import { composeEventHandlers } from "../../Slot/index.ts";
10
+ import {
11
+ NavigationMenuPanelContext,
12
+ useNavigationMenuContext,
13
+ } from "../NavigationMenuContext";
14
+ import type { NavigationMenuLinkProps } from "../types";
15
+
16
+ import { useNavigationMenuTopLevelEntry } from "./useNavigationMenuTopLevelEntry";
17
+
18
+ export function useNavigationMenuLink({
19
+ onKeyDown,
20
+ onClick,
21
+ onPointerEnter,
22
+ }: Pick<
23
+ NavigationMenuLinkProps,
24
+ "onKeyDown" | "onClick" | "onPointerEnter"
25
+ >): {
26
+ linkRef: RefObject<HTMLAnchorElement | null>;
27
+ handleKeyDown: (event: KeyboardEvent<HTMLAnchorElement>) => void;
28
+ handleClick: (event: MouseEvent<HTMLAnchorElement>) => void;
29
+ handlePointerEnter: (event: PointerEvent<HTMLAnchorElement>) => void;
30
+ } {
31
+ const { setOpenValue, cancelOpen, openOnHover } = useNavigationMenuContext();
32
+ // A link directly inside an Item is a top-level entry and joins the
33
+ // arrow-key travel order; a link inside a Content panel is reached by Tab
34
+ // instead, so it must stay out of that order.
35
+ const insidePanel = useContext(NavigationMenuPanelContext);
36
+ const { entryRef: linkRef, handleKeyDown: handleArrowKeyDown } =
37
+ useNavigationMenuTopLevelEntry<HTMLAnchorElement>(!insidePanel);
38
+
39
+ const handleKeyDown = composeEventHandlers<KeyboardEvent<HTMLAnchorElement>>(
40
+ onKeyDown,
41
+ handleArrowKeyDown,
42
+ );
43
+ // Following a link ends the interaction, so the menu closes — whether the
44
+ // link was in a panel or a top-level entry alongside one. Leaving a panel
45
+ // hanging open over the page the user just navigated to is the bug this
46
+ // avoids.
47
+ const handleClick = composeEventHandlers<MouseEvent<HTMLAnchorElement>>(
48
+ onClick,
49
+ () => {
50
+ cancelOpen();
51
+ setOpenValue("");
52
+ },
53
+ );
54
+
55
+ // Hovering a top-level link dismisses whatever is open, so travelling along the
56
+ // bar past a plain entry behaves like travelling onto another trigger. Without
57
+ // this, moving from an open trigger onto a link beside it leaves that panel
58
+ // hanging over the page with the pointer nowhere near it.
59
+ //
60
+ // Panel links are exempt for the obvious reason: hovering a row inside a panel
61
+ // must not close the panel it lives in. And it follows `openOnHover`, so a
62
+ // click-only nav stays click-only in both directions.
63
+ const handlePointerEnter = composeEventHandlers<
64
+ PointerEvent<HTMLAnchorElement>
65
+ >(onPointerEnter, () => {
66
+ if (insidePanel || !openOnHover) return;
67
+ cancelOpen();
68
+ setOpenValue("");
69
+ });
70
+
71
+ return { linkRef, handleKeyDown, handleClick, handlePointerEnter };
72
+ }
@@ -0,0 +1,301 @@
1
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import type { CSSProperties, FocusEvent, KeyboardEvent } from "react";
3
+
4
+ import { useCollection, useControllableState } from "../../hooks/index.ts";
5
+
6
+ import type {
7
+ NavigationMenuContextValue,
8
+ NavigationMenuOrientation,
9
+ NavigationMenuReadingDirection,
10
+ NavigationMenuRootProps,
11
+ } from "../types";
12
+ import { getTriggerAnchorName, getTriggerAndPanelIds } from "../utils";
13
+
14
+ /** The presentation props arrive **resolved**: `NavigationMenu.Root` applies
15
+ * its own documented defaults (and inherits `dir` from `DirectionProvider`)
16
+ * before calling the hook, so they are required here rather than defaulted a
17
+ * second time — one set of defaults, in the place consumers read them from. */
18
+ type UseNavigationMenuRootArgs = {
19
+ orientation: NavigationMenuOrientation;
20
+ dir: NavigationMenuReadingDirection;
21
+ openOnHover: boolean;
22
+ delayDuration: number;
23
+ closeDelay: number;
24
+ } & Pick<
25
+ NavigationMenuRootProps,
26
+ "defaultValue" | "value" | "onValueChange"
27
+ >;
28
+
29
+ export function useNavigationMenuRoot({
30
+ orientation,
31
+ dir,
32
+ openOnHover,
33
+ delayDuration,
34
+ closeDelay,
35
+ defaultValue,
36
+ value,
37
+ onValueChange,
38
+ }: UseNavigationMenuRootArgs): {
39
+ contextValue: NavigationMenuContextValue;
40
+ cancelClose: () => void;
41
+ closeWithDelay: () => void;
42
+ closeOnFocusOutside: (event: FocusEvent<HTMLElement>) => void;
43
+ handleKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
44
+ /** Publishes the open entry's trigger `anchor-name` as
45
+ * `--primitiv-navigation-menu-active-trigger-anchor`, so the shared
46
+ * Viewport's `position-anchor` can target whichever trigger is open. Unset
47
+ * (dropped by React) while nothing is open. */
48
+ style: CSSProperties;
49
+ } {
50
+ const navigationMenuId = useId();
51
+ const [openValue, setOpenValue] = useControllableState<string>(
52
+ value,
53
+ defaultValue ?? "",
54
+ onValueChange,
55
+ );
56
+
57
+ // What was open before the current value, so a panel can tell which way the
58
+ // pointer travelled. Adjusted during render — the pattern React documents for
59
+ // deriving state from a changed input — rather than in an effect: an effect
60
+ // lands a frame late, by which time the panel has already begun animating with
61
+ // no direction to animate in.
62
+ const [trackedValue, setTrackedValue] = useState(openValue);
63
+ // The seed is only ever fed to `itemValues.indexOf()`, which rejects "" and any
64
+ // other unregistered string identically — so its value is unobservable. It has to
65
+ // be "" rather than `openValue`, though: seeding it with the open value would give
66
+ // a nav mounted with a `defaultValue` a spurious travel direction on first paint.
67
+ // Stryker disable next-line StringLiteral: equivalent — see above.
68
+ const [previousValue, setPreviousValue] = useState("");
69
+ if (trackedValue !== openValue) {
70
+ setPreviousValue(trackedValue);
71
+ setTrackedValue(openValue);
72
+ }
73
+
74
+ const {
75
+ register: registerEntry,
76
+ itemsRef: entriesRef,
77
+ keys: entryKeys,
78
+ } = useCollection<string, HTMLElement>();
79
+
80
+ // A second registry, keyed by the Item's own `value`. The entry registry above
81
+ // can't serve this: it keys by a generated id because plain link entries join it
82
+ // too and have no value. Registration order is mount order, which for siblings is
83
+ // DOM order — so `itemValues` is the left-to-right order of the disclosure
84
+ // entries, which is what a panel needs to know which way it is travelling.
85
+ const {
86
+ register: registerItem,
87
+ itemsRef: itemElementsRef,
88
+ keys: registeredValues,
89
+ } = useCollection<string, HTMLLIElement>();
90
+
91
+ // Sorted by document position, not registration order. `useCollection` keys are
92
+ // insertion-ordered, which matches the DOM only while nothing changes: re-register
93
+ // an entry (a `value` that changes) and its key is appended, so the order silently
94
+ // stops describing the layout and panels slide the wrong way. Asking the DOM is the
95
+ // only source that cannot drift.
96
+ const itemValues = useMemo(
97
+ () =>
98
+ [...registeredValues].sort((a, b) => {
99
+ // Non-null: `register` writes the element and the key in the same call, so a
100
+ // key present in `registeredValues` always has a live element in the map —
101
+ // the same invariant Tree's root relies on for its own registry reads.
102
+ const first = itemElementsRef.current.get(a)!;
103
+ const second = itemElementsRef.current.get(b)!;
104
+ return first.compareDocumentPosition(second) &
105
+ Node.DOCUMENT_POSITION_FOLLOWING
106
+ ? -1
107
+ : 1;
108
+ }),
109
+ [registeredValues, itemElementsRef],
110
+ );
111
+
112
+ const focusEntry = useCallback(
113
+ (key: string) => {
114
+ // `key` only ever arrives from `entryKeys`, and `useCollection`'s
115
+ // register() writes `entriesRef` and the `keys` state in the same call —
116
+ // so a key that reaches here always has a live element in the map.
117
+ // Stryker disable next-line OptionalChaining: unreachable given that invariant.
118
+ entriesRef.current.get(key)?.focus();
119
+ },
120
+ // `entriesRef` is a stable RefObject, so emptying this array yields the
121
+ // identical memoised function.
122
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
123
+ [entriesRef],
124
+ );
125
+
126
+ // Held as state, not a ref, because Content has to re-render once the
127
+ // viewport exists in order to portal into it. The Viewport hands its element
128
+ // over in a ref callback, which React runs before paint — so the one commit
129
+ // where Content still renders in place never reaches the screen. `setViewport`
130
+ // *is* the registrar: it already has the ref-callback signature and a stable
131
+ // identity, so wrapping it would only add indirection.
132
+ const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
133
+
134
+ // `undefined` rather than `null` for "no timer pending", because
135
+ // `clearTimeout(undefined)` is a spec'd no-op — cancelling is then one
136
+ // unconditional clear instead of a guarded one, with nothing to get wrong.
137
+ const openTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
138
+ undefined,
139
+ );
140
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
141
+ undefined,
142
+ );
143
+
144
+ // `openValue` is read inside callbacks that must not be re-created on every
145
+ // open/close — a fresh `openWithIntent` identity would re-run the Trigger's
146
+ // handlers. A ref keeps the reads current without widening the deps.
147
+ const openValueRef = useRef(openValue);
148
+ openValueRef.current = openValue;
149
+
150
+ const cancelOpen = useCallback(
151
+ () => {
152
+ clearTimeout(openTimerRef.current);
153
+ openTimerRef.current = undefined;
154
+ },
155
+ // Stryker's only mutation of an empty dependency array is
156
+ // `["Stryker was here"]` — a string literal React compares `===`-equal on
157
+ // every render, so the memoised identity is unchanged either way.
158
+ // Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze.
159
+ [],
160
+ );
161
+
162
+ const cancelClose = useCallback(
163
+ () => {
164
+ clearTimeout(closeTimerRef.current);
165
+ closeTimerRef.current = undefined;
166
+ },
167
+ // Stryker disable next-line ArrayDeclaration: equivalent — no dependency to freeze (see cancelOpen).
168
+ [],
169
+ );
170
+
171
+ const openWithIntent = useCallback(
172
+ (next: string) => {
173
+ cancelClose();
174
+ cancelOpen();
175
+ // Once one panel is open the nav is already "active", so crossing to a
176
+ // sibling trigger swaps panels instantly — waiting again would feel
177
+ // broken. The delay only guards the first open.
178
+ if (openValueRef.current !== "" || delayDuration === 0) {
179
+ setOpenValue(next);
180
+ return;
181
+ }
182
+ openTimerRef.current = setTimeout(() => {
183
+ openTimerRef.current = undefined;
184
+ setOpenValue(next);
185
+ }, delayDuration);
186
+ },
187
+ [cancelClose, cancelOpen, delayDuration, setOpenValue],
188
+ );
189
+
190
+ // Escape is handled once, at the `<nav>`, because it must work from anywhere
191
+ // inside the menu — including from a link deep in an open panel — and
192
+ // returning focus to the trigger is what stops the user being stranded on an
193
+ // element that just went `hidden`.
194
+ const closeAndRefocusTrigger = useCallback(() => {
195
+ const value = openValueRef.current;
196
+ if (value === "") return;
197
+ cancelOpen();
198
+ const { triggerId } = getTriggerAndPanelIds(navigationMenuId, value);
199
+ document.getElementById(triggerId)?.focus();
200
+ setOpenValue("");
201
+ }, [cancelOpen, navigationMenuId, setOpenValue]);
202
+
203
+ const closeWithDelay = useCallback(() => {
204
+ cancelOpen();
205
+ closeTimerRef.current = setTimeout(() => {
206
+ closeTimerRef.current = undefined;
207
+ setOpenValue("");
208
+ }, closeDelay);
209
+ }, [cancelOpen, closeDelay, setOpenValue]);
210
+
211
+ // Keyboard focus leaving the `<nav>` entirely — Tab past the last entry, or
212
+ // into an unrelated element such as a second, independent NavigationMenu —
213
+ // means the open panel is no longer where focus is. Unlike Escape this
214
+ // doesn't refocus the trigger: focus already chose where it's going, and
215
+ // fighting that would break Tab order. `relatedTarget` is `null` for some
216
+ // focus transitions (e.g. the browser chrome); treated as "outside", the
217
+ // same call Carousel's own blur handler makes.
218
+ const closeOnFocusOutside = useCallback((event: FocusEvent<HTMLElement>) => {
219
+ if (openValueRef.current === "") return;
220
+ const next = event.relatedTarget;
221
+ if (next && event.currentTarget.contains(next)) return;
222
+ cancelOpen();
223
+ setOpenValue("");
224
+ }, [cancelOpen, setOpenValue]);
225
+
226
+ useEffect(
227
+ () => () => {
228
+ cancelOpen();
229
+ cancelClose();
230
+ },
231
+ // Both are `useCallback(…, [])`, so their identities are fixed for the
232
+ // component's lifetime: emptying this array subscribes the cleanup exactly
233
+ // once either way.
234
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependencies.
235
+ [cancelOpen, cancelClose],
236
+ );
237
+
238
+ const contextValue = useMemo<NavigationMenuContextValue>(
239
+ () => ({
240
+ orientation,
241
+ dir,
242
+ navigationMenuId,
243
+ openValue,
244
+ previousValue,
245
+ setOpenValue,
246
+ openOnHover,
247
+ openWithIntent,
248
+ cancelOpen,
249
+ registerEntry,
250
+ entryKeys,
251
+ registerItem,
252
+ itemValues,
253
+ focusEntry,
254
+ viewport,
255
+ registerViewport: setViewport,
256
+ }),
257
+ [
258
+ orientation,
259
+ dir,
260
+ navigationMenuId,
261
+ openValue,
262
+ previousValue,
263
+ setOpenValue,
264
+ openOnHover,
265
+ openWithIntent,
266
+ cancelOpen,
267
+ registerEntry,
268
+ entryKeys,
269
+ registerItem,
270
+ itemValues,
271
+ focusEntry,
272
+ viewport,
273
+ ],
274
+ );
275
+
276
+ const handleKeyDown = useCallback(
277
+ (event: KeyboardEvent<HTMLElement>) => {
278
+ if (event.key !== "Escape") return;
279
+ closeAndRefocusTrigger();
280
+ },
281
+ [closeAndRefocusTrigger],
282
+ );
283
+
284
+ const style = {
285
+ "--primitiv-navigation-menu-active-trigger-anchor":
286
+ openValue === ""
287
+ ? undefined
288
+ : getTriggerAnchorName(
289
+ getTriggerAndPanelIds(navigationMenuId, openValue).triggerId,
290
+ ),
291
+ } as CSSProperties;
292
+
293
+ return {
294
+ contextValue,
295
+ cancelClose,
296
+ closeWithDelay,
297
+ closeOnFocusOutside,
298
+ handleKeyDown,
299
+ style,
300
+ };
301
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, useId, useRef } from "react";
2
+ import type { KeyboardEvent, RefObject } from "react";
3
+
4
+ import { useRovingTabindex } from "../../hooks/index.ts";
5
+ import { useNavigationMenuContext } from "../NavigationMenuContext";
6
+
7
+ /** Keydown handler for an element that is not a top-level entry: every travel
8
+ * key is left to the browser, so `Home` inside an open panel still scrolls the
9
+ * page rather than jumping out of the panel. */
10
+ function ignoreTravelKeys(): void {}
11
+
12
+ /**
13
+ * Joins an element to the nav's top-level travel order: registers it so the
14
+ * arrow keys can reach it, and returns the keydown handler that does the
15
+ * reaching.
16
+ *
17
+ * Despite building on `useRovingTabindex`, this is deliberately **not** a
18
+ * roving tabindex — no `tabIndex={-1}` is handed back, so every top-level
19
+ * entry stays in the tab order. That is the ARIA APG *Disclosure Navigation
20
+ * Menu* contract: these are links to pages, and a keyboard user must be able
21
+ * to Tab through them without learning that arrows are required. The hook
22
+ * uses `useRovingTabindex` purely for its orientation/RTL-aware keymap.
23
+ *
24
+ * @param enabled Whether this element is a top-level entry at all. A
25
+ * `NavigationMenu.Link` inside a `NavigationMenu.Content` passes `false`:
26
+ * panel links are reached by Tab, not by the top-level arrow keys.
27
+ */
28
+ export function useNavigationMenuTopLevelEntry<T extends HTMLElement>(
29
+ enabled = true,
30
+ ): {
31
+ entryRef: RefObject<T | null>;
32
+ handleKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
33
+ } {
34
+ const { orientation, dir, registerEntry, entryKeys, focusEntry } =
35
+ useNavigationMenuContext();
36
+ const key = useId();
37
+ const entryRef = useRef<T | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (!enabled) return;
41
+ registerEntry(key, entryRef.current);
42
+ return () => registerEntry(key, null);
43
+ // `key` is a `useId`, `registerEntry` a stable context callback, and
44
+ // `enabled` is fixed by this element's position in the tree (inside a
45
+ // Content panel or not) — none can change while the entry stays mounted.
46
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependencies.
47
+ }, [enabled, key, registerEntry]);
48
+
49
+ const { handleKeyDown: travel } = useRovingTabindex<string>({
50
+ orientation,
51
+ dir,
52
+ navigable: entryKeys,
53
+ currentKey: key,
54
+ includeHomeEnd: true,
55
+ // Enter/Space must reach the element as a native activation — a button's
56
+ // click, a link's navigation. Claiming them here would preventDefault
57
+ // both.
58
+ includeActivate: false,
59
+ onNavigate: (target) => focusEntry(target),
60
+ });
61
+
62
+ // A panel link must not be able to reach the top-level order — not with the
63
+ // arrows, and not with Home/End either — so it gets no travel handler at all.
64
+ return { entryRef, handleKeyDown: enabled ? travel : ignoreTravelKeys };
65
+ }