@primitiv-ui/react 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/README.md +4 -0
  2. package/package.json +6 -1
  3. package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
  4. package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
  5. package/src/AccessibleIcon/types.ts +15 -2
  6. package/src/Accordion/Accordion.tsx +25 -4
  7. package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
  8. package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
  9. package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
  10. package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
  11. package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
  12. package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
  13. package/src/Accordion/types.ts +72 -21
  14. package/src/Alert/Alert.tsx +36 -7
  15. package/src/Alert/__tests__/Alert.test.tsx +5 -0
  16. package/src/Alert/types.ts +18 -2
  17. package/src/Avatar/Avatar.tsx +99 -15
  18. package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
  19. package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
  20. package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
  21. package/src/Avatar/hooks/useAvatarImage.ts +16 -0
  22. package/src/Avatar/types.ts +15 -3
  23. package/src/Breadcrumb/Breadcrumb.tsx +136 -18
  24. package/src/Breadcrumb/README.md +47 -0
  25. package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
  26. package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
  27. package/src/Breadcrumb/types.ts +73 -7
  28. package/src/Button/Button.tsx +2 -0
  29. package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
  30. package/src/Button/types.ts +21 -0
  31. package/src/Carousel/Carousel.tsx +263 -15
  32. package/src/Carousel/README.md +749 -72
  33. package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
  34. package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
  35. package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
  36. package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
  37. package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
  38. package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
  39. package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
  40. package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
  41. package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
  42. package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
  43. package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
  44. package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
  45. package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
  46. package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
  47. package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
  48. package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
  49. package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
  50. package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
  51. package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
  52. package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
  53. package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
  54. package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
  55. package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
  56. package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
  57. package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
  58. package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
  59. package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
  60. package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
  61. package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
  62. package/src/Carousel/hooks/index.ts +1 -0
  63. package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
  64. package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
  65. package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
  66. package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
  67. package/src/Carousel/loopEngine.ts +54 -0
  68. package/src/Carousel/types.ts +457 -35
  69. package/src/Checkbox/Checkbox.tsx +9 -1
  70. package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
  71. package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
  72. package/src/Checkbox/types.ts +19 -0
  73. package/src/CheckboxCard/CheckboxCard.tsx +13 -4
  74. package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
  75. package/src/CheckboxCard/types.ts +65 -17
  76. package/src/Collapsible/Collapsible.tsx +49 -3
  77. package/src/Collapsible/README.md +40 -0
  78. package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
  79. package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
  80. package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
  81. package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
  82. package/src/Collapsible/types.ts +89 -22
  83. package/src/ContextMenu/ContextMenu.tsx +270 -4
  84. package/src/ContextMenu/types.ts +148 -6
  85. package/src/DirectionProvider/DirectionProvider.tsx +41 -9
  86. package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
  87. package/src/DirectionProvider/types.ts +28 -3
  88. package/src/Divider/Divider.tsx +24 -10
  89. package/src/Divider/__tests__/Divider.test.tsx +5 -0
  90. package/src/Divider/types.ts +10 -3
  91. package/src/Drawer/Drawer.tsx +315 -0
  92. package/src/Drawer/README.md +124 -0
  93. package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
  94. package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
  95. package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
  96. package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
  97. package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
  98. package/src/Drawer/index.ts +2 -0
  99. package/src/Drawer/types.ts +66 -0
  100. package/src/Dropdown/Dropdown.tsx +117 -0
  101. package/src/Dropdown/types.ts +241 -11
  102. package/src/EmptyState/EmptyState.tsx +74 -4
  103. package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
  104. package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
  105. package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
  106. package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
  107. package/src/EmptyState/types.ts +49 -6
  108. package/src/Field/Field.tsx +23 -0
  109. package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
  110. package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
  111. package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
  112. package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
  113. package/src/Field/types.ts +60 -12
  114. package/src/Fieldset/Fieldset.tsx +54 -18
  115. package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
  116. package/src/Fieldset/types.ts +20 -2
  117. package/src/Input/Input.tsx +14 -9
  118. package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
  119. package/src/Input/types.ts +19 -2
  120. package/src/InputGroup/InputGroup.tsx +11 -3
  121. package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
  122. package/src/InputGroup/types.ts +26 -5
  123. package/src/MillerColumns/MillerColumns.tsx +86 -19
  124. package/src/MillerColumns/types.ts +83 -27
  125. package/src/Modal/Modal.tsx +31 -4
  126. package/src/Modal/README.md +19 -10
  127. package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
  128. package/src/Modal/hooks/useModalContent.ts +32 -0
  129. package/src/Modal/types.ts +110 -29
  130. package/src/NavigationMenu/NavigationMenu.tsx +890 -0
  131. package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
  132. package/src/NavigationMenu/README.md +355 -0
  133. package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
  134. package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
  135. package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
  136. package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
  137. package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
  138. package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
  139. package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
  140. package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
  141. package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
  142. package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
  143. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
  144. package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
  145. package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
  146. package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
  147. package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
  148. package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
  149. package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
  150. package/src/NavigationMenu/constants.ts +5 -0
  151. package/src/NavigationMenu/hooks/index.ts +7 -0
  152. package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
  153. package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
  154. package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
  155. package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
  156. package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
  157. package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
  158. package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
  159. package/src/NavigationMenu/index.ts +3 -0
  160. package/src/NavigationMenu/types.ts +259 -0
  161. package/src/NavigationMenu/utils.ts +89 -0
  162. package/src/Popover/Popover.tsx +427 -0
  163. package/src/Popover/PopoverContext.ts +13 -0
  164. package/src/Popover/README.md +106 -0
  165. package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
  166. package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
  167. package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
  168. package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
  169. package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
  170. package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
  171. package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
  172. package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
  173. package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
  174. package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
  175. package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
  176. package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
  177. package/src/Popover/hooks/index.ts +3 -0
  178. package/src/Popover/hooks/usePopoverContent.ts +98 -0
  179. package/src/Popover/hooks/usePopoverRoot.ts +84 -0
  180. package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
  181. package/src/Popover/index.ts +2 -0
  182. package/src/Popover/types.ts +197 -0
  183. package/src/Portal/Portal.tsx +50 -10
  184. package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
  185. package/src/Portal/types.ts +5 -0
  186. package/src/Progress/Progress.tsx +33 -1
  187. package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
  188. package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
  189. package/src/Progress/types.ts +20 -4
  190. package/src/Radio/Radio.tsx +9 -1
  191. package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
  192. package/src/Radio/hooks/useRadioRoot.ts +3 -0
  193. package/src/Radio/types.ts +20 -0
  194. package/src/RadioCard/RadioCard.tsx +11 -0
  195. package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
  196. package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
  197. package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
  198. package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
  199. package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
  200. package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
  201. package/src/RadioCard/types.ts +95 -22
  202. package/src/RadioGroup/RadioGroup.tsx +11 -0
  203. package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
  204. package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
  205. package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
  206. package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
  207. package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
  208. package/src/RadioGroup/types.ts +73 -9
  209. package/src/SegmentedControl/README.md +154 -0
  210. package/src/SegmentedControl/SegmentedControl.tsx +309 -0
  211. package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
  212. package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
  213. package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
  214. package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
  215. package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
  216. package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
  217. package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
  218. package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
  219. package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
  220. package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
  221. package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
  222. package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
  223. package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
  224. package/src/SegmentedControl/hooks/index.ts +2 -0
  225. package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
  226. package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
  227. package/src/SegmentedControl/index.ts +2 -0
  228. package/src/SegmentedControl/types.ts +138 -0
  229. package/src/Select/README.md +122 -157
  230. package/src/Select/Select.tsx +595 -68
  231. package/src/Select/SelectContext.ts +38 -0
  232. package/src/Select/SelectItemIndicatorContext.ts +29 -0
  233. package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
  234. package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
  235. package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
  236. package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
  237. package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
  238. package/src/Select/__tests__/Select.form.test.tsx +38 -0
  239. package/src/Select/__tests__/Select.group.test.tsx +6 -6
  240. package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
  241. package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
  242. package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
  243. package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
  244. package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
  245. package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
  246. package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
  247. package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
  248. package/src/Select/__tests__/Select.selection.test.tsx +104 -0
  249. package/src/Select/__tests__/Select.separator.test.tsx +71 -0
  250. package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
  251. package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
  252. package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
  253. package/src/Select/constants.ts +8 -0
  254. package/src/Select/hooks/index.ts +2 -0
  255. package/src/Select/hooks/useSelectContent.ts +149 -0
  256. package/src/Select/hooks/useSelectRoot.ts +149 -0
  257. package/src/Select/types.ts +199 -31
  258. package/src/SkipNav/SkipNav.tsx +69 -29
  259. package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
  260. package/src/SkipNav/types.ts +17 -9
  261. package/src/Slider/Slider.tsx +33 -1
  262. package/src/Slider/SliderContext.ts +28 -1
  263. package/src/Slider/types.ts +157 -9
  264. package/src/Slot/Slot.tsx +94 -19
  265. package/src/Slot/__tests__/Slot.test.tsx +110 -0
  266. package/src/Slot/composeEventHandlers.ts +26 -2
  267. package/src/Slot/types.ts +16 -0
  268. package/src/Status/Status.tsx +36 -15
  269. package/src/Status/__tests__/Status.test.tsx +5 -0
  270. package/src/Status/types.ts +12 -1
  271. package/src/Switch/Switch.tsx +15 -2
  272. package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
  273. package/src/Switch/hooks/useSwitchRoot.ts +3 -0
  274. package/src/Switch/types.ts +49 -22
  275. package/src/Table/Table.tsx +44 -10
  276. package/src/Table/types.ts +66 -17
  277. package/src/Tabs/Tabs.tsx +8 -0
  278. package/src/Tabs/types.ts +29 -4
  279. package/src/Textarea/Textarea.tsx +9 -1
  280. package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
  281. package/src/Textarea/types.ts +20 -3
  282. package/src/Toggle/Toggle.tsx +51 -13
  283. package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
  284. package/src/Toggle/types.ts +54 -4
  285. package/src/ToggleGroup/ToggleGroup.tsx +14 -1
  286. package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
  287. package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
  288. package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
  289. package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
  290. package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
  291. package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
  292. package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
  293. package/src/ToggleGroup/types.ts +55 -2
  294. package/src/Tooltip/Tooltip.tsx +98 -20
  295. package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
  296. package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
  297. package/src/Tooltip/types.ts +89 -8
  298. package/src/Tree/Tree.tsx +187 -33
  299. package/src/Tree/types.ts +54 -12
  300. package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
  301. package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
  302. package/src/VisuallyHidden/types.ts +11 -1
  303. package/src/index.ts +4 -0
  304. package/src/test/intersectionObserverPolyfill.ts +7 -0
  305. package/src/test/resizeObserverPolyfill.ts +80 -0
  306. package/src/utils/focusable.ts +15 -0
  307. package/src/utils/index.ts +1 -0
@@ -10,63 +10,136 @@ export type RadioCardOrientation = "horizontal" | "vertical" | "both";
10
10
  /** Reading direction — swaps the horizontal arrow pair when `"rtl"`. */
11
11
  export type RadioCardReadingDirection = "ltr" | "rtl";
12
12
 
13
- /** Props shared by both controlled and uncontrolled `RadioCard.Root` usage.
14
- * Extends the native `<div>` props (minus `role`). */
15
- export type RadioCardRootBaseProps = Omit<ComponentProps<"div">, "role"> & {
13
+ /**
14
+ * Shared base for both {@link RadioCardRootProps} variants — the native
15
+ * `<div>` attributes (minus the component-owned `role` and the narrowed
16
+ * `dir`) plus the `asChild` escape hatch, orientation, reading direction,
17
+ * and a typed `ref`.
18
+ */
19
+ export type RadioCardRootBaseProps = Omit<
20
+ ComponentProps<"div">,
21
+ "role" | "dir" | "defaultValue"
22
+ > & {
23
+ /** The group's cards — typically {@link RadioCardItemProps | `RadioCard.Item`}
24
+ * elements, each optionally wrapping a {@link RadioCardIndicatorProps | `RadioCard.Indicator`}. */
16
25
  children?: ReactNode;
17
- /** Ref to the rendered root element. */
26
+ /** Forwarded to the underlying `HTMLDivElement`. */
18
27
  ref?: Ref<HTMLDivElement>;
19
- /** Render the child element instead of the default `<div>`. */
28
+ /**
29
+ * When `true`, Root delegates rendering to a single consumer-supplied
30
+ * element via the {@link Slot} pattern instead of the default `<div>`;
31
+ * `role="radiogroup"`, `aria-orientation`, `dir`, and remaining props
32
+ * are merged onto it.
33
+ * @default false
34
+ */
20
35
  asChild?: boolean;
21
- /** Which arrow keys navigate the group. */
36
+ /**
37
+ * Which arrow keys move focus and selection through the group.
38
+ * `"both"` enables all four arrows; `"horizontal"` only Arrow
39
+ * Left/Right; `"vertical"` only Arrow Up/Down. When not `"both"`, the
40
+ * value is also reflected as `aria-orientation` on the root.
41
+ * @default "both"
42
+ */
22
43
  orientation?: RadioCardOrientation;
23
- /** Reading direction for horizontal arrow navigation. */
44
+ /**
45
+ * Reading direction. In `"rtl"` the horizontal arrow pair is swapped so
46
+ * Arrow Left moves forward. When omitted, it is inherited from the
47
+ * nearest {@link DirectionProvider}, falling back to `"ltr"`.
48
+ */
24
49
  dir?: RadioCardReadingDirection;
25
50
  };
26
51
 
27
- /** Uncontrolled `RadioCard.Root` props: the component owns the selection,
28
- * seeded by an optional `defaultValue`. */
52
+ /**
53
+ * Uncontrolled variant of {@link RadioCardRootProps}: the component owns
54
+ * the selected value. Pass `defaultValue` (or omit it); `onValueChange` is
55
+ * optional and `value` is forbidden.
56
+ */
29
57
  export type RadioCardRootUncontrolledProps = RadioCardRootBaseProps & {
30
- /** Value selected on first render. */
58
+ /** Value of the card selected on first render. Omit for nothing selected
59
+ * on mount. */
31
60
  defaultValue?: string;
61
+ /** Forbidden in uncontrolled mode — use `defaultValue` instead. */
32
62
  value?: never;
33
- /** Called with the value when the selection changes. */
63
+ /** Called with the new value whenever the selection changes. Optional in
64
+ * uncontrolled mode. */
34
65
  onValueChange?: (value: string) => void;
35
66
  };
36
67
 
37
- /** Controlled `RadioCard.Root` props: the caller owns the selection via
38
- * `value` and is notified through the required `onValueChange`. */
68
+ /**
69
+ * Controlled variant of {@link RadioCardRootProps}: the parent owns the
70
+ * selected value. Pass `value` and `onValueChange` together; `defaultValue`
71
+ * is forbidden.
72
+ */
39
73
  export type RadioCardRootControlledProps = RadioCardRootBaseProps & {
74
+ /** Forbidden in controlled mode — use `value` instead. */
40
75
  defaultValue?: never;
41
- /** Value of the currently selected card. */
76
+ /** The currently selected value. Must be kept in sync by the parent via
77
+ * `onValueChange`. */
42
78
  value: string;
43
- /** Called with the value when the user selects a card. */
79
+ /** Called with the new value whenever the user selects a card. Required
80
+ * in controlled mode. */
44
81
  onValueChange: (value: string) => void;
45
82
  };
46
83
 
47
- /** Props for `RadioCard.Root` — resolves to either the controlled or
48
- * uncontrolled prop shape. */
84
+ /**
85
+ * Props for {@link RadioCard.Root}. A discriminated union of
86
+ * {@link RadioCardRootUncontrolledProps} and
87
+ * {@link RadioCardRootControlledProps}, so TypeScript accepts exactly one
88
+ * state mode.
89
+ */
49
90
  export type RadioCardRootProps =
50
91
  | RadioCardRootUncontrolledProps
51
92
  | RadioCardRootControlledProps;
52
93
 
53
- /** Props for `RadioCard.Item` — a selectable card rendered as a
54
- * `role="radio"` button, identified by its required `value`. */
94
+ /**
95
+ * Props for {@link RadioCardItem | `RadioCard.Item`} — a selectable card
96
+ * rendered as a `role="radio"` button. `value` identifies the option; all
97
+ * native `<button>` attributes (minus the component-owned `type`, `role`,
98
+ * `aria-checked`, and `value`) plus the `asChild` escape hatch and a typed
99
+ * `ref` are passed through.
100
+ */
55
101
  export type RadioCardItemProps = Omit<
56
102
  ComponentProps<"button">,
57
103
  "type" | "role" | "aria-checked" | "value"
58
104
  > & {
105
+ /** Uniquely identifies this card within the group and is reported to
106
+ * `onValueChange` / matched against Root's `value` when selected. Required. */
59
107
  value: string;
108
+ /** The card's content — the whole surface is the interactive control,
109
+ * and typically includes a {@link RadioCardIndicatorProps | `RadioCard.Indicator`}. */
60
110
  children?: ReactNode;
111
+ /** Forwarded to the underlying `HTMLButtonElement`. */
61
112
  ref?: Ref<HTMLButtonElement>;
113
+ /**
114
+ * When `true`, renders the single child element via the {@link Slot}
115
+ * pattern instead of the default `<button>`; ARIA, `data-state`,
116
+ * `tabIndex`, `onClick`, `onKeyDown`, `disabled`, and `ref` are merged
117
+ * onto it. A non-focusable child must be made focusable by the consumer.
118
+ * @default false
119
+ */
62
120
  asChild?: boolean;
63
121
  };
64
122
 
65
- /** Props for `RadioCard.Indicator` — the visual marker rendered inside the
66
- * selected item. By default it renders only when its item is checked; set
67
- * `forceMount` to keep it mounted for exit transitions. */
123
+ /**
124
+ * Props for {@link RadioCardIndicator | `RadioCard.Indicator`} — the visual
125
+ * marker rendered inside the selected card. All native `<span>` attributes
126
+ * plus `forceMount` and the `asChild` escape hatch.
127
+ */
68
128
  export type RadioCardIndicatorProps = ComponentProps<"span"> & {
129
+ /** The visual mark shown while the parent Item is selected. */
69
130
  children?: ReactNode;
131
+ /**
132
+ * When `true`, keeps the indicator mounted while unchecked so a CSS exit
133
+ * animation can play against `data-state="unchecked"`. Consumers who set
134
+ * it own the exit lifecycle.
135
+ * @default false
136
+ */
70
137
  forceMount?: boolean;
138
+ /**
139
+ * When `true`, renders the single child element via the {@link Slot}
140
+ * pattern instead of the default `<span>`; `aria-hidden` and `data-state`
141
+ * are merged onto it.
142
+ * @default false
143
+ */
71
144
  asChild?: boolean;
72
145
  };
@@ -50,6 +50,8 @@ import {
50
50
  * element (e.g. `<menu role="menu">` for dropdown composition) with
51
51
  * the RadioGroup's props merged in. The native `<div>` is dropped.
52
52
  *
53
+ * @extends HTMLDivElement
54
+ *
53
55
  * @example Uncontrolled
54
56
  * ```tsx
55
57
  * <RadioGroup.Root defaultValue="comfortable" aria-label="Density">
@@ -131,6 +133,11 @@ export function RadioGroupRoot({
131
133
  }
132
134
 
133
135
  /** @internal */
136
+ // Runtime-dead: the compound alias below (same object via Object.assign)
137
+ // overwrites this to "RadioGroup" at load, so the value is never observable.
138
+ // The assignment stays because it declares `displayName` on
139
+ // `typeof RadioGroupRoot`, which TRadioGroupCompound extends.
140
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
134
141
  RadioGroupRoot.displayName = "RadioGroupRoot";
135
142
 
136
143
  /**
@@ -163,6 +170,8 @@ RadioGroupRoot.displayName = "RadioGroupRoot";
163
170
  * consumers who render a non-focusable element are responsible for
164
171
  * making it focusable.
165
172
  *
173
+ * @extends HTMLButtonElement
174
+ *
166
175
  * @throws if rendered outside a `RadioGroup.Root`.
167
176
  */
168
177
  export function RadioGroupItem({
@@ -263,6 +272,8 @@ RadioGroupItem.displayName = "RadioGroupItem";
263
272
  * `data-state="unchecked"`. Consumers who use `forceMount` own the
264
273
  * exit lifecycle themselves.
265
274
  *
275
+ * @extends HTMLSpanElement
276
+ *
266
277
  * @example Default span wrapper
267
278
  * ```tsx
268
279
  * <RadioGroup.Item value="red">
@@ -69,4 +69,12 @@ describe("RadioGroup basic rendering", () => {
69
69
  "unchecked",
70
70
  );
71
71
  });
72
+
73
+ it("sets a displayName on the compound, Item, and Indicator", () => {
74
+ // Assert — empty displayNames would render each as anonymous in DevTools.
75
+ // Root aliases the compound (Object.assign), so its name is "RadioGroup".
76
+ expect(RadioGroup.displayName).toBe("RadioGroup");
77
+ expect(RadioGroup.Item.displayName).toBe("RadioGroupItem");
78
+ expect(RadioGroup.Indicator.displayName).toBe("RadioGroupIndicator");
79
+ });
72
80
  });
@@ -54,6 +54,33 @@ describe("RadioGroup disabled", () => {
54
54
  expect(red).toHaveAttribute("aria-checked", "true");
55
55
  });
56
56
 
57
+ it("re-registers an item's disabled flag when it changes after mount", async () => {
58
+ // Arrange — all enabled at mount; green becomes disabled afterwards.
59
+ const user = userEvent.setup();
60
+ function Fixture({ greenDisabled }: { greenDisabled: boolean }) {
61
+ return (
62
+ <RadioGroup.Root aria-label="Colour" defaultValue="red">
63
+ <RadioGroup.Item value="red">Red</RadioGroup.Item>
64
+ <RadioGroup.Item value="green" disabled={greenDisabled}>
65
+ Green
66
+ </RadioGroup.Item>
67
+ <RadioGroup.Item value="blue">Blue</RadioGroup.Item>
68
+ </RadioGroup.Root>
69
+ );
70
+ }
71
+ const { rerender } = render(<Fixture greenDisabled={false} />);
72
+
73
+ // Act — disable green after mount, then arrow forwards from red.
74
+ rerender(<Fixture greenDisabled />);
75
+ screen.getByRole("radio", { name: "Red" }).focus();
76
+ await user.keyboard("{ArrowDown}");
77
+
78
+ // Assert — the registrar effect re-ran, so green is skipped and focus lands
79
+ // on blue. A stale registration would keep green navigable and strand focus
80
+ // (a disabled button cannot receive it).
81
+ expect(screen.getByRole("radio", { name: "Blue" })).toHaveFocus();
82
+ });
83
+
57
84
  it("skips disabled items when computing the home-base tab stop", () => {
58
85
  // Arrange & Act
59
86
  render(
@@ -42,4 +42,33 @@ describe("RadioGroup.Item ref forwarding", () => {
42
42
  const button = screen.getByRole("radio", { name: "Red" });
43
43
  expect(objectRef.current).toBe(button);
44
44
  });
45
+
46
+ it("re-attaches to a replacement ref when the ref prop changes", () => {
47
+ // Arrange — the item's ref memo must depend on `ref`, or a changed ref
48
+ // callback is never invoked with the element.
49
+ const secondReceived: (HTMLButtonElement | null)[] = [];
50
+ const firstRef = () => {};
51
+ const secondRef = (node: HTMLButtonElement | null) => {
52
+ secondReceived.push(node);
53
+ };
54
+
55
+ // Act — mount with the first ref, then re-render with a different one.
56
+ const { rerender } = render(
57
+ <RadioGroup.Root aria-label="Colour">
58
+ <RadioGroup.Item value="red" ref={firstRef}>
59
+ Red
60
+ </RadioGroup.Item>
61
+ </RadioGroup.Root>,
62
+ );
63
+ rerender(
64
+ <RadioGroup.Root aria-label="Colour">
65
+ <RadioGroup.Item value="red" ref={secondRef}>
66
+ Red
67
+ </RadioGroup.Item>
68
+ </RadioGroup.Root>,
69
+ );
70
+
71
+ // Assert — the replacement ref received the button.
72
+ expect(secondReceived).toContain(screen.getByRole("radio", { name: "Red" }));
73
+ });
45
74
  });
@@ -75,6 +75,37 @@ describe("RadioGroup roving tabindex", () => {
75
75
  expect(green).toHaveAttribute("tabindex", "0");
76
76
  });
77
77
 
78
+ it("unregisters an unmounted item so the home-base tab stop moves", () => {
79
+ // Arrange — nothing selected, so the first item is the home base.
80
+ function Fixture({ items }: { items: string[] }) {
81
+ return (
82
+ <RadioGroup.Root aria-label="Colour">
83
+ {items.map((v) => (
84
+ <RadioGroup.Item key={v} value={v}>
85
+ {v}
86
+ </RadioGroup.Item>
87
+ ))}
88
+ </RadioGroup.Root>
89
+ );
90
+ }
91
+ const { rerender } = render(<Fixture items={["red", "green"]} />);
92
+ expect(screen.getByRole("radio", { name: "red" })).toHaveAttribute(
93
+ "tabindex",
94
+ "0",
95
+ );
96
+
97
+ // Act — remove "red"; its effect cleanup must unregister it.
98
+ rerender(<Fixture items={["green"]} />);
99
+
100
+ // Assert — with "red" gone from the registry, "green" is the home base. A
101
+ // no-op cleanup would leave "red" registered ahead of it, stranding the tab
102
+ // stop and leaving "green" at tabindex -1.
103
+ expect(screen.getByRole("radio", { name: "green" })).toHaveAttribute(
104
+ "tabindex",
105
+ "0",
106
+ );
107
+ });
108
+
78
109
  it("tabs into the single home-base item and not the others", async () => {
79
110
  // Arrange
80
111
  const user = userEvent.setup();
@@ -52,6 +52,9 @@ export function useRadioGroupRoot({
52
52
  ) => {
53
53
  registerItemBase(itemValue, element ? { element, disabled } : null);
54
54
  },
55
+ // `registerItemBase` is a stable useCollection callback (its own deps never
56
+ // change), so emptying this array yields the identical memoised function.
57
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
55
58
  [registerItemBase],
56
59
  );
57
60
 
@@ -71,8 +74,16 @@ export function useRadioGroupRoot({
71
74
 
72
75
  const focusItem = useCallback(
73
76
  (itemValue: string) => {
77
+ // `itemValue` is always sourced from `navigable` (the enabled subset of
78
+ // `itemValues`), and `useCollection`'s register() writes `itemsRef` and
79
+ // the `itemValues` state together in the same call — so a value that
80
+ // appears here is guaranteed to already have a live entry in the map.
81
+ // Stryker disable next-line OptionalChaining: unreachable given that invariant.
74
82
  itemsRef.current.get(itemValue)?.element.focus();
75
83
  },
84
+ // `itemsRef` is a stable RefObject, so emptying this array yields the
85
+ // identical memoised function.
86
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
76
87
  [itemsRef],
77
88
  );
78
89
 
@@ -12,14 +12,40 @@ export type RadioGroupReadingDirection = "ltr" | "rtl";
12
12
 
13
13
  /**
14
14
  * Shared base for both {@link RadioGroupRootProps} variants — the native
15
- * `<div>` attributes (minus `role`) plus the `asChild` escape hatch,
16
- * orientation, reading direction, and a typed `ref`.
15
+ * `<div>` attributes (minus the component-owned `role` and the narrowed
16
+ * `dir`) plus the `asChild` escape hatch, orientation, reading direction,
17
+ * and a typed `ref`.
17
18
  */
18
- export type RadioGroupRootBaseProps = Omit<ComponentProps<"div">, "role"> & {
19
+ export type RadioGroupRootBaseProps = Omit<
20
+ ComponentProps<"div">,
21
+ "role" | "dir" | "defaultValue"
22
+ > & {
23
+ /** The group's radios — typically {@link RadioGroupItemProps | `RadioGroup.Item`}
24
+ * elements, each optionally wrapping a {@link RadioGroupIndicatorProps | `RadioGroup.Indicator`}. */
19
25
  children?: ReactNode;
26
+ /** Forwarded to the underlying `HTMLDivElement`. */
20
27
  ref?: Ref<HTMLDivElement>;
28
+ /**
29
+ * When `true`, Root delegates rendering to a single consumer-supplied
30
+ * element (e.g. `<menu role="menu">` for dropdown composition) via the
31
+ * {@link Slot} pattern instead of the default `<div>`; `role="radiogroup"`,
32
+ * `aria-orientation`, `dir`, and remaining props are merged onto it.
33
+ * @default false
34
+ */
21
35
  asChild?: boolean;
36
+ /**
37
+ * Which arrow keys move focus and selection through the group.
38
+ * `"both"` enables all four arrows; `"horizontal"` only Arrow
39
+ * Left/Right; `"vertical"` only Arrow Up/Down. When not `"both"`, the
40
+ * value is also reflected as `aria-orientation` on the root.
41
+ * @default "both"
42
+ */
22
43
  orientation?: RadioGroupOrientation;
44
+ /**
45
+ * Reading direction. In `"rtl"` the horizontal arrow pair is swapped so
46
+ * Arrow Left moves forward. When omitted, it is inherited from the
47
+ * nearest {@link DirectionProvider}, falling back to `"ltr"`.
48
+ */
23
49
  dir?: RadioGroupReadingDirection;
24
50
  };
25
51
 
@@ -29,8 +55,13 @@ export type RadioGroupRootBaseProps = Omit<ComponentProps<"div">, "role"> & {
29
55
  * optional and `value` is forbidden.
30
56
  */
31
57
  export type RadioGroupRootUncontrolledProps = RadioGroupRootBaseProps & {
58
+ /** Value of the radio selected on first render. Omit for nothing
59
+ * selected on mount. */
32
60
  defaultValue?: string;
61
+ /** Forbidden in uncontrolled mode — use `defaultValue` instead. */
33
62
  value?: never;
63
+ /** Called with the new value whenever the selection changes. Optional
64
+ * in uncontrolled mode. */
34
65
  onValueChange?: (value: string) => void;
35
66
  };
36
67
 
@@ -40,8 +71,13 @@ export type RadioGroupRootUncontrolledProps = RadioGroupRootBaseProps & {
40
71
  * is forbidden.
41
72
  */
42
73
  export type RadioGroupRootControlledProps = RadioGroupRootBaseProps & {
74
+ /** Forbidden in controlled mode — use `value` instead. */
43
75
  defaultValue?: never;
76
+ /** The currently selected value. Must be kept in sync by the parent
77
+ * via `onValueChange`. */
44
78
  value: string;
79
+ /** Called with the new value whenever the selection changes. Required
80
+ * in controlled mode. */
45
81
  onValueChange: (value: string) => void;
46
82
  };
47
83
 
@@ -56,27 +92,55 @@ export type RadioGroupRootProps =
56
92
  | RadioGroupRootControlledProps;
57
93
 
58
94
  /**
59
- * Props for {@link RadioGroup.Item} — the radio button. `value` identifies
60
- * the option; all native `<button>` attributes (minus the component-owned
61
- * ones) plus the `asChild` escape hatch and a typed `ref` are passed through.
95
+ * Props for {@link RadioGroupItem | `RadioGroup.Item`} — the radio button.
96
+ * `value` identifies the option; all native `<button>` attributes (minus
97
+ * the component-owned `type`, `role`, `aria-checked`, and `value`) plus the
98
+ * `asChild` escape hatch and a typed `ref` are passed through.
62
99
  */
63
100
  export type RadioGroupItemProps = Omit<
64
101
  ComponentProps<"button">,
65
102
  "type" | "role" | "aria-checked" | "value"
66
103
  > & {
104
+ /** Uniquely identifies this radio within the group and is reported to
105
+ * `onValueChange` / matched against Root's `value` when selected. Required. */
67
106
  value: string;
107
+ /** The radio's visible content — its label, and typically a leading
108
+ * {@link RadioGroupIndicatorProps | `RadioGroup.Indicator`}. */
68
109
  children?: ReactNode;
110
+ /** Forwarded to the underlying `HTMLButtonElement`. */
69
111
  ref?: Ref<HTMLButtonElement>;
112
+ /**
113
+ * When `true`, renders the single child element (e.g.
114
+ * `<li role="menuitemradio">`) via the {@link Slot} pattern instead of
115
+ * the default `<button>`; ARIA, `data-state`, `tabIndex`, `onClick`,
116
+ * `onKeyDown`, `disabled`, and `ref` are merged onto it. A non-focusable
117
+ * child must be made focusable by the consumer.
118
+ * @default false
119
+ */
70
120
  asChild?: boolean;
71
121
  };
72
122
 
73
123
  /**
74
- * Props for {@link RadioGroup.Indicator} — all `<span>` attributes plus
75
- * `forceMount` (keep mounted while unselected for exit animations) and the
76
- * `asChild` escape hatch.
124
+ * Props for {@link RadioGroupIndicator | `RadioGroup.Indicator`} — all
125
+ * native `<span>` attributes plus `forceMount` and the `asChild` escape
126
+ * hatch.
77
127
  */
78
128
  export type RadioGroupIndicatorProps = ComponentProps<"span"> & {
129
+ /** The visual mark (e.g. a filled dot) shown while the parent Item is
130
+ * selected. */
79
131
  children?: ReactNode;
132
+ /**
133
+ * When `true`, keeps the indicator mounted while unchecked so a CSS exit
134
+ * animation can play against `data-state="unchecked"`. Consumers who set
135
+ * it own the exit lifecycle.
136
+ * @default false
137
+ */
80
138
  forceMount?: boolean;
139
+ /**
140
+ * When `true`, renders the single child element (typically an `<svg>`
141
+ * dot) via the {@link Slot} pattern instead of the default `<span>`;
142
+ * `aria-hidden` and `data-state` are merged onto it.
143
+ * @default false
144
+ */
81
145
  asChild?: boolean;
82
146
  };
@@ -0,0 +1,154 @@
1
+ # SegmentedControl
2
+
3
+ A headless, accessible compound component for picking **one** option from a
4
+ small linear set — the segmented-control pattern (iOS-style pill strip,
5
+ "consumption mode" switch, view toggles). It implements the
6
+ [WAI-ARIA Radio Group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/),
7
+ so exactly one segment is always selected and the selection can never be
8
+ cleared to nothing.
9
+
10
+ SegmentedControl renders native `<button role="radio">` elements wrapped in a
11
+ `<div role="radiogroup">` so it gets keyboard activation (`Space` / `Enter`),
12
+ focus ring, and disabled semantics for free from the browser. The React layer
13
+ adds single-selection state, roving tabindex, arrow-key navigation, a
14
+ whole-control `disabled`, and the `asChild` composition every primitive in
15
+ this package supports.
16
+
17
+ ```tsx
18
+ import { SegmentedControl } from "@primitiv-ui/react";
19
+
20
+ <SegmentedControl.Root defaultValue="headless" aria-label="Consumption mode">
21
+ <SegmentedControl.Item value="headless">Headless</SegmentedControl.Item>
22
+ <SegmentedControl.Item value="styled">Styled</SegmentedControl.Item>
23
+ <SegmentedControl.Item value="figma">Figma</SegmentedControl.Item>
24
+ </SegmentedControl.Root>;
25
+ ```
26
+
27
+ ## SegmentedControl vs ToggleGroup
28
+
29
+ Reach for `SegmentedControl` when the choice is a **value** — one option is
30
+ always active, like a radio group (view mode, density, a tab-like switch).
31
+ Reach for [`ToggleGroup`](../ToggleGroup/README.md) when the buttons are
32
+ **commands or toggles** that can each be on or off (text formatting, a
33
+ toolbar), including single-select-with-clear. The two look similar but carry
34
+ different semantics: `SegmentedControl` is `role="radiogroup"` /
35
+ `role="radio"` + `aria-checked`; `ToggleGroup` is `role="group"` +
36
+ `aria-pressed` and allows deselecting to nothing.
37
+
38
+ ## Sub-components
39
+
40
+ | Export | Element | Notes |
41
+ | -------------------------- | ---------- | ---------------------------------------------------------------------------------------------- |
42
+ | `SegmentedControl.Root` | `<div>` | `role="radiogroup"`, `aria-orientation`, `data-orientation`, `data-disabled`. State owner. `asChild` |
43
+ | `SegmentedControl.Item` | `<button>` | `role="radio"`, `aria-checked`, `data-state`, `data-disabled`, participates in roving tabindex. `asChild` |
44
+
45
+ ## State modes
46
+
47
+ - **Uncontrolled** — pass `defaultValue` (or omit for no initial selection).
48
+ - **Controlled** — pass `value` **and** `onValueChange` together.
49
+
50
+ The two shapes are statically discriminated at the type level; TypeScript
51
+ rejects mixing them.
52
+
53
+ ```tsx
54
+ // Uncontrolled
55
+ <SegmentedControl.Root defaultValue="headless" aria-label="Mode">
56
+ …
57
+ </SegmentedControl.Root>;
58
+
59
+ // Controlled
60
+ const [value, setValue] = useState("headless");
61
+ <SegmentedControl.Root value={value} onValueChange={setValue} aria-label="Mode">
62
+ …
63
+ </SegmentedControl.Root>;
64
+ ```
65
+
66
+ ## Keyboard interaction
67
+
68
+ | Key | Behaviour |
69
+ | -------------------------- | -------------------------------------------------------------------------------- |
70
+ | `Tab` | Moves focus to the control's single tab stop (selected segment, or first if none). |
71
+ | `Space` / `Enter` | Selects the focused segment (native `<button>` activation). |
72
+ | `ArrowRight` / `ArrowLeft` | (horizontal) Moves focus **and** selection to the next / previous segment, wrapping. |
73
+ | `ArrowDown` / `ArrowUp` | (vertical) Moves focus **and** selection to the next / previous segment, wrapping. |
74
+
75
+ The control has exactly one tab stop — the selected segment, or the first
76
+ non-disabled segment if nothing is selected. `Tab` takes the user into and out
77
+ of the control in one keystroke; arrow keys navigate inside it.
78
+
79
+ ## Orientation
80
+
81
+ `orientation` defaults to `"horizontal"` (the usual segmented-control layout),
82
+ which binds `ArrowLeft` / `ArrowRight`. Pass `orientation="vertical"` to bind
83
+ `ArrowUp` / `ArrowDown` instead. Either way the axis is reflected to assistive
84
+ technology via `aria-orientation`, and to CSS via `data-orientation`, on the
85
+ `radiogroup` element.
86
+
87
+ ## Reading direction
88
+
89
+ Pass `dir` (`"ltr"` / `"rtl"`) to swap the horizontal arrow pair, so
90
+ `ArrowLeft` moves forward and `ArrowRight` moves backward in RTL. The vertical
91
+ pair is never swapped. When `dir` is omitted, it is inherited from the nearest
92
+ [`DirectionProvider`](../DirectionProvider/README.md), falling back to `"ltr"`.
93
+ An explicit `dir` prop always wins over the inherited value.
94
+
95
+ ## Disabled
96
+
97
+ Disable a **single segment** by passing `disabled` to a
98
+ `SegmentedControl.Item`. It forwards the native `disabled` attribute (removing
99
+ it from the focus ring and suppressing clicks), sets `data-disabled=""`, and
100
+ excludes the segment from arrow-key navigation and the roving-tabindex home
101
+ base.
102
+
103
+ Disable the **whole control** by passing `disabled` to `SegmentedControl.Root`.
104
+ Every segment becomes disabled, `data-disabled=""` is set on the root, and no
105
+ segment is navigable or takes a tab stop. The current selection is still
106
+ reflected (`aria-checked` / `data-state`) so the control reads correctly while
107
+ inert.
108
+
109
+ ```tsx
110
+ <SegmentedControl.Root aria-label="Plan" disabled defaultValue="pro">
111
+ <SegmentedControl.Item value="free">Free</SegmentedControl.Item>
112
+ <SegmentedControl.Item value="pro">Pro</SegmentedControl.Item>
113
+ </SegmentedControl.Root>
114
+ ```
115
+
116
+ ## `asChild` composition
117
+
118
+ `SegmentedControl.Root` and `SegmentedControl.Item` each accept an `asChild`
119
+ boolean. When set, the component delegates rendering to its single child
120
+ element and merges its own ARIA attributes, `data-state`, `data-disabled`,
121
+ composed event handlers, and ref onto the child (the asChild contract: the
122
+ child's handler runs first, the library's runs second unless the child calls
123
+ `preventDefault`).
124
+
125
+ ```tsx
126
+ <SegmentedControl.Root asChild aria-label="View">
127
+ <nav>
128
+ <SegmentedControl.Item value="grid" asChild>
129
+ <a href="#grid">Grid</a>
130
+ </SegmentedControl.Item>
131
+ <SegmentedControl.Item value="list" asChild>
132
+ <a href="#list">List</a>
133
+ </SegmentedControl.Item>
134
+ </nav>
135
+ </SegmentedControl.Root>
136
+ ```
137
+
138
+ ## Styling hooks
139
+
140
+ `data-state="checked" | "unchecked"` is set on each `SegmentedControl.Item`,
141
+ and `data-orientation` / `data-disabled` on the root, letting any CSS system
142
+ style the selected segment and the disabled state from plain attribute
143
+ selectors:
144
+
145
+ ```css
146
+ button[role="radio"][data-state="checked"] {
147
+ background: var(--primitiv-action-primary-default);
148
+ color: var(--primitiv-action-primary-foreground-default);
149
+ }
150
+ [role="radiogroup"][data-disabled] {
151
+ opacity: 0.5;
152
+ pointer-events: none;
153
+ }
154
+ ```