@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
@@ -76,4 +76,18 @@ describe("Checkbox basic rendering", () => {
76
76
  "unchecked",
77
77
  );
78
78
  });
79
+
80
+ it("sets a displayName on the compound and the Indicator", () => {
81
+ // Assert — empty displayNames would render each as anonymous in DevTools.
82
+ // Root aliases the compound (Object.assign), so its name is "Checkbox".
83
+ expect(Checkbox.displayName).toBe("Checkbox");
84
+ expect(Checkbox.Indicator.displayName).toBe("CheckboxIndicator");
85
+ });
86
+
87
+ it("throws a helpful error when a sub-component renders outside Checkbox.Root", () => {
88
+ // Assert — the strict-context guard names the required parent.
89
+ expect(() => render(<Checkbox.Indicator />)).toThrow(
90
+ "Checkbox sub-components must be rendered inside a <Checkbox.Root>.",
91
+ );
92
+ });
79
93
  });
@@ -37,6 +37,10 @@ export function useCheckboxInput({
37
37
  onChange?.(event);
38
38
  if (event.defaultPrevented) return;
39
39
  const next = event.target.checked;
40
+ // Stryker disable next-line ConditionalExpression: in controlled mode
41
+ // `inputStateProps` reads controlledChecked, never `mirror`, so setting
42
+ // mirror here (the mutant's always-run form) has no observable effect —
43
+ // equivalent.
40
44
  if (!isControlled) setMirror(next);
41
45
  onCheckedChange?.(next);
42
46
  };
@@ -10,7 +10,16 @@ export type CheckedState = boolean | "indeterminate";
10
10
  * `:indeterminate` state.
11
11
  */
12
12
  export type CheckboxIndicatorProps = ComponentProps<"span"> & {
13
+ /** Custom mark content. Omit to let the shipped CSS draw the tick/bar off the
14
+ * input's native `:checked` / `:indeterminate` state; provide your own (an
15
+ * icon, glyph, or nested element) to override it. */
13
16
  children?: ReactNode;
17
+ /**
18
+ * When `true`, render `children` as the indicator element itself
19
+ * (via the {@link Slot} pattern) instead of wrapping them in a `<span>`.
20
+ * `data-state` and `aria-hidden` are merged onto that element.
21
+ * @default false
22
+ */
14
23
  asChild?: boolean;
15
24
  };
16
25
 
@@ -38,7 +47,11 @@ export type CheckboxRootBaseProps = Omit<
38
47
  * `defaultChecked` may be `"indeterminate"` for a mixed-on-mount checkbox.
39
48
  */
40
49
  export type CheckboxRootUncontrolledProps = CheckboxRootBaseProps & {
50
+ /** Checked value on first render; the component owns it thereafter. May be
51
+ * `"indeterminate"` for a mixed-on-mount checkbox. Omit for an initially
52
+ * unchecked box. */
41
53
  defaultChecked?: CheckedState;
54
+ /** Forbidden in uncontrolled mode — use `defaultChecked` instead. */
42
55
  checked?: never;
43
56
  };
44
57
 
@@ -48,8 +61,14 @@ export type CheckboxRootUncontrolledProps = CheckboxRootBaseProps & {
48
61
  * forbidden.
49
62
  */
50
63
  export type CheckboxRootControlledProps = CheckboxRootBaseProps & {
64
+ /** Forbidden in controlled mode — use `checked` instead. */
51
65
  defaultChecked?: never;
66
+ /** The current checked value, owned by the parent. May be `"indeterminate"`
67
+ * for the tri-state; clicking a mixed checkbox resolves it to `true`. Keep it
68
+ * in sync via `onCheckedChange`. */
52
69
  checked: CheckedState;
70
+ /** Called with the new boolean checked value on every user toggle. Required
71
+ * in controlled mode so the parent can keep `checked` in sync. */
53
72
  onCheckedChange: (checked: boolean) => void;
54
73
  };
55
74
 
@@ -40,8 +40,11 @@ function dataStateOf(checked: CheckedState) {
40
40
  * on the root, plus `data-disabled=""` when disabled.
41
41
  *
42
42
  * **`asChild` prop.** Pass `asChild` to render any consumer-supplied element
43
- * with the checkbox's ARIA attributes, data-state, composed onClick, and ref
44
- * merged in.
43
+ * with the checkbox's ARIA attributes, `data-state`, composed `onClick`, and
44
+ * `ref` merged in via the {@link Slot} pattern. See
45
+ * {@link CheckboxCardRootBaseProps}.
46
+ *
47
+ * @extends HTMLButtonElement
45
48
  *
46
49
  * @example Uncontrolled
47
50
  * ```tsx
@@ -106,7 +109,11 @@ export function CheckboxCardRoot(props: CheckboxCardRootProps): ReactElement {
106
109
  );
107
110
  }
108
111
 
109
- /** @internal */
112
+ // Runtime-dead: the compound alias below (same object via Object.assign)
113
+ // overwrites this to "CheckboxCard" at load, so the value is never observable.
114
+ // The assignment stays because it declares `displayName` on
115
+ // `typeof CheckboxCardRoot`, which TCheckboxCardCompound extends.
116
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
110
117
  CheckboxCardRoot.displayName = "CheckboxCardRoot";
111
118
 
112
119
  /**
@@ -124,7 +131,9 @@ CheckboxCardRoot.displayName = "CheckboxCardRoot";
124
131
  *
125
132
  * **`forceMount` prop.** Pass `forceMount` to keep the indicator in the DOM
126
133
  * while unchecked so a CSS exit animation can play against
127
- * `data-state="unchecked"`.
134
+ * `data-state="unchecked"`. See {@link CheckboxCardIndicatorProps}.
135
+ *
136
+ * @extends HTMLSpanElement
128
137
  *
129
138
  * @example
130
139
  * ```tsx
@@ -55,4 +55,11 @@ describe("CheckboxCard basic rendering", () => {
55
55
  expect(screen.getByTestId("icon")).toBeInTheDocument();
56
56
  expect(screen.getByText("Feature name")).toBeInTheDocument();
57
57
  });
58
+
59
+ it("sets a displayName on the compound and the Indicator", () => {
60
+ // Assert — empty displayNames would render each as anonymous in DevTools.
61
+ // Root aliases the compound (Object.assign), so its name is "CheckboxCard".
62
+ expect(CheckboxCard.displayName).toBe("CheckboxCard");
63
+ expect(CheckboxCard.Indicator.displayName).toBe("CheckboxCardIndicator");
64
+ });
58
65
  });
@@ -1,49 +1,97 @@
1
1
  import { ComponentProps, ReactNode, Ref } from "react";
2
2
  import type { CheckedState } from "../Checkbox/types";
3
3
 
4
- /** Props for `CheckboxCard.Indicator` — the visual marker shown when the card
5
- * is checked. By default it renders only while checked; set `forceMount` to
6
- * keep it mounted for exit transitions. */
4
+ /**
5
+ * Props for {@link CheckboxCardIndicator | `CheckboxCard.Indicator`} — the
6
+ * decorative visual marker (typically a tick icon) shown while the card is
7
+ * checked or indeterminate. All `HTMLSpanElement` attributes, plus the
8
+ * `forceMount` and `asChild` escape hatches.
9
+ */
7
10
  export type CheckboxCardIndicatorProps = ComponentProps<"span"> & {
11
+ /** The indicator content — typically an `<svg>` tick or check glyph. */
8
12
  children?: ReactNode;
13
+ /**
14
+ * Keep the indicator mounted while the card is **unchecked** so a CSS exit
15
+ * animation can play against `data-state="unchecked"`. By default the
16
+ * indicator unmounts as soon as the card is unchecked.
17
+ * @default false
18
+ */
9
19
  forceMount?: boolean;
20
+ /**
21
+ * When `true`, render the consumer's own element (typically an `<svg>`) as
22
+ * the indicator instead of the default `<span>`, merging `aria-hidden` and
23
+ * `data-state` onto it via the {@link Slot} pattern.
24
+ * @default false
25
+ */
10
26
  asChild?: boolean;
11
27
  };
12
28
 
13
- /** Props shared by both controlled and uncontrolled `CheckboxCard.Root` usage.
14
- * Extends the native `<button>` props (minus the checkbox-managed ones). */
29
+ /**
30
+ * Shared base for both {@link CheckboxCardRootProps} variants — the native
31
+ * `<button>` attributes (minus the ones the component owns: `type`, `role`,
32
+ * `aria-checked`, and `defaultChecked`), plus the `asChild` escape hatch and a
33
+ * typed `ref`.
34
+ */
15
35
  export type CheckboxCardRootBaseProps = Omit<
16
36
  ComponentProps<"button">,
17
37
  "type" | "role" | "aria-checked" | "defaultChecked"
18
38
  > & {
19
- /** Render the child element instead of the default `<button>`. */
39
+ /**
40
+ * When `true`, render the consumer's own element instead of the default
41
+ * `<button type="button">`, merging `role`, `aria-checked`, `data-state`,
42
+ * the composed `onClick`, `disabled`, and `ref` onto it via the
43
+ * {@link Slot} pattern.
44
+ * @default false
45
+ */
20
46
  asChild?: boolean;
21
- /** Ref to the rendered element. */
47
+ /** Forwarded to the rendered element (the `<button>` by default). */
22
48
  ref?: Ref<HTMLButtonElement>;
23
49
  };
24
50
 
25
- /** Uncontrolled `CheckboxCard.Root` props: the component owns the checked
26
- * state, seeded by an optional `defaultChecked`. */
51
+ /**
52
+ * Uncontrolled variant of {@link CheckboxCardRootProps}: the component owns the
53
+ * checked state, seeded by an optional `defaultChecked`. Pass `defaultChecked`
54
+ * (or omit it); `checked` is forbidden.
55
+ */
27
56
  export type CheckboxCardRootUncontrolledProps = CheckboxCardRootBaseProps & {
28
- /** Checked state on first render. */
57
+ /** Tri-state checked value on first render (`true` / `false` /
58
+ * `"indeterminate"`). Defaults to `false` when omitted.
59
+ * @default false */
29
60
  defaultChecked?: CheckedState;
61
+ /** Forbidden in uncontrolled mode — use `defaultChecked` instead. */
30
62
  checked?: never;
31
- /** Called with the new boolean checked state when it changes. */
63
+ /** Called with the new **boolean** checked state whenever the card toggles.
64
+ * An indeterminate card resolves to `true` on first click. Optional in
65
+ * uncontrolled mode. */
32
66
  onCheckedChange?: (checked: boolean) => void;
33
67
  };
34
68
 
35
- /** Controlled `CheckboxCard.Root` props: the caller owns the checked state via
36
- * `checked` and is notified through the required `onCheckedChange`. */
69
+ /**
70
+ * Controlled variant of {@link CheckboxCardRootProps}: the caller owns the
71
+ * checked state via `checked` and is notified through the required
72
+ * `onCheckedChange`. Pass both together; `defaultChecked` is forbidden.
73
+ */
37
74
  export type CheckboxCardRootControlledProps = CheckboxCardRootBaseProps & {
75
+ /** Forbidden in controlled mode — use `checked` instead. */
38
76
  defaultChecked?: never;
39
- /** Current checked state. */
77
+ /** The controlled tri-state checked value (`true` / `false` /
78
+ * `"indeterminate"`). Must be kept in sync by the parent via
79
+ * `onCheckedChange`. */
40
80
  checked: CheckedState;
41
- /** Called with the new boolean checked state when the user toggles. */
81
+ /** Called with the new **boolean** checked state when the user toggles. An
82
+ * indeterminate card resolves to `true` on click. Required in controlled
83
+ * mode. */
42
84
  onCheckedChange: (checked: boolean) => void;
43
85
  };
44
86
 
45
- /** Props for `CheckboxCard.Root` — resolves to either the controlled or
46
- * uncontrolled prop shape. */
87
+ /**
88
+ * Props for {@link CheckboxCardRoot | `CheckboxCard.Root`}.
89
+ *
90
+ * Resolves to either {@link CheckboxCardRootUncontrolledProps} or
91
+ * {@link CheckboxCardRootControlledProps} — only one shape is accepted by
92
+ * TypeScript at a time. Both accept the tri-state {@link CheckedState}
93
+ * (`boolean | "indeterminate"`).
94
+ */
47
95
  export type CheckboxCardRootProps =
48
96
  | CheckboxCardRootUncontrolledProps
49
97
  | CheckboxCardRootControlledProps;
@@ -1,5 +1,5 @@
1
1
  import { Ref } from "react";
2
- import type { ReactElement } from "react";
2
+ import type { CSSProperties, ReactElement } from "react";
3
3
 
4
4
  import { Slot } from "../Slot/index.ts";
5
5
 
@@ -39,9 +39,18 @@ import type {
39
39
  * mirrored onto Root, Trigger, and Content so a single selector covers
40
40
  * every sub-component.
41
41
  *
42
+ * **Context.** Root establishes {@link CollapsibleContext}, from which
43
+ * {@link CollapsibleTrigger}, {@link CollapsibleContent}, and
44
+ * {@link CollapsibleTriggerIcon} read the open state, the `disabled` flag,
45
+ * the `toggle` action, and the generated `triggerId` / `contentId` that
46
+ * wire the Trigger's `aria-controls` to the Content's `id`. Rendering any
47
+ * sub-component outside a Root throws.
48
+ *
42
49
  * **Styling hooks.** `data-state="open" | "closed"` and
43
50
  * `data-disabled="true" | "false"` are emitted on the rendered container.
44
51
  *
52
+ * @extends HTMLDivElement
53
+ *
45
54
  * @example Uncontrolled
46
55
  * ```tsx
47
56
  * <Collapsible.Root defaultOpen>
@@ -89,6 +98,7 @@ export function CollapsibleRoot({
89
98
  }
90
99
 
91
100
  /** @internal */
101
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
92
102
  CollapsibleRoot.displayName = "CollapsibleRoot";
93
103
 
94
104
  /**
@@ -133,6 +143,8 @@ CollapsibleRoot.displayName = "CollapsibleRoot";
133
143
  * - `data-state="open" | "closed"` on the rendered element.
134
144
  * - `data-disabled="true" | "false"`.
135
145
  *
146
+ * @extends HTMLButtonElement
147
+ *
136
148
  * @example Basic
137
149
  * ```tsx
138
150
  * <Collapsible.Trigger>Toggle</Collapsible.Trigger>
@@ -206,10 +218,21 @@ CollapsibleTrigger.displayName = "CollapsibleTrigger";
206
218
  * this by passing `aria-hidden` explicitly (it appears after the
207
219
  * automatic value in the spread).
208
220
  *
221
+ * **`collapsedHeight` prop.** Pass `collapsedHeight` to show a **clamped
222
+ * preview** of the panel while closed (the read-more pattern) instead of
223
+ * hiding it. A number is pixels; a string is a verbatim CSS length. When set,
224
+ * the panel is always mounted and never `hidden` / `aria-hidden` (the preview
225
+ * is real content), and the value is published as the
226
+ * `--primitiv-collapsible-collapsed-height` custom property for the styling
227
+ * layer to clamp the closed height to and anchor a bottom fade-shadow, keyed
228
+ * off `data-state="closed"`.
229
+ *
209
230
  * **Styling hooks.**
210
231
  * - `data-state="open" | "closed"` on the rendered element.
211
232
  * - `data-disabled="true" | "false"`.
212
233
  *
234
+ * @extends HTMLDivElement
235
+ *
213
236
  * @example Default (hidden attribute)
214
237
  * ```tsx
215
238
  * <Collapsible.Content>Hidden content</Collapsible.Content>
@@ -225,17 +248,38 @@ CollapsibleTrigger.displayName = "CollapsibleTrigger";
225
248
  export function CollapsibleContent({
226
249
  children,
227
250
  forceMount = false,
251
+ collapsedHeight,
252
+ style,
228
253
  ...rest
229
254
  }: CollapsibleContentProps): ReactElement {
230
255
  const { open, disabled, contentId } = useCollapsibleContext();
231
256
 
257
+ // A clamped preview keeps real, readable content on screen while closed, so
258
+ // it is always mounted (forceMount is implied) and never `hidden` /
259
+ // `aria-hidden`. The height it clamps to is published as a custom property
260
+ // the styling layer consumes.
261
+ const clamped = collapsedHeight != null;
262
+ const mounted = forceMount || clamped;
263
+ // Always publish the custom property; an `undefined` value (no
264
+ // `collapsedHeight`) is dropped by React, so there is no redundant guard to
265
+ // go stale. `clamped` gates only the mount / aria behaviour, where it is
266
+ // observable.
267
+ const mergedStyle = {
268
+ "--primitiv-collapsible-collapsed-height":
269
+ typeof collapsedHeight === "number"
270
+ ? `${collapsedHeight}px`
271
+ : collapsedHeight,
272
+ ...style,
273
+ } as CSSProperties;
274
+
232
275
  return (
233
276
  <div
234
277
  id={contentId}
235
- hidden={forceMount ? undefined : !open}
236
- aria-hidden={forceMount && !open ? true : undefined}
278
+ hidden={mounted ? undefined : !open}
279
+ aria-hidden={forceMount && !open && !clamped ? true : undefined}
237
280
  data-state={open ? "open" : "closed"}
238
281
  data-disabled={disabled}
282
+ style={mergedStyle}
239
283
  {...rest}
240
284
  >
241
285
  {children}
@@ -261,6 +305,8 @@ CollapsibleContent.displayName = "CollapsibleContent";
261
305
  * - `data-state="open" | "closed"` on the rendered `<span>`.
262
306
  * - `aria-hidden="true"` on the rendered `<span>`.
263
307
  *
308
+ * @extends HTMLSpanElement
309
+ *
264
310
  * @example Inline SVG
265
311
  * ```tsx
266
312
  * <Collapsible.Trigger>
@@ -97,6 +97,46 @@ is applied automatically so assistive technology skips the off-screen
97
97
  content. It is removed when the panel opens. Consumers can override this
98
98
  by passing `aria-hidden` explicitly.
99
99
 
100
+ ## `collapsedHeight` — clamped preview (read-more)
101
+
102
+ Pass `collapsedHeight` to `Collapsible.Content` to show a **clamped preview**
103
+ of the panel while closed instead of hiding it — the read-more / "show more"
104
+ pattern. A number is pixels; a string is a verbatim CSS length (`"4rem"`,
105
+ `"20cqi"`).
106
+
107
+ ```tsx
108
+ <Collapsible.Content collapsedHeight={96} className="preview">
109
+ A long passage whose first 96px stays visible while collapsed…
110
+ </Collapsible.Content>
111
+ ```
112
+
113
+ When set, the panel is **always mounted** and never receives `hidden` /
114
+ `aria-hidden` (the preview is real, readable content — so `forceMount` is
115
+ implied), and the value is published as the
116
+ `--primitiv-collapsible-collapsed-height` custom property. The styling layer
117
+ clamps the closed height to it and anchors a bottom fade-shadow, keyed off
118
+ `data-state="closed"`:
119
+
120
+ ```css
121
+ .preview {
122
+ overflow: hidden;
123
+ max-block-size: var(--primitiv-collapsible-collapsed-height);
124
+ transition: max-block-size 250ms;
125
+ }
126
+ .preview[data-state="open"] {
127
+ max-block-size: 100vh; /* or a large enough clamp */
128
+ }
129
+ /* Bottom fade over the clamped preview, gone once open. */
130
+ .preview[data-state="closed"]::after {
131
+ content: "";
132
+ position: absolute;
133
+ inset-inline: 0;
134
+ inset-block-end: 0;
135
+ block-size: 2rem;
136
+ background: linear-gradient(to bottom, transparent, var(--surface));
137
+ }
138
+ ```
139
+
100
140
  ## `asChild` composition
101
141
 
102
142
  `Collapsible.Trigger` accepts an `asChild` prop to render any child
@@ -119,6 +119,95 @@ describe("Collapsible asChild and keyboard tests", () => {
119
119
  expect(content).toBeVisible();
120
120
  });
121
121
 
122
+ // A plain <div> (no native button semantics and no role) never fires a
123
+ // synthetic click on Enter/Space, so these isolate the Trigger's own
124
+ // keydown handler — the anchor tests above can toggle via native activation
125
+ // even when the handler does nothing, masking mutations in it.
126
+ it("toggles on Enter for a non-native asChild element", async () => {
127
+ const user = userEvent.setup();
128
+ render(
129
+ <Collapsible.Root>
130
+ <Collapsible.Trigger asChild>
131
+ <div tabIndex={0} data-testid="trigger">
132
+ Toggle
133
+ </div>
134
+ </Collapsible.Trigger>
135
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
136
+ </Collapsible.Root>,
137
+ );
138
+ screen.getByTestId("trigger").focus();
139
+ await user.keyboard("{Enter}");
140
+ expect(screen.getByTestId("content")).toBeVisible();
141
+ });
142
+
143
+ it("toggles on Space for a non-native asChild element", async () => {
144
+ const user = userEvent.setup();
145
+ render(
146
+ <Collapsible.Root>
147
+ <Collapsible.Trigger asChild>
148
+ <div tabIndex={0} data-testid="trigger">
149
+ Toggle
150
+ </div>
151
+ </Collapsible.Trigger>
152
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
153
+ </Collapsible.Root>,
154
+ );
155
+ screen.getByTestId("trigger").focus();
156
+ await user.keyboard(" ");
157
+ expect(screen.getByTestId("content")).toBeVisible();
158
+ });
159
+
160
+ it("does not toggle on a non-activation key", async () => {
161
+ const user = userEvent.setup();
162
+ render(
163
+ <Collapsible.Root>
164
+ <Collapsible.Trigger asChild>
165
+ <div tabIndex={0} data-testid="trigger">
166
+ Toggle
167
+ </div>
168
+ </Collapsible.Trigger>
169
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
170
+ </Collapsible.Root>,
171
+ );
172
+ screen.getByTestId("trigger").focus();
173
+ await user.keyboard("a");
174
+ expect(screen.getByTestId("content")).not.toBeVisible();
175
+ });
176
+
177
+ it("does not toggle on Enter when disabled (non-native asChild)", async () => {
178
+ const user = userEvent.setup();
179
+ render(
180
+ <Collapsible.Root disabled>
181
+ <Collapsible.Trigger asChild>
182
+ <div tabIndex={0} data-testid="trigger">
183
+ Toggle
184
+ </div>
185
+ </Collapsible.Trigger>
186
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
187
+ </Collapsible.Root>,
188
+ );
189
+ screen.getByTestId("trigger").focus();
190
+ await user.keyboard("{Enter}");
191
+ expect(screen.getByTestId("content")).not.toBeVisible();
192
+ });
193
+
194
+ it("does not toggle on Space when disabled (non-native asChild)", async () => {
195
+ const user = userEvent.setup();
196
+ render(
197
+ <Collapsible.Root disabled>
198
+ <Collapsible.Trigger asChild>
199
+ <div tabIndex={0} data-testid="trigger">
200
+ Toggle
201
+ </div>
202
+ </Collapsible.Trigger>
203
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
204
+ </Collapsible.Root>,
205
+ );
206
+ screen.getByTestId("trigger").focus();
207
+ await user.keyboard(" ");
208
+ expect(screen.getByTestId("content")).not.toBeVisible();
209
+ });
210
+
122
211
  it("should NOT toggle on Enter or Space when disabled (asChild)", async () => {
123
212
  // Arrange
124
213
  const user = userEvent.setup();
@@ -58,6 +58,33 @@ describe("Collapsible basic rendering tests", () => {
58
58
  expect(content.id).not.toBe("");
59
59
  });
60
60
 
61
+ it("gives the Trigger a '-trigger' suffixed id distinct from the Content id", () => {
62
+ // Arrange
63
+ render(
64
+ <Collapsible.Root>
65
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
66
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
67
+ </Collapsible.Root>,
68
+ );
69
+ const trigger = screen.getByRole("button", { name: "Toggle" });
70
+ const content = screen.getByTestId("content");
71
+
72
+ // Assert — a derived, non-empty id that cannot collide with the content's.
73
+ expect(trigger.id).toMatch(/-trigger$/);
74
+ expect(trigger.id).not.toBe(content.id);
75
+ });
76
+
77
+ it("sets a displayName on the compound and each sub-component", () => {
78
+ // Assert — empty displayNames would render each as anonymous in DevTools.
79
+ // `Collapsible`, `Collapsible.Root`, and the underlying function are one
80
+ // object (Object.assign compound), so the compound's "Collapsible" alias is
81
+ // the observable Root displayName; the sub-components are distinct objects.
82
+ expect(Collapsible.displayName).toBe("Collapsible");
83
+ expect(Collapsible.Trigger.displayName).toBe("CollapsibleTrigger");
84
+ expect(Collapsible.Content.displayName).toBe("CollapsibleContent");
85
+ expect(Collapsible.TriggerIcon.displayName).toBe("CollapsibleTriggerIcon");
86
+ });
87
+
61
88
  it("should hide Content when closed", () => {
62
89
  // Arrange
63
90
  render(
@@ -0,0 +1,140 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+
4
+ import { Collapsible } from "../Collapsible";
5
+
6
+ const VAR = "--primitiv-collapsible-collapsed-height";
7
+
8
+ describe("Collapsible collapsedHeight (clamped preview)", () => {
9
+ it("keeps the closed panel mounted and un-hidden (a visible preview)", () => {
10
+ // Arrange & Act
11
+ render(
12
+ <Collapsible.Root>
13
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
14
+ <Collapsible.Content collapsedHeight={64} data-testid="content">
15
+ Content
16
+ </Collapsible.Content>
17
+ </Collapsible.Root>,
18
+ );
19
+ const content = screen.getByTestId("content");
20
+
21
+ // Assert — visible preview, not hidden, not aria-hidden
22
+ expect(content).toBeInTheDocument();
23
+ expect(content).not.toHaveAttribute("hidden");
24
+ expect(content).not.toHaveAttribute("aria-hidden");
25
+ expect(content).toHaveAttribute("data-state", "closed");
26
+ });
27
+
28
+ it("publishes a numeric collapsedHeight as a pixel custom property", () => {
29
+ // Arrange & Act
30
+ render(
31
+ <Collapsible.Root>
32
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
33
+ <Collapsible.Content collapsedHeight={64} data-testid="content">
34
+ Content
35
+ </Collapsible.Content>
36
+ </Collapsible.Root>,
37
+ );
38
+
39
+ // Assert
40
+ expect(
41
+ screen.getByTestId("content").style.getPropertyValue(VAR),
42
+ ).toBe("64px");
43
+ });
44
+
45
+ it("uses a string collapsedHeight verbatim as a CSS length", () => {
46
+ // Arrange & Act
47
+ render(
48
+ <Collapsible.Root>
49
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
50
+ <Collapsible.Content collapsedHeight="4rem" data-testid="content">
51
+ Content
52
+ </Collapsible.Content>
53
+ </Collapsible.Root>,
54
+ );
55
+
56
+ // Assert
57
+ expect(
58
+ screen.getByTestId("content").style.getPropertyValue(VAR),
59
+ ).toBe("4rem");
60
+ });
61
+
62
+ it("toggles data-state open/closed while staying mounted", async () => {
63
+ // Arrange
64
+ const user = userEvent.setup();
65
+ render(
66
+ <Collapsible.Root>
67
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
68
+ <Collapsible.Content collapsedHeight={64} data-testid="content">
69
+ Content
70
+ </Collapsible.Content>
71
+ </Collapsible.Root>,
72
+ );
73
+ const content = screen.getByTestId("content");
74
+
75
+ // Act
76
+ await user.click(screen.getByRole("button", { name: "Toggle" }));
77
+
78
+ // Assert — opened, still no hidden/aria-hidden, var still present
79
+ expect(content).toHaveAttribute("data-state", "open");
80
+ expect(content).not.toHaveAttribute("hidden");
81
+ expect(content).not.toHaveAttribute("aria-hidden");
82
+ expect(content.style.getPropertyValue(VAR)).toBe("64px");
83
+ });
84
+
85
+ it("merges the custom property with a consumer style", () => {
86
+ // Arrange & Act
87
+ render(
88
+ <Collapsible.Root>
89
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
90
+ <Collapsible.Content
91
+ collapsedHeight={64}
92
+ style={{ color: "rgb(1, 2, 3)" }}
93
+ data-testid="content"
94
+ >
95
+ Content
96
+ </Collapsible.Content>
97
+ </Collapsible.Root>,
98
+ );
99
+ const content = screen.getByTestId("content");
100
+
101
+ // Assert — both the private var and the consumer's declaration survive
102
+ expect(content.style.getPropertyValue(VAR)).toBe("64px");
103
+ expect(content.style.color).toBe("rgb(1, 2, 3)");
104
+ });
105
+
106
+ it("does not aria-hide a clamped panel even when forceMount is also set", () => {
107
+ // Arrange & Act — clamped wins: the preview stays readable, so no
108
+ // aria-hidden despite forceMount being present.
109
+ render(
110
+ <Collapsible.Root>
111
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
112
+ <Collapsible.Content
113
+ forceMount
114
+ collapsedHeight={64}
115
+ data-testid="content"
116
+ >
117
+ Content
118
+ </Collapsible.Content>
119
+ </Collapsible.Root>,
120
+ );
121
+
122
+ // Assert
123
+ expect(screen.getByTestId("content")).not.toHaveAttribute("aria-hidden");
124
+ });
125
+
126
+ it("does not set the custom property when collapsedHeight is omitted", () => {
127
+ // Arrange & Act
128
+ render(
129
+ <Collapsible.Root>
130
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
131
+ <Collapsible.Content data-testid="content">Content</Collapsible.Content>
132
+ </Collapsible.Root>,
133
+ );
134
+ const content = screen.getByTestId("content");
135
+
136
+ // Assert — default fully-hidden behaviour is untouched
137
+ expect(content.style.getPropertyValue(VAR)).toBe("");
138
+ expect(content).toHaveAttribute("hidden");
139
+ });
140
+ });