@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
@@ -55,6 +55,8 @@ import { getRangeStyle } from "./utils";
55
55
  *
56
56
  * @throws if `min` is not less than `max`, or `step` is not greater than 0.
57
57
  *
58
+ * @extends HTMLSpanElement
59
+ *
58
60
  * @example Single-thumb, uncontrolled
59
61
  * ```tsx
60
62
  * <Slider.Root defaultValue={[50]}>
@@ -153,6 +155,15 @@ SliderRoot.displayName = "SliderRoot";
153
155
  * **`asChild` prop.** Render a consumer element in place of the `<span>`.
154
156
  *
155
157
  * @throws if rendered outside a `Slider.Root`.
158
+ *
159
+ * @extends HTMLSpanElement
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * <Slider.Track>
164
+ * <Slider.Range />
165
+ * </Slider.Track>
166
+ * ```
156
167
  */
157
168
  export function SliderTrack({
158
169
  children,
@@ -184,6 +195,15 @@ SliderTrack.displayName = "SliderTrack";
184
195
  * **`asChild` prop.** Render a consumer element in place of the `<span>`.
185
196
  *
186
197
  * @throws if rendered outside a `Slider.Root`.
198
+ *
199
+ * @extends HTMLSpanElement
200
+ *
201
+ * @example
202
+ * ```tsx
203
+ * <Slider.Track>
204
+ * <Slider.Range />
205
+ * </Slider.Track>
206
+ * ```
187
207
  */
188
208
  export function SliderRange({
189
209
  style,
@@ -226,12 +246,24 @@ SliderRange.displayName = "SliderRange";
226
246
  * **Styling hooks.** `data-orientation` and `data-disabled`. Position is set
227
247
  * via an inline `style` offset; consumer `style` is shallow-merged on top.
228
248
  *
229
- * Provide an accessible name with `aria-label` / `aria-labelledby`.
249
+ * **Accessibility.** Provide an accessible name with `aria-label` /
250
+ * `aria-labelledby`. `aria-valuenow` always tracks the raw number; when that
251
+ * number is not self-explanatory to a screen reader (a currency amount, a
252
+ * date, a named step), pass `aria-valuetext` with the human-readable form
253
+ * (e.g. `aria-valuetext="$50"`) — it is forwarded straight through to the
254
+ * element and read in place of the bare number.
230
255
  *
231
256
  * **`asChild` prop.** Render a consumer element in place of the `<span>`;
232
257
  * the slider role, ARIA value attributes, keyboard handler, and ref merge in.
233
258
  *
234
259
  * @throws if rendered outside a `Slider.Root`.
260
+ *
261
+ * @extends HTMLSpanElement
262
+ *
263
+ * @example Named value with `aria-valuetext`
264
+ * ```tsx
265
+ * <Slider.Thumb aria-label="Price" aria-valuetext={`$${value}`} />
266
+ * ```
235
267
  */
236
268
  export function SliderThumb({
237
269
  style,
@@ -3,19 +3,46 @@ import { createStrictContext } from "../utils/index.ts";
3
3
 
4
4
  import type { SliderDirection, SliderOrientation } from "./types";
5
5
 
6
- /** Shared state published by `Slider.Root` to its sub-components: the current values, range/step bounds, orientation and direction, thumb registration, and value-commit callbacks. */
6
+ /**
7
+ * Shared state published by {@link SliderRoot | `Slider.Root`} to its
8
+ * sub-components: the current values, range/step bounds, orientation and
9
+ * direction, thumb registration, and the value-change / value-commit
10
+ * callbacks. Read it with {@link useSliderContext}; it throws when a
11
+ * sub-component is rendered outside a `<Slider.Root>`.
12
+ */
7
13
  export type SliderContextValue = {
14
+ /** The current value array — one entry per registered thumb, in order. */
8
15
  values: number[];
16
+ /** Lowest value (track start edge). */
9
17
  min: number;
18
+ /** Highest value (track end edge). */
10
19
  max: number;
20
+ /** Granularity; every value snaps to a multiple of this anchored at `min`. */
11
21
  step: number;
22
+ /** Resolved layout axis; see {@link SliderOrientation}. */
12
23
  orientation: SliderOrientation;
24
+ /** Resolved reading direction (after inheriting from `DirectionProvider`); see {@link SliderDirection}. */
13
25
  dir: SliderDirection;
26
+ /** Whether the axis is reversed relative to its natural direction. */
14
27
  inverted: boolean;
28
+ /** Whether interaction is disabled across every part. */
15
29
  disabled: boolean;
30
+ /**
31
+ * Registers (or, with `null`, unregisters) a thumb by its stable id so Root
32
+ * can order the thumbs and focus them on pointer interaction. Called from
33
+ * each {@link SliderThumb | `Slider.Thumb`} on mount/unmount.
34
+ */
16
35
  registerThumb: (id: string, element: HTMLSpanElement | null) => void;
36
+ /** Registered thumb ids in DOM order; a thumb's index into `values` is its position here. */
17
37
  orderedThumbIds: string[];
38
+ /**
39
+ * Sets the thumb at `index` to `value`, clamped between its neighbours (plus
40
+ * `minStepsBetweenThumbs`) and the `[min, max]` bounds. Returns the next
41
+ * value array when it changed, or `null` when the clamped value was
42
+ * unchanged (so callers can skip a redundant commit).
43
+ */
18
44
  setThumbValue: (index: number, value: number) => number[] | null;
45
+ /** Fires `onValueCommit` with the settled array at the end of an interaction. */
19
46
  commit: (values: number[]) => void;
20
47
  };
21
48
 
@@ -1,57 +1,205 @@
1
1
  import type { ComponentProps } from "react";
2
2
 
3
- /** Layout axis of the slider track. */
3
+ /**
4
+ * Layout axis of the slider track. `"horizontal"` lays the track out along
5
+ * the x-axis and binds ArrowLeft/ArrowRight (plus ArrowUp/ArrowDown);
6
+ * `"vertical"` lays it out along the y-axis, positions thumbs from the
7
+ * bottom edge, and maps ArrowUp to *increase*.
8
+ */
4
9
  export type SliderOrientation = "horizontal" | "vertical";
5
- /** Reading direction of the slider, affecting which end is the minimum. */
10
+ /**
11
+ * Reading direction of a **horizontal** slider, affecting which end of the
12
+ * track is the minimum and the arrow-key direction. `"rtl"` mirrors the axis
13
+ * so the value increases leftwards. Ignored on the vertical axis.
14
+ */
6
15
  export type SliderDirection = "ltr" | "rtl";
7
16
 
8
- /** Props common to both controlled and uncontrolled `Slider.Root`: range/step bounds, orientation, direction, and the native `<span>` attributes. */
17
+ /**
18
+ * Props common to both the controlled ({@link SliderRootControlledProps}) and
19
+ * uncontrolled ({@link SliderRootUncontrolledProps}) `Slider.Root` shapes:
20
+ * the range/step bounds, orientation, reading direction, form `name`, and the
21
+ * native `<span>` attributes.
22
+ *
23
+ * `defaultValue` and `dir` are `Omit`-ted from the inherited `<span>` props
24
+ * because both are redeclared with narrower slider-specific types — the
25
+ * native `defaultValue` is a string/number DOM attribute (replaced by the
26
+ * `number[]` value array) and the native `dir` is `string` (narrowed to
27
+ * {@link SliderDirection}). Omitting them keeps the narrowed types (and this
28
+ * JSDoc) visible to the docs-data extractor rather than the wider DOM ones.
29
+ */
9
30
  export type SliderRootSharedProps = Omit<
10
31
  ComponentProps<"span">,
11
32
  "defaultValue" | "dir"
12
33
  > & {
34
+ /**
35
+ * Lowest value the slider can take — the value at the track's start edge.
36
+ * Must be strictly less than {@link SliderRootSharedProps.max | `max`};
37
+ * `Slider.Root` throws during render otherwise.
38
+ * @default 0
39
+ */
13
40
  min?: number;
41
+ /**
42
+ * Highest value the slider can take — the value at the track's end edge.
43
+ * Must be strictly greater than {@link SliderRootSharedProps.min | `min`};
44
+ * `Slider.Root` throws during render otherwise.
45
+ * @default 100
46
+ */
14
47
  max?: number;
48
+ /**
49
+ * Granularity of the value: every thumb value snaps to a multiple of `step`
50
+ * anchored at `min`, and each arrow-key press moves by one `step` (Page
51
+ * Up/Down move by ten). Fractional steps (e.g. `0.1`) are snapped to the
52
+ * step's decimal precision. Must be greater than `0`; `Slider.Root` throws
53
+ * during render otherwise.
54
+ * @default 1
55
+ */
15
56
  step?: number;
57
+ /**
58
+ * Minimum gap, expressed **in steps**, enforced between adjacent thumbs on a
59
+ * multi-thumb slider. The gap in value units is `minStepsBetweenThumbs *
60
+ * step`. A thumb is clamped so it can never come closer than this to its
61
+ * neighbours (and can never cross them). Has no effect on a single-thumb
62
+ * slider.
63
+ * @default 0
64
+ */
16
65
  minStepsBetweenThumbs?: number;
66
+ /**
67
+ * Layout axis of the track; see {@link SliderOrientation}. Sets
68
+ * `data-orientation` and each thumb's `aria-orientation`, and selects which
69
+ * pointer axis and arrow keys drive the value.
70
+ * @default "horizontal"
71
+ */
17
72
  orientation?: SliderOrientation;
73
+ /**
74
+ * Reading direction of a horizontal slider; see {@link SliderDirection}.
75
+ * Affects which end is the minimum, the arrow-key direction, pointer
76
+ * mapping, and the inline positioning styles. When omitted, it is inherited
77
+ * from the nearest {@link DirectionProvider}, falling back to `"ltr"` when
78
+ * there is no provider; an explicit prop always wins.
79
+ */
18
80
  dir?: SliderDirection;
81
+ /**
82
+ * Reverse the direction the value increases along the axis. Combines with
83
+ * {@link SliderRootSharedProps.orientation | `orientation`} and
84
+ * {@link SliderRootSharedProps.dir | `dir`} to flip the pointer mapping,
85
+ * arrow keys, and the edge the thumb/range offset is anchored to.
86
+ * @default false
87
+ */
19
88
  inverted?: boolean;
89
+ /**
90
+ * Disable all interaction. Every part receives `data-disabled=""`, thumbs
91
+ * leave the tab order and gain `aria-disabled="true"`, and both keyboard and
92
+ * pointer handlers become no-ops.
93
+ * @default false
94
+ */
20
95
  disabled?: boolean;
96
+ /**
97
+ * When set, `Slider.Root` renders one hidden `<input>` per thumb carrying
98
+ * that thumb's value so the slider posts with a surrounding `<form>`. A
99
+ * multi-thumb slider suffixes the name with `[]` (e.g. `name="range"` →
100
+ * `range[]`); a single-thumb slider uses the bare name. When omitted, no
101
+ * hidden inputs are rendered.
102
+ */
21
103
  name?: string;
104
+ /**
105
+ * Render the child element in place of the default `<span>` via the
106
+ * {@link Slot} pattern, merging `Slider.Root`'s `dir`, `data-*` hooks,
107
+ * pointer handler, and ref onto it.
108
+ * @default false
109
+ */
22
110
  asChild?: boolean;
23
111
  };
24
112
 
25
- /** Uncontrolled `Slider.Root` props: seed with `defaultValue`; `value` is disallowed. */
113
+ /**
114
+ * Uncontrolled variant of {@link SliderRootProps}: the component owns the
115
+ * value array internally. Seed it with `defaultValue` (or omit it for a
116
+ * single thumb seeded at `min`); `value` is forbidden.
117
+ */
26
118
  export type SliderRootUncontrolledProps = SliderRootSharedProps & {
119
+ /**
120
+ * The value array on first render — one number per
121
+ * {@link SliderThumb | `Slider.Thumb`}, in order (`[value]` for a single
122
+ * thumb, `[low, high]` for a range). After mount the component owns the
123
+ * value. Defaults to `[min]` when omitted.
124
+ */
27
125
  defaultValue?: number[];
126
+ /** Forbidden in uncontrolled mode — use `defaultValue` instead. */
28
127
  value?: never;
128
+ /**
129
+ * Called with the full next value array on every change (each arrow-key
130
+ * press and every pointer increment during a drag). Optional in
131
+ * uncontrolled mode.
132
+ */
29
133
  onValueChange?: (value: number[]) => void;
134
+ /**
135
+ * Called **once** with the settled value array when an interaction ends — a
136
+ * pointer release, or a completed keyboard press. Use it to persist only the
137
+ * final value rather than every intermediate step.
138
+ */
30
139
  onValueCommit?: (value: number[]) => void;
31
140
  };
32
141
 
33
- /** Controlled `Slider.Root` props: drive with `value`; `defaultValue` is disallowed. */
142
+ /**
143
+ * Controlled variant of {@link SliderRootProps}: the parent owns the value
144
+ * array via `value` and is expected to keep it in sync through
145
+ * `onValueChange`. `defaultValue` is forbidden.
146
+ */
34
147
  export type SliderRootControlledProps = SliderRootSharedProps & {
148
+ /** Forbidden in controlled mode — use `value` instead. */
35
149
  defaultValue?: never;
150
+ /**
151
+ * The current value array — one number per
152
+ * {@link SliderThumb | `Slider.Thumb`}, in order (`[value]` for a single
153
+ * thumb, `[low, high]` for a range). Must be kept in sync by the parent via
154
+ * `onValueChange`.
155
+ */
36
156
  value: number[];
157
+ /**
158
+ * Called with the full next value array on every change (each arrow-key
159
+ * press and every pointer increment during a drag). Update `value` from it
160
+ * to keep the slider in sync.
161
+ */
37
162
  onValueChange?: (value: number[]) => void;
163
+ /**
164
+ * Called **once** with the settled value array when an interaction ends — a
165
+ * pointer release, or a completed keyboard press. Use it to persist only the
166
+ * final value rather than every intermediate step.
167
+ */
38
168
  onValueCommit?: (value: number[]) => void;
39
169
  };
40
170
 
41
- /** Props for `Slider.Root` — the discriminated union of controlled ({@link SliderRootControlledProps}) and uncontrolled ({@link SliderRootUncontrolledProps}) modes. */
171
+ /** Props for {@link SliderRoot | `Slider.Root`} — the discriminated union of controlled ({@link SliderRootControlledProps}) and uncontrolled ({@link SliderRootUncontrolledProps}) modes; only one shape is accepted by TypeScript at a time. */
42
172
  export type SliderRootProps =
43
173
  | SliderRootUncontrolledProps
44
174
  | SliderRootControlledProps;
45
175
 
46
- /** Props for `Slider.Track` — the full-length rail; native `<span>` props plus `asChild`. */
176
+ /** Props for {@link SliderTrack | `Slider.Track`} — the full-length rail the thumbs travel along; all native `<span>` attributes plus `asChild`. */
47
177
  export type SliderTrackProps = ComponentProps<"span"> & {
178
+ /**
179
+ * Render the child element in place of the default `<span>` via the
180
+ * {@link Slot} pattern, merging the `data-orientation` / `data-disabled`
181
+ * hooks onto it.
182
+ * @default false
183
+ */
48
184
  asChild?: boolean;
49
185
  };
50
- /** Props for `Slider.Range` — the filled segment between the minimum and the active thumb; native `<span>` props plus `asChild`. */
186
+ /** Props for {@link SliderRange | `Slider.Range`} — the filled segment between the lowest and highest thumb (or from the track start to the single thumb); all native `<span>` attributes plus `asChild`. */
51
187
  export type SliderRangeProps = ComponentProps<"span"> & {
188
+ /**
189
+ * Render the child element in place of the default `<span>` via the
190
+ * {@link Slot} pattern, merging the `data-orientation` / `data-disabled`
191
+ * hooks and the computed inline positioning `style` onto it.
192
+ * @default false
193
+ */
52
194
  asChild?: boolean;
53
195
  };
54
- /** Props for `Slider.Thumb` — a draggable handle; native `<span>` props plus `asChild`. */
196
+ /** Props for {@link SliderThumb | `Slider.Thumb`} — a draggable, focusable handle; render one per entry in the value array. All native `<span>` attributes plus `asChild`. */
55
197
  export type SliderThumbProps = ComponentProps<"span"> & {
198
+ /**
199
+ * Render the child element in place of the default `<span>` via the
200
+ * {@link Slot} pattern, merging the `role="slider"` semantics, `aria-value*`
201
+ * attributes, keyboard handler, positioning `style`, and ref onto it.
202
+ * @default false
203
+ */
56
204
  asChild?: boolean;
57
205
  };
package/src/Slot/Slot.tsx CHANGED
@@ -34,7 +34,15 @@ import { AnyProps, PossibleRef } from "../types";
34
34
  function setRef<T>(ref: PossibleRef<T>, value: T) {
35
35
  if (typeof ref === "function") {
36
36
  ref(value);
37
- } else if (ref !== null && ref !== undefined) {
37
+ return;
38
+ }
39
+ // Forcing this guard true dereferences a null/undefined ref (a child with no
40
+ // ref of its own), throwing during React's commit-phase ref assignment. The
41
+ // "no ref of its own" test catches it, but Stryker's vitest runner can't
42
+ // attribute that commit-phase throw (the Avatar/Slot-guard limitation) — not
43
+ // equivalent.
44
+ // Stryker disable next-line ConditionalExpression
45
+ if (ref !== null && ref !== undefined) {
38
46
  (ref as RefObject<T>).current = value;
39
47
  }
40
48
  }
@@ -42,7 +50,15 @@ function setRef<T>(ref: PossibleRef<T>, value: T) {
42
50
  /**
43
51
  * Combines multiple refs into a single callback ref that sets all of them
44
52
  * simultaneously. Handles function refs, object refs (`{ current }`), and
45
- * `undefined` — any of which may be mixed freely.
53
+ * `undefined` — any of which may be mixed freely, in any order, and any
54
+ * number of them.
55
+ *
56
+ * Used throughout the headless components whenever a sub-component owns an
57
+ * internal ref (for measuring, focus management, roving-tabindex, etc.)
58
+ * *and* needs to forward the consumer's own `ref` to the same DOM node —
59
+ * two refs, one element, one callback ref that satisfies both. `Slot`
60
+ * itself uses `composeRefs` internally to merge its `forwardedRef` with the
61
+ * child element's own ref (see {@link Slot}).
46
62
  *
47
63
  * @example Compose an internal ref with a consumer-supplied external ref:
48
64
  * ```tsx
@@ -67,6 +83,10 @@ function getRef(element: React.ReactElement): PossibleRef<unknown> {
67
83
  const { props, ref } = element as React.ReactElement & {
68
84
  ref?: PossibleRef<unknown>;
69
85
  };
86
+ // Stryker disable next-line ConditionalExpression: in React 19, element.ref
87
+ // returns the same ref as props.ref for real elements, so preferring props.ref
88
+ // vs. falling back to ref is indistinguishable at runtime — an equivalent
89
+ // mutant. The ternary stays for the React <=18 shape, where the two differ.
70
90
  return (props as AnyProps).ref !== undefined
71
91
  ? ((props as AnyProps).ref as PossibleRef<unknown>)
72
92
  : ref;
@@ -109,29 +129,67 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
109
129
  /**
110
130
  * Renders its single child element with the Slot's own props merged in.
111
131
  *
112
- * Used to implement the `asChild` pattern: a component that normally renders
113
- * its own DOM element can instead delegate to a consumer-supplied element
114
- * while preserving all of its own behaviour (ARIA attributes, event handlers,
115
- * `ref`, etc.).
132
+ * `Slot` is the composition primitive behind every `asChild` prop in this
133
+ * library (`Button`, `Tabs.Trigger`, `Select.Root`, `Breadcrumb.Link`, and
134
+ * more). A component that normally renders its own DOM element (say,
135
+ * `<button>`) can instead delegate rendering to a single consumer-supplied
136
+ * element — typically a routing library's `<Link>` or a custom styled
137
+ * element — while preserving all of its own behaviour: ARIA attributes,
138
+ * data attributes, event handlers, and `ref` all still land on the
139
+ * rendered DOM node, just not the node `Slot` itself would have created.
140
+ * The pattern avoids an extra wrapper element in the DOM, which matters for
141
+ * layout (no unwanted `<button><a>…</a></button>` nesting) and for CSS
142
+ * selectors that expect a single element.
116
143
  *
117
- * **Prop-merging rules**:
118
- * - **Event handlers** compose — child's handler fires first, then Slot's.
119
- * - **`style`** is shallow-merged — child wins on key collisions.
120
- * - **`className`** strings are concatenated (`slotClass childClass`).
121
- * - **All other props** default to the child's value; Slot provides the
122
- * fallback when the child doesn't specify one.
123
- * - **Refs** from both sides are composed via {@link composeRefs}.
144
+ * **Usage shape** — every component using `Slot` follows the same
145
+ * branch:
146
+ *
147
+ * ```tsx
148
+ * if (asChild) {
149
+ * return <Slot {...ownProps}>{children}</Slot>;
150
+ * }
151
+ * return <button {...ownProps}>{children}</button>;
152
+ * ```
153
+ *
154
+ * `ownProps` is the exact same object passed to both branches — `Slot`'s
155
+ * job is only to change *where* those props land, never to change what
156
+ * they are.
124
157
  *
125
- * **Constraints**
126
- * - Exactly one React element child is required; Slot throws otherwise.
127
- * - The child must accept a `ref` (i.e. a DOM element or a `forwardRef`
128
- * component).
158
+ * **Prop-merging rules** (`Slot`'s own props are the "slot" side, the
159
+ * single child's own props are the "child" side):
160
+ * - **Event handlers** (any prop matching `/^on[A-Z]/`) compose — the
161
+ * **child's** handler fires first, then the **slot's**. This is the
162
+ * opposite composition direction, and a different merge strategy, from
163
+ * the standalone {@link composeEventHandlers} helper (their-handler-first
164
+ * with a `preventDefault()` veto) — the two are unrelated mechanisms
165
+ * that happen to solve adjacent problems. `Slot`'s handlers always both
166
+ * run; there is no opt-out.
167
+ * - **`style`** is shallow-merged — the child's style object wins on key
168
+ * collisions, so a consumer's inline `style` can override individual
169
+ * properties Slot would otherwise set.
170
+ * - **`className`** strings are concatenated (`"slotClass childClass"`),
171
+ * so both the component's own class hooks and the consumer's class
172
+ * survive.
173
+ * - **All other props** default to the child's value; Slot's value is only
174
+ * used as a fallback when the child doesn't specify that prop at all
175
+ * (key absent, or explicitly `undefined`).
176
+ * - **Refs** from both sides are composed via {@link composeRefs}, so
177
+ * neither the component's internal ref nor the consumer's own ref on the
178
+ * child element is lost.
129
179
  *
130
- * **React version compatibility.** Slot reads the child's ref from
180
+ * **Constraints.** Exactly one React element child is required — `Slot`
181
+ * throws `"Slot requires exactly one React element child."` for zero
182
+ * children or a non-element child (e.g. a plain string). The child must
183
+ * also accept a `ref` (a DOM element or a `forwardRef` component); passing
184
+ * a plain function component as the child silently drops the ref.
185
+ *
186
+ * **React version compatibility.** Slot reads the child's own ref from
131
187
  * `element.props.ref` (React 19+) with a fallback to `element.ref`
132
188
  * (React ≤18) so both runtime versions compose refs correctly.
133
189
  *
134
- * @example
190
+ * @extends HTMLElement
191
+ *
192
+ * @example Basic `asChild` branch inside a component
135
193
  * ```tsx
136
194
  * // Inside a component that normally renders <button>:
137
195
  * if (asChild) {
@@ -139,11 +197,28 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
139
197
  * }
140
198
  * return <button {...buttonProps}>{children}</button>;
141
199
  * ```
200
+ *
201
+ * @example Consumer rendering a router link in place of the default element
202
+ * ```tsx
203
+ * <Button asChild onClick={trackClick}>
204
+ * <RouterLink to="/settings" className="settings-link">
205
+ * Settings
206
+ * </RouterLink>
207
+ * </Button>
208
+ * // Rendered DOM: a single <a class="…settings-link"> — trackClick and the
209
+ * // link's own onClick both fire; no wrapping <button>.
210
+ * ```
142
211
  */
143
212
  export const Slot: ForwardRefExoticComponent<
144
213
  PropsWithoutRef<SlotProps> & RefAttributes<HTMLElement>
145
214
  > = forwardRef<HTMLElement, SlotProps>(
146
215
  ({ children, ...slotProps }, forwardedRef) => {
216
+ // Stryker disable next-line ConditionalExpression: forcing this guard false
217
+ // lets invalid children fall through to a *different* throw (the destructure
218
+ // in getRef below), which the error tests do catch — but Stryker's vitest
219
+ // runner does not attribute that React render-time throw to a covering test
220
+ // (the same limitation seen on Avatar's ref guard). The operator/block twins
221
+ // of this line are scored; only this variant can't be. Not equivalent.
147
222
  if (Children.count(children) !== 1 || !isValidElement(children)) {
148
223
  throw new Error("Slot requires exactly one React element child.");
149
224
  }
@@ -159,5 +159,115 @@ describe("Slot tests", () => {
159
159
  // Assert
160
160
  expect(screen.getByText("text")).toHaveClass("slot-class", "child-class");
161
161
  });
162
+
163
+ it("drops a falsy className from either side (no stray whitespace)", () => {
164
+ // Arrange — child provides an empty className; the filter must remove it.
165
+ const { container } = render(
166
+ <Slot className="slot-class">
167
+ <span className="">text</span>
168
+ </Slot>,
169
+ );
170
+
171
+ // Assert — exactly "slot-class", not "slot-class " with a trailing space
172
+ expect((container.firstChild as HTMLElement).getAttribute("class")).toBe(
173
+ "slot-class",
174
+ );
175
+ });
176
+ });
177
+
178
+ describe("ref composition (exercised through the component)", () => {
179
+ it("calls a function ref forwarded through the Slot with the element", () => {
180
+ // Arrange
181
+ const fnRef = vi.fn();
182
+
183
+ // Act — a function ref on the Slot is composed onto the child element
184
+ render(
185
+ <Slot ref={fnRef}>
186
+ <button type="button">Click me</button>
187
+ </Slot>,
188
+ );
189
+
190
+ // Assert
191
+ expect(fnRef).toHaveBeenCalledWith(screen.getByRole("button"));
192
+ });
193
+
194
+ it("populates an object ref and tolerates a child with no ref of its own", () => {
195
+ // Arrange — the Slot's object ref composes with the child's (absent) ref;
196
+ // the absent side must be skipped, not dereferenced.
197
+ const objRef = createRef<HTMLElement>();
198
+
199
+ // Act
200
+ render(
201
+ <Slot ref={objRef}>
202
+ <button type="button">Click me</button>
203
+ </Slot>,
204
+ );
205
+
206
+ // Assert
207
+ expect(objRef.current).toBe(screen.getByRole("button"));
208
+ });
209
+ });
210
+
211
+ describe("mergeProps — event handlers (both sides)", () => {
212
+ it("composes handlers when both provide one — child first, then slot", async () => {
213
+ // Arrange
214
+ const user = userEvent.setup();
215
+ const calls: string[] = [];
216
+ const slotClick = vi.fn(() => calls.push("slot"));
217
+ const childClick = vi.fn(() => calls.push("child"));
218
+ render(
219
+ <Slot onClick={slotClick}>
220
+ <button type="button" onClick={childClick}>
221
+ Click me
222
+ </button>
223
+ </Slot>,
224
+ );
225
+
226
+ // Act
227
+ await user.click(screen.getByRole("button"));
228
+
229
+ // Assert — both fire, child's handler first
230
+ expect(childClick).toHaveBeenCalledTimes(1);
231
+ expect(slotClick).toHaveBeenCalledTimes(1);
232
+ expect(calls).toEqual(["child", "slot"]);
233
+ });
234
+ });
235
+
236
+ describe("mergeProps — other props", () => {
237
+ it("keeps non-special props, child winning over the slot on collision", () => {
238
+ // Arrange
239
+ const { container } = render(
240
+ <Slot id="slot-id" data-origin="slot">
241
+ <span id="child-id">text</span>
242
+ </Slot>,
243
+ );
244
+ const el = container.firstChild as HTMLElement;
245
+
246
+ // Assert — child's id wins; the slot-only prop still passes through
247
+ expect(el.id).toBe("child-id");
248
+ expect(el.getAttribute("data-origin")).toBe("slot");
249
+ });
250
+
251
+ it("does not treat a prop merely containing on[A-Z] as an event handler", () => {
252
+ // Arrange — "iconOnly" contains "onO" but is not a handler; only a
253
+ // start-anchored /^on[A-Z]/ correctly excludes it, so the child value must
254
+ // win by the default merge rather than being composed into a function.
255
+ // "iconOnly" is not a real DOM attribute, hence the spread casts.
256
+ const { container } = render(
257
+ <Slot {...({ iconOnly: "slot" } as object)}>
258
+ <span {...({ iconOnly: "child" } as object)}>text</span>
259
+ </Slot>,
260
+ );
261
+
262
+ // Assert
263
+ expect((container.firstChild as HTMLElement).getAttribute("icononly")).toBe(
264
+ "child",
265
+ );
266
+ });
267
+ });
268
+
269
+ it('sets displayName to "Slot" for React DevTools and stack traces', () => {
270
+ // Assert — an empty displayName would render it as an anonymous component.
271
+ expect(Slot.displayName).toBe("Slot");
162
272
  });
163
273
  });
@@ -5,8 +5,25 @@
5
5
  *
6
6
  * This is the standard composition pattern used throughout headless
7
7
  * components to let consumers attach their own listeners onto a
8
- * sub-component without clobbering the component's own behaviour, while
9
- * still giving them an opt-out via `preventDefault()`.
8
+ * sub-component's *own* native event props (e.g. `Tabs.Trigger`'s
9
+ * `onClick`, `Modal.Overlay`'s `onClick`) without clobbering the
10
+ * component's own behaviour, while still giving them an opt-out via
11
+ * `preventDefault()`. Wrap it around the component's internal handler at
12
+ * the point the prop is consumed:
13
+ *
14
+ * ```tsx
15
+ * const handleClick = composeEventHandlers(onClick, () => {
16
+ * // the component's own behaviour, e.g. activating a tab
17
+ * });
18
+ * ```
19
+ *
20
+ * **Not the same mechanism as {@link Slot}.** `Slot`'s own prop-merging
21
+ * (used for the `asChild` pattern) also composes event handlers, but with
22
+ * the *opposite* order (child fires first, then Slot) and *no*
23
+ * `preventDefault()` opt-out — both handlers always run there. Reach for
24
+ * `composeEventHandlers` when a component owns a native DOM prop directly;
25
+ * reach for `Slot` when delegating rendering to a consumer-supplied
26
+ * element via `asChild`. The two are never used together on the same prop.
10
27
  *
11
28
  * @example Consumer logs every click; component still closes the modal
12
29
  * afterwards:
@@ -20,6 +37,13 @@
20
37
  * if (formIsDirty) e.preventDefault(); // don't close
21
38
  * }} />
22
39
  * ```
40
+ *
41
+ * @example Opting out of the veto — always run our handler too
42
+ * ```tsx
43
+ * const handleClick = composeEventHandlers(onClick, ourHandler, {
44
+ * checkForDefaultPrevented: false,
45
+ * });
46
+ * ```
23
47
  */
24
48
  export function composeEventHandlers<E>(
25
49
  theirHandler: ((event: E) => void) | undefined,
package/src/Slot/types.ts CHANGED
@@ -1,5 +1,21 @@
1
1
  import { HTMLAttributes, ReactNode } from "react";
2
2
 
3
+ /**
4
+ * Props for {@link Slot} — all native `HTMLAttributes` (event handlers,
5
+ * `style`, `className`, `aria-*`, `data-*`, …), merged onto the single
6
+ * child element rather than rendered onto an element `Slot` creates
7
+ * itself. See {@link Slot} for the exact prop-merging rules (event
8
+ * handlers compose child-first, `style` shallow-merges child-wins,
9
+ * `className` concatenates, everything else defaults to the child).
10
+ *
11
+ * @extends HTMLElement
12
+ */
3
13
  export type SlotProps = HTMLAttributes<HTMLElement> & {
14
+ /**
15
+ * The element to clone `Slot`'s props onto. Must be exactly one valid
16
+ * React element that accepts a `ref` — passing zero children, more than
17
+ * one, or a non-element (e.g. a plain string) throws
18
+ * `"Slot requires exactly one React element child."`.
19
+ */
4
20
  children?: ReactNode;
5
21
  };