@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,309 @@
1
+ import { useEffect, useMemo, useRef } from "react";
2
+ import type { ReactElement } from "react";
3
+
4
+ import { useDirection } from "../DirectionProvider/index.ts";
5
+ import { useRovingTabindex } from "../hooks/index.ts";
6
+ import { Slot, composeEventHandlers, composeRefs } from "../Slot/index.ts";
7
+
8
+ import { SegmentedControlContext } from "./SegmentedControlContext";
9
+ import {
10
+ useSegmentedControlContext,
11
+ useSegmentedControlRoot,
12
+ } from "./hooks/index.ts";
13
+ import {
14
+ SegmentedControlItemProps,
15
+ SegmentedControlRootProps,
16
+ } from "./types";
17
+
18
+ /**
19
+ * The root of a SegmentedControl — a `<div role="radiogroup">` that owns
20
+ * the selected value and provides {@link SegmentedControlContext} to
21
+ * descendant {@link SegmentedControlItem | `SegmentedControl.Item`}s. It is
22
+ * the single-select value picker of the design system (the same WAI-ARIA
23
+ * radio-group semantics as {@link RadioGroupRoot | `RadioGroup`}), styled as
24
+ * a linear strip of segments rather than a stack of radios.
25
+ *
26
+ * Supports two state modes, statically discriminated at the type level:
27
+ *
28
+ * - **Uncontrolled** — pass
29
+ * {@link SegmentedControlRootProps.defaultValue | `defaultValue`} (or omit
30
+ * for nothing selected on mount). The component owns the value.
31
+ * - **Controlled** — pass
32
+ * {@link SegmentedControlRootProps.value | `value`} *and*
33
+ * {@link SegmentedControlRootProps.onValueChange | `onValueChange`}
34
+ * together. The parent owns the value; the component defers every change
35
+ * back through the callback.
36
+ *
37
+ * **ARIA.** `role="radiogroup"` and `aria-orientation` are set
38
+ * automatically. Provide an accessible name via `aria-label` or
39
+ * `aria-labelledby`.
40
+ *
41
+ * **Orientation.** `"horizontal"` (default) binds the Arrow Left/Right pair;
42
+ * `"vertical"` binds Arrow Up/Down. Reflected as `data-orientation` on the
43
+ * root for styling.
44
+ *
45
+ * **Disabled.** Pass `disabled` to disable the whole control — every segment
46
+ * becomes non-interactive and drops out of arrow-key navigation, and
47
+ * `data-disabled=""` is set on the root.
48
+ *
49
+ * **Reading direction.** `dir` (`"ltr"` / `"rtl"`) swaps the horizontal
50
+ * arrow pair so Arrow Left moves forward in RTL. When omitted, it is
51
+ * inherited from the nearest {@link DirectionProvider}, falling back to
52
+ * `"ltr"`.
53
+ *
54
+ * **`asChild` prop.** Pass `asChild` to render any consumer-supplied element
55
+ * in place of the native `<div>`, with the control's `role`,
56
+ * `aria-orientation`, `data-orientation`, `data-disabled`, `dir`, and
57
+ * remaining props merged in.
58
+ *
59
+ * @extends HTMLDivElement
60
+ *
61
+ * @example Uncontrolled
62
+ * ```tsx
63
+ * <SegmentedControl.Root defaultValue="headless" aria-label="Consumption mode">
64
+ * <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
65
+ * <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
66
+ * <SegmentedControl.Item value="figma">Figma</SegmentedControl.Item>
67
+ * </SegmentedControl.Root>
68
+ * ```
69
+ *
70
+ * @example Controlled
71
+ * ```tsx
72
+ * const [value, setValue] = useState("headless");
73
+ *
74
+ * <SegmentedControl.Root value={value} onValueChange={setValue} aria-label="...">
75
+ * <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
76
+ * <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
77
+ * </SegmentedControl.Root>
78
+ * ```
79
+ */
80
+ export function SegmentedControlRoot({
81
+ defaultValue,
82
+ value: controlledValue,
83
+ onValueChange,
84
+ orientation = "horizontal",
85
+ dir,
86
+ disabled = false,
87
+ asChild = false,
88
+ children,
89
+ ...rest
90
+ }: SegmentedControlRootProps): ReactElement {
91
+ const resolvedDir = dir ?? useDirection();
92
+ const { value, select, registerItem, itemValues, disabledValues, focusItem } =
93
+ useSegmentedControlRoot({
94
+ defaultValue,
95
+ value: controlledValue,
96
+ onValueChange,
97
+ });
98
+ const contextValue = useMemo(
99
+ () => ({
100
+ value,
101
+ select,
102
+ registerItem,
103
+ itemValues,
104
+ disabledValues,
105
+ focusItem,
106
+ orientation,
107
+ dir: resolvedDir,
108
+ disabled,
109
+ }),
110
+ [
111
+ value,
112
+ select,
113
+ registerItem,
114
+ itemValues,
115
+ disabledValues,
116
+ focusItem,
117
+ orientation,
118
+ resolvedDir,
119
+ disabled,
120
+ ],
121
+ );
122
+ const rootProps = {
123
+ role: "radiogroup" as const,
124
+ "aria-orientation": orientation,
125
+ "data-orientation": orientation,
126
+ "data-disabled": disabled ? "" : undefined,
127
+ dir: resolvedDir,
128
+ ...rest,
129
+ };
130
+ return (
131
+ <SegmentedControlContext.Provider value={contextValue}>
132
+ {asChild ? (
133
+ <Slot {...rootProps}>{children}</Slot>
134
+ ) : (
135
+ <div {...rootProps}>{children}</div>
136
+ )}
137
+ </SegmentedControlContext.Provider>
138
+ );
139
+ }
140
+
141
+ /** @internal */
142
+ // Runtime-dead: the compound alias below (same object via Object.assign)
143
+ // overwrites this to "SegmentedControl" at load, so the value is never
144
+ // observable. The assignment stays because it declares `displayName` on
145
+ // `typeof SegmentedControlRoot`, which TSegmentedControlCompound extends.
146
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
147
+ SegmentedControlRoot.displayName = "SegmentedControlRoot";
148
+
149
+ /**
150
+ * An individual segment inside a SegmentedControl — a native
151
+ * `<button role="radio">` that reports its state, participates in the
152
+ * roving tabindex, and handles arrow-key navigation within the control.
153
+ *
154
+ * **Selection.** Clicking a segment (or pressing Space / Enter on the
155
+ * focused segment via native `<button>` behaviour) selects it. The arrow
156
+ * keys enabled by the control's `orientation` move focus and selection to
157
+ * the next or previous non-disabled segment, wrapping at the ends.
158
+ *
159
+ * **Roving tabindex.** Only one segment per control is in the document tab
160
+ * sequence at a time: the selected one if any, otherwise the first
161
+ * non-disabled segment. All others have `tabIndex=-1` so `Tab` escapes the
162
+ * control in a single keystroke.
163
+ *
164
+ * **Disabled.** Passing `disabled` forwards the native attribute and
165
+ * excludes the segment from arrow-key navigation and the roving-tabindex
166
+ * home base. The whole control can also be disabled via Root's `disabled`
167
+ * prop, which disables every segment.
168
+ *
169
+ * **Styling hooks.** `data-state="checked" | "unchecked"` mirrors the
170
+ * selection state; `data-disabled=""` is set when the segment (or the whole
171
+ * control) is disabled.
172
+ *
173
+ * **`asChild` prop.** Pass `asChild` to render any consumer element in place
174
+ * of the native `<button>`, with the segment's ARIA, `data-state`,
175
+ * `data-disabled`, `tabIndex`, `onClick`, `onKeyDown`, `disabled`, and `ref`
176
+ * merged onto it. A non-focusable child must be made focusable by the
177
+ * consumer.
178
+ *
179
+ * @extends HTMLButtonElement
180
+ *
181
+ * @throws if rendered outside a `SegmentedControl.Root`.
182
+ */
183
+ export function SegmentedControlItem({
184
+ value,
185
+ children,
186
+ onClick,
187
+ onKeyDown,
188
+ disabled: itemDisabled,
189
+ asChild = false,
190
+ ref,
191
+ ...rest
192
+ }: SegmentedControlItemProps): ReactElement {
193
+ const {
194
+ value: selectedValue,
195
+ select,
196
+ registerItem,
197
+ itemValues,
198
+ disabledValues,
199
+ focusItem,
200
+ orientation,
201
+ dir,
202
+ disabled: groupDisabled,
203
+ } = useSegmentedControlContext();
204
+ const isChecked = selectedValue === value;
205
+ const isDisabled = itemDisabled || groupDisabled;
206
+ // Arrow-key navigation walks only enabled items: the per-item disabled
207
+ // set is excluded, and when the whole control is disabled the predicate
208
+ // fails for every item so the navigable list is empty.
209
+ const enabledValues = useMemo(
210
+ () => itemValues.filter((v) => !groupDisabled && !disabledValues.has(v)),
211
+ [groupDisabled, itemValues, disabledValues],
212
+ );
213
+ const isTabStop =
214
+ selectedValue !== undefined ? isChecked : enabledValues[0] === value;
215
+
216
+ const localRef = useRef<HTMLButtonElement | null>(null);
217
+ const setRef = useMemo(() => composeRefs(localRef, ref), [ref]);
218
+
219
+ useEffect(() => {
220
+ registerItem(value, localRef.current, itemDisabled);
221
+ return () => registerItem(value, null);
222
+ }, [value, itemDisabled, registerItem]);
223
+
224
+ const { handleKeyDown } = useRovingTabindex<string>({
225
+ orientation,
226
+ dir,
227
+ navigable: enabledValues,
228
+ currentKey: value,
229
+ onNavigate: (target) => {
230
+ select(target);
231
+ focusItem(target);
232
+ },
233
+ });
234
+
235
+ const itemProps = {
236
+ ...rest,
237
+ ref: setRef,
238
+ role: "radio" as const,
239
+ "aria-checked": isChecked,
240
+ "data-state": isChecked ? ("checked" as const) : ("unchecked" as const),
241
+ "data-disabled": isDisabled ? "" : undefined,
242
+ tabIndex: isTabStop ? 0 : -1,
243
+ disabled: isDisabled,
244
+ onClick: composeEventHandlers(onClick, () => select(value)),
245
+ onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown),
246
+ };
247
+
248
+ return asChild ? (
249
+ <Slot {...itemProps}>{children}</Slot>
250
+ ) : (
251
+ <button type="button" {...itemProps}>
252
+ {children}
253
+ </button>
254
+ );
255
+ }
256
+
257
+ /** @internal */
258
+ SegmentedControlItem.displayName = "SegmentedControlItem";
259
+
260
+ /** Type of the {@link SegmentedControl} compound: the root callable plus its attached sub-components. */
261
+ export type TSegmentedControlCompound = typeof SegmentedControlRoot & {
262
+ Root: typeof SegmentedControlRoot;
263
+ Item: typeof SegmentedControlItem;
264
+ };
265
+
266
+ /**
267
+ * Headless, accessible **SegmentedControl** — a compound component for
268
+ * picking one option from a small linear set. It implements the
269
+ * [WAI-ARIA Radio Group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/)
270
+ * on native `<button role="radio">` elements: exactly one segment is
271
+ * selected at a time and the selection can never be cleared to nothing
272
+ * (unlike a toggle group). Zero styles ship.
273
+ *
274
+ * `SegmentedControl` is both callable (an alias of
275
+ * {@link SegmentedControlRoot | `SegmentedControl.Root`}) and carries its
276
+ * sub-components as static properties. Prefer the namespaced form in
277
+ * application code for readability and grep-ability.
278
+ *
279
+ * - {@link SegmentedControlRoot | `SegmentedControl.Root`} — state owner,
280
+ * context provider, `<div role="radiogroup">` wrapper.
281
+ * - {@link SegmentedControlItem | `SegmentedControl.Item`} — a selectable
282
+ * segment participating in the roving tabindex.
283
+ *
284
+ * @example Minimal usage
285
+ * ```tsx
286
+ * import { SegmentedControl } from "@primitiv-ui/react";
287
+ *
288
+ * <SegmentedControl.Root defaultValue="headless" aria-label="Consumption mode">
289
+ * <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
290
+ * <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
291
+ * <SegmentedControl.Item value="figma">Figma</SegmentedControl.Item>
292
+ * </SegmentedControl.Root>;
293
+ * ```
294
+ *
295
+ * @see {@link SegmentedControlRoot} for state modes, orientation, and ARIA.
296
+ * @see {@link SegmentedControlItem} for selection, roving tabindex, and keyboard navigation.
297
+ */
298
+ const SegmentedControlCompound: TSegmentedControlCompound = Object.assign(
299
+ SegmentedControlRoot,
300
+ {
301
+ Root: SegmentedControlRoot,
302
+ Item: SegmentedControlItem,
303
+ },
304
+ );
305
+
306
+ /** @internal */
307
+ SegmentedControlCompound.displayName = "SegmentedControl";
308
+
309
+ export { SegmentedControlCompound as SegmentedControl };
@@ -0,0 +1,28 @@
1
+ import { createStrictContext } from "../utils/index.ts";
2
+
3
+ import {
4
+ SegmentedControlOrientation,
5
+ SegmentedControlReadingDirection,
6
+ } from "./types";
7
+
8
+ export type SegmentedControlContextValue = {
9
+ value: string | undefined;
10
+ select: (value: string) => void;
11
+ registerItem: (
12
+ value: string,
13
+ element: HTMLButtonElement | null,
14
+ disabled?: boolean,
15
+ ) => void;
16
+ itemValues: string[];
17
+ disabledValues: Set<string>;
18
+ focusItem: (value: string) => void;
19
+ orientation: SegmentedControlOrientation;
20
+ dir: SegmentedControlReadingDirection;
21
+ /** Whether the whole control is disabled (Root's `disabled` prop). */
22
+ disabled: boolean;
23
+ };
24
+
25
+ export const [SegmentedControlContext, useSegmentedControlContext] =
26
+ createStrictContext<SegmentedControlContextValue>(
27
+ "SegmentedControl sub-components must be rendered inside a <SegmentedControl.Root>.",
28
+ );
@@ -0,0 +1,50 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { SegmentedControl } from "../SegmentedControl";
5
+
6
+ describe("SegmentedControl asChild composition", () => {
7
+ it("Root asChild renders the consumer element with radiogroup role preserved", () => {
8
+ // Arrange & Act
9
+ render(
10
+ <SegmentedControl.Root asChild aria-label="Mode">
11
+ <section data-testid="wrap">
12
+ <SegmentedControl.Item value="a">A</SegmentedControl.Item>
13
+ </section>
14
+ </SegmentedControl.Root>,
15
+ );
16
+
17
+ // Assert
18
+ const root = screen.getByTestId("wrap");
19
+ expect(root.tagName).toBe("SECTION");
20
+ expect(root).toHaveAttribute("role", "radiogroup");
21
+ expect(root).toHaveAttribute("aria-label", "Mode");
22
+ expect(root).toHaveAttribute("data-orientation", "horizontal");
23
+ });
24
+
25
+ it("Item asChild delegates to the child element while keeping ARIA + selection wiring", async () => {
26
+ // Arrange
27
+ const user = userEvent.setup();
28
+ const onValueChange = vi.fn();
29
+ render(
30
+ <SegmentedControl.Root aria-label="Mode" onValueChange={onValueChange}>
31
+ <SegmentedControl.Item value="a" asChild>
32
+ <li>A</li>
33
+ </SegmentedControl.Item>
34
+ </SegmentedControl.Root>,
35
+ );
36
+ const item = screen.getByRole("radio", { name: "A" });
37
+
38
+ // Assert element is the consumer's <li>
39
+ expect(item.tagName).toBe("LI");
40
+ expect(item).toHaveAttribute("aria-checked", "false");
41
+ expect(item).toHaveAttribute("data-state", "unchecked");
42
+
43
+ // Act
44
+ await user.click(item);
45
+
46
+ // Assert selection wiring still fires via composed onClick
47
+ expect(onValueChange).toHaveBeenCalledWith("a");
48
+ expect(item).toHaveAttribute("aria-checked", "true");
49
+ });
50
+ });
@@ -0,0 +1,100 @@
1
+ import { render, screen } from "@testing-library/react";
2
+
3
+ import { SegmentedControl } from "../SegmentedControl";
4
+
5
+ describe("SegmentedControl basic rendering", () => {
6
+ it('renders a container with role="radiogroup"', () => {
7
+ // Arrange & Act
8
+ render(
9
+ <SegmentedControl.Root aria-label="Mode">
10
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
11
+ </SegmentedControl.Root>,
12
+ );
13
+
14
+ // Assert
15
+ expect(screen.getByRole("radiogroup", { name: "Mode" })).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders each item as a <button role="radio"> with aria-checked="false" by default', () => {
19
+ // Arrange & Act
20
+ render(
21
+ <SegmentedControl.Root aria-label="Mode">
22
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
23
+ <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
24
+ </SegmentedControl.Root>,
25
+ );
26
+ const headless = screen.getByRole("radio", { name: "Headless" });
27
+ const styled = screen.getByRole("radio", { name: "Styled" });
28
+
29
+ // Assert
30
+ expect(headless.tagName).toBe("BUTTON");
31
+ expect(headless).toHaveAttribute("aria-checked", "false");
32
+ expect(headless).toHaveAttribute("data-state", "unchecked");
33
+ expect(styled.tagName).toBe("BUTTON");
34
+ expect(styled).toHaveAttribute("aria-checked", "false");
35
+ });
36
+
37
+ it('defaults type="button" on items so they never submit an enclosing form', () => {
38
+ // Arrange & Act
39
+ render(
40
+ <SegmentedControl.Root aria-label="Mode">
41
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
42
+ </SegmentedControl.Root>,
43
+ );
44
+
45
+ // Assert
46
+ expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
47
+ "type",
48
+ "button",
49
+ );
50
+ });
51
+
52
+ it('defaults to horizontal orientation on the root (aria + data)', () => {
53
+ // Arrange & Act
54
+ render(
55
+ <SegmentedControl.Root aria-label="Mode">
56
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
57
+ </SegmentedControl.Root>,
58
+ );
59
+ const root = screen.getByRole("radiogroup", { name: "Mode" });
60
+
61
+ // Assert
62
+ expect(root).toHaveAttribute("aria-orientation", "horizontal");
63
+ expect(root).toHaveAttribute("data-orientation", "horizontal");
64
+ });
65
+
66
+ it("reflects vertical orientation on the root when requested", () => {
67
+ // Arrange & Act
68
+ render(
69
+ <SegmentedControl.Root aria-label="Mode" orientation="vertical">
70
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
71
+ </SegmentedControl.Root>,
72
+ );
73
+ const root = screen.getByRole("radiogroup", { name: "Mode" });
74
+
75
+ // Assert
76
+ expect(root).toHaveAttribute("aria-orientation", "vertical");
77
+ expect(root).toHaveAttribute("data-orientation", "vertical");
78
+ });
79
+
80
+ it("does not set data-disabled on the root by default", () => {
81
+ // Arrange & Act
82
+ render(
83
+ <SegmentedControl.Root aria-label="Mode">
84
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
85
+ </SegmentedControl.Root>,
86
+ );
87
+
88
+ // Assert
89
+ expect(
90
+ screen.getByRole("radiogroup", { name: "Mode" }),
91
+ ).not.toHaveAttribute("data-disabled");
92
+ });
93
+
94
+ it("sets a displayName on the compound and Item", () => {
95
+ // Assert — empty displayNames would render each as anonymous in DevTools.
96
+ // Root aliases the compound (Object.assign), so its name is "SegmentedControl".
97
+ expect(SegmentedControl.displayName).toBe("SegmentedControl");
98
+ expect(SegmentedControl.Item.displayName).toBe("SegmentedControlItem");
99
+ });
100
+ });
@@ -0,0 +1,107 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useState } from "react";
4
+
5
+ import { SegmentedControl } from "../SegmentedControl";
6
+
7
+ describe("SegmentedControl controlled state", () => {
8
+ it("reflects the controlled `value` prop", () => {
9
+ // Arrange & Act
10
+ const { rerender } = render(
11
+ <SegmentedControl.Root
12
+ aria-label="Mode"
13
+ value="headless"
14
+ onValueChange={() => {}}
15
+ >
16
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
17
+ <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
18
+ </SegmentedControl.Root>,
19
+ );
20
+ expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
21
+ "aria-checked",
22
+ "true",
23
+ );
24
+
25
+ rerender(
26
+ <SegmentedControl.Root
27
+ aria-label="Mode"
28
+ value="styled"
29
+ onValueChange={() => {}}
30
+ >
31
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
32
+ <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
33
+ </SegmentedControl.Root>,
34
+ );
35
+
36
+ // Assert
37
+ expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
38
+ "aria-checked",
39
+ "false",
40
+ );
41
+ expect(screen.getByRole("radio", { name: "Styled" })).toHaveAttribute(
42
+ "aria-checked",
43
+ "true",
44
+ );
45
+ });
46
+
47
+ it("does not update rendered state when the parent refuses to update `value`", async () => {
48
+ // Arrange
49
+ const user = userEvent.setup();
50
+ const onValueChange = vi.fn();
51
+ render(
52
+ <SegmentedControl.Root
53
+ aria-label="Mode"
54
+ value="headless"
55
+ onValueChange={onValueChange}
56
+ >
57
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
58
+ <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
59
+ </SegmentedControl.Root>,
60
+ );
61
+ const styled = screen.getByRole("radio", { name: "Styled" });
62
+
63
+ // Act
64
+ await user.click(styled);
65
+
66
+ // Assert: callback fires but the pinned `value` keeps state on "headless".
67
+ expect(onValueChange).toHaveBeenCalledWith("styled");
68
+ expect(screen.getByRole("radio", { name: "Headless" })).toHaveAttribute(
69
+ "aria-checked",
70
+ "true",
71
+ );
72
+ expect(styled).toHaveAttribute("aria-checked", "false");
73
+ });
74
+
75
+ it("lets a parent drive the value end to end", async () => {
76
+ // Arrange
77
+ const user = userEvent.setup();
78
+ function Harness() {
79
+ const [value, setValue] = useState("styled");
80
+ return (
81
+ <SegmentedControl.Root
82
+ aria-label="Mode"
83
+ value={value}
84
+ onValueChange={setValue}
85
+ >
86
+ <SegmentedControl.Item value="headless">
87
+ Headless
88
+ </SegmentedControl.Item>
89
+ <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
90
+ </SegmentedControl.Root>
91
+ );
92
+ }
93
+ render(<Harness />);
94
+ const headless = screen.getByRole("radio", { name: "Headless" });
95
+ const styled = screen.getByRole("radio", { name: "Styled" });
96
+ expect(styled).toHaveAttribute("aria-checked", "true");
97
+
98
+ // Act & Assert
99
+ await user.click(headless);
100
+ expect(headless).toHaveAttribute("aria-checked", "true");
101
+ expect(styled).toHaveAttribute("aria-checked", "false");
102
+
103
+ await user.click(styled);
104
+ expect(styled).toHaveAttribute("aria-checked", "true");
105
+ expect(headless).toHaveAttribute("aria-checked", "false");
106
+ });
107
+ });
@@ -0,0 +1,62 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+
3
+ import { SegmentedControl } from "../SegmentedControl";
4
+
5
+ describe("SegmentedControl arrow-key guards for disabled focus", () => {
6
+ it("does nothing when the focused segment is disabled but others are enabled", () => {
7
+ // Arrange: `asChild` onto `<li>` so `disabled` doesn't stop the
8
+ // element receiving the keydown the way a native `<button disabled>` would.
9
+ const onValueChange = vi.fn();
10
+ render(
11
+ <SegmentedControl.Root aria-label="Mode" onValueChange={onValueChange}>
12
+ <SegmentedControl.Item value="a" disabled asChild>
13
+ <li>A</li>
14
+ </SegmentedControl.Item>
15
+ <SegmentedControl.Item value="b">B</SegmentedControl.Item>
16
+ <SegmentedControl.Item value="c">C</SegmentedControl.Item>
17
+ </SegmentedControl.Root>,
18
+ );
19
+ const a = screen.getByRole("radio", { name: "A" });
20
+
21
+ // Act: ArrowRight while focus is on a disabled segment
22
+ fireEvent.keyDown(a, { key: "ArrowRight" });
23
+
24
+ // Assert: nothing selected — the handler bailed on the
25
+ // "focused item not in enabledValues" guard.
26
+ expect(onValueChange).not.toHaveBeenCalled();
27
+ expect(screen.getByRole("radio", { name: "B" })).toHaveAttribute(
28
+ "aria-checked",
29
+ "false",
30
+ );
31
+ });
32
+
33
+ it("does nothing when the whole control is disabled (empty navigable set)", () => {
34
+ // Arrange: group-disabled means no segment is navigable; without the
35
+ // empty-navigable guard, arrowing would compute an undefined target.
36
+ const onValueChange = vi.fn();
37
+ render(
38
+ <SegmentedControl.Root
39
+ aria-label="Mode"
40
+ disabled
41
+ defaultValue="a"
42
+ onValueChange={onValueChange}
43
+ >
44
+ <SegmentedControl.Item value="a" asChild>
45
+ <li>A</li>
46
+ </SegmentedControl.Item>
47
+ <SegmentedControl.Item value="b" asChild>
48
+ <li>B</li>
49
+ </SegmentedControl.Item>
50
+ </SegmentedControl.Root>,
51
+ );
52
+ const a = screen.getByRole("radio", { name: "A" });
53
+ expect(a).toHaveAttribute("aria-checked", "true");
54
+
55
+ // Act
56
+ fireEvent.keyDown(a, { key: "ArrowRight" });
57
+
58
+ // Assert: the pre-existing selection survives and no callback fires.
59
+ expect(onValueChange).not.toHaveBeenCalled();
60
+ expect(a).toHaveAttribute("aria-checked", "true");
61
+ });
62
+ });