@primitiv-ui/react 0.1.28 → 0.1.30

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 (456) hide show
  1. package/README.md +19 -2
  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 +32 -11
  7. package/src/Accordion/README.md +2 -2
  8. package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
  9. package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
  10. package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
  11. package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
  12. package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
  13. package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
  14. package/src/Accordion/types.ts +72 -21
  15. package/src/Alert/Alert.tsx +36 -7
  16. package/src/Alert/__tests__/Alert.test.tsx +5 -0
  17. package/src/Alert/types.ts +18 -2
  18. package/src/Avatar/Avatar.tsx +99 -15
  19. package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
  20. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
  21. package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
  22. package/src/Avatar/hooks/useAvatarImage.ts +16 -0
  23. package/src/Avatar/types.ts +15 -3
  24. package/src/Breadcrumb/Breadcrumb.tsx +139 -21
  25. package/src/Breadcrumb/README.md +47 -0
  26. package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
  27. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
  28. package/src/Breadcrumb/types.ts +73 -7
  29. package/src/Button/Button.tsx +2 -0
  30. package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
  31. package/src/Button/__tests__/Button.contract.test.tsx +1 -1
  32. package/src/Button/types.ts +21 -0
  33. package/src/Carousel/Carousel.tsx +268 -20
  34. package/src/Carousel/README.md +764 -87
  35. package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
  36. package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
  37. package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
  38. package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
  39. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
  40. package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
  41. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +90 -2
  42. package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
  43. package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
  44. package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
  45. package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
  46. package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
  47. package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
  48. package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
  49. package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
  50. package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
  51. package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
  52. package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
  53. package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
  54. package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
  55. package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
  56. package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
  57. package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
  58. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
  59. package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
  60. package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
  61. package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
  62. package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
  63. package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
  64. package/src/Carousel/hooks/index.ts +1 -0
  65. package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
  66. package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
  67. package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
  68. package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
  69. package/src/Carousel/loopEngine.ts +54 -0
  70. package/src/Carousel/types.ts +458 -36
  71. package/src/Checkbox/Checkbox.tsx +11 -3
  72. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
  73. package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
  74. package/src/Checkbox/types.ts +19 -0
  75. package/src/CheckboxCard/CheckboxCard.tsx +14 -5
  76. package/src/CheckboxCard/README.md +1 -1
  77. package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
  78. package/src/CheckboxCard/types.ts +65 -17
  79. package/src/Collapsible/Collapsible.tsx +49 -3
  80. package/src/Collapsible/README.md +41 -1
  81. package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
  82. package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
  83. package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
  84. package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
  85. package/src/Collapsible/types.ts +89 -22
  86. package/src/Combobox/Combobox.tsx +508 -0
  87. package/src/Combobox/ComboboxContext.ts +23 -0
  88. package/src/Combobox/README.md +170 -0
  89. package/src/Combobox/__tests__/Combobox.asChild.test.tsx +62 -0
  90. package/src/Combobox/__tests__/Combobox.basic-rendering.test.tsx +16 -0
  91. package/src/Combobox/__tests__/Combobox.content.test.tsx +36 -0
  92. package/src/Combobox/__tests__/Combobox.controlled-state.test.tsx +88 -0
  93. package/src/Combobox/__tests__/Combobox.empty.test.tsx +42 -0
  94. package/src/Combobox/__tests__/Combobox.ids.test.tsx +66 -0
  95. package/src/Combobox/__tests__/Combobox.item.test.tsx +76 -0
  96. package/src/Combobox/__tests__/Combobox.keyboard-interaction.test.tsx +196 -0
  97. package/src/Combobox/__tests__/Combobox.light-dismiss.test.tsx +101 -0
  98. package/src/Combobox/__tests__/Combobox.query.test.tsx +105 -0
  99. package/src/Combobox/hooks/index.ts +1 -0
  100. package/src/Combobox/hooks/useComboboxRoot.ts +2 -0
  101. package/src/Combobox/index.ts +3 -0
  102. package/src/Combobox/types.ts +133 -0
  103. package/src/ContextMenu/ContextMenu.tsx +270 -4
  104. package/src/ContextMenu/README.md +1 -1
  105. package/src/ContextMenu/types.ts +148 -6
  106. package/src/DirectionProvider/DirectionProvider.tsx +41 -9
  107. package/src/DirectionProvider/README.md +4 -4
  108. package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
  109. package/src/DirectionProvider/types.ts +28 -3
  110. package/src/Divider/Divider.tsx +24 -10
  111. package/src/Divider/__tests__/Divider.contract.test.tsx +1 -1
  112. package/src/Divider/__tests__/Divider.test.tsx +5 -0
  113. package/src/Divider/types.ts +10 -3
  114. package/src/Drawer/Drawer.tsx +315 -0
  115. package/src/Drawer/README.md +124 -0
  116. package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
  117. package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
  118. package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
  119. package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
  120. package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
  121. package/src/Drawer/index.ts +2 -0
  122. package/src/Drawer/types.ts +66 -0
  123. package/src/Dropdown/Dropdown.tsx +119 -2
  124. package/src/Dropdown/README.md +7 -5
  125. package/src/Dropdown/types.ts +241 -11
  126. package/src/EmptyState/EmptyState.tsx +74 -4
  127. package/src/EmptyState/README.md +2 -2
  128. package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
  129. package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
  130. package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
  131. package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
  132. package/src/EmptyState/types.ts +49 -6
  133. package/src/Field/Field.tsx +23 -0
  134. package/src/Field/README.md +1 -1
  135. package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
  136. package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
  137. package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
  138. package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
  139. package/src/Field/hooks/useFieldProps.ts +1 -1
  140. package/src/Field/types.ts +60 -12
  141. package/src/Fieldset/Fieldset.tsx +55 -19
  142. package/src/Fieldset/README.md +2 -2
  143. package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
  144. package/src/Fieldset/types.ts +20 -2
  145. package/src/Input/Input.tsx +15 -10
  146. package/src/Input/README.md +2 -2
  147. package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
  148. package/src/Input/types.ts +19 -2
  149. package/src/InputGroup/InputGroup.tsx +11 -3
  150. package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
  151. package/src/InputGroup/types.ts +26 -5
  152. package/src/Listbox/Listbox.tsx +475 -0
  153. package/src/Listbox/ListboxContext.ts +48 -0
  154. package/src/Listbox/README.md +248 -0
  155. package/src/Listbox/__tests__/Listbox.active-descendant.test.tsx +128 -0
  156. package/src/Listbox/__tests__/Listbox.asChild.test.tsx +91 -0
  157. package/src/Listbox/__tests__/Listbox.basic-rendering.test.tsx +16 -0
  158. package/src/Listbox/__tests__/Listbox.controlled-state.test.tsx +114 -0
  159. package/src/Listbox/__tests__/Listbox.disabled.test.tsx +90 -0
  160. package/src/Listbox/__tests__/Listbox.fixtures.ts +27 -0
  161. package/src/Listbox/__tests__/Listbox.group-label.test.tsx +97 -0
  162. package/src/Listbox/__tests__/Listbox.group.test.tsx +45 -0
  163. package/src/Listbox/__tests__/Listbox.keyboard-edge-cases.test.tsx +144 -0
  164. package/src/Listbox/__tests__/Listbox.keyboard-interaction.test.tsx +112 -0
  165. package/src/Listbox/__tests__/Listbox.modifier-keys.test.tsx +86 -0
  166. package/src/Listbox/__tests__/Listbox.multi-select-keyboard.test.tsx +368 -0
  167. package/src/Listbox/__tests__/Listbox.multiple-mode.test.tsx +91 -0
  168. package/src/Listbox/__tests__/Listbox.mutation-hardening.test.tsx +480 -0
  169. package/src/Listbox/__tests__/Listbox.orientation.test.tsx +59 -0
  170. package/src/Listbox/__tests__/Listbox.reading-direction.test.tsx +65 -0
  171. package/src/Listbox/__tests__/Listbox.reorder.test.tsx +78 -0
  172. package/src/Listbox/__tests__/Listbox.selection-follows-focus.test.tsx +64 -0
  173. package/src/Listbox/__tests__/Listbox.strict-context.test.tsx +21 -0
  174. package/src/Listbox/__tests__/Listbox.typeahead.test.tsx +123 -0
  175. package/src/Listbox/__tests__/Listbox.uncontrolled-state.test.tsx +48 -0
  176. package/src/Listbox/constants.ts +2 -0
  177. package/src/Listbox/hooks/index.ts +1 -0
  178. package/src/Listbox/hooks/useListboxRoot.ts +407 -0
  179. package/src/Listbox/index.ts +3 -0
  180. package/src/Listbox/types.ts +238 -0
  181. package/src/MillerColumns/MillerColumns.tsx +186 -45
  182. package/src/MillerColumns/README.md +90 -20
  183. package/src/MillerColumns/__tests__/MillerColumns.aria.test.tsx +45 -0
  184. package/src/MillerColumns/__tests__/MillerColumns.auto-scroll.test.tsx +4 -2
  185. package/src/MillerColumns/__tests__/MillerColumns.basic-rendering.test.tsx +18 -3
  186. package/src/MillerColumns/__tests__/MillerColumns.data-attributes.test.tsx +48 -0
  187. package/src/MillerColumns/__tests__/MillerColumns.keyboard-interaction.test.tsx +35 -2
  188. package/src/MillerColumns/__tests__/MillerColumns.keyboard-selection-state.test.tsx +96 -0
  189. package/src/MillerColumns/__tests__/MillerColumns.preview-panel.test.tsx +29 -4
  190. package/src/MillerColumns/__tests__/MillerColumns.preview-visibility.test.tsx +79 -0
  191. package/src/MillerColumns/__tests__/MillerColumns.resize.test.tsx +189 -0
  192. package/src/MillerColumns/__tests__/MillerColumns.styled-parts.test.tsx +143 -0
  193. package/src/MillerColumns/__tests__/MillerColumns.typeahead.test.tsx +104 -0
  194. package/src/MillerColumns/constants.ts +2 -0
  195. package/src/MillerColumns/hooks/useMillerColumnsColumn.ts +5 -2
  196. package/src/MillerColumns/hooks/useMillerColumnsItem.ts +29 -7
  197. package/src/MillerColumns/hooks/useMillerColumnsResizeHandle.ts +80 -10
  198. package/src/MillerColumns/hooks/useMillerColumnsRoot.ts +0 -0
  199. package/src/MillerColumns/index.ts +4 -0
  200. package/src/MillerColumns/types.ts +136 -30
  201. package/src/MillerColumns/utils.ts +69 -3
  202. package/src/Modal/Modal.tsx +35 -8
  203. package/src/Modal/README.md +24 -15
  204. package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
  205. package/src/Modal/hooks/useModalContent.ts +32 -0
  206. package/src/Modal/types.ts +110 -29
  207. package/src/NavigationMenu/NavigationMenu.tsx +890 -0
  208. package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
  209. package/src/NavigationMenu/README.md +355 -0
  210. package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
  211. package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
  212. package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
  213. package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
  214. package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
  215. package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
  216. package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
  217. package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
  218. package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
  219. package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
  220. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
  221. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
  222. package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
  223. package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
  224. package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
  225. package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
  226. package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
  227. package/src/NavigationMenu/constants.ts +5 -0
  228. package/src/NavigationMenu/hooks/index.ts +7 -0
  229. package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
  230. package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
  231. package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
  232. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
  233. package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
  234. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
  235. package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
  236. package/src/NavigationMenu/index.ts +3 -0
  237. package/src/NavigationMenu/types.ts +259 -0
  238. package/src/NavigationMenu/utils.ts +89 -0
  239. package/src/Popover/Popover.tsx +427 -0
  240. package/src/Popover/PopoverContext.ts +13 -0
  241. package/src/Popover/README.md +106 -0
  242. package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
  243. package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
  244. package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
  245. package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
  246. package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
  247. package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
  248. package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
  249. package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
  250. package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
  251. package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
  252. package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
  253. package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
  254. package/src/Popover/hooks/index.ts +3 -0
  255. package/src/Popover/hooks/usePopoverContent.ts +98 -0
  256. package/src/Popover/hooks/usePopoverRoot.ts +84 -0
  257. package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
  258. package/src/Popover/index.ts +2 -0
  259. package/src/Popover/types.ts +197 -0
  260. package/src/Portal/Portal.tsx +50 -10
  261. package/src/Portal/README.md +2 -2
  262. package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
  263. package/src/Portal/types.ts +5 -0
  264. package/src/Progress/Progress.tsx +33 -1
  265. package/src/Progress/README.md +1 -1
  266. package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
  267. package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
  268. package/src/Progress/types.ts +20 -4
  269. package/src/Radio/Radio.tsx +10 -2
  270. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
  271. package/src/Radio/hooks/useRadioRoot.ts +3 -0
  272. package/src/Radio/types.ts +20 -0
  273. package/src/RadioCard/RadioCard.tsx +11 -0
  274. package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
  275. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
  276. package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
  277. package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
  278. package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
  279. package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
  280. package/src/RadioCard/types.ts +95 -22
  281. package/src/RadioGroup/README.md +2 -2
  282. package/src/RadioGroup/RadioGroup.tsx +12 -1
  283. package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
  284. package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
  285. package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
  286. package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
  287. package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
  288. package/src/RadioGroup/types.ts +73 -9
  289. package/src/SegmentedControl/README.md +154 -0
  290. package/src/SegmentedControl/SegmentedControl.tsx +309 -0
  291. package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
  292. package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
  293. package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
  294. package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
  295. package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
  296. package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
  297. package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
  298. package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
  299. package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
  300. package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
  301. package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
  302. package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
  303. package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
  304. package/src/SegmentedControl/hooks/index.ts +2 -0
  305. package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
  306. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +87 -0
  307. package/src/SegmentedControl/index.ts +2 -0
  308. package/src/SegmentedControl/types.ts +138 -0
  309. package/src/Select/README.md +122 -157
  310. package/src/Select/Select.tsx +596 -69
  311. package/src/Select/SelectContext.ts +38 -0
  312. package/src/Select/SelectItemIndicatorContext.ts +29 -0
  313. package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
  314. package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
  315. package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
  316. package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
  317. package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
  318. package/src/Select/__tests__/Select.form.test.tsx +38 -0
  319. package/src/Select/__tests__/Select.group.test.tsx +6 -6
  320. package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
  321. package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
  322. package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
  323. package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
  324. package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
  325. package/src/Select/__tests__/Select.placeholder.test.tsx +7 -7
  326. package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
  327. package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
  328. package/src/Select/__tests__/Select.selection.test.tsx +104 -0
  329. package/src/Select/__tests__/Select.separator.test.tsx +71 -0
  330. package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
  331. package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
  332. package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
  333. package/src/Select/constants.ts +8 -0
  334. package/src/Select/hooks/index.ts +2 -0
  335. package/src/Select/hooks/useSelectContent.ts +149 -0
  336. package/src/Select/hooks/useSelectRoot.ts +149 -0
  337. package/src/Select/types.ts +199 -31
  338. package/src/SkipNav/README.md +3 -3
  339. package/src/SkipNav/SkipNav.tsx +72 -32
  340. package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
  341. package/src/SkipNav/types.ts +17 -9
  342. package/src/Slider/README.md +37 -7
  343. package/src/Slider/Slider.tsx +42 -4
  344. package/src/Slider/SliderContext.ts +28 -1
  345. package/src/Slider/types.ts +157 -9
  346. package/src/Slot/Slot.tsx +94 -19
  347. package/src/Slot/__tests__/Slot.test.tsx +112 -2
  348. package/src/Slot/composeEventHandlers.ts +26 -2
  349. package/src/Slot/types.ts +16 -0
  350. package/src/SplitButton/README.md +225 -0
  351. package/src/SplitButton/SplitButton.tsx +457 -0
  352. package/src/SplitButton/SplitButtonContext.ts +26 -0
  353. package/src/SplitButton/__tests__/SplitButton.accessibility.test.tsx +55 -0
  354. package/src/SplitButton/__tests__/SplitButton.asChild.test.tsx +51 -0
  355. package/src/SplitButton/__tests__/SplitButton.basic-rendering.test.tsx +57 -0
  356. package/src/SplitButton/__tests__/SplitButton.data-attributes.test.tsx +28 -0
  357. package/src/SplitButton/__tests__/SplitButton.disabled.test.tsx +79 -0
  358. package/src/SplitButton/__tests__/SplitButton.error-handling.test.tsx +29 -0
  359. package/src/SplitButton/__tests__/SplitButton.keyboard-interaction.test.tsx +56 -0
  360. package/src/SplitButton/__tests__/SplitButton.state-modes.test.tsx +68 -0
  361. package/src/SplitButton/index.ts +2 -0
  362. package/src/SplitButton/types.ts +186 -0
  363. package/src/Status/Status.tsx +36 -15
  364. package/src/Status/__tests__/Status.test.tsx +5 -0
  365. package/src/Status/types.ts +12 -1
  366. package/src/Switch/README.md +1 -1
  367. package/src/Switch/Switch.tsx +17 -4
  368. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
  369. package/src/Switch/hooks/useSwitchRoot.ts +3 -0
  370. package/src/Switch/types.ts +49 -22
  371. package/src/Table/README.md +110 -8
  372. package/src/Table/Table.tsx +246 -16
  373. package/src/Table/TableExpandableContext.ts +7 -0
  374. package/src/Table/__tests__/Table.DetailRow.test.tsx +83 -0
  375. package/src/Table/__tests__/Table.ExpandTrigger.test.tsx +102 -0
  376. package/src/Table/__tests__/Table.Expandable.test.tsx +121 -0
  377. package/src/Table/types.ts +191 -17
  378. package/src/Tabs/README.md +4 -4
  379. package/src/Tabs/Tabs.tsx +13 -4
  380. package/src/Tabs/__tests__/Tabs.activation-mode.test.tsx +58 -0
  381. package/src/Tabs/__tests__/Tabs.basic-rendering.test.tsx +15 -0
  382. package/src/Tabs/__tests__/Tabs.change-event-callbacks.test.tsx +24 -0
  383. package/src/Tabs/__tests__/Tabs.disabled-tabs.test.tsx +36 -0
  384. package/src/Tabs/__tests__/Tabs.lazy-mount.test.tsx +17 -0
  385. package/src/Tabs/hooks/useTabsContent.ts +5 -0
  386. package/src/Tabs/hooks/useTabsRoot.ts +29 -5
  387. package/src/Tabs/hooks/useTabsTrigger.ts +7 -2
  388. package/src/Tabs/types.ts +29 -4
  389. package/src/Textarea/Textarea.tsx +9 -1
  390. package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
  391. package/src/Textarea/types.ts +20 -3
  392. package/src/Toggle/Toggle.tsx +51 -13
  393. package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
  394. package/src/Toggle/types.ts +54 -4
  395. package/src/ToggleGroup/README.md +4 -4
  396. package/src/ToggleGroup/ToggleGroup.tsx +14 -1
  397. package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
  398. package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
  399. package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
  400. package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
  401. package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
  402. package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
  403. package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
  404. package/src/ToggleGroup/types.ts +55 -2
  405. package/src/Tooltip/README.md +7 -7
  406. package/src/Tooltip/Tooltip.tsx +100 -22
  407. package/src/Tooltip/__tests__/Tooltip.basic-rendering.test.tsx +50 -0
  408. package/src/Tooltip/__tests__/Tooltip.error-handling.test.tsx +37 -0
  409. package/src/Tooltip/__tests__/Tooltip.escape-hatches.test.tsx +73 -0
  410. package/src/Tooltip/__tests__/Tooltip.keyboard-interaction.test.tsx +134 -0
  411. package/src/Tooltip/__tests__/Tooltip.skip-delay.test.tsx +216 -0
  412. package/src/Tooltip/__tests__/Tooltip.timer-coordination.test.tsx +223 -0
  413. package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
  414. package/src/Tooltip/hooks/useTooltipContent.ts +3 -0
  415. package/src/Tooltip/hooks/useTooltipProvider.ts +46 -8
  416. package/src/Tooltip/hooks/useTooltipRoot.ts +30 -6
  417. package/src/Tooltip/types.ts +89 -8
  418. package/src/Tree/README.md +69 -5
  419. package/src/Tree/Tree.tsx +188 -34
  420. package/src/Tree/__tests__/Tree.styled-wrapper.test.tsx +159 -0
  421. package/src/Tree/hooks/useTreePath.ts +33 -1
  422. package/src/Tree/index.ts +6 -1
  423. package/src/Tree/types.ts +54 -12
  424. package/src/Tree/utils.ts +46 -4
  425. package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
  426. package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
  427. package/src/VisuallyHidden/types.ts +11 -1
  428. package/src/hooks/index.ts +1 -0
  429. package/src/hooks/useTypeahead.ts +95 -0
  430. package/src/index.ts +10 -0
  431. package/src/test/intersectionObserverPolyfill.ts +7 -0
  432. package/src/test/resizeObserverPolyfill.ts +80 -0
  433. package/src/useLocalStorage/__tests__/useLocalStorage.reading.test.tsx +31 -0
  434. package/src/useLocalStorage/__tests__/useLocalStorage.resilience.test.tsx +97 -0
  435. package/src/useLocalStorage/__tests__/useLocalStorage.ssr.test.tsx +20 -0
  436. package/src/useLocalStorage/__tests__/useLocalStorage.writing.test.tsx +72 -0
  437. package/src/useLocalStorage/index.ts +1 -0
  438. package/src/useLocalStorage/useLocalStorage.ts +140 -0
  439. package/src/useMediaQuery/README.md +66 -0
  440. package/src/useMediaQuery/__tests__/useMediaQuery.matching.test.tsx +42 -0
  441. package/src/useMediaQuery/__tests__/useMediaQuery.ssr.test.tsx +19 -0
  442. package/src/useMediaQuery/__tests__/useMediaQuery.subscription.test.tsx +89 -0
  443. package/src/useMediaQuery/index.ts +1 -0
  444. package/src/useMediaQuery/useMediaQuery.ts +49 -0
  445. package/src/usePagination/README.md +170 -0
  446. package/src/usePagination/__tests__/usePagination.navigation.test.tsx +109 -0
  447. package/src/usePagination/__tests__/usePagination.page-count.test.tsx +56 -0
  448. package/src/usePagination/__tests__/usePagination.page-state.test.tsx +87 -0
  449. package/src/usePagination/__tests__/usePagination.range.test.tsx +82 -0
  450. package/src/usePagination/__tests__/usePagination.slice.test.tsx +59 -0
  451. package/src/usePagination/__tests__/usePagination.windowing.test.tsx +68 -0
  452. package/src/usePagination/index.ts +1 -0
  453. package/src/usePagination/usePagination.ts +292 -0
  454. package/src/utils/focusable.ts +15 -0
  455. package/src/utils/getKeyToActionMap.ts +1 -1
  456. package/src/utils/index.ts +1 -0
@@ -0,0 +1,170 @@
1
+ # Combobox
2
+
3
+ A headless, accessible **combobox** — an editable text field with a filtered
4
+ popup listbox, implementing the
5
+ [WAI-ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
6
+
7
+ ```tsx
8
+ import { Combobox } from "@primitiv-ui/react";
9
+
10
+ <Combobox.Root onQueryChange={setQuery} onValueChange={setFramework}>
11
+ <Combobox.Input aria-label="Framework" />
12
+ <Combobox.Content aria-label="Frameworks">
13
+ <Combobox.Item value="react">React</Combobox.Item>
14
+ <Combobox.Item value="preact">Preact</Combobox.Item>
15
+ </Combobox.Content>
16
+ </Combobox.Root>;
17
+ ```
18
+
19
+ ## Sub-components
20
+
21
+ | Export | Element | Notes |
22
+ | ------------------- | --------- | ---------------------------------------------------------------------------------- |
23
+ | `Combobox.Root` | `<div>` | Owns open state, value, query and cursor; context provider; `asChild` |
24
+ | `Combobox.Input` | `<input>` | `role="combobox"`, the full keyboard model, all the ARIA wiring; `asChild` |
25
+ | `Combobox.Content` | `<div>` | `role="listbox"` `popover="auto"` — top layer + light dismiss, **unmounted while closed**; `asChild` |
26
+ | `Combobox.Item` | `<div>` | `role="option"`, `aria-selected`, `data-highlighted`; `asChild` |
27
+ | `Combobox.Empty` | `<div>` | `role="presentation"` no-results message, never navigable; `asChild` |
28
+
29
+ ## Filtering is yours
30
+
31
+ There is deliberately **no `filter` prop**. `onQueryChange` reports every
32
+ keystroke and you render the options you want:
33
+
34
+ ```tsx
35
+ const [query, setQuery] = useState("");
36
+ const matches = FRAMEWORKS.filter((f) =>
37
+ f.label.toLowerCase().includes(query.toLowerCase()),
38
+ );
39
+
40
+ <Combobox.Root onQueryChange={setQuery}>
41
+ <Combobox.Input aria-label="Framework" />
42
+ <Combobox.Content aria-label="Frameworks">
43
+ {matches.map((f) => (
44
+ <Combobox.Item key={f.value} value={f.value}>
45
+ {f.label}
46
+ </Combobox.Item>
47
+ ))}
48
+ {matches.length === 0 && <Combobox.Empty>No matches</Combobox.Empty>}
49
+ </Combobox.Content>
50
+ </Combobox.Root>;
51
+ ```
52
+
53
+ That keeps async loading, fuzzy matching and sorting where the data lives,
54
+ rather than inventing a filtering API the component would have to own.
55
+
56
+ ## Query versus value
57
+
58
+ One field, two readings, disambiguated by whether the popup is open:
59
+
60
+ - **while open** — the input holds whatever the user typed (the query);
61
+ - **on commit, Escape, or a dismiss** — the text is reset **from the value**.
62
+
63
+ So the field never sits showing a half-typed query that is not the value,
64
+ which is the classic combobox bug.
65
+
66
+ **Emptying the field is a deselect.** Clearing the text clears the value too —
67
+ `onValueChange` fires with `""` — so the reset above cannot resurrect a label the
68
+ user just deleted. That keeps the invariant intact (empty text, empty value: the
69
+ field still isn't showing something that is not the value) while letting the
70
+ gesture mean what it looks like. Clearing a field that has no value fires nothing,
71
+ so an empty field never reports a change the consumer didn't cause.
72
+
73
+ The label shown after committing comes from the item's children when they are a
74
+ string. When an item renders elements (an icon plus a span, say), there is no
75
+ reliable way to read a label out of arbitrary JSX, so **the value stands in** —
76
+ pass string children if you want a nicer closed-state label.
77
+
78
+ ## Dismissal
79
+
80
+ Three ways out, and all three land in the same state:
81
+
82
+ | Gesture | How |
83
+ | ----------------- | ------------------------------------------------------------ |
84
+ | <kbd>Escape</kbd> | Handled on the input |
85
+ | Click outside | The browser's own light dismiss |
86
+ | Commit an item | Click or <kbd>Enter</kbd> |
87
+
88
+ `Combobox.Content` renders as a **`popover="auto"`** element and promotes itself
89
+ with `showPopover()`, which is what buys the outside-click behaviour: the browser
90
+ does the hit-testing and reports the result back as a `toggle` event. There is
91
+ deliberately no hand-rolled pointerdown-outside listener. The same promotion puts
92
+ the panel in the **top layer**, so nothing on the page can paint over it and no
93
+ `z-index` is needed anywhere.
94
+
95
+ A light dismiss runs the *same* path as Escape — closing, clearing the cursor and
96
+ resetting the text — rather than only closing, so clicking away can't strand a
97
+ half-typed query on screen.
98
+
99
+ Note this is why the panel can animate **in** but not out: it unmounts on close,
100
+ so there is no element left for an exit rule to match. That's the deliberate
101
+ trade for a closed combobox having no listbox in the accessibility tree at all.
102
+
103
+ ## Keyboard
104
+
105
+ | Key | Behaviour |
106
+ | --------------------- | ---------------------------------------------------------------- |
107
+ | any printable char | Sets the query and **opens** the popup |
108
+ | clearing the field | Clears the query **and the value** (see above) |
109
+ | <kbd>↓</kbd> | Moves the cursor down; seeds it on the **first** item if none |
110
+ | <kbd>↑</kbd> | Moves the cursor up; seeds it on the **last** item if none |
111
+ | <kbd>Home</kbd> / <kbd>End</kbd> | Jumps the cursor to the first / last item |
112
+ | <kbd>Enter</kbd> | Commits the cursor item. Ignored when there is no cursor |
113
+ | <kbd>Escape</kbd> | Closes, clears the cursor, restores the committed label |
114
+
115
+ Arrow travel wraps at both ends, and every cursor move **scrolls the item into
116
+ view** (`scrollIntoView({ block: "nearest" })`, so a move to an already-visible item
117
+ doesn't jerk the list). That matters because the styled panel scrolls — without it
118
+ the cursor walks off the bottom of a long filtered list.
119
+
120
+ ## Virtual focus, not a roving tabstop
121
+
122
+ DOM focus **never leaves the input**. The cursor is published as
123
+ `aria-activedescendant` on the input and `data-highlighted` on the item; no
124
+ option is ever given a `tabIndex` or `focus()`ed. That is what lets the input
125
+ keep focus while driving the list, and it is the same model
126
+ [`Listbox`](../Listbox/README.md) uses — the two were designed to agree.
127
+
128
+ ## Controlled and uncontrolled
129
+
130
+ Both `open` and `value` support either shape:
131
+
132
+ ```tsx
133
+ // uncontrolled
134
+ <Combobox.Root defaultOpen defaultValue="react">
135
+
136
+ // controlled — the combobox asks, the parent decides
137
+ <Combobox.Root open={open} onOpenChange={setOpen} value={value} onValueChange={setValue}>
138
+ ```
139
+
140
+ Under a controlled prop the combobox never moves on its own: it calls the
141
+ change handler and waits.
142
+
143
+ ## Styling hooks
144
+
145
+ | Hook | Where | When |
146
+ | ------------------------ | ----------------- | -------------------------------------- |
147
+ | `aria-expanded` | `Combobox.Input` | Reflects open state |
148
+ | `aria-activedescendant` | `Combobox.Input` | Present only once a cursor exists |
149
+ | `aria-selected` | `Combobox.Item` | The committed value |
150
+ | `data-highlighted` | `Combobox.Item` | The cursor sits on this item |
151
+
152
+ Selection and cursor are **separate** states and are meant to look different —
153
+ one says "this is chosen", the other "Enter picks this".
154
+
155
+ ## Accessibility notes
156
+
157
+ - The input carries `role="combobox"`, `aria-autocomplete="list"` and
158
+ `aria-controls` pointing at the listbox id, which is derived per instance so
159
+ two comboboxes on one page never collide.
160
+ - `Combobox.Content` is unmounted while closed rather than hidden, so a closed
161
+ combobox contributes no list to the accessibility tree.
162
+ - Give the input an accessible name (`aria-label` or a `<label>`) and the
163
+ listbox one too.
164
+
165
+ ## Not in v1
166
+
167
+ Deliberately out of scope, recorded on the Figma "Combobox — exploration" page:
168
+ a built-in `filter` prop, async option loading, multi-select with token chips,
169
+ and virtualization. The **command palette** is a separate composite that
170
+ *wraps* this component in a `Modal` — it is not a Combobox mode.
@@ -0,0 +1,62 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Combobox } from "../Combobox";
6
+
7
+ describe("Combobox asChild", () => {
8
+ it("renders the consumer's element for each part", () => {
9
+ render(
10
+ <Combobox.Root asChild defaultOpen>
11
+ <section data-testid="root">
12
+ <Combobox.Input asChild aria-label="Framework">
13
+ <input data-testid="input" />
14
+ </Combobox.Input>
15
+ <Combobox.Content asChild aria-label="Frameworks">
16
+ <ul data-testid="content">
17
+ <Combobox.Item asChild value="react">
18
+ <li data-testid="item">React</li>
19
+ </Combobox.Item>
20
+ <Combobox.Empty asChild>
21
+ <p data-testid="empty">Nothing</p>
22
+ </Combobox.Empty>
23
+ </ul>
24
+ </Combobox.Content>
25
+ </section>
26
+ </Combobox.Root>,
27
+ );
28
+
29
+ expect(screen.getByTestId("root").tagName).toBe("SECTION");
30
+ expect(screen.getByTestId("content").tagName).toBe("UL");
31
+ expect(screen.getByTestId("item").tagName).toBe("LI");
32
+ expect(screen.getByTestId("empty").tagName).toBe("P");
33
+
34
+ // the behaviour-bearing props still land on the consumer's elements
35
+ expect(screen.getByTestId("input")).toHaveAttribute("role", "combobox");
36
+ expect(screen.getByTestId("content")).toHaveAttribute("role", "listbox");
37
+ expect(screen.getByTestId("item")).toHaveAttribute("role", "option");
38
+ expect(screen.getByTestId("empty")).toHaveAttribute("role", "presentation");
39
+ });
40
+
41
+ it("composes the consumer's handlers rather than replacing them", async () => {
42
+ const user = userEvent.setup();
43
+ const onItemClick = vi.fn();
44
+ const onValueChange = vi.fn();
45
+
46
+ render(
47
+ <Combobox.Root defaultOpen onValueChange={onValueChange}>
48
+ <Combobox.Input aria-label="Framework" />
49
+ <Combobox.Content aria-label="Frameworks">
50
+ <Combobox.Item asChild value="react">
51
+ <li onClick={onItemClick}>React</li>
52
+ </Combobox.Item>
53
+ </Combobox.Content>
54
+ </Combobox.Root>,
55
+ );
56
+
57
+ await user.click(screen.getByRole("option", { hidden: true, name: "React" }));
58
+
59
+ expect(onItemClick).toHaveBeenCalledTimes(1);
60
+ expect(onValueChange).toHaveBeenCalledWith("react");
61
+ });
62
+ });
@@ -0,0 +1,16 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { Combobox } from "../Combobox";
5
+
6
+ describe("Combobox basic rendering", () => {
7
+ it("renders a combobox role on the input", () => {
8
+ render(
9
+ <Combobox.Root>
10
+ <Combobox.Input aria-label="Framework" />
11
+ </Combobox.Root>,
12
+ );
13
+
14
+ expect(screen.getByRole("combobox", { name: "Framework" })).toBeInTheDocument();
15
+ });
16
+ });
@@ -0,0 +1,36 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { Combobox } from "../Combobox";
5
+
6
+ describe("Combobox content", () => {
7
+ it("keeps the popup unmounted while closed and reports aria-expanded=false", () => {
8
+ render(
9
+ <Combobox.Root>
10
+ <Combobox.Input aria-label="Framework" />
11
+ <Combobox.Content aria-label="Frameworks" />
12
+ </Combobox.Root>,
13
+ );
14
+
15
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveAttribute(
16
+ "aria-expanded",
17
+ "false",
18
+ );
19
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
20
+ });
21
+
22
+ it("renders the popup when open and reports aria-expanded=true", () => {
23
+ render(
24
+ <Combobox.Root defaultOpen>
25
+ <Combobox.Input aria-label="Framework" />
26
+ <Combobox.Content aria-label="Frameworks" />
27
+ </Combobox.Root>,
28
+ );
29
+
30
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveAttribute(
31
+ "aria-expanded",
32
+ "true",
33
+ );
34
+ expect(screen.getByRole("listbox", { hidden: true })).toBeInTheDocument();
35
+ });
36
+ });
@@ -0,0 +1,88 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Combobox } from "../Combobox";
6
+
7
+ describe("Combobox controlled state", () => {
8
+ it("does not open on its own when `open` is controlled", async () => {
9
+ const user = userEvent.setup();
10
+ const onOpenChange = vi.fn();
11
+
12
+ render(
13
+ <Combobox.Root open={false} onOpenChange={onOpenChange}>
14
+ <Combobox.Input aria-label="Framework" />
15
+ <Combobox.Content aria-label="Frameworks">
16
+ <Combobox.Item value="react">React</Combobox.Item>
17
+ </Combobox.Content>
18
+ </Combobox.Root>,
19
+ );
20
+
21
+ await user.type(screen.getByRole("combobox", { name: "Framework" }), "R");
22
+
23
+ // the parent owns it: we ask, we do not assume
24
+ expect(onOpenChange).toHaveBeenCalledWith(true);
25
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
26
+ });
27
+
28
+ it("honours a controlled `open` set by the parent", () => {
29
+ render(
30
+ <Combobox.Root open>
31
+ <Combobox.Input aria-label="Framework" />
32
+ <Combobox.Content aria-label="Frameworks">
33
+ <Combobox.Item value="react">React</Combobox.Item>
34
+ </Combobox.Content>
35
+ </Combobox.Root>,
36
+ );
37
+
38
+ expect(screen.getByRole("listbox", { hidden: true })).toBeInTheDocument();
39
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveAttribute(
40
+ "aria-expanded",
41
+ "true",
42
+ );
43
+ });
44
+
45
+ it("marks the controlled value as selected without any interaction", () => {
46
+ render(
47
+ <Combobox.Root open value="preact">
48
+ <Combobox.Input aria-label="Framework" />
49
+ <Combobox.Content aria-label="Frameworks">
50
+ <Combobox.Item value="react">React</Combobox.Item>
51
+ <Combobox.Item value="preact">Preact</Combobox.Item>
52
+ </Combobox.Content>
53
+ </Combobox.Root>,
54
+ );
55
+
56
+ expect(screen.getByRole("option", { hidden: true, name: "Preact" })).toHaveAttribute(
57
+ "aria-selected",
58
+ "true",
59
+ );
60
+ expect(screen.getByRole("option", { hidden: true, name: "React" })).toHaveAttribute(
61
+ "aria-selected",
62
+ "false",
63
+ );
64
+ });
65
+
66
+ it("does not change the selected value on its own when `value` is controlled", async () => {
67
+ const user = userEvent.setup();
68
+ const onValueChange = vi.fn();
69
+
70
+ render(
71
+ <Combobox.Root open value="react" onValueChange={onValueChange}>
72
+ <Combobox.Input aria-label="Framework" />
73
+ <Combobox.Content aria-label="Frameworks">
74
+ <Combobox.Item value="react">React</Combobox.Item>
75
+ <Combobox.Item value="preact">Preact</Combobox.Item>
76
+ </Combobox.Content>
77
+ </Combobox.Root>,
78
+ );
79
+
80
+ await user.click(screen.getByRole("option", { hidden: true, name: "Preact" }));
81
+
82
+ expect(onValueChange).toHaveBeenCalledWith("preact");
83
+ expect(screen.getByRole("option", { hidden: true, name: "React" })).toHaveAttribute(
84
+ "aria-selected",
85
+ "true",
86
+ );
87
+ });
88
+ });
@@ -0,0 +1,42 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Combobox } from "../Combobox";
6
+
7
+ describe("Combobox empty", () => {
8
+ it("renders a no-results message that is not itself an option", () => {
9
+ render(
10
+ <Combobox.Root defaultOpen>
11
+ <Combobox.Input aria-label="Framework" />
12
+ <Combobox.Content aria-label="Frameworks">
13
+ <Combobox.Empty>No frameworks match “xyz”</Combobox.Empty>
14
+ </Combobox.Content>
15
+ </Combobox.Root>,
16
+ );
17
+
18
+ expect(screen.getByText("No frameworks match “xyz”")).toBeInTheDocument();
19
+ expect(screen.queryAllByRole("option", { hidden: true })).toHaveLength(0);
20
+ });
21
+
22
+ it("is not navigable — the cursor has nothing to land on", async () => {
23
+ const user = userEvent.setup();
24
+
25
+ render(
26
+ <Combobox.Root defaultOpen>
27
+ <Combobox.Input aria-label="Framework" />
28
+ <Combobox.Content aria-label="Frameworks">
29
+ <Combobox.Empty>Nothing here</Combobox.Empty>
30
+ </Combobox.Content>
31
+ </Combobox.Root>,
32
+ );
33
+
34
+ const input = screen.getByRole("combobox", { name: "Framework" });
35
+ await user.click(input);
36
+ await user.keyboard("{ArrowDown}");
37
+
38
+ expect(input).not.toHaveAttribute("aria-activedescendant");
39
+ // and the panel stays open so the user can keep typing
40
+ expect(screen.getByRole("listbox", { hidden: true })).toBeInTheDocument();
41
+ });
42
+ });
@@ -0,0 +1,66 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { Combobox } from "../Combobox";
5
+
6
+ describe("Combobox ids and ARIA wiring", () => {
7
+ it("points the input's aria-controls at the popup listbox", () => {
8
+ render(
9
+ <Combobox.Root defaultOpen>
10
+ <Combobox.Input aria-label="Framework" />
11
+ <Combobox.Content aria-label="Frameworks" />
12
+ </Combobox.Root>,
13
+ );
14
+
15
+ const input = screen.getByRole("combobox", { name: "Framework" });
16
+ const listbox = screen.getByRole("listbox", { hidden: true });
17
+
18
+ expect(listbox.id).not.toBe("");
19
+ expect(input).toHaveAttribute("aria-controls", listbox.id);
20
+ });
21
+
22
+ it("declares list autocomplete on the input", () => {
23
+ render(
24
+ <Combobox.Root>
25
+ <Combobox.Input aria-label="Framework" />
26
+ <Combobox.Content aria-label="Frameworks" />
27
+ </Combobox.Root>,
28
+ );
29
+
30
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveAttribute(
31
+ "aria-autocomplete",
32
+ "list",
33
+ );
34
+ });
35
+
36
+ it("scopes ids per instance so two comboboxes never collide", () => {
37
+ render(
38
+ <>
39
+ <Combobox.Root defaultOpen>
40
+ <Combobox.Input aria-label="First" />
41
+ <Combobox.Content aria-label="First list" />
42
+ </Combobox.Root>
43
+ <Combobox.Root defaultOpen>
44
+ <Combobox.Input aria-label="Second" />
45
+ <Combobox.Content aria-label="Second list" />
46
+ </Combobox.Root>
47
+ </>,
48
+ );
49
+
50
+ // Indexed rather than named: the popup is a [popover], which jsdom leaves
51
+ // `display: none` (it does not wire up the `:popover-open` selector), and an
52
+ // `aria-label` on a hidden element yields no accessible name — so `{ name }`
53
+ // cannot tell these two apart. DOM order can.
54
+ const [first, second] = screen.getAllByRole("listbox", { hidden: true });
55
+
56
+ expect(first.id).not.toBe(second.id);
57
+ expect(screen.getByRole("combobox", { name: "First" })).toHaveAttribute(
58
+ "aria-controls",
59
+ first.id,
60
+ );
61
+ expect(screen.getByRole("combobox", { name: "Second" })).toHaveAttribute(
62
+ "aria-controls",
63
+ second.id,
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,76 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Combobox } from "../Combobox";
6
+
7
+ function renderCombobox(props: Record<string, unknown> = {}) {
8
+ return render(
9
+ <Combobox.Root defaultOpen {...props}>
10
+ <Combobox.Input aria-label="Framework" />
11
+ <Combobox.Content aria-label="Frameworks">
12
+ <Combobox.Item value="react">React</Combobox.Item>
13
+ <Combobox.Item value="preact">Preact</Combobox.Item>
14
+ </Combobox.Content>
15
+ </Combobox.Root>,
16
+ );
17
+ }
18
+
19
+ describe("Combobox item", () => {
20
+ it("renders each item as an option", () => {
21
+ renderCombobox();
22
+
23
+ expect(screen.getByRole("option", { hidden: true, name: "React" })).toBeInTheDocument();
24
+ expect(screen.getByRole("option", { hidden: true, name: "Preact" })).toBeInTheDocument();
25
+ });
26
+
27
+ it("selects on click: reports the value, closes, and shows the label in the input", async () => {
28
+ const user = userEvent.setup();
29
+ const onValueChange = vi.fn();
30
+ renderCombobox({ onValueChange });
31
+
32
+ await user.click(screen.getByRole("option", { hidden: true, name: "Preact" }));
33
+
34
+ expect(onValueChange).toHaveBeenCalledWith("preact");
35
+ expect(screen.queryByRole("listbox", { hidden: true })).not.toBeInTheDocument();
36
+ // D1: closing resets the input text from the value, not from the query
37
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveValue("Preact");
38
+ });
39
+
40
+ it("falls back to the value as the label when the item has element children", async () => {
41
+ const user = userEvent.setup();
42
+
43
+ render(
44
+ <Combobox.Root defaultOpen>
45
+ <Combobox.Input aria-label="Framework" />
46
+ <Combobox.Content aria-label="Frameworks">
47
+ <Combobox.Item value="react">
48
+ <svg aria-hidden="true" />
49
+ <span>React</span>
50
+ </Combobox.Item>
51
+ </Combobox.Content>
52
+ </Combobox.Root>,
53
+ );
54
+
55
+ await user.click(screen.getByRole("option", { hidden: true, name: "React" }));
56
+
57
+ expect(screen.getByRole("combobox", { name: "Framework" })).toHaveValue("react");
58
+ });
59
+
60
+ it("marks only the selected item aria-selected", async () => {
61
+ const user = userEvent.setup();
62
+ renderCombobox();
63
+
64
+ await user.click(screen.getByRole("option", { hidden: true, name: "Preact" }));
65
+ await user.type(screen.getByRole("combobox", { name: "Framework" }), "a");
66
+
67
+ expect(screen.getByRole("option", { hidden: true, name: "Preact" })).toHaveAttribute(
68
+ "aria-selected",
69
+ "true",
70
+ );
71
+ expect(screen.getByRole("option", { hidden: true, name: "React" })).toHaveAttribute(
72
+ "aria-selected",
73
+ "false",
74
+ );
75
+ });
76
+ });