@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,890 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import type {
3
+ FocusEvent,
4
+ KeyboardEvent,
5
+ PointerEvent,
6
+ ReactElement,
7
+ ReactPortal,
8
+ Ref,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
11
+
12
+ import { useDirection } from "../DirectionProvider/index.ts";
13
+ import { Slot, composeEventHandlers, composeRefs } from "../Slot/index.ts";
14
+
15
+ import {
16
+ useNavigationMenuEntry,
17
+ useNavigationMenuIndicator,
18
+ useNavigationMenuLink,
19
+ useNavigationMenuRoot,
20
+ useNavigationMenuTrigger,
21
+ useNavigationMenuViewport,
22
+ } from "./hooks/index.ts";
23
+ import {
24
+ NavigationMenuItemProvider,
25
+ NavigationMenuPanelProvider,
26
+ NavigationMenuProvider,
27
+ useNavigationMenuContext,
28
+ } from "./NavigationMenuContext";
29
+ import { getPanelMotion } from "./utils";
30
+ import type {
31
+ NavigationMenuContentProps,
32
+ NavigationMenuIndicatorProps,
33
+ NavigationMenuItemContextValue,
34
+ NavigationMenuItemProps,
35
+ NavigationMenuLinkProps,
36
+ NavigationMenuListProps,
37
+ NavigationMenuRootProps,
38
+ NavigationMenuTriggerProps,
39
+ NavigationMenuViewportProps,
40
+ } from "./types";
41
+
42
+ /**
43
+ * The root of a NavigationMenu — renders the `<nav>` landmark, owns which panel
44
+ * is open, and provides context to every descendant.
45
+ *
46
+ * The `<nav>` defaults to `aria-label="Main"` so assistive technology announces
47
+ * it as the primary navigation landmark, distinguishing it from any other
48
+ * `<nav>` on the page. Override `aria-label` if your product uses different
49
+ * terminology, or if this is a secondary nav.
50
+ *
51
+ * **State.** Exactly one panel is open at a time, identified by the enclosing
52
+ * {@link NavigationMenuItem | `Item`}'s `value`. **The empty string means
53
+ * nothing is open** — there is no separate `open` flag. Two modes, statically
54
+ * discriminated at the type level so TypeScript accepts only one shape:
55
+ *
56
+ * - **Uncontrolled** — pass
57
+ * {@link NavigationMenuRootProps.defaultValue | `defaultValue`}, or omit it to
58
+ * start closed. The component owns the open value.
59
+ * - **Controlled** — pass {@link NavigationMenuRootProps.value | `value`} *and*
60
+ * {@link NavigationMenuRootProps.onValueChange | `onValueChange`} together.
61
+ * Every open and close request defers to the callback, which is called with
62
+ * `""` to close.
63
+ *
64
+ * **Hover intent.** With
65
+ * {@link NavigationMenuRootProps.openOnHover | `openOnHover`} (the default),
66
+ * hovering a trigger opens its panel after
67
+ * {@link NavigationMenuRootProps.delayDuration | `delayDuration`} — but only
68
+ * for the *first* open. Once one panel is open, crossing to a sibling trigger
69
+ * switches instantly. Leaving the `<nav>` closes after
70
+ * {@link NavigationMenuRootProps.closeDelay | `closeDelay`}, and coming back
71
+ * within that window cancels the close. The close intent lives on the `<nav>`
72
+ * rather than on each trigger, which is what lets the pointer travel from a
73
+ * trigger into its own panel without dismissing it.
74
+ *
75
+ * **Escape** is handled here, once, so it works from anywhere inside the menu —
76
+ * including a link deep in an open panel. It closes the panel and returns focus
77
+ * to that panel's trigger, so the user is never left focused on an element that
78
+ * just became `hidden`.
79
+ *
80
+ * **Styling hooks.** `data-orientation="horizontal" | "vertical"`,
81
+ * `--primitiv-navigation-menu-active-trigger-anchor` (the open trigger's
82
+ * `anchor-name`, for CSS-anchor-positioning the {@link NavigationMenuViewport
83
+ * | `Viewport`} — see its docs).
84
+ *
85
+ * **Reading direction.** {@link NavigationMenuRootProps.dir | `dir`} sets the
86
+ * horizontal arrow-key direction and the container's `dir` attribute. When
87
+ * omitted it is inherited from the nearest {@link DirectionProvider}, falling
88
+ * back to `"ltr"`.
89
+ *
90
+ * @extends HTMLElement
91
+ *
92
+ * @example Uncontrolled, with a shared viewport and an indicator
93
+ * ```tsx
94
+ * <NavigationMenu.Root>
95
+ * <NavigationMenu.List>
96
+ * <NavigationMenu.Item value="concepts">
97
+ * <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
98
+ * <NavigationMenu.Content>
99
+ * <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
100
+ * </NavigationMenu.Content>
101
+ * </NavigationMenu.Item>
102
+ * <NavigationMenu.Item>
103
+ * <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
104
+ * </NavigationMenu.Item>
105
+ * </NavigationMenu.List>
106
+ * <NavigationMenu.Indicator />
107
+ * <NavigationMenu.Viewport />
108
+ * </NavigationMenu.Root>
109
+ * ```
110
+ *
111
+ * @example Controlled, closing the menu on every route change
112
+ * ```tsx
113
+ * const [open, setOpen] = useState("");
114
+ * const { pathname } = useLocation();
115
+ * useEffect(() => setOpen(""), [pathname]);
116
+ *
117
+ * <NavigationMenu.Root value={open} onValueChange={setOpen}>
118
+ * …
119
+ * </NavigationMenu.Root>
120
+ * ```
121
+ *
122
+ * @example Click-only, for a nav that also has to work under touch
123
+ * ```tsx
124
+ * <NavigationMenu.Root openOnHover={false} aria-label="Docs">
125
+ * …
126
+ * </NavigationMenu.Root>
127
+ * ```
128
+ */
129
+ export function NavigationMenuRoot({
130
+ children,
131
+ orientation = "horizontal",
132
+ dir,
133
+ openOnHover = true,
134
+ delayDuration = 200,
135
+ closeDelay = 150,
136
+ defaultValue,
137
+ value,
138
+ onValueChange,
139
+ onPointerEnter,
140
+ onPointerLeave,
141
+ onKeyDown,
142
+ onBlur,
143
+ ...rest
144
+ }: NavigationMenuRootProps): ReactElement {
145
+ const resolvedDir = dir ?? useDirection();
146
+ const {
147
+ contextValue,
148
+ cancelClose,
149
+ closeWithDelay,
150
+ closeOnFocusOutside,
151
+ handleKeyDown: handleEscapeKeyDown,
152
+ style,
153
+ } = useNavigationMenuRoot({
154
+ orientation,
155
+ dir: resolvedDir,
156
+ openOnHover,
157
+ delayDuration,
158
+ closeDelay,
159
+ defaultValue,
160
+ value,
161
+ onValueChange,
162
+ });
163
+
164
+ // The close intent belongs to the whole nav, not to any one trigger: leaving
165
+ // a trigger for its own panel must not dismiss it, and only leaving the
166
+ // `<nav>` means the user is done with the menu.
167
+ const handlePointerEnter = composeEventHandlers<PointerEvent<HTMLElement>>(
168
+ onPointerEnter,
169
+ cancelClose,
170
+ );
171
+ const handlePointerLeave = composeEventHandlers<PointerEvent<HTMLElement>>(
172
+ onPointerLeave,
173
+ closeWithDelay,
174
+ );
175
+ const handleKeyDown = composeEventHandlers<KeyboardEvent<HTMLElement>>(
176
+ onKeyDown,
177
+ handleEscapeKeyDown,
178
+ );
179
+ // Closes an open panel when keyboard focus leaves the nav for good — Tab
180
+ // past the last entry, or into an unrelated element such as a second,
181
+ // independent NavigationMenu. See useNavigationMenuRoot's closeOnFocusOutside.
182
+ const handleBlur = composeEventHandlers<FocusEvent<HTMLElement>>(
183
+ onBlur,
184
+ closeOnFocusOutside,
185
+ );
186
+
187
+ return (
188
+ <NavigationMenuProvider value={contextValue}>
189
+ <nav
190
+ aria-label="Main"
191
+ dir={resolvedDir}
192
+ data-orientation={orientation}
193
+ onPointerEnter={handlePointerEnter}
194
+ onPointerLeave={handlePointerLeave}
195
+ onKeyDown={handleKeyDown}
196
+ onBlur={handleBlur}
197
+ {...rest}
198
+ // Placed after `...rest` so it isn't clobbered by a bare consumer
199
+ // `style` — a consumer's own value for this property (unlikely, but
200
+ // possible) still wins inside this merge.
201
+ style={{ ...style, ...rest.style }}
202
+ >
203
+ {children}
204
+ </nav>
205
+ </NavigationMenuProvider>
206
+ );
207
+ }
208
+
209
+ // Runtime-dead: the compound alias below (the same object via Object.assign)
210
+ // overwrites this to "NavigationMenu" at load, so the value is never observable.
211
+ // The assignment stays because it declares `displayName` on
212
+ // `typeof NavigationMenuRoot`, which TNavigationMenuCompound extends.
213
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
214
+ NavigationMenuRoot.displayName = "NavigationMenuRoot";
215
+
216
+ /**
217
+ * The list of top-level entries — renders a `<ul>`.
218
+ *
219
+ * A plain list, not a `role="menubar"`: these are links to pages, so the
220
+ * correct semantics are list-and-link, and screen-reader users get the
221
+ * "navigation, list of N items" announcement they expect rather than
222
+ * application-menu semantics that promise keyboard behaviour a nav doesn't
223
+ * have.
224
+ *
225
+ * **Styling hooks.** `data-orientation="horizontal" | "vertical"`, inherited
226
+ * from {@link NavigationMenuRoot | `NavigationMenu.Root`}.
227
+ *
228
+ * @extends HTMLUListElement
229
+ *
230
+ * @example
231
+ * ```tsx
232
+ * <NavigationMenu.List>
233
+ * <NavigationMenu.Item value="concepts">…</NavigationMenu.Item>
234
+ * </NavigationMenu.List>
235
+ * ```
236
+ */
237
+ export function NavigationMenuList({
238
+ children,
239
+ ...rest
240
+ }: NavigationMenuListProps): ReactElement {
241
+ const { orientation } = useNavigationMenuContext();
242
+
243
+ return (
244
+ <ul data-orientation={orientation} {...rest}>
245
+ {children}
246
+ </ul>
247
+ );
248
+ }
249
+
250
+ /** @internal */
251
+ NavigationMenuList.displayName = "NavigationMenuList";
252
+
253
+ /**
254
+ * One top-level entry — renders an `<li>` and publishes its
255
+ * {@link NavigationMenuItemProps.value | `value`} to its
256
+ * {@link NavigationMenuTrigger | `Trigger`} and
257
+ * {@link NavigationMenuContent | `Content`}.
258
+ *
259
+ * **`value` is what makes an entry a disclosure.** Give it a value and the
260
+ * entry can hold a `Trigger` + `Content` pair. Omit it and the entry is a plain
261
+ * link — the shape the wireframe's *Changelog* row takes. A `Trigger` or
262
+ * `Content` inside a value-less Item throws, because it would derive an id that
263
+ * could never be opened.
264
+ *
265
+ * @extends HTMLLIElement
266
+ *
267
+ * @example A panelled entry
268
+ * ```tsx
269
+ * <NavigationMenu.Item value="concepts">
270
+ * <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
271
+ * <NavigationMenu.Content>…</NavigationMenu.Content>
272
+ * </NavigationMenu.Item>
273
+ * ```
274
+ *
275
+ * @example A plain link entry — no value
276
+ * ```tsx
277
+ * <NavigationMenu.Item>
278
+ * <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
279
+ * </NavigationMenu.Item>
280
+ * ```
281
+ */
282
+ export function NavigationMenuItem({
283
+ children,
284
+ value,
285
+ ...rest
286
+ }: NavigationMenuItemProps): ReactElement {
287
+ const { registerItem } = useNavigationMenuContext();
288
+ const itemRef = useRef<HTMLLIElement | null>(null);
289
+ const itemContextValue = useMemo<NavigationMenuItemContextValue>(
290
+ () => ({ value }),
291
+ [value],
292
+ );
293
+
294
+ // Only a disclosure entry joins the value-keyed registry: a plain link entry has
295
+ // no value, and nothing derives a direction from it.
296
+ useEffect(() => {
297
+ // The guard is here to narrow the type — registering `undefined` would also be
298
+ // harmless, since the only consumer compares *positions* of real values and a
299
+ // key nothing can match never changes their relative order.
300
+ // Stryker disable next-line ConditionalExpression: equivalent — see above.
301
+ if (value === undefined) return;
302
+ registerItem(value, itemRef.current);
303
+ // Unregistering can't be observed through the published order: a replaced key
304
+ // still points at the same <li>, so a leaked one sorts to the same position as
305
+ // its replacement. It is here to stop the registry growing without bound.
306
+ // Stryker disable next-line ArrowFunction: equivalent — see above.
307
+ return () => registerItem(value, null);
308
+ }, [value, registerItem]);
309
+
310
+ return (
311
+ <NavigationMenuItemProvider value={itemContextValue}>
312
+ <li ref={itemRef} {...rest}>
313
+ {children}
314
+ </li>
315
+ </NavigationMenuItemProvider>
316
+ );
317
+ }
318
+
319
+ /** @internal */
320
+ NavigationMenuItem.displayName = "NavigationMenuItem";
321
+
322
+ /**
323
+ * The control that opens an entry's panel — renders a `<button>` with
324
+ * `aria-expanded`, `aria-controls`, and the hover-intent and keyboard handlers
325
+ * wired up.
326
+ *
327
+ * The ids used for `aria-controls` and the matching `Content`'s
328
+ * `aria-labelledby` are derived from the root's `useId()` plus the Item's
329
+ * `value`, so they are stable and unique even with several navs on a page.
330
+ *
331
+ * **Keyboard support** (ARIA APG *Disclosure Navigation Menu*):
332
+ *
333
+ * | Key | Behaviour |
334
+ * | --- | --- |
335
+ * | `Enter` / `Space` | Toggle this entry's panel |
336
+ * | `ArrowRight` / `ArrowLeft` | Move between top-level entries (horizontal), mirrored under RTL |
337
+ * | `ArrowUp` / `ArrowDown` | Move between top-level entries (vertical) |
338
+ * | `Home` / `End` | Jump to the first / last top-level entry |
339
+ * | `ArrowDown` (horizontal), `ArrowRight`/`ArrowLeft` (vertical) | Open the panel and move focus to its first link |
340
+ * | `Escape` | Close and return focus here |
341
+ *
342
+ * Movement **wraps** at the ends. Every top-level entry stays in the tab order
343
+ * — this is deliberately **not** a roving tabindex, because a keyboard user
344
+ * must be able to Tab through page links without discovering that arrows are
345
+ * required.
346
+ *
347
+ * **Clicking is not the same as hovering.** A pointer that arrives to click
348
+ * fires `pointerenter` first, which with hover-to-open opens the panel before
349
+ * the click lands. The trigger therefore toggles against what was open when the
350
+ * pointer *arrived*, so a click never undoes the user's own hover.
351
+ *
352
+ * **`asChild` prop.** Pass `asChild` to render an arbitrary element instead of
353
+ * the `<button>`; all ARIA attributes, handlers, and the internal registry ref
354
+ * are merged onto the child following the {@link Slot} composition rules
355
+ * (handlers compose, `style` shallow-merges with the child winning, `className`
356
+ * concatenates, refs compose). The child must be a single element accepting a
357
+ * `ref`.
358
+ *
359
+ * **Styling hooks.** `data-state="open" | "closed"`. Also publishes its own
360
+ * `anchor-name` (a plain inline style, not a data attribute) so the shared
361
+ * {@link NavigationMenuViewport | `Viewport`} can be CSS-anchor-positioned to
362
+ * whichever trigger opened it — no consumer wiring required.
363
+ *
364
+ * @extends HTMLButtonElement
365
+ *
366
+ * @example
367
+ * ```tsx
368
+ * <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
369
+ * ```
370
+ *
371
+ * @example Label plus a chevron the stylesheet rotates on open
372
+ * ```tsx
373
+ * <NavigationMenu.Trigger>
374
+ * <span>Concepts</span>
375
+ * <ChevronDownIcon aria-hidden />
376
+ * </NavigationMenu.Trigger>
377
+ * ```
378
+ */
379
+ export function NavigationMenuTrigger<
380
+ T extends HTMLElement = HTMLButtonElement,
381
+ >({
382
+ ref: externalRef,
383
+ children,
384
+ asChild = false,
385
+ onClick,
386
+ onPointerEnter,
387
+ onPointerLeave,
388
+ onKeyDown,
389
+ ...rest
390
+ }: NavigationMenuTriggerProps<T>): ReactElement {
391
+ const {
392
+ triggerRef,
393
+ triggerId,
394
+ panelId,
395
+ open,
396
+ state,
397
+ anchorName,
398
+ handleClick,
399
+ handlePointerEnter,
400
+ handlePointerLeave,
401
+ handleKeyDown,
402
+ } = useNavigationMenuTrigger({
403
+ onClick,
404
+ onPointerEnter,
405
+ onPointerLeave,
406
+ onKeyDown,
407
+ });
408
+
409
+ // The external ref is cast to the internal ref's element type: React's
410
+ // RefObject is invariant, but at runtime the callback receives whichever DOM
411
+ // element actually rendered — the button, or the asChild child.
412
+ //
413
+ // `composeRefs` accepts `undefined`, so always composing would reach the same
414
+ // ref target; the ternary is here to avoid handing React a fresh callback-ref
415
+ // identity on every render, which would detach and re-attach the node each
416
+ // time. That churn settles within the same commit, so no assertion can see it.
417
+ // Stryker disable next-line ConditionalExpression: the always-compose variant is equivalent — same ref target, only unobservable re-attach churn.
418
+ const composedRef = externalRef
419
+ ? composeRefs(triggerRef, externalRef as Ref<HTMLButtonElement>)
420
+ : triggerRef;
421
+
422
+ const triggerProps = {
423
+ ref: composedRef,
424
+ id: triggerId,
425
+ "aria-expanded": open,
426
+ "aria-controls": panelId,
427
+ "data-state": state,
428
+ onClick: handleClick,
429
+ onPointerEnter: handlePointerEnter,
430
+ onPointerLeave: handlePointerLeave,
431
+ onKeyDown: handleKeyDown,
432
+ ...rest,
433
+ // Placed after `...rest` so it isn't clobbered by a bare consumer `style` —
434
+ // a consumer's own anchor-name (if any) still wins inside this merge.
435
+ style: { anchorName, ...rest.style },
436
+ };
437
+
438
+ if (asChild) {
439
+ return <Slot {...triggerProps}>{children}</Slot>;
440
+ }
441
+
442
+ return (
443
+ <button type="button" {...triggerProps}>
444
+ {children}
445
+ </button>
446
+ );
447
+ }
448
+
449
+ /** @internal */
450
+ NavigationMenuTrigger.displayName = "NavigationMenuTrigger";
451
+
452
+ /**
453
+ * An entry's panel — renders a `<div>` linked to its trigger by
454
+ * `aria-labelledby`, hidden with the native `hidden` attribute while closed.
455
+ *
456
+ * Inactive panels stay **mounted**, which preserves their state (scroll
457
+ * position, form input) across opens. Render the `Content` conditionally
458
+ * yourself if you need true unmount semantics.
459
+ *
460
+ * **Projection.** When a {@link NavigationMenuViewport | `Viewport`} is present
461
+ * anywhere in the Root, the panel is portalled into it instead of rendering
462
+ * where it was authored — that is what lets one open panel morph into the next
463
+ * rather than each entry growing its own box. Without a Viewport it renders in
464
+ * place. Authoring is identical either way: always nest the `Content` inside its
465
+ * `Item`, next to its `Trigger`.
466
+ *
467
+ * **Panel links are not top-level entries.** A
468
+ * {@link NavigationMenuLink | `Link`} inside a `Content` is reached by Tab and
469
+ * is deliberately excluded from the top-level arrow-key order, so `Home`/`End`
470
+ * inside a panel don't jump out of it.
471
+ *
472
+ * **Styling hooks.** `data-state="open" | "closed"`.
473
+ *
474
+ * @extends HTMLDivElement
475
+ *
476
+ * @example
477
+ * ```tsx
478
+ * <NavigationMenu.Content>
479
+ * <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
480
+ * <NavigationMenu.Link href="/themes">Themes</NavigationMenu.Link>
481
+ * </NavigationMenu.Content>
482
+ * ```
483
+ *
484
+ * @example forceMount, for a CSS-animated panel
485
+ * ```tsx
486
+ * <NavigationMenu.Content forceMount className="panel">…</NavigationMenu.Content>
487
+ * ```
488
+ * ```css
489
+ * .panel { opacity: 0; transition: opacity 150ms; }
490
+ * .panel[data-state="open"] { opacity: 1; }
491
+ * ```
492
+ *
493
+ * **`data-motion`.** When the open entry changes, the entering and leaving panels
494
+ * report the direction of travel — `from-start` / `from-end` / `to-start` /
495
+ * `to-end` — so a stylesheet can slide them rather than cross-fading every
496
+ * switch. Absent for the first open and the full close, which have no direction.
497
+ */
498
+ export function NavigationMenuContent({
499
+ children,
500
+ forceMount = false,
501
+ ...rest
502
+ }: NavigationMenuContentProps): ReactElement | ReactPortal {
503
+ const { viewport, openValue, previousValue, itemValues } =
504
+ useNavigationMenuContext();
505
+ const { triggerId, panelId, open, state, value } = useNavigationMenuEntry();
506
+ // Which way this panel is travelling, so a stylesheet can slide it in the
507
+ // direction of the pointer rather than cross-fading every switch. Absent for
508
+ // the first open and the full close, which have no direction.
509
+ const motion = getPanelMotion({
510
+ value,
511
+ openValue,
512
+ previousValue,
513
+ itemValues,
514
+ });
515
+
516
+ const panel = (
517
+ <NavigationMenuPanelProvider value={true}>
518
+ <div
519
+ id={panelId}
520
+ aria-labelledby={triggerId}
521
+ data-state={state}
522
+ data-motion={motion}
523
+ hidden={forceMount ? undefined : !open}
524
+ aria-hidden={forceMount && !open ? true : undefined}
525
+ {...rest}
526
+ >
527
+ {children}
528
+ </div>
529
+ </NavigationMenuPanelProvider>
530
+ );
531
+
532
+ // With a Viewport every panel lives in that one box, which is what lets the
533
+ // open panel morph into the next instead of each entry growing its own.
534
+ // Without one the panel simply stays where it was authored.
535
+ return viewport ? createPortal(panel, viewport) : panel;
536
+ }
537
+
538
+ /** @internal */
539
+ NavigationMenuContent.displayName = "NavigationMenuContent";
540
+
541
+ /**
542
+ * Publishes the open panel's measured size as
543
+ * `--primitiv-navigation-menu-viewport-width` / `-height`, so a stylesheet can
544
+ * transition the shared box between panels. The measurement is kept through the
545
+ * close — clearing it would collapse the box just as the exit needs its size.
546
+ *
547
+ * The single shared box every {@link NavigationMenuContent | `Content`} renders
548
+ * into — renders a `<div>`, hidden while nothing is open.
549
+ *
550
+ * Optional. Mount one when you want the desktop "one panel morphs into the
551
+ * next" behaviour: because all panels live in this element, a CSS transition on
552
+ * its size animates between them instead of each entry expanding separately.
553
+ * Omit it and panels render in place, which is the simpler layout.
554
+ *
555
+ * Place it as a sibling of {@link NavigationMenuList | `List`}, inside the
556
+ * Root. It has no children of its own to author — panels arrive by portal,
557
+ * projected into an internal element it renders for exactly that.
558
+ *
559
+ * **Styling hooks.** `data-state="open" | "closed"`,
560
+ * `data-orientation="horizontal" | "vertical"`, and `data-value` carrying the
561
+ * open entry's value, so a stylesheet can size or theme the box per panel
562
+ * without the consumer threading state back in.
563
+ *
564
+ * @extends HTMLDivElement
565
+ *
566
+ * @example
567
+ * ```tsx
568
+ * <NavigationMenu.Root>
569
+ * <NavigationMenu.List>…</NavigationMenu.List>
570
+ * <NavigationMenu.Viewport className="viewport" />
571
+ * </NavigationMenu.Root>
572
+ * ```
573
+ *
574
+ * @example Sizing the shared box per panel
575
+ * ```css
576
+ * .viewport { transition: height 200ms; }
577
+ * .viewport[data-value="registry"] { height: 12rem; }
578
+ * ```
579
+ */
580
+ export function NavigationMenuViewport({
581
+ forceMount = false,
582
+ ...rest
583
+ }: NavigationMenuViewportProps): ReactElement {
584
+ const { registerViewport } = useNavigationMenuContext();
585
+ const { open, openValue, orientation, style } = useNavigationMenuViewport();
586
+
587
+ return (
588
+ <div
589
+ data-orientation={orientation}
590
+ data-state={open ? "open" : "closed"}
591
+ // The open entry's value, so a stylesheet can size or theme the shared
592
+ // box per panel without the consumer threading state back in.
593
+ data-value={openValue || undefined}
594
+ hidden={forceMount ? undefined : !open}
595
+ // The measured size of the open panel, so the shared box can transition
596
+ // between panels. A consumer `style` merges over the top.
597
+ style={{ ...style, ...rest.style }}
598
+ {...rest}
599
+ >
600
+ {/* The portal target, split from the outer box above: the outer one
601
+ hosts the hover-forgiveness collar (a ::before that must extend past
602
+ its own edges to widen the hit area — see the registry stylesheet),
603
+ which an overflow clip on that same box would cut off. This inner
604
+ element is what actually clips a sliding panel to the visible box,
605
+ and is what Content portals into — `viewport` in context is this
606
+ node, not the outer one. className is hardcoded (the same pattern
607
+ Carousel's own internal track div uses): it names an implementation
608
+ detail no consumer ever targets via a prop, not a themable part. */}
609
+ <div
610
+ ref={registerViewport}
611
+ className="primitiv-navigation-menu__viewport-clip"
612
+ data-navigation-menu-viewport-clip=""
613
+ />
614
+ </div>
615
+ );
616
+ }
617
+
618
+ /** @internal */
619
+ NavigationMenuViewport.displayName = "NavigationMenuViewport";
620
+
621
+ /**
622
+ * The marker that tracks the open trigger — an underline, arrow, or highlight —
623
+ * rendered as a `<div>`, hidden while nothing is open.
624
+ *
625
+ * Optional, and purely a measurement carrier: because no styles ship with the
626
+ * library, the Indicator cannot position itself, but it is the only part that
627
+ * can know *where* the open trigger is. It measures that trigger and publishes
628
+ * the result as two inline custom properties, leaving the stylesheet to decide
629
+ * whether tracking means a `translate`, a `left`, or a border:
630
+ *
631
+ * - `--primitiv-navigation-menu-indicator-position` — the trigger's
632
+ * `offsetLeft` when horizontal, `offsetTop` when vertical.
633
+ * - `--primitiv-navigation-menu-indicator-size` — its `offsetWidth` when
634
+ * horizontal, `offsetHeight` when vertical.
635
+ *
636
+ * Both are re-measured whenever the open entry changes and on window `resize`,
637
+ * so the marker doesn't drift off its trigger after the nav rewraps. When the
638
+ * open value names an entry with no rendered trigger, neither property is set —
639
+ * better an unpositioned marker than one parked at `0`.
640
+ *
641
+ * **`asChild` prop.** Pass `asChild` to make the marker an element of your own
642
+ * — an `<svg>` arrow, an icon component — instead of a styled `<div>`. The
643
+ * `data-*` hooks and the geometry custom properties are merged onto the child
644
+ * via {@link Slot}, so an icon tracks the trigger exactly as the default box
645
+ * does.
646
+ *
647
+ * **Styling hooks.** `data-state="open" | "closed"`,
648
+ * `data-orientation="horizontal" | "vertical"`, `data-value`.
649
+ *
650
+ * @extends HTMLDivElement
651
+ *
652
+ * @example
653
+ * ```tsx
654
+ * <NavigationMenu.Indicator className="indicator" />
655
+ * ```
656
+ *
657
+ * @example asChild — an arrow icon rather than a styled box
658
+ * ```tsx
659
+ * <NavigationMenu.Indicator asChild>
660
+ * <ChevronUpIcon aria-hidden />
661
+ * </NavigationMenu.Indicator>
662
+ * ```
663
+ * ```css
664
+ * .indicator {
665
+ * position: absolute;
666
+ * inset-block-end: 0;
667
+ * block-size: 2px;
668
+ * inline-size: var(--primitiv-navigation-menu-indicator-size);
669
+ * translate: var(--primitiv-navigation-menu-indicator-position) 0;
670
+ * transition: translate 200ms, inline-size 200ms;
671
+ * }
672
+ * ```
673
+ */
674
+ export function NavigationMenuIndicator({
675
+ children,
676
+ forceMount = false,
677
+ asChild = false,
678
+ style,
679
+ ...rest
680
+ }: NavigationMenuIndicatorProps): ReactElement {
681
+ const {
682
+ open,
683
+ openValue,
684
+ orientation,
685
+ style: geometryStyle,
686
+ } = useNavigationMenuIndicator();
687
+
688
+ const indicatorProps = {
689
+ "data-orientation": orientation,
690
+ "data-state": open ? ("open" as const) : ("closed" as const),
691
+ "data-value": openValue || undefined,
692
+ hidden: forceMount ? undefined : !open,
693
+ // Consumer style last: the geometry is ours to publish, but everything
694
+ // else about the marker belongs to the stylesheet.
695
+ style: { ...geometryStyle, ...style },
696
+ ...rest,
697
+ };
698
+
699
+ if (asChild) {
700
+ return <Slot {...indicatorProps}>{children}</Slot>;
701
+ }
702
+
703
+ return <div {...indicatorProps}>{children}</div>;
704
+ }
705
+
706
+ /** @internal */
707
+ NavigationMenuIndicator.displayName = "NavigationMenuIndicator";
708
+
709
+ /**
710
+ * A link to a page — renders an `<a>`.
711
+ *
712
+ * The one part **shared by both presentations** of the nav. On desktop it is
713
+ * used inside a {@link NavigationMenuContent | `Content`} panel and as a
714
+ * top-level {@link NavigationMenuItem | `Item`}'s only child; in the composed
715
+ * mobile nav (a `Drawer` + `Collapsible` per section) it is imported directly,
716
+ * so active-state logic is written once rather than per presentation.
717
+ *
718
+ * **Active state.** {@link NavigationMenuLinkProps.active | `active`} sets
719
+ * `aria-current="page"` and the `data-active` styling hook. No route matching
720
+ * happens here — the consumer owns the router, so the consumer owns the
721
+ * comparison.
722
+ *
723
+ * **Clicking closes the menu**, whether the link sits in a panel or beside one.
724
+ * Leaving a panel hanging open over the page the user just navigated to is the
725
+ * bug this avoids. Consumers can veto it by calling `event.preventDefault()` in
726
+ * their own `onClick`.
727
+ *
728
+ * **Position decides keyboard reachability.** Directly inside an `Item`, the
729
+ * link is a top-level entry and joins the arrow-key travel order. Inside a
730
+ * `Content`, it is reached by Tab and stays out of that order.
731
+ *
732
+ * **`asChild` prop.** Pass `asChild` to render a routing library's link
733
+ * component instead of the native `<a>`, with `aria-current`, `data-active`, the
734
+ * dismissing click handler and the ref merged onto it via {@link Slot}.
735
+ *
736
+ * **Styling hooks.** `data-active=""` when active (omitted otherwise), so CSS
737
+ * can target `[data-active]`.
738
+ *
739
+ * @extends HTMLAnchorElement
740
+ *
741
+ * @example
742
+ * ```tsx
743
+ * <NavigationMenu.Link href="/tokens" active={pathname === "/tokens"}>
744
+ * Tokens
745
+ * </NavigationMenu.Link>
746
+ * ```
747
+ *
748
+ * @example With a router link
749
+ * ```tsx
750
+ * <NavigationMenu.Link asChild active={isActive}>
751
+ * <RouterLink to="/tokens">Tokens</RouterLink>
752
+ * </NavigationMenu.Link>
753
+ * ```
754
+ */
755
+ export function NavigationMenuLink({
756
+ children,
757
+ active = false,
758
+ asChild = false,
759
+ onKeyDown,
760
+ onClick,
761
+ onPointerEnter,
762
+ ...rest
763
+ }: NavigationMenuLinkProps): ReactElement {
764
+ const { linkRef, handleKeyDown, handleClick, handlePointerEnter } =
765
+ useNavigationMenuLink({
766
+ onKeyDown,
767
+ onClick,
768
+ onPointerEnter,
769
+ });
770
+
771
+ const linkProps = {
772
+ ref: linkRef,
773
+ "aria-current": active ? ("page" as const) : undefined,
774
+ "data-active": active ? "" : undefined,
775
+ onKeyDown: handleKeyDown,
776
+ onClick: handleClick,
777
+ onPointerEnter: handlePointerEnter,
778
+ ...rest,
779
+ };
780
+
781
+ if (asChild) {
782
+ return <Slot {...linkProps}>{children}</Slot>;
783
+ }
784
+
785
+ return <a {...linkProps}>{children}</a>;
786
+ }
787
+
788
+ /** @internal */
789
+ NavigationMenuLink.displayName = "NavigationMenuLink";
790
+
791
+ /** Type of the {@link NavigationMenu} compound: the callable root plus its
792
+ * attached sub-components. */
793
+ export type TNavigationMenuCompound = typeof NavigationMenuRoot & {
794
+ Root: typeof NavigationMenuRoot;
795
+ List: typeof NavigationMenuList;
796
+ Item: typeof NavigationMenuItem;
797
+ Trigger: typeof NavigationMenuTrigger;
798
+ Content: typeof NavigationMenuContent;
799
+ Viewport: typeof NavigationMenuViewport;
800
+ Indicator: typeof NavigationMenuIndicator;
801
+ Link: typeof NavigationMenuLink;
802
+ };
803
+
804
+ /**
805
+ * Headless, accessible **NavigationMenu** — the desktop dropdown site nav, built
806
+ * on the [ARIA APG Disclosure Navigation Menu
807
+ * pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/):
808
+ * a `<nav>` landmark wrapping a list of top-level entries, each either a plain
809
+ * link or a trigger that discloses a panel. One panel is open at a time. Zero
810
+ * styles ship.
811
+ *
812
+ * `NavigationMenu` is both callable (an alias of
813
+ * {@link NavigationMenuRoot | `NavigationMenu.Root`}) and carries its
814
+ * sub-components as static properties. Prefer the namespaced form in application
815
+ * code for readability and grep-ability:
816
+ *
817
+ * - {@link NavigationMenuRoot | `NavigationMenu.Root`} — `<nav>` landmark, state owner, context provider.
818
+ * - {@link NavigationMenuList | `NavigationMenu.List`} — `<ul>` of top-level entries.
819
+ * - {@link NavigationMenuItem | `NavigationMenu.Item`} — `<li>`; its `value` makes the entry a disclosure.
820
+ * - {@link NavigationMenuTrigger | `NavigationMenu.Trigger`} — `<button>` that opens a panel.
821
+ * - {@link NavigationMenuContent | `NavigationMenu.Content`} — the panel, projected into a `Viewport` when one exists.
822
+ * - {@link NavigationMenuViewport | `NavigationMenu.Viewport`} — *optional* shared host so panels morph in one box.
823
+ * - {@link NavigationMenuIndicator | `NavigationMenu.Indicator`} — *optional* marker tracking the open trigger.
824
+ * - {@link NavigationMenuLink | `NavigationMenu.Link`} — `<a>` with `aria-current`; shared with the mobile nav.
825
+ *
826
+ * **Scope: this is the desktop presentation.** The mobile nav is deliberately
827
+ * *not* a mode of this component — its interaction model differs (several
828
+ * sections expanded in place, rather than one panel at a time), so it is built
829
+ * as a composition of `Drawer` + `Collapsible` reusing this component's
830
+ * `NavigationMenu.Link`. Both presentations map over the same nav data, which
831
+ * stays the consumer's. See RFC 0019 §3–4a for the reasoning.
832
+ *
833
+ * @example Minimal usage
834
+ * ```tsx
835
+ * import { NavigationMenu } from "@primitiv-ui/react";
836
+ *
837
+ * export function SiteNav() {
838
+ * return (
839
+ * <NavigationMenu.Root>
840
+ * <NavigationMenu.List>
841
+ * <NavigationMenu.Item value="concepts">
842
+ * <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
843
+ * <NavigationMenu.Content>
844
+ * <NavigationMenu.Link href="/tokens">Tokens</NavigationMenu.Link>
845
+ * </NavigationMenu.Content>
846
+ * </NavigationMenu.Item>
847
+ * <NavigationMenu.Item>
848
+ * <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
849
+ * </NavigationMenu.Item>
850
+ * </NavigationMenu.List>
851
+ * </NavigationMenu.Root>
852
+ * );
853
+ * }
854
+ * ```
855
+ *
856
+ * @example Styling with any system
857
+ * Because no styles ship, target the rendered elements or the `data-*` hooks
858
+ * with whatever system you use:
859
+ *
860
+ * ```css
861
+ * nav [data-state="closed"] { display: none; }
862
+ * nav button[data-state="open"] > svg { rotate: 180deg; }
863
+ * nav a[data-active] { font-weight: 600; }
864
+ * ```
865
+ *
866
+ * @see {@link NavigationMenuRoot} for the state modes, hover-intent timings, and Escape handling.
867
+ * @see {@link NavigationMenuItem} for why `value` is what makes an entry a disclosure.
868
+ * @see {@link NavigationMenuTrigger} for the full keyboard-interaction table.
869
+ * @see {@link NavigationMenuViewport} for the shared-panel morph.
870
+ * @see {@link NavigationMenuIndicator} for the geometry custom properties.
871
+ * @see {@link NavigationMenuLink} for the active-state contract shared with the mobile nav.
872
+ */
873
+ const NavigationMenuCompound: TNavigationMenuCompound = Object.assign(
874
+ NavigationMenuRoot,
875
+ {
876
+ Root: NavigationMenuRoot,
877
+ List: NavigationMenuList,
878
+ Item: NavigationMenuItem,
879
+ Trigger: NavigationMenuTrigger,
880
+ Content: NavigationMenuContent,
881
+ Viewport: NavigationMenuViewport,
882
+ Indicator: NavigationMenuIndicator,
883
+ Link: NavigationMenuLink,
884
+ },
885
+ );
886
+
887
+ /** @internal */
888
+ NavigationMenuCompound.displayName = "NavigationMenu";
889
+
890
+ export { NavigationMenuCompound as NavigationMenu };