@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
package/src/Tree/utils.ts CHANGED
@@ -3,14 +3,56 @@ import type { ReactElement, ReactNode } from "react";
3
3
 
4
4
  import { TreeBranchControl, TreeBranchContent } from "./Tree";
5
5
 
6
- /** Whether `node` is a `Tree.BranchControl` element. */
6
+ /**
7
+ * The property a component sets on itself to declare which `Tree` part it
8
+ * stands in for.
9
+ *
10
+ * `Tree.Branch` finds its control and content by inspecting its children before
11
+ * rendering them, so it sees the component the consumer wrote — not what that
12
+ * component renders. A styled layer wraps every part in its own component to
13
+ * attach classes (that is exactly what `primitiv add tree` copies), and without
14
+ * a marker those wrappers are unrecognisable, making `Tree` the one primitive
15
+ * in the library that cannot be wrapped at all.
16
+ *
17
+ * Set it on the wrapper and it partitions like the part it renders:
18
+ *
19
+ * ```tsx
20
+ * function StyledBranchControl(props: TreeBranchControlProps) {
21
+ * return <Tree.BranchControl {...props} className="row" />;
22
+ * }
23
+ * StyledBranchControl[TREE_PART] = "branch-control";
24
+ * ```
25
+ */
26
+ export const TREE_PART: "__primitivTreePart" = "__primitivTreePart";
27
+
28
+ /** The part names {@link TREE_PART} accepts. */
29
+ export type TreePartName = "branch-control" | "branch-content";
30
+
31
+ /** Read a component's {@link TREE_PART} marker, if it declares one. */
32
+ function treePartOf(type: unknown): TreePartName | undefined {
33
+ return (type as Record<string, TreePartName | undefined> | null)?.[TREE_PART];
34
+ }
35
+
36
+ /**
37
+ * Whether `node` is a `Tree.BranchControl` element — either the part itself, or
38
+ * a component that marks itself as standing in for it (see {@link TREE_PART}).
39
+ */
7
40
  export function isBranchControlElement(node: ReactNode): node is ReactElement {
8
- return isValidElement(node) && node.type === TreeBranchControl;
41
+ return (
42
+ isValidElement(node) &&
43
+ (node.type === TreeBranchControl || treePartOf(node.type) === "branch-control")
44
+ );
9
45
  }
10
46
 
11
- /** Whether `node` is a `Tree.BranchContent` element. */
47
+ /**
48
+ * Whether `node` is a `Tree.BranchContent` element — either the part itself, or
49
+ * a component that marks itself as standing in for it (see {@link TREE_PART}).
50
+ */
12
51
  export function isBranchContentElement(node: ReactNode): node is ReactElement {
13
- return isValidElement(node) && node.type === TreeBranchContent;
52
+ return (
53
+ isValidElement(node) &&
54
+ (node.type === TreeBranchContent || treePartOf(node.type) === "branch-content")
55
+ );
14
56
  }
15
57
 
16
58
  /**
@@ -19,18 +19,35 @@ const visuallyHiddenStyle: CSSProperties = {
19
19
  /**
20
20
  * Visually hides its children while keeping them in the accessibility tree.
21
21
  *
22
- * Renders a `<span>` carrying the canonical screen-reader-only clip styles:
23
- * the content is removed from the visual layout but still announced by
24
- * assistive technology. Use it for text that gives a control or region an
25
- * accessible name without showing on screen.
22
+ * Renders a `<span>` carrying the canonical screen-reader-only clip styles
23
+ * (WCAG 2.1 Technique C7): the content is removed from the visual layout but
24
+ * still announced by assistive technology. Use it for text that gives a control
25
+ * or region an accessible name without showing on screen.
26
26
  *
27
27
  * **Functional styles.** Unlike other `@primitiv-ui/react` components, the
28
- * clip styles are applied inline because they *are* the component's
29
- * behaviour, not decoration. A consumer `style` is merged on top, so any
30
- * individual property can still be overridden.
28
+ * clip styles are applied inline because they *are* the component's behaviour,
29
+ * not decoration:
30
+ *
31
+ * ```css
32
+ * position: absolute;
33
+ * width: 1px; height: 1px;
34
+ * padding: 0; margin: -1px;
35
+ * overflow: hidden;
36
+ * clip: rect(0 0 0 0);
37
+ * clip-path: inset(50%);
38
+ * white-space: nowrap;
39
+ * border-width: 0;
40
+ * ```
41
+ *
42
+ * A consumer `style` prop is merged on top, so any individual property can
43
+ * still be overridden.
31
44
  *
32
45
  * **`asChild` composition.** Renders the consumer's element instead of a
33
- * `<span>`, merging the clip styles in via the {@link Slot} utility.
46
+ * `<span>`, merging the clip styles onto the child via the {@link Slot}
47
+ * utility. Useful when the hidden content needs specific semantics (e.g. a
48
+ * heading or landmark). The child must be a single React element.
49
+ *
50
+ * @extends HTMLSpanElement
34
51
  *
35
52
  * @example Accessible name for an icon-only button
36
53
  * ```tsx
@@ -40,6 +57,14 @@ const visuallyHiddenStyle: CSSProperties = {
40
57
  * </button>
41
58
  * ```
42
59
  *
60
+ * @example Region label hidden from sighted users
61
+ * ```tsx
62
+ * <nav aria-labelledby="nav-label">
63
+ * <VisuallyHidden id="nav-label">Primary navigation</VisuallyHidden>
64
+ * ...
65
+ * </nav>
66
+ * ```
67
+ *
43
68
  * @example asChild — keep semantic markup hidden
44
69
  * ```tsx
45
70
  * <VisuallyHidden asChild>
@@ -15,15 +15,24 @@ describe("VisuallyHidden component", () => {
15
15
  // Arrange
16
16
  render(<VisuallyHidden>Loading complete</VisuallyHidden>);
17
17
 
18
- // Assert
18
+ // Assert — the full WCAG C7 clip set; every property matters, so each is
19
+ // asserted (a missing one lets a mutant blank that value undetected).
19
20
  const hidden = screen.getByText("Loading complete");
20
21
  expect(hidden).toHaveStyle({
21
22
  position: "absolute",
22
23
  width: "1px",
23
24
  height: "1px",
25
+ padding: "0px",
26
+ margin: "-1px",
24
27
  overflow: "hidden",
25
28
  whiteSpace: "nowrap",
29
+ borderWidth: "0px",
26
30
  });
31
+ // jsdom's computed style doesn't surface clip/clip-path, so assert them on
32
+ // the inline style directly.
33
+ // jsdom normalises `rect(0 0 0 0)` to `rect(0px)`.
34
+ expect(hidden.style.clip).toBe("rect(0px)");
35
+ expect(hidden.style.clipPath).toBe("inset(50%)");
27
36
  });
28
37
 
29
38
  it("should merge a consumer style over the clip styles", () => {
@@ -56,4 +65,9 @@ describe("VisuallyHidden component", () => {
56
65
  expect(hidden.tagName).toBe("LABEL");
57
66
  expect(hidden).toHaveStyle({ position: "absolute", overflow: "hidden" });
58
67
  });
68
+
69
+ it('sets displayName to "VisuallyHidden" for React DevTools and stack traces', () => {
70
+ // Assert — an empty displayName would render it as an anonymous component.
71
+ expect(VisuallyHidden.displayName).toBe("VisuallyHidden");
72
+ });
59
73
  });
@@ -1,9 +1,19 @@
1
- import { ComponentProps } from "react";
1
+ import { ComponentProps, ReactNode } from "react";
2
2
 
3
3
  /**
4
4
  * Props for {@link VisuallyHidden} — all native `<span>` attributes plus
5
5
  * the `asChild` escape hatch.
6
6
  */
7
7
  export type VisuallyHiddenProps = ComponentProps<"span"> & {
8
+ /**
9
+ * Renders the child element instead of a native `<span>`, merging the
10
+ * clip styles onto it via {@link Slot}. Useful when the hidden content
11
+ * needs specific semantics (e.g. a heading or landmark element). The
12
+ * child must be a single React element. See the `asChild` example on
13
+ * {@link VisuallyHidden}.
14
+ * @default false
15
+ */
8
16
  asChild?: boolean;
17
+ /** Content to visually hide. Under `asChild`, becomes the single child element whose clip styles are merged. */
18
+ children?: ReactNode;
9
19
  };
@@ -1,3 +1,4 @@
1
1
  export { useControllableState } from "./useControllableState";
2
2
  export { useCollection } from "./useCollection";
3
3
  export { useRovingTabindex } from "./useRovingTabindex";
4
+ export { useTypeahead } from "./useTypeahead";
@@ -0,0 +1,95 @@
1
+ import { useCallback, useRef, type KeyboardEvent } from "react";
2
+
3
+ type UseTypeaheadArgs = {
4
+ /** Reads the navigable (enabled) option values in current DOM order. Called
5
+ * per keystroke so an in-place reorder is picked up. */
6
+ getNavigable: () => string[];
7
+ /** Reads an option's visible label, used for prefix matching. */
8
+ getLabel: (optionValue: string) => string;
9
+ /** The option the cursor is on, or `undefined` before it is seeded. */
10
+ currentKey: string | undefined;
11
+ /** Moves the cursor to the matched option. */
12
+ onMatch: (optionValue: string) => void;
13
+ /** Milliseconds of inactivity after which the accumulated query resets. */
14
+ resetMs: number;
15
+ };
16
+
17
+ /**
18
+ * Printable-character typeahead for a list cursor, per APG's
19
+ * recommendation for lists longer than about seven options. Shared by
20
+ * `Listbox` and `MillerColumns` (which scopes it to the focused column).
21
+ *
22
+ * Keystrokes accumulate into a query that resets after `resetMs` of
23
+ * inactivity. A query of all-identical
24
+ * characters is treated as a repeat of that single character, which cycles
25
+ * through the options starting with it rather than searching for a literal
26
+ * run — so pressing "a" three times walks the "a" options instead of looking
27
+ * for "aaa". Matching is a case-insensitive prefix test against the option's
28
+ * text, and the search wraps.
29
+ */
30
+ export function useTypeahead({
31
+ getNavigable,
32
+ getLabel,
33
+ currentKey,
34
+ onMatch,
35
+ resetMs,
36
+ }: UseTypeaheadArgs): {
37
+ handleTypeahead: (event: KeyboardEvent<HTMLElement>) => void;
38
+ } {
39
+ const stateRef = useRef<{ query: string; timer: number | null }>({
40
+ query: "",
41
+ timer: null,
42
+ });
43
+
44
+ const handleTypeahead = useCallback(
45
+ (event: KeyboardEvent<HTMLElement>) => {
46
+ if (event.key.length !== 1) return;
47
+
48
+ const navigable = getNavigable();
49
+ // Stryker disable next-line ConditionalExpression: equivalent — the match loop is bounded by navigable.length, so an empty list exits immediately anyway.
50
+ if (navigable.length === 0) return;
51
+
52
+ const state = stateRef.current;
53
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — keystroke handling is synchronous, so clearing (or not clearing) the pending reset cannot change this search's outcome.
54
+ if (state.timer !== null) window.clearTimeout(state.timer);
55
+ state.query = (state.query + event.key).toLowerCase();
56
+ state.timer = window.setTimeout(() => {
57
+ state.query = "";
58
+ state.timer = null;
59
+ }, resetMs);
60
+
61
+ const isRepeat =
62
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — for a single character `every()` is trivially true, so both forms yield the same isRepeat.
63
+ state.query.length > 1 &&
64
+ state.query.split("").every((char) => char === state.query[0]);
65
+ const searchQuery = isRepeat ? state.query[0] : state.query;
66
+
67
+ // A single character (or a repeat of one) advances past the current
68
+ // option so pressing the same letter walks the matches; a genuine
69
+ // multi-character query narrows in place instead. With no cursor at all
70
+ // there is nothing to advance past, so the search starts at the top.
71
+ const currentIndex =
72
+ // Stryker disable next-line ConditionalExpression: equivalent — indexOf(undefined) is itself -1.
73
+ currentKey === undefined ? -1 : navigable.indexOf(currentKey);
74
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent — with no cursor the two spellings produce the same scan: startIndex -1 with offset 1 and startIndex 0 with offset 0 both begin at index 0 and step identically.
75
+ const hasCursor = currentIndex >= 0;
76
+ const startIndex = hasCursor ? currentIndex : 0;
77
+ const offset =
78
+ hasCursor && (searchQuery.length === 1 || isRepeat) ? 1 : 0;
79
+
80
+ // Stryker disable next-line EqualityOperator: equivalent — the index wraps modulo navigable.length, so an extra iteration only re-checks an already-visited option.
81
+ for (let i = 0; i < navigable.length; i++) {
82
+ const index = (startIndex + offset + i) % navigable.length;
83
+ const label = getLabel(navigable[index]).trim().toLowerCase();
84
+ if (label.startsWith(searchQuery)) {
85
+ event.preventDefault();
86
+ onMatch(navigable[index]);
87
+ return;
88
+ }
89
+ }
90
+ },
91
+ [getNavigable, getLabel, currentKey, onMatch, resetMs],
92
+ );
93
+
94
+ return { handleTypeahead };
95
+ }
package/src/index.ts CHANGED
@@ -8,27 +8,34 @@ export * from "./Carousel/index.ts";
8
8
  export * from "./Checkbox/index.ts";
9
9
  export * from "./CheckboxCard/index.ts";
10
10
  export * from "./Collapsible/index.ts";
11
+ export * from "./Combobox/index.ts";
11
12
  export * from "./ContextMenu/index.ts";
12
13
  export * from "./DirectionProvider/index.ts";
13
14
  export * from "./Divider/index.ts";
15
+ export * from "./Drawer/index.ts";
14
16
  export * from "./Dropdown/index.ts";
15
17
  export * from "./EmptyState/index.ts";
16
18
  export * from "./Field/index.ts";
17
19
  export * from "./Fieldset/index.ts";
18
20
  export * from "./Input/index.ts";
19
21
  export * from "./InputGroup/index.ts";
22
+ export * from "./Listbox/index.ts";
20
23
  export * from "./MillerColumns/index.ts";
21
24
  export * from "./Modal/index.ts";
25
+ export * from "./NavigationMenu/index.ts";
26
+ export * from "./Popover/index.ts";
22
27
  export * from "./Portal/index.ts";
23
28
  export * from "./Progress/index.ts";
24
29
  export * from "./Radio/index.ts";
25
30
  export * from "./RadioCard/index.ts";
26
31
  export * from "./RadioGroup/index.ts";
32
+ export * from "./SegmentedControl/index.ts";
27
33
  export * from "./Select/index.ts";
28
34
  export * from "./SkipNav/index.ts";
29
35
  export * from "./Slider/index.ts";
30
36
  export { Slot } from "./Slot/index.ts";
31
37
  export type { SlotProps } from "./Slot/index.ts";
38
+ export * from "./SplitButton/index.ts";
32
39
  export * from "./Status/index.ts";
33
40
  export * from "./Switch/index.ts";
34
41
  export * from "./Table/index.ts";
@@ -38,5 +45,8 @@ export * from "./Toggle/index.ts";
38
45
  export * from "./ToggleGroup/index.ts";
39
46
  export * from "./Tooltip/index.ts";
40
47
  export * from "./Tree/index.ts";
48
+ export * from "./useLocalStorage/index.ts";
49
+ export * from "./useMediaQuery/index.ts";
50
+ export * from "./usePagination/index.ts";
41
51
  export * from "./VisuallyHidden/index.ts";
42
52
  export type { HeadingLevel } from "./types.ts";
@@ -41,6 +41,13 @@ export class MockIntersectionObserver {
41
41
  }
42
42
 
43
43
  observe(target: Element) {
44
+ // Mirror the browser: `observe` throws a TypeError when handed anything
45
+ // that is not an Element (e.g. `undefined` from a stale slide lookup).
46
+ if (!(target instanceof Element)) {
47
+ throw new TypeError(
48
+ "Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'Element'.",
49
+ );
50
+ }
44
51
  this.observed.add(target);
45
52
  }
46
53
  unobserve(target: Element) {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal jsdom polyfill for ResizeObserver.
3
+ *
4
+ * jsdom does not implement ResizeObserver. The component relies on it
5
+ * to keep a multi-slide page's `scroll-margin` extension (the
6
+ * center/end alignment fix for slidesPerPage > 1) correct across
7
+ * viewport resizes, since equal-share slide widths derive from the
8
+ * viewport's own size. Tests can grab the most-recently-constructed
9
+ * instance via `MockResizeObserver.latest` and call `.fire()` to
10
+ * simulate a resize on observed elements. When a component attaches
11
+ * more than one observer to the same element (e.g. the scroll-margin
12
+ * fix and the scroll-progress signal both observe the Viewport),
13
+ * `.latest` only reaches whichever was constructed last — use
14
+ * `MockResizeObserver.fireAll()` instead to simulate "a resize
15
+ * happened" without depending on construction order.
16
+ */
17
+
18
+ export class MockResizeObserver {
19
+ static instances: MockResizeObserver[] = [];
20
+ static get latest(): MockResizeObserver | undefined {
21
+ return MockResizeObserver.instances[MockResizeObserver.instances.length - 1];
22
+ }
23
+ static reset() {
24
+ MockResizeObserver.instances = [];
25
+ }
26
+ /** Test-only: fire every constructed observer, regardless of which
27
+ * element(s) it's watching or when it was created. */
28
+ static fireAll() {
29
+ MockResizeObserver.instances.forEach((observer) => observer.fire());
30
+ }
31
+
32
+ callback: ResizeObserverCallback;
33
+ observed = new Set<Element>();
34
+
35
+ constructor(cb: ResizeObserverCallback) {
36
+ this.callback = cb;
37
+ MockResizeObserver.instances.push(this);
38
+ }
39
+
40
+ observe(target: Element) {
41
+ // Mirror the browser: `observe` throws a TypeError when handed anything
42
+ // that is not an Element.
43
+ if (!(target instanceof Element)) {
44
+ throw new TypeError(
45
+ "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'Element'.",
46
+ );
47
+ }
48
+ this.observed.add(target);
49
+ }
50
+ unobserve(target: Element) {
51
+ this.observed.delete(target);
52
+ }
53
+ disconnect() {
54
+ this.observed.clear();
55
+ }
56
+
57
+ /** Test-only: invoke the callback for every currently-observed target. */
58
+ fire() {
59
+ const entries = [...this.observed].map(
60
+ (target) =>
61
+ ({
62
+ target,
63
+ contentRect: target.getBoundingClientRect(),
64
+ borderBoxSize: [],
65
+ contentBoxSize: [],
66
+ devicePixelContentBoxSize: [],
67
+ }) as ResizeObserverEntry,
68
+ );
69
+ if (entries.length > 0) {
70
+ this.callback(entries, this as unknown as ResizeObserver);
71
+ }
72
+ }
73
+ }
74
+
75
+ export function installResizeObserverPolyfill() {
76
+ if (typeof window === "undefined") return;
77
+ (
78
+ window as unknown as { ResizeObserver: typeof MockResizeObserver }
79
+ ).ResizeObserver = MockResizeObserver;
80
+ }
@@ -0,0 +1,31 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+
4
+ import { useLocalStorage } from "../useLocalStorage.ts";
5
+
6
+ const KEY = "primitiv-test-key";
7
+
8
+ function Probe({ fallback }: { fallback: string }) {
9
+ const [value] = useLocalStorage(KEY, fallback);
10
+ return <span data-testid="value">{value}</span>;
11
+ }
12
+
13
+ describe("useLocalStorage reading", () => {
14
+ afterEach(() => {
15
+ window.localStorage.clear();
16
+ });
17
+
18
+ it("should return the fallback when nothing is stored under the key", () => {
19
+ render(<Probe fallback="light" />);
20
+
21
+ expect(screen.getByTestId("value")).toHaveTextContent("light");
22
+ });
23
+
24
+ it("should return the stored value when one is present", () => {
25
+ window.localStorage.setItem(KEY, JSON.stringify("dark"));
26
+
27
+ render(<Probe fallback="light" />);
28
+
29
+ expect(screen.getByTestId("value")).toHaveTextContent("dark");
30
+ });
31
+ });
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { act, render, screen } from "@testing-library/react";
3
+ import { userEvent } from "@testing-library/user-event";
4
+
5
+ import { useLocalStorage } from "../useLocalStorage.ts";
6
+
7
+ const KEY = "primitiv-test-key";
8
+
9
+ function Probe() {
10
+ const [value, setValue] = useLocalStorage(KEY, "light");
11
+ return (
12
+ <>
13
+ <span data-testid="value">{value}</span>
14
+ <button type="button" onClick={() => setValue("dark")}>
15
+ set
16
+ </button>
17
+ </>
18
+ );
19
+ }
20
+
21
+ /*
22
+ * Storage methods are stubbed on `Storage.prototype`, NOT on
23
+ * `window.localStorage`. jsdom's localStorage is a Proxy, so an instance spy
24
+ * silently never intercepts — `vi.spyOn(window.localStorage, "setItem")`
25
+ * records zero calls and the real write goes through. That is exactly the shape
26
+ * of bug that makes a test pass for the wrong reason, so each test below stores
27
+ * a real value first, ensuring the fallback can ONLY come from the throw path.
28
+ */
29
+ describe("useLocalStorage resilience", () => {
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ window.localStorage.clear();
33
+ });
34
+
35
+ it("should fall back when the stored value is not valid JSON", () => {
36
+ // Storage is shared, long-lived and hand-editable, so a bad value has to
37
+ // degrade rather than throw.
38
+ window.localStorage.setItem(KEY, "{not json");
39
+
40
+ render(<Probe />);
41
+
42
+ expect(screen.getByTestId("value")).toHaveTextContent("light");
43
+ });
44
+
45
+ it("should fall back when reading storage throws", () => {
46
+ // A readable "dark" is present, so "light" can only mean the read threw.
47
+ window.localStorage.setItem(KEY, JSON.stringify("dark"));
48
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
49
+ throw new Error("SecurityError");
50
+ });
51
+
52
+ render(<Probe />);
53
+
54
+ expect(screen.getByTestId("value")).toHaveTextContent("light");
55
+ });
56
+
57
+ it("should leave the value untouched when writing to storage throws", async () => {
58
+ const user = userEvent.setup();
59
+ render(<Probe />);
60
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
61
+ throw new Error("QuotaExceededError");
62
+ });
63
+
64
+ await user.click(screen.getByRole("button", { name: "set" }));
65
+
66
+ // The write failed, so storage still holds nothing and the re-read returns
67
+ // the fallback — the hook does not optimistically keep an unsaved value.
68
+ expect(screen.getByTestId("value")).toHaveTextContent("light");
69
+ });
70
+
71
+ it("should pick up a change made in another tab", () => {
72
+ render(<Probe />);
73
+
74
+ // What the browser dispatches when a different document writes the key.
75
+ act(() => {
76
+ window.localStorage.setItem(KEY, JSON.stringify("dark"));
77
+ window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
78
+ });
79
+
80
+ expect(screen.getByTestId("value")).toHaveTextContent("dark");
81
+ });
82
+
83
+ it("should stop listening once the last consumer unmounts", () => {
84
+ const add = vi.spyOn(window, "addEventListener");
85
+ const remove = vi.spyOn(window, "removeEventListener");
86
+
87
+ const { unmount } = render(<Probe />);
88
+ unmount();
89
+
90
+ const added = add.mock.calls.filter(([type]) => type === "storage").length;
91
+ const removed = remove.mock.calls.filter(
92
+ ([type]) => type === "storage",
93
+ ).length;
94
+ expect(added).toBeGreaterThan(0);
95
+ expect(removed).toBe(added);
96
+ });
97
+ });
@@ -0,0 +1,20 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from "vitest";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+
5
+ import { useLocalStorage } from "../useLocalStorage.ts";
6
+
7
+ function Probe() {
8
+ const [value] = useLocalStorage("primitiv-theme", "light");
9
+ return <>{value}</>;
10
+ }
11
+
12
+ describe("useLocalStorage SSR", () => {
13
+ it("should render the fallback when there is no window to read storage from", () => {
14
+ expect(typeof window).toBe("undefined");
15
+
16
+ const markup = renderToStaticMarkup(<Probe />);
17
+
18
+ expect(markup).toBe("light");
19
+ });
20
+ });
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { userEvent } from "@testing-library/user-event";
4
+
5
+ import { useLocalStorage } from "../useLocalStorage.ts";
6
+
7
+ const KEY = "primitiv-test-key";
8
+
9
+ function Probe({ next }: { next: string }) {
10
+ const [value, setValue] = useLocalStorage(KEY, "light");
11
+ return (
12
+ <>
13
+ <span data-testid="value">{value}</span>
14
+ <button type="button" onClick={() => setValue(next)}>
15
+ set
16
+ </button>
17
+ </>
18
+ );
19
+ }
20
+
21
+ /** Two independent consumers of the SAME key, to prove they stay in step. */
22
+ function TwoProbes() {
23
+ return (
24
+ <>
25
+ <Probe next="dark" />
26
+ <SecondReader />
27
+ </>
28
+ );
29
+ }
30
+
31
+ function SecondReader() {
32
+ const [value] = useLocalStorage(KEY, "light");
33
+ return <span data-testid="second">{value}</span>;
34
+ }
35
+
36
+ describe("useLocalStorage writing", () => {
37
+ afterEach(() => {
38
+ window.localStorage.clear();
39
+ });
40
+
41
+ it("should update the returned value when the setter is called", async () => {
42
+ const user = userEvent.setup();
43
+ render(<Probe next="dark" />);
44
+
45
+ await user.click(screen.getByRole("button", { name: "set" }));
46
+
47
+ expect(screen.getByTestId("value")).toHaveTextContent("dark");
48
+ });
49
+
50
+ it("should persist the new value to localStorage as JSON", async () => {
51
+ const user = userEvent.setup();
52
+ render(<Probe next="dark" />);
53
+
54
+ await user.click(screen.getByRole("button", { name: "set" }));
55
+
56
+ expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify("dark"));
57
+ });
58
+
59
+ /*
60
+ * The `storage` event does NOT fire in the document that performed the write,
61
+ * so without an in-document notification a second consumer of the same key
62
+ * would keep rendering the stale value until something else re-rendered it.
63
+ */
64
+ it("should update every consumer of the same key in the same document", async () => {
65
+ const user = userEvent.setup();
66
+ render(<TwoProbes />);
67
+
68
+ await user.click(screen.getByRole("button", { name: "set" }));
69
+
70
+ expect(screen.getByTestId("second")).toHaveTextContent("dark");
71
+ });
72
+ });
@@ -0,0 +1 @@
1
+ export { useLocalStorage } from "./useLocalStorage.ts";