@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
package/README.md CHANGED
@@ -31,6 +31,7 @@ import { Tabs } from "@primitiv-ui/react";
31
31
  | [ContextMenu](src/ContextMenu/README.md) | WAI-ARIA Menu pattern for right-click / long-press / context-key menus — native HTML `popover` compound component positioned at the cursor, with nested submenus, checkbox and radio items, `ItemIndicator` icon slot, groups and labels, keyboard navigation, typeahead, disabled-item skipping, `onSelect` escape hatch, and `asChild` composition |
32
32
  | [DirectionProvider](src/DirectionProvider/README.md) | Reading-direction context — broadcasts `ltr`/`rtl` to descendants and exposes the `useDirection` hook so direction-aware components inherit `dir` instead of each being passed it explicitly; renders no DOM |
33
33
  | [Divider](src/Divider/README.md) | WAI-ARIA separator role — horizontal or vertical content separator, with semantic and decorative use |
34
+ | [Drawer](src/Drawer/README.md) | Edge-anchored dialog — a thin composition over Modal that reuses its native `<dialog>` machinery (focus trap, inert background, top layer, Esc, bounding-rect click-outside, controlled/uncontrolled state, imperative API, `asChild`, `forceMount`), adding a `side` (`top`/`right`/`bottom`/`left`) axis emitted as `data-side` for the styled layer to slide the panel from that edge |
34
35
  | [Dropdown](src/Dropdown/README.md) | WAI-ARIA Menu Button / Menu pattern — native HTML `popover` compound component with nested submenus, checkbox and radio items, `ItemIndicator` icon slot, groups and labels, keyboard navigation, typeahead, disabled-item skipping, `onSelect` escape hatch, and `asChild` composition |
35
36
  | [EmptyState](src/EmptyState/README.md) | Placeholder for empty collections, searches, and views — stateless compound component: a `role="status"` live region announcing on appearance, an `aria-hidden` `Media` illustration slot, `Title` and `Description` copy, an `Actions` recovery slot, and `asChild` composition on every part |
36
37
  | [Field](src/Field/README.md) | Headless form-field coordinator — provides `FieldContext` with auto-generated `id` (via `useId`) and derived `descriptionId` / `errorId`, plus `invalid` / `disabled` / `required` cascade; sub-components for `Label` (`htmlFor`-wired), `Description`, and `ErrorText` (auto-gated by invalid state, `role="alert"`); `Input` opts in automatically when nested |
@@ -39,11 +40,14 @@ import { Tabs } from "@primitiv-ui/react";
39
40
  | [InputGroup](src/InputGroup/README.md) | Stateless frame for `Input` (or any framed control) with named leading and trailing adornment slots — decorative icons, currency symbols, clear buttons, password-reveal toggles; `data-input-group` / `data-input-group-adornment` styling hooks, CSS-driven disabled / invalid / focus-within state via `:has()`, and `asChild` composition on every part |
40
41
  | [MillerColumns](src/MillerColumns/README.md) | Miller columns / cascading-lists pattern — a horizontal strip of `role="group"` columns of `role="treeitem"` items, authored by recursive composition with portal-projected child columns, controlled/uncontrolled active path, single-tabstop roving keyboard navigation, disabled items, and `asChild` composition |
41
42
  | [Modal](src/Modal/README.md) | WAI-ARIA Modal Dialog pattern — native `<dialog>`-based compound component with Portal, bounding-rect click-outside, controlled/uncontrolled state, escape hatches, imperative API, `asChild`, and `forceMount` animation hooks |
43
+ | [NavigationMenu](src/NavigationMenu/README.md) | ARIA APG Disclosure Navigation Menu pattern — the desktop dropdown site nav: `<nav>` landmark + `<ul>` of entries, one panel open at a time (`""` means closed), hover-intent open with instant switching and a nav-wide close grace, axis/RTL-aware arrow travel that keeps every entry in the tab order (not a roving tabindex), cross-axis arrow to enter a panel, Escape-to-close with focus return, an optional `Viewport` panels portal into so the open one morphs into the next, an optional `Indicator` publishing the open trigger's measured geometry as custom properties, and a `Link` carrying `aria-current` that the composed mobile nav (`Drawer` + `Collapsible`) reuses |
44
+ | [Popover](src/Popover/README.md) | WAI-ARIA non-modal dialog — native HTML Popover API (`popover="auto"`) compound with trigger/anchor/content/close/title/description, controlled/uncontrolled state, light-dismiss sync, focus-in-on-open + Escape focus-return (no focus trap), CSS-anchor-positioning placement, and `asChild` composition |
42
45
  | [Portal](src/Portal/README.md) | DOM-escape utility — renders children into `document.body` (or a consumer-supplied container) via `createPortal`; compose with any component that needs to break out of the current stacking context |
43
46
  | [Progress](src/Progress/README.md) | WAI-ARIA progressbar pattern — display-only compound component reflecting a consumer-supplied `value`, with determinate/indeterminate states, configurable `max`, `getValueLabel` for `aria-valuetext`, `data-*` styling hooks on the fill `Indicator`, and `asChild` composition |
44
47
  | [Radio](src/Radio/README.md) | Standalone — a real, visually-hidden native `<input type="radio">` in a `<label>` box, so siblings sharing a `name` form a native radio group (the browser enforces single-selection) and it participates in forms; controlled/uncontrolled state, `Indicator` dot, `disabled` with `data-disabled` styling hook, and `asChild` on the indicator (use `RadioGroup` for managed roving-tabindex sets) |
45
48
  | [RadioCard](src/RadioCard/README.md) | WAI-ARIA Radio Group pattern in a card/tile layout — the entire card surface is the interactive element, with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
46
49
  | [RadioGroup](src/RadioGroup/README.md) | WAI-ARIA Radio Group pattern — native `<button role="radio">` items with single-selection state, roving tabindex, arrow-key navigation, disabled-item skipping, `Indicator` slot, and `asChild` composition |
50
+ | [SegmentedControl](src/SegmentedControl/README.md) | Single-select value picker (WAI-ARIA Radio Group semantics) styled as a linear pill strip — native `<button role="radio">` segments with controlled/uncontrolled state, roving tabindex, arrow-key navigation, horizontal/vertical orientation, per-item **and** whole-control `disabled`, `data-state` / `data-orientation` / `data-disabled` styling hooks, and `asChild` composition (use `ToggleGroup` for on/off command toggles) |
47
51
  | [Select](src/Select/README.md) | Headless wrapper over native `<select>` / `<option>` / `<optgroup>` — controlled/uncontrolled state via `onValueChange`, `Select.Placeholder` with auto-`defaultValue` inference, `Select.Group` for optgroup labelling, `data-disabled` styling hook, `asChild` composition, and Field-aware (opts into `FieldContext` for auto-wired `id` / `aria-describedby` / `aria-invalid` / `disabled` / `required`) |
48
52
  | [SkipNav](src/SkipNav/README.md) | WCAG 2.4.1 Bypass Blocks technique — a stateless "skip to main content" link and its `tabIndex=-1` focus target, connected by a shared content id, working with no JavaScript at runtime |
49
53
  | [Slider](src/Slider/README.md) | WAI-ARIA Slider & Multi-Thumb Slider pattern — an array-valued range input with any number of independently focusable thumbs, keyboard and pointer dragging, `min`/`max`/`step`, `minStepsBetweenThumbs`, horizontal/vertical orientation, RTL and `inverted` direction, `onValueCommit`, hidden inputs for form submission, `disabled` with `data-disabled` styling hook, and `asChild` composition |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/react",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Headless, accessible React components built on the WAI-ARIA authoring patterns. Zero styles ship with this package — bring your own (CSS, Tailwind, CSS-in-JS, design tokens).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,6 +34,8 @@
34
34
  }
35
35
  },
36
36
  "devDependencies": {
37
+ "@stryker-mutator/core": "9.6.1",
38
+ "@stryker-mutator/vitest-runner": "9.6.1",
37
39
  "@testing-library/dom": "^10.4.1",
38
40
  "@testing-library/jest-dom": "^6.9.1",
39
41
  "@testing-library/react": "^16.3.2",
@@ -57,6 +59,9 @@
57
59
  "qa": "pnpm qa:units",
58
60
  "qa:units": "vitest run --coverage",
59
61
  "qa:units:watch": "vitest --coverage",
62
+ "mutate": "stryker run",
63
+ "mutate:component": "node ../../scripts/mutate.mjs",
64
+ "mutate:local": "node ../../scripts/mutate-local.mjs",
60
65
  "typecheck": "tsc -p tsconfig.json --noEmit"
61
66
  }
62
67
  }
@@ -5,17 +5,31 @@ import { AccessibleIconProps } from "./types";
5
5
  type DecorativeIconProps = { "aria-hidden": string; focusable: string };
6
6
 
7
7
  /**
8
- * Gives an icon-only control an accessible name.
8
+ * Gives an icon-only control an accessible name without adding a visible label.
9
9
  *
10
- * Wraps a single decorative icon: the icon element is cloned with
11
- * `aria-hidden="true"` (removing it from the accessibility tree) and
12
- * `focusable="false"` (so legacy browsers skip the `<svg>` in the tab
13
- * order), and a {@link VisuallyHidden} text label is rendered alongside
14
- * it. The label is announced by assistive technology while the icon
15
- * stays the only thing on screen.
10
+ * Wraps a single decorative icon element and performs two mutations via
11
+ * `cloneElement`:
16
12
  *
17
- * Place it inside an interactive element — the visually hidden label
18
- * becomes that element's accessible name:
13
+ * - Sets `aria-hidden="true"` on the icon, removing the purely decorative
14
+ * graphic from the accessibility tree.
15
+ * - Sets `focusable="false"` on the icon, so legacy browsers (IE/Edge
16
+ * classic) do not place the `<svg>` in the tab order.
17
+ *
18
+ * The {@link AccessibleIconProps.label | `label`} string is rendered
19
+ * alongside the icon inside a {@link VisuallyHidden} span — invisible on
20
+ * screen but announced by assistive technology. When `AccessibleIcon` is
21
+ * placed inside an interactive element (`<button>`, `<a>`, etc.), the
22
+ * hidden label becomes that element's accessible name. A screen reader
23
+ * will announce, for example, "Close dialog, button" rather than nothing.
24
+ *
25
+ * **Runtime validation.** `children` must be exactly **one** React
26
+ * element. Passing zero, multiple, or a plain string throws a React
27
+ * `Children.only` error. Wrap the glyph in a single `<svg>` or component
28
+ * if your icon library returns a fragment.
29
+ *
30
+ * **Renders no host element.** `AccessibleIcon` returns a React fragment
31
+ * (`<>…</>`), so it adds no DOM node of its own. The icon element and the
32
+ * {@link VisuallyHidden} span are the only nodes emitted.
19
33
  *
20
34
  * @example Icon-only button
21
35
  * ```tsx
@@ -25,6 +39,15 @@ type DecorativeIconProps = { "aria-hidden": string; focusable: string };
25
39
  * </AccessibleIcon>
26
40
  * </button>
27
41
  * ```
42
+ *
43
+ * @example Icon-only link
44
+ * ```tsx
45
+ * <a href="/home">
46
+ * <AccessibleIcon label="Go to home page">
47
+ * <HomeIcon />
48
+ * </AccessibleIcon>
49
+ * </a>
50
+ * ```
28
51
  */
29
52
  export function AccessibleIcon({
30
53
  label,
@@ -44,4 +44,9 @@ describe("AccessibleIcon component", () => {
44
44
  const label = screen.getByText("Search");
45
45
  expect(label).toHaveStyle({ position: "absolute", overflow: "hidden" });
46
46
  });
47
+
48
+ it('sets displayName to "AccessibleIcon" for React DevTools and stack traces', () => {
49
+ // Assert — an empty displayName would render it as an anonymous component.
50
+ expect(AccessibleIcon.displayName).toBe("AccessibleIcon");
51
+ });
47
52
  });
@@ -3,10 +3,23 @@ import { ReactElement } from "react";
3
3
  /**
4
4
  * Props for {@link AccessibleIcon} — a single icon element plus the
5
5
  * visually-hidden `label` announced as its accessible name.
6
+ *
7
+ * `AccessibleIconProps` does not extend any HTML element props: the
8
+ * component renders a React fragment with no host element of its own.
6
9
  */
7
10
  export type AccessibleIconProps = {
8
- /** Text announced by assistive technology as the icon's accessible name. */
11
+ /**
12
+ * Text announced by assistive technology as the accessible name of the
13
+ * surrounding interactive control. Keep it concise and action-oriented
14
+ * (e.g. `"Close dialog"`, `"Go to home page"`). This string is rendered
15
+ * inside a {@link VisuallyHidden} span — it is never visible on screen.
16
+ */
9
17
  label: string;
10
- /** A single icon element (e.g. an `<svg>`). */
18
+ /**
19
+ * Exactly one React element representing the icon (typically an `<svg>`
20
+ * or a component that renders one). The element is cloned with
21
+ * `aria-hidden="true"` and `focusable="false"` applied. Passing zero,
22
+ * multiple, or a non-element child throws a React `Children.only` error.
23
+ */
11
24
  children: ReactElement;
12
25
  };
@@ -52,6 +52,8 @@ import { useAccordionTrigger } from "./hooks/useAccordionTrigger";
52
52
  * When omitted, it is inherited from the nearest {@link DirectionProvider},
53
53
  * falling back to `"ltr"` when there is no provider.
54
54
  *
55
+ * @extends HTMLDivElement
56
+ *
55
57
  * @example Uncontrolled — single open item
56
58
  * ```tsx
57
59
  * <Accordion.Root defaultValue="item-1">
@@ -100,6 +102,7 @@ export function AccordionRoot({
100
102
  }
101
103
 
102
104
  /** @internal */
105
+ // Stryker disable next-line StringLiteral: overwritten by the compound alias — an equivalent mutant.
103
106
  AccordionRoot.displayName = "AccordionRoot";
104
107
 
105
108
  /**
@@ -112,6 +115,8 @@ AccordionRoot.displayName = "AccordionRoot";
112
115
  * stable auto-generated ID is used via `useId()` — useful for fully anonymous
113
116
  * items where the consumer doesn't need to drive state from outside.
114
117
  *
118
+ * @extends HTMLDivElement
119
+ *
115
120
  * @example
116
121
  * ```tsx
117
122
  * <Accordion.Item value="shipping">
@@ -148,6 +153,8 @@ AccordionItem.displayName = "AccordionItem";
148
153
  * The WAI-ARIA Accordion pattern requires each trigger to be wrapped in a
149
154
  * heading at the appropriate level for its position in the document outline.
150
155
  *
156
+ * @extends HTMLHeadingElement
157
+ *
151
158
  * @example
152
159
  * ```tsx
153
160
  * <Accordion.Header level={2}>
@@ -216,6 +223,8 @@ AccordionHeader.displayName = "AccordionHeader";
216
223
  * - `data-state="open" | "closed"` on the rendered element.
217
224
  * - `data-disabled="true" | "false"`.
218
225
  *
226
+ * @extends HTMLButtonElement
227
+ *
219
228
  * @example Basic
220
229
  * ```tsx
221
230
  * <Accordion.Trigger>Shipping policy</Accordion.Trigger>
@@ -293,6 +302,8 @@ AccordionTrigger.displayName = "AccordionTrigger";
293
302
  * **Styling hooks.**
294
303
  * - `data-state="open" | "closed"` on the rendered element.
295
304
  *
305
+ * @extends HTMLDivElement
306
+ *
296
307
  * @example Default (hidden attribute)
297
308
  * ```tsx
298
309
  * <Accordion.Content>Free shipping on orders over £50.</Accordion.Content>
@@ -313,10 +324,18 @@ export function AccordionContent({
313
324
  const { panelId, buttonId, itemId, isExpanded } = useAccordionItemContext();
314
325
  const { registerPanel, unregisterPanel } = useAccordionContext();
315
326
 
316
- useEffect(() => {
317
- registerPanel(itemId);
318
- return () => unregisterPanel(itemId);
319
- }, [itemId, registerPanel, unregisterPanel]);
327
+ useEffect(
328
+ () => {
329
+ registerPanel(itemId);
330
+ return () => unregisterPanel(itemId);
331
+ },
332
+ // `registerPanel`/`unregisterPanel` are stable, and `itemId` is effectively
333
+ // immutable: changing an item's value produces a phantom-key validation
334
+ // throw (the collection keeps unmounted keys), so it is never a supported
335
+ // runtime transition. Emptying the deps is therefore equivalent.
336
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable/immutable dependencies.
337
+ [itemId, registerPanel, unregisterPanel],
338
+ );
320
339
 
321
340
  return (
322
341
  <div
@@ -352,6 +371,8 @@ AccordionContent.displayName = "AccordionContent";
352
371
  * - `data-state="open" | "closed"` on the rendered `<span>`.
353
372
  * - `aria-hidden="true"` on the rendered `<span>`.
354
373
  *
374
+ * @extends HTMLSpanElement
375
+ *
355
376
  * @example Inline SVG
356
377
  * ```tsx
357
378
  * <Accordion.Trigger>
@@ -234,4 +234,50 @@ describe("Accordion asChild tests", () => {
234
234
  // Assert — no role="button" injected on enabled asChild elements
235
235
  expect(trigger).not.toHaveAttribute("role", "button");
236
236
  });
237
+
238
+ // A plain <div> (no native button semantics, no role) fires no synthetic
239
+ // click on Enter/Space, so these isolate the Trigger's own keydown handler —
240
+ // a native <button> or anchor would toggle via click even if the handler did
241
+ // nothing, masking mutations in it.
242
+ it("toggles on Enter for a non-native asChild element", async () => {
243
+ const user = userEvent.setup();
244
+ render(
245
+ <Accordion.Root>
246
+ <Accordion.Item value="a">
247
+ <Accordion.Header>
248
+ <Accordion.Trigger asChild>
249
+ <div tabIndex={0} data-testid="trigger">
250
+ Toggle
251
+ </div>
252
+ </Accordion.Trigger>
253
+ </Accordion.Header>
254
+ <Accordion.Content data-testid="content">Content</Accordion.Content>
255
+ </Accordion.Item>
256
+ </Accordion.Root>,
257
+ );
258
+ screen.getByTestId("trigger").focus();
259
+ await user.keyboard("{Enter}");
260
+ expect(screen.getByTestId("content")).toBeVisible();
261
+ });
262
+
263
+ it("toggles on Space for a non-native asChild element", async () => {
264
+ const user = userEvent.setup();
265
+ render(
266
+ <Accordion.Root>
267
+ <Accordion.Item value="a">
268
+ <Accordion.Header>
269
+ <Accordion.Trigger asChild>
270
+ <div tabIndex={0} data-testid="trigger">
271
+ Toggle
272
+ </div>
273
+ </Accordion.Trigger>
274
+ </Accordion.Header>
275
+ <Accordion.Content data-testid="content">Content</Accordion.Content>
276
+ </Accordion.Item>
277
+ </Accordion.Root>,
278
+ );
279
+ screen.getByTestId("trigger").focus();
280
+ await user.keyboard(" ");
281
+ expect(screen.getByTestId("content")).toBeVisible();
282
+ });
237
283
  });
@@ -3,6 +3,21 @@ import { render, screen } from "@testing-library/react";
3
3
  import { Accordion } from "../Accordion";
4
4
  import { type HeadingLevel } from "../../types";
5
5
 
6
+ describe("Accordion displayNames", () => {
7
+ it("sets a displayName on the compound and each sub-component", () => {
8
+ // Assert — empty displayNames would render each as anonymous in DevTools.
9
+ // `Accordion`, `Accordion.Root`, and the underlying function are one object
10
+ // (Object.assign compound), so the compound's "Accordion" alias is the
11
+ // observable Root displayName; the sub-components are distinct objects.
12
+ expect(Accordion.displayName).toBe("Accordion");
13
+ expect(Accordion.Item.displayName).toBe("AccordionItem");
14
+ expect(Accordion.Header.displayName).toBe("AccordionHeader");
15
+ expect(Accordion.Trigger.displayName).toBe("AccordionTrigger");
16
+ expect(Accordion.Content.displayName).toBe("AccordionContent");
17
+ expect(Accordion.TriggerIcon.displayName).toBe("AccordionTriggerIcon");
18
+ });
19
+ });
20
+
6
21
  describe("Accordion basic rendering tests", () => {
7
22
  it('should apply the attribute data-orientation="vertical" to the accordion by default', () => {
8
23
  // Arrange
@@ -279,6 +279,48 @@ describe("Accordion disabled items tests", () => {
279
279
  expect(trigger2).toHaveFocus();
280
280
  });
281
281
 
282
+ it("re-registers a trigger's disabled flag when it changes after mount", async () => {
283
+ // Arrange — all enabled at mount; the middle trigger becomes disabled after.
284
+ const user = userEvent.setup();
285
+ function Fixture({ middleDisabled }: { middleDisabled: boolean }) {
286
+ return (
287
+ <Accordion.Root>
288
+ <Accordion.Item value="a">
289
+ <Accordion.Header>
290
+ <Accordion.Trigger>Trigger 1</Accordion.Trigger>
291
+ </Accordion.Header>
292
+ <Accordion.Content>Content</Accordion.Content>
293
+ </Accordion.Item>
294
+ <Accordion.Item value="b">
295
+ <Accordion.Header>
296
+ <Accordion.Trigger disabled={middleDisabled}>
297
+ Trigger 2
298
+ </Accordion.Trigger>
299
+ </Accordion.Header>
300
+ <Accordion.Content>Content</Accordion.Content>
301
+ </Accordion.Item>
302
+ <Accordion.Item value="c">
303
+ <Accordion.Header>
304
+ <Accordion.Trigger>Trigger 3</Accordion.Trigger>
305
+ </Accordion.Header>
306
+ <Accordion.Content>Content</Accordion.Content>
307
+ </Accordion.Item>
308
+ </Accordion.Root>
309
+ );
310
+ }
311
+ const { rerender } = render(<Fixture middleDisabled={false} />);
312
+
313
+ // Act — disable Trigger 2 after mount, then arrow down from Trigger 1.
314
+ rerender(<Fixture middleDisabled />);
315
+ screen.getByRole("button", { name: "Trigger 1" }).focus();
316
+ await user.keyboard("[ArrowDown]");
317
+
318
+ // Assert — the registrar effect re-ran, so Trigger 2 is skipped and focus
319
+ // lands on Trigger 3. A stale registration would keep Trigger 2 navigable,
320
+ // landing focus on it (aria-disabled stays focusable).
321
+ expect(screen.getByRole("button", { name: "Trigger 3" })).toHaveFocus();
322
+ });
323
+
282
324
  it("should not throw when all triggers are disabled and arrow key is pressed", async () => {
283
325
  // Arrange
284
326
  const user = userEvent.setup();
@@ -92,6 +92,39 @@ describe("Accordion error handling tests", () => {
92
92
  }).toThrow(/AccordionTrigger.*no corresponding AccordionContent/);
93
93
  });
94
94
 
95
+ it("re-validates after a panel unmounts: a trigger left without content throws", () => {
96
+ // Arrange — item A valid (trigger + content); item B mountable to force a
97
+ // re-validation later by changing the registered trigger set.
98
+ function Fixture({ showAContent }: { showAContent: boolean }) {
99
+ return (
100
+ <Accordion.Root>
101
+ <Accordion.Item value="a">
102
+ <Accordion.Header>
103
+ <Accordion.Trigger>A</Accordion.Trigger>
104
+ </Accordion.Header>
105
+ {showAContent && <Accordion.Content>A content</Accordion.Content>}
106
+ </Accordion.Item>
107
+ {!showAContent && (
108
+ <Accordion.Item value="b">
109
+ <Accordion.Header>
110
+ <Accordion.Trigger>B</Accordion.Trigger>
111
+ </Accordion.Header>
112
+ <Accordion.Content>B content</Accordion.Content>
113
+ </Accordion.Item>
114
+ )}
115
+ </Accordion.Root>
116
+ );
117
+ }
118
+ const { rerender } = render(<Fixture showAContent />);
119
+
120
+ // Act & Assert — removing A's content unregisters its panel; adding B
121
+ // re-runs validation, which now finds A's trigger has no content. If the
122
+ // panel cleanup were a no-op, A would stay registered and no throw occurs.
123
+ expect(() => rerender(<Fixture showAContent={false} />)).toThrow(
124
+ /AccordionTrigger.*no corresponding AccordionContent/,
125
+ );
126
+ });
127
+
95
128
  it("should not throw when AccordionItem with Trigger and Content is added dynamically", () => {
96
129
  // Arrange
97
130
  function DynamicAccordion({ showItem }: { showItem: boolean }) {
@@ -20,21 +20,35 @@ export function useAccordionRoot(
20
20
  const accordionId = useId();
21
21
  // updateKeysOnCleanup is false so trigger unmounts don't fire setState
22
22
  // outside act() after a render-time validation throw.
23
+ // `updateKeysOnCleanup: false` avoids a setState-outside-act warning when a
24
+ // trigger unmounts after a render-time validation throw (see below). Flipping
25
+ // it to the default (true) changes only the timing of internal keys-state on
26
+ // unmount; it has no user-facing behavioural difference the contract exposes,
27
+ // so no test can distinguish it.
28
+ // Stryker disable next-line all
29
+ const triggerCollectionOptions = { updateKeysOnCleanup: false };
23
30
  const {
24
31
  register: registerTriggerBase,
25
32
  itemsRef: triggersRef,
26
33
  keys: registeredTriggerItemIds,
27
- } = useCollection<string, TriggerMeta>({ updateKeysOnCleanup: false });
34
+ } = useCollection<string, TriggerMeta>(triggerCollectionOptions);
28
35
  const panelsRef = useRef<Set<string>>(new Set());
29
36
 
30
37
  const registerTrigger = useCallback(
31
38
  (
32
39
  itemId: string,
33
40
  element: HTMLButtonElement | null,
41
+ // The default is only reached by the cleanup call `registerTrigger(id,
42
+ // null)`, where `element` is null and `disabled` is discarded — so its
43
+ // value is never observable.
44
+ // Stryker disable next-line BooleanLiteral
34
45
  disabled = false,
35
46
  ) => {
36
47
  registerTriggerBase(itemId, element ? { element, disabled } : null);
37
48
  },
49
+ // `registerTriggerBase` is a stable useCollection callback, so emptying this
50
+ // array yields the identical memoised function.
51
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
38
52
  [registerTriggerBase],
39
53
  );
40
54
 
@@ -54,8 +68,14 @@ export function useAccordionRoot(
54
68
 
55
69
  const focusTrigger = useCallback(
56
70
  (itemId: string) => {
71
+ // `itemId` is always sourced from the navigable set (registered triggers),
72
+ // so a value that reaches here is guaranteed to have a live entry.
73
+ // Stryker disable next-line OptionalChaining: unreachable given that invariant.
57
74
  triggersRef.current.get(itemId)?.element.focus();
58
75
  },
76
+ // `triggersRef` is a stable RefObject, so emptying this array yields the
77
+ // identical memoised function.
78
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependency.
59
79
  [triggersRef],
60
80
  );
61
81
 
@@ -67,6 +87,10 @@ export function useAccordionRoot(
67
87
  Set<string>
68
88
  >(
69
89
  controlledValue !== undefined ? new Set(controlledValue) : undefined,
90
+ // Forcing this condition true yields `new Set([undefined])` when no
91
+ // defaultValue is given; `undefined` can never equal an item id (ids are
92
+ // `value ?? useId()`), so it is observationally identical to the empty set.
93
+ // Stryker disable next-line ConditionalExpression
70
94
  defaultValue !== undefined ? new Set([defaultValue]) : new Set(),
71
95
  );
72
96
 
@@ -87,6 +111,9 @@ export function useAccordionRoot(
87
111
  (itemId: string) => {
88
112
  const next = computeNext(expandedItems, itemId);
89
113
  if (isControlled) {
114
+ // Controlled mode requires `onValueChange` (discriminated-union prop
115
+ // type), so the optional call can never no-op in valid usage.
116
+ // Stryker disable next-line OptionalChaining: unreachable — controlled requires onValueChange.
90
117
  onValueChange?.(Array.from(next));
91
118
  } else {
92
119
  setExpandedItems(next);
@@ -100,13 +127,23 @@ export function useAccordionRoot(
100
127
  // runs all effects in a commit before flushing batched state updates, the
101
128
  // panel effect always executes (and updates panelsRef) before the re-render
102
129
  // triggered by trigger registration reaches the validation effect.
103
- const registerPanel = useCallback((itemId: string) => {
104
- panelsRef.current.add(itemId);
105
- }, []);
130
+ const registerPanel = useCallback(
131
+ (itemId: string) => {
132
+ panelsRef.current.add(itemId);
133
+ },
134
+ // Empty, stable deps — filling the array yields the identical callback.
135
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependencies.
136
+ [],
137
+ );
106
138
 
107
- const unregisterPanel = useCallback((itemId: string) => {
108
- panelsRef.current.delete(itemId);
109
- }, []);
139
+ const unregisterPanel = useCallback(
140
+ (itemId: string) => {
141
+ panelsRef.current.delete(itemId);
142
+ },
143
+ // Empty, stable deps — filling the array yields the identical callback.
144
+ // Stryker disable next-line ArrayDeclaration: equivalent — stable dependencies.
145
+ [],
146
+ );
110
147
 
111
148
  useEffect(() => {
112
149
  for (const triggerId of registeredTriggerItemIds) {
@@ -12,7 +12,9 @@ export function useAccordionTrigger({
12
12
  ref,
13
13
  onClick,
14
14
  disabled,
15
- asChild = false,
15
+ // No default: the `AccordionTrigger` component always supplies a resolved
16
+ // `asChild` boolean, so a default here would be unreachable (dead) code.
17
+ asChild,
16
18
  ...rest
17
19
  }: Omit<AccordionTriggerProps, "children">) {
18
20
  const { buttonId, panelId, itemId, isExpanded } = useAccordionItemContext();
@@ -35,6 +37,12 @@ export function useAccordionTrigger({
35
37
  // useRovingTabindex expects.
36
38
  useEffect(() => {
37
39
  registerTrigger(itemId, triggerRef.current, disabled);
40
+ // Because the collection uses `updateKeysOnCleanup: false`, an unmount
41
+ // clears only this trigger's ref entry without a keys change, and focusing
42
+ // a removed trigger is a no-op whether the entry is absent or detached —
43
+ // so this cleanup has no observable effect the contract exposes. (On a dep
44
+ // change the effect body re-registers first, overwriting it.)
45
+ // Stryker disable next-line ArrowFunction: equivalent — no observable effect.
38
46
  return () => registerTrigger(itemId, null);
39
47
  }, [itemId, disabled, registerTrigger]);
40
48