@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,294 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { DeadEndNav, ThreeEntryNav } from "./NavigationMenu.fixtures";
5
+
6
+ describe("NavigationMenu — keyboard interaction", () => {
7
+ it("toggles a panel with Enter on its trigger", async () => {
8
+ const user = userEvent.setup();
9
+ render(<ThreeEntryNav />);
10
+
11
+ screen.getByRole("button", { name: "Concepts" }).focus();
12
+ await user.keyboard("{Enter}");
13
+
14
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
15
+
16
+ await user.keyboard("{Enter}");
17
+
18
+ expect(screen.getByTestId("concepts-panel")).not.toBeVisible();
19
+ });
20
+
21
+ it("toggles a panel with Space on its trigger", async () => {
22
+ const user = userEvent.setup();
23
+ render(<ThreeEntryNav />);
24
+
25
+ screen.getByRole("button", { name: "Concepts" }).focus();
26
+ await user.keyboard(" ");
27
+
28
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
29
+ });
30
+
31
+ it("opens the panel and moves focus into it on ArrowDown when horizontal", async () => {
32
+ const user = userEvent.setup();
33
+ render(<ThreeEntryNav />);
34
+
35
+ screen.getByRole("button", { name: "Concepts" }).focus();
36
+ await user.keyboard("{ArrowDown}");
37
+
38
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
39
+ await waitFor(() =>
40
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
41
+ );
42
+ });
43
+
44
+ it("moves focus into an already-open panel on ArrowDown", async () => {
45
+ const user = userEvent.setup();
46
+ render(<ThreeEntryNav defaultValue="concepts" />);
47
+
48
+ screen.getByRole("button", { name: "Concepts" }).focus();
49
+ await user.keyboard("{ArrowDown}");
50
+
51
+ await waitFor(() =>
52
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
53
+ );
54
+ });
55
+
56
+ it("uses ArrowRight to enter the panel when vertical", async () => {
57
+ const user = userEvent.setup();
58
+ render(<ThreeEntryNav orientation="vertical" />);
59
+
60
+ screen.getByRole("button", { name: "Concepts" }).focus();
61
+ await user.keyboard("{ArrowRight}");
62
+
63
+ await waitFor(() =>
64
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
65
+ );
66
+ });
67
+
68
+ it("uses ArrowLeft to enter the panel when vertical and rtl", async () => {
69
+ const user = userEvent.setup();
70
+ render(<ThreeEntryNav orientation="vertical" dir="rtl" />);
71
+
72
+ screen.getByRole("button", { name: "Concepts" }).focus();
73
+ await user.keyboard("{ArrowLeft}");
74
+
75
+ await waitFor(() =>
76
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
77
+ );
78
+ });
79
+
80
+ it("closes on Escape and returns focus to the trigger", async () => {
81
+ const user = userEvent.setup();
82
+ render(<ThreeEntryNav defaultValue="concepts" />);
83
+
84
+ screen.getByRole("link", { name: "Tokens" }).focus();
85
+ await user.keyboard("{Escape}");
86
+
87
+ expect(screen.getByTestId("concepts-panel")).not.toBeVisible();
88
+ expect(screen.getByRole("button", { name: "Concepts" })).toHaveFocus();
89
+ });
90
+
91
+ it("leaves focus alone when Escape is pressed with nothing open", async () => {
92
+ const user = userEvent.setup();
93
+ render(<ThreeEntryNav />);
94
+
95
+ const changelog = screen.getByRole("link", { name: "Changelog" });
96
+ changelog.focus();
97
+ await user.keyboard("{Escape}");
98
+
99
+ expect(changelog).toHaveFocus();
100
+ });
101
+
102
+ it("does nothing on an unhandled key", async () => {
103
+ const user = userEvent.setup();
104
+ render(<ThreeEntryNav defaultValue="concepts" />);
105
+
106
+ const trigger = screen.getByRole("button", { name: "Concepts" });
107
+ trigger.focus();
108
+ await user.keyboard("a");
109
+
110
+ expect(trigger).toHaveFocus();
111
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
112
+ });
113
+
114
+ it("ignores the enter-panel key on an entry with no panel", async () => {
115
+ const user = userEvent.setup();
116
+ render(<ThreeEntryNav />);
117
+
118
+ const changelog = screen.getByRole("link", { name: "Changelog" });
119
+ changelog.focus();
120
+ await user.keyboard("{ArrowDown}");
121
+
122
+ expect(changelog).toHaveFocus();
123
+ });
124
+
125
+ it("moves focus into a panel it opened by click, on the enter-panel arrow", async () => {
126
+ const user = userEvent.setup();
127
+ render(<ThreeEntryNav />);
128
+
129
+ await user.click(screen.getByRole("button", { name: "Concepts" }));
130
+ await user.keyboard("{ArrowDown}");
131
+
132
+ // The panel is open by the time the arrow lands, so the arrow has to read
133
+ // the live open state — an open value captured at mount takes the wrong
134
+ // branch and never focuses anything.
135
+ await waitFor(() =>
136
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
137
+ );
138
+ });
139
+
140
+ it("keeps the panel open while tabbing across its own entries", async () => {
141
+ const user = userEvent.setup();
142
+ render(<ThreeEntryNav defaultValue="concepts" />);
143
+
144
+ screen.getByRole("button", { name: "Concepts" }).focus();
145
+ await user.tab(); // Tokens
146
+ await user.tab(); // Themes
147
+ await user.tab(); // Changelog
148
+ await user.tab(); // Registry & CLI
149
+
150
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
151
+ });
152
+
153
+ it("closes an open panel when Tab moves focus into a separate NavigationMenu instance", async () => {
154
+ const user = userEvent.setup();
155
+ render(
156
+ <>
157
+ <ThreeEntryNav defaultValue="concepts" aria-label="First" />
158
+ <ThreeEntryNav aria-label="Second" />
159
+ </>,
160
+ );
161
+
162
+ const [firstNav, secondNav] = screen.getAllByRole("navigation");
163
+ within(firstNav)
164
+ .getByRole("button", { name: "Registry & CLI" })
165
+ .focus();
166
+ await user.tab();
167
+
168
+ expect(
169
+ within(secondNav).getByRole("button", { name: "Concepts" }),
170
+ ).toHaveFocus();
171
+ expect(within(firstNav).getByTestId("concepts-panel")).not.toBeVisible();
172
+ });
173
+
174
+ it("leaves Home and End to the browser inside an open panel", async () => {
175
+ const user = userEvent.setup();
176
+ render(<ThreeEntryNav defaultValue="concepts" />);
177
+
178
+ const tokens = screen.getByRole("link", { name: "Tokens" });
179
+ tokens.focus();
180
+ await user.keyboard("{Home}");
181
+
182
+ // A panel link is reached by Tab, not by the top-level order — so Home must
183
+ // not jump out of the panel onto the first trigger.
184
+ expect(tokens).toHaveFocus();
185
+ });
186
+ });
187
+
188
+ /**
189
+ * Focus only enters a panel when the enter-panel arrow asked for it. The
190
+ * mechanism is a pending flag spent by an effect, and every way of opening a
191
+ * panel runs that effect — so each of these proves an open that *didn't* come
192
+ * from the arrow leaves focus where the user put it.
193
+ */
194
+ describe("NavigationMenu — unsolicited panel focus", () => {
195
+ it("does not move focus into a panel opened by defaultValue", () => {
196
+ render(<ThreeEntryNav defaultValue="concepts" />);
197
+
198
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
199
+ expect(document.body).toHaveFocus();
200
+ });
201
+
202
+ it("keeps focus on the trigger when a click opens the panel", async () => {
203
+ const user = userEvent.setup();
204
+ render(<ThreeEntryNav />);
205
+
206
+ const trigger = screen.getByRole("button", { name: "Concepts" });
207
+ await user.click(trigger);
208
+
209
+ expect(screen.getByTestId("concepts-panel")).toBeVisible();
210
+ expect(trigger).toHaveFocus();
211
+ });
212
+
213
+ it("does not replay the arrow's intent on the next open", async () => {
214
+ const user = userEvent.setup();
215
+ render(<ThreeEntryNav />);
216
+
217
+ const trigger = screen.getByRole("button", { name: "Concepts" });
218
+ trigger.focus();
219
+ await user.keyboard("{ArrowDown}");
220
+ await waitFor(() =>
221
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
222
+ );
223
+ await user.keyboard("{Escape}");
224
+ await user.click(trigger);
225
+
226
+ // The arrow's intent was spent on the first open; a second open by click
227
+ // must not inherit it.
228
+ expect(trigger).toHaveFocus();
229
+ });
230
+
231
+ it("does not replay the intent after entering an already-open panel", async () => {
232
+ const user = userEvent.setup();
233
+ render(<ThreeEntryNav defaultValue="concepts" />);
234
+
235
+ const trigger = screen.getByRole("button", { name: "Concepts" });
236
+ trigger.focus();
237
+ await user.keyboard("{ArrowDown}");
238
+ await waitFor(() =>
239
+ expect(screen.getByRole("link", { name: "Tokens" })).toHaveFocus(),
240
+ );
241
+ await user.keyboard("{Escape}");
242
+ await user.click(trigger);
243
+
244
+ expect(trigger).toHaveFocus();
245
+ });
246
+ });
247
+
248
+ describe("NavigationMenu — enter-panel arrow with nothing to focus", () => {
249
+ it("opens an entry that has no panel at all", async () => {
250
+ const user = userEvent.setup();
251
+ render(<DeadEndNav />);
252
+
253
+ const trigger = screen.getByRole("button", { name: "Panelless" });
254
+ trigger.focus();
255
+ await user.keyboard("{ArrowDown}");
256
+
257
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
258
+ expect(trigger).toHaveFocus();
259
+ });
260
+
261
+ it("survives the arrow on an already-open entry with no panel", async () => {
262
+ const user = userEvent.setup();
263
+ render(<DeadEndNav defaultValue="panelless" />);
264
+
265
+ const trigger = screen.getByRole("button", { name: "Panelless" });
266
+ trigger.focus();
267
+ await user.keyboard("{ArrowDown}");
268
+
269
+ expect(trigger).toHaveFocus();
270
+ });
271
+
272
+ it("opens a panel that holds nothing focusable", async () => {
273
+ const user = userEvent.setup();
274
+ render(<DeadEndNav />);
275
+
276
+ const trigger = screen.getByRole("button", { name: "Prose" });
277
+ trigger.focus();
278
+ await user.keyboard("{ArrowDown}");
279
+
280
+ expect(screen.getByTestId("prose-panel")).toBeVisible();
281
+ expect(trigger).toHaveFocus();
282
+ });
283
+
284
+ it("survives the arrow on an already-open panel with nothing focusable", async () => {
285
+ const user = userEvent.setup();
286
+ render(<DeadEndNav defaultValue="prose" />);
287
+
288
+ const trigger = screen.getByRole("button", { name: "Prose" });
289
+ trigger.focus();
290
+ await user.keyboard("{ArrowDown}");
291
+
292
+ expect(trigger).toHaveFocus();
293
+ });
294
+ });
@@ -0,0 +1,126 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useState } from "react";
4
+
5
+ import { NavigationMenu } from "../NavigationMenu";
6
+
7
+ import {
8
+ horizontalArrowCases,
9
+ rtlArrowCases,
10
+ ThreeEntryNav,
11
+ verticalArrowCases,
12
+ } from "./NavigationMenu.fixtures";
13
+
14
+ describe("NavigationMenu — keyboard navigation", () => {
15
+ describe.each(horizontalArrowCases)(
16
+ "horizontal: $key from $from",
17
+ ({ key, from, expected }) => {
18
+ it(`moves focus to ${expected}`, async () => {
19
+ const user = userEvent.setup();
20
+ render(<ThreeEntryNav />);
21
+
22
+ screen.getByRole(from === "Changelog" ? "link" : "button", {
23
+ name: from,
24
+ }).focus();
25
+ await user.keyboard(key);
26
+
27
+ expect(screen.getByText(expected)).toHaveFocus();
28
+ });
29
+ },
30
+ );
31
+
32
+ describe.each(verticalArrowCases)(
33
+ "vertical: $key from $from",
34
+ ({ key, from, expected }) => {
35
+ it(`moves focus to ${expected}`, async () => {
36
+ const user = userEvent.setup();
37
+ render(<ThreeEntryNav orientation="vertical" />);
38
+
39
+ screen.getByRole("button", { name: from }).focus();
40
+ await user.keyboard(key);
41
+
42
+ expect(screen.getByText(expected)).toHaveFocus();
43
+ });
44
+ },
45
+ );
46
+
47
+ describe.each(rtlArrowCases)(
48
+ "rtl: $key from $from",
49
+ ({ key, from, expected }) => {
50
+ it(`moves focus to ${expected}`, async () => {
51
+ const user = userEvent.setup();
52
+ render(<ThreeEntryNav dir="rtl" />);
53
+
54
+ screen.getByRole(from === "Changelog" ? "link" : "button", {
55
+ name: from,
56
+ }).focus();
57
+ await user.keyboard(key);
58
+
59
+ expect(screen.getByText(expected)).toHaveFocus();
60
+ });
61
+ },
62
+ );
63
+
64
+ it("keeps every top-level entry in the tab order", async () => {
65
+ const user = userEvent.setup();
66
+ render(<ThreeEntryNav />);
67
+
68
+ await user.tab();
69
+ expect(screen.getByRole("button", { name: "Concepts" })).toHaveFocus();
70
+
71
+ await user.tab();
72
+ expect(screen.getByRole("link", { name: "Changelog" })).toHaveFocus();
73
+
74
+ await user.tab();
75
+ expect(screen.getByRole("button", { name: "Registry & CLI" })).toHaveFocus();
76
+ });
77
+
78
+ it("skips an entry that has since unmounted", async () => {
79
+ const user = userEvent.setup();
80
+
81
+ function ShrinkingNav() {
82
+ const [showBeta, setShowBeta] = useState(true);
83
+ return (
84
+ <>
85
+ <button type="button" onClick={() => setShowBeta(false)}>
86
+ Drop Beta
87
+ </button>
88
+ <NavigationMenu.Root>
89
+ <NavigationMenu.List>
90
+ <NavigationMenu.Item>
91
+ <NavigationMenu.Link href="/alpha">Alpha</NavigationMenu.Link>
92
+ </NavigationMenu.Item>
93
+ {showBeta && (
94
+ <NavigationMenu.Item>
95
+ <NavigationMenu.Link href="/beta">Beta</NavigationMenu.Link>
96
+ </NavigationMenu.Item>
97
+ )}
98
+ <NavigationMenu.Item>
99
+ <NavigationMenu.Link href="/gamma">Gamma</NavigationMenu.Link>
100
+ </NavigationMenu.Item>
101
+ </NavigationMenu.List>
102
+ </NavigationMenu.Root>
103
+ </>
104
+ );
105
+ }
106
+
107
+ render(<ShrinkingNav />);
108
+ await user.click(screen.getByRole("button", { name: "Drop Beta" }));
109
+ screen.getByRole("link", { name: "Alpha" }).focus();
110
+ await user.keyboard("{ArrowRight}");
111
+
112
+ // An entry that fails to unregister leaves a detached element in the travel
113
+ // order, and the arrow lands on nothing at all.
114
+ expect(screen.getByRole("link", { name: "Gamma" })).toHaveFocus();
115
+ });
116
+
117
+ it("does not treat links inside a panel as top-level entries", async () => {
118
+ const user = userEvent.setup();
119
+ render(<ThreeEntryNav defaultValue="concepts" />);
120
+
121
+ screen.getByRole("button", { name: "Concepts" }).focus();
122
+ await user.keyboard("{ArrowRight}");
123
+
124
+ expect(screen.getByRole("link", { name: "Changelog" })).toHaveFocus();
125
+ });
126
+ });
@@ -0,0 +1,139 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { NavigationMenu } from "../NavigationMenu";
5
+
6
+ import { ThreeEntryNav } from "./NavigationMenu.fixtures";
7
+
8
+ describe("NavigationMenu.Link", () => {
9
+ it("marks the active link as the current page", () => {
10
+ render(
11
+ <NavigationMenu.Root>
12
+ <NavigationMenu.List>
13
+ <NavigationMenu.Item>
14
+ <NavigationMenu.Link href="/start" active>
15
+ Start Here
16
+ </NavigationMenu.Link>
17
+ </NavigationMenu.Item>
18
+ <NavigationMenu.Item>
19
+ <NavigationMenu.Link href="/changelog">Changelog</NavigationMenu.Link>
20
+ </NavigationMenu.Item>
21
+ </NavigationMenu.List>
22
+ </NavigationMenu.Root>,
23
+ );
24
+
25
+ const active = screen.getByRole("link", { name: "Start Here" });
26
+ const inactive = screen.getByRole("link", { name: "Changelog" });
27
+
28
+ expect(active).toHaveAttribute("aria-current", "page");
29
+ expect(active).toHaveAttribute("data-active", "");
30
+ expect(inactive).not.toHaveAttribute("aria-current");
31
+ expect(inactive).not.toHaveAttribute("data-active");
32
+ });
33
+
34
+ it("closes the open panel when a link inside it is clicked", async () => {
35
+ const user = userEvent.setup();
36
+ render(<ThreeEntryNav defaultValue="concepts" />);
37
+
38
+ await user.click(screen.getByRole("link", { name: "Tokens" }));
39
+
40
+ expect(screen.getByTestId("concepts-panel")).not.toBeVisible();
41
+ });
42
+
43
+ it("requests the close with the empty string, not the link's own entry", async () => {
44
+ const user = userEvent.setup();
45
+ const onValueChange = vi.fn();
46
+ render(<ThreeEntryNav value="concepts" onValueChange={onValueChange} />);
47
+
48
+ await user.click(screen.getByRole("link", { name: "Tokens" }));
49
+
50
+ // A controlled parent has to be told "nothing is open" — any other value
51
+ // reads as "open that entry instead", which only looks like a close because
52
+ // no entry answers to it.
53
+ expect(onValueChange).toHaveBeenCalledWith("");
54
+ });
55
+
56
+ it("closes the open panel when a top-level link is clicked", async () => {
57
+ const user = userEvent.setup();
58
+ render(<ThreeEntryNav defaultValue="concepts" />);
59
+
60
+ await user.click(screen.getByRole("link", { name: "Changelog" }));
61
+
62
+ expect(screen.getByTestId("concepts-panel")).not.toBeVisible();
63
+ });
64
+
65
+ it("composes the consumer's own link handlers with its own", async () => {
66
+ const user = userEvent.setup();
67
+ const onClick = vi.fn();
68
+ const onKeyDown = vi.fn();
69
+ render(
70
+ <NavigationMenu.Root>
71
+ <NavigationMenu.List>
72
+ <NavigationMenu.Item>
73
+ <NavigationMenu.Link
74
+ href="/changelog"
75
+ onClick={onClick}
76
+ onKeyDown={onKeyDown}
77
+ >
78
+ Changelog
79
+ </NavigationMenu.Link>
80
+ </NavigationMenu.Item>
81
+ </NavigationMenu.List>
82
+ </NavigationMenu.Root>,
83
+ );
84
+
85
+ const link = screen.getByRole("link", { name: "Changelog" });
86
+ link.focus();
87
+ await user.keyboard("{ArrowRight}");
88
+ await user.click(link);
89
+
90
+ expect(onKeyDown).toHaveBeenCalledOnce();
91
+ expect(onClick).toHaveBeenCalledOnce();
92
+ });
93
+
94
+ it("renders a consumer element instead of the anchor with asChild", () => {
95
+ render(
96
+ <NavigationMenu.Root>
97
+ <NavigationMenu.List>
98
+ <NavigationMenu.Item>
99
+ <NavigationMenu.Link asChild active>
100
+ <button type="button">Start Here</button>
101
+ </NavigationMenu.Link>
102
+ </NavigationMenu.Item>
103
+ </NavigationMenu.List>
104
+ </NavigationMenu.Root>,
105
+ );
106
+
107
+ const rendered = screen.getByRole("button", { name: "Start Here" });
108
+
109
+ expect(rendered.tagName).toBe("BUTTON");
110
+ expect(rendered).toHaveAttribute("aria-current", "page");
111
+ expect(screen.queryByRole("link")).toBeNull();
112
+ });
113
+
114
+ it("still closes the panel through an asChild link", async () => {
115
+ const user = userEvent.setup();
116
+ const onClick = vi.fn();
117
+ render(
118
+ <NavigationMenu.Root defaultValue="concepts">
119
+ <NavigationMenu.List>
120
+ <NavigationMenu.Item value="concepts">
121
+ <NavigationMenu.Trigger>Concepts</NavigationMenu.Trigger>
122
+ <NavigationMenu.Content data-testid="concepts-panel">
123
+ <NavigationMenu.Link asChild>
124
+ <a href="/tokens" onClick={onClick}>
125
+ Tokens
126
+ </a>
127
+ </NavigationMenu.Link>
128
+ </NavigationMenu.Content>
129
+ </NavigationMenu.Item>
130
+ </NavigationMenu.List>
131
+ </NavigationMenu.Root>,
132
+ );
133
+
134
+ await user.click(screen.getByRole("link", { name: "Tokens" }));
135
+
136
+ expect(onClick).toHaveBeenCalledOnce();
137
+ expect(screen.getByTestId("concepts-panel")).not.toBeVisible();
138
+ });
139
+ });