@primitiv-ui/react 0.1.27 → 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
@@ -0,0 +1,167 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+
3
+ import { MockResizeObserver } from "../../test/resizeObserverPolyfill";
4
+ import { Carousel } from "../index.ts";
5
+
6
+ // scroll-margin extends a multi-slide page's *leading* slide's native
7
+ // scroll-snap "snap area" out to the page's actual last member, so a user's
8
+ // own swipe/wheel/touch settling (driven entirely by the browser's snap
9
+ // engine, never our JS) agrees with the programmatic scrollTo fix in
10
+ // Carousel.scroll-sync.test.tsx — both now measure the whole page, not just
11
+ // the leading slide's own box. Mocks are attached to the shared prototypes
12
+ // (not specific instances found via screen.getByTestId after render) since
13
+ // the effect that sets scroll-margin runs on the very first render.
14
+ describe("Carousel multi-slide scroll-margin", () => {
15
+ function mockRects(rectsByTestId: Record<string, Partial<DOMRect>>) {
16
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
17
+ function (this: HTMLElement) {
18
+ const rect = rectsByTestId[this.dataset.testid ?? ""];
19
+ return { left: 0, top: 0, width: 0, height: 0, ...rect } as DOMRect;
20
+ },
21
+ );
22
+ }
23
+
24
+ it("should set scroll-margin-inline-end on each page's leading slide to reach its last member", () => {
25
+ mockRects({
26
+ "slide-0": { left: 0, width: 240 },
27
+ "slide-1": { left: 240, width: 240 },
28
+ "slide-2": { left: 480, width: 240 },
29
+ "slide-3": { left: 720, width: 240 },
30
+ });
31
+
32
+ render(
33
+ <Carousel.Root ariaLabel="Featured products" slidesPerPage={2}>
34
+ <Carousel.Viewport>
35
+ <Carousel.Slide data-testid="slide-0" />
36
+ <Carousel.Slide data-testid="slide-1" />
37
+ <Carousel.Slide data-testid="slide-2" />
38
+ <Carousel.Slide data-testid="slide-3" />
39
+ </Carousel.Viewport>
40
+ </Carousel.Root>,
41
+ );
42
+
43
+ // Page 0 = [slide-0, slide-1]; page 1 = [slide-2, slide-3]. Each leading
44
+ // slide's own trailing edge (240 / 720) must extend to its page's real
45
+ // last member's trailing edge (480 / 960) — 240px either way.
46
+ expect(screen.getByTestId("slide-0").style.scrollMarginInlineEnd).toBe(
47
+ "240px",
48
+ );
49
+ expect(screen.getByTestId("slide-2").style.scrollMarginInlineEnd).toBe(
50
+ "240px",
51
+ );
52
+ // Non-leading members never get it set at all.
53
+ expect(screen.getByTestId("slide-1").style.scrollMarginInlineEnd).toBe("");
54
+ expect(screen.getByTestId("slide-3").style.scrollMarginInlineEnd).toBe("");
55
+ });
56
+
57
+ it("should not set any scroll-margin when slidesPerPage is 1 (the common case)", () => {
58
+ mockRects({
59
+ "slide-0": { left: 0, width: 480 },
60
+ "slide-1": { left: 480, width: 480 },
61
+ });
62
+
63
+ render(
64
+ <Carousel.Root ariaLabel="Featured products">
65
+ <Carousel.Viewport>
66
+ <Carousel.Slide data-testid="slide-0" />
67
+ <Carousel.Slide data-testid="slide-1" />
68
+ </Carousel.Viewport>
69
+ </Carousel.Root>,
70
+ );
71
+
72
+ expect(screen.getByTestId("slide-0").style.scrollMarginInlineEnd).toBe("");
73
+ expect(screen.getByTestId("slide-1").style.scrollMarginInlineEnd).toBe("");
74
+ });
75
+
76
+ it("should use scroll-margin-block-end (not inline) when vertical, leaving inline unset", () => {
77
+ mockRects({
78
+ "slide-0": { top: 0, height: 180 },
79
+ "slide-1": { top: 180, height: 180 },
80
+ });
81
+
82
+ render(
83
+ <Carousel.Root
84
+ ariaLabel="Featured products"
85
+ orientation="vertical"
86
+ slidesPerPage={2}
87
+ >
88
+ <Carousel.Viewport>
89
+ <Carousel.Slide data-testid="slide-0" />
90
+ <Carousel.Slide data-testid="slide-1" />
91
+ </Carousel.Viewport>
92
+ </Carousel.Root>,
93
+ );
94
+
95
+ expect(screen.getByTestId("slide-0").style.scrollMarginBlockEnd).toBe(
96
+ "180px",
97
+ );
98
+ expect(screen.getByTestId("slide-0").style.scrollMarginInlineEnd).toBe("");
99
+ });
100
+
101
+ it("should correctly measure the offset-shifted, overlapping last page (uneven total)", () => {
102
+ // total=5, perPage=2: offsets 0, 2, maxOffset=3 → windows [0,1] [2,3] [3,4].
103
+ // The last page's leading slide is 3, sharing slide-3 with the middle
104
+ // page — its scroll-margin must still reach slide-4, not slide-1 or a
105
+ // stale value from the earlier page.
106
+ mockRects({
107
+ "slide-0": { left: 0, width: 200 },
108
+ "slide-1": { left: 200, width: 200 },
109
+ "slide-2": { left: 400, width: 200 },
110
+ "slide-3": { left: 600, width: 200 },
111
+ "slide-4": { left: 800, width: 200 },
112
+ });
113
+
114
+ render(
115
+ <Carousel.Root ariaLabel="Featured products" slidesPerPage={2}>
116
+ <Carousel.Viewport>
117
+ <Carousel.Slide data-testid="slide-0" />
118
+ <Carousel.Slide data-testid="slide-1" />
119
+ <Carousel.Slide data-testid="slide-2" />
120
+ <Carousel.Slide data-testid="slide-3" />
121
+ <Carousel.Slide data-testid="slide-4" />
122
+ </Carousel.Viewport>
123
+ </Carousel.Root>,
124
+ );
125
+
126
+ expect(screen.getByTestId("slide-3").style.scrollMarginInlineEnd).toBe(
127
+ "200px",
128
+ );
129
+ });
130
+
131
+ it("should recompute the scroll-margin when the viewport resizes", () => {
132
+ mockRects({
133
+ "slide-0": { left: 0, width: 240 },
134
+ "slide-1": { left: 240, width: 240 },
135
+ });
136
+
137
+ render(
138
+ <Carousel.Root ariaLabel="Featured products" slidesPerPage={2}>
139
+ <Carousel.Viewport data-testid="viewport">
140
+ <Carousel.Slide data-testid="slide-0" />
141
+ <Carousel.Slide data-testid="slide-1" />
142
+ </Carousel.Viewport>
143
+ </Carousel.Root>,
144
+ );
145
+
146
+ expect(screen.getByTestId("slide-0").style.scrollMarginInlineEnd).toBe(
147
+ "240px",
148
+ );
149
+
150
+ // Simulate a narrower viewport shrinking both slides proportionally.
151
+ mockRects({
152
+ "slide-0": { left: 0, width: 120 },
153
+ "slide-1": { left: 120, width: 120 },
154
+ });
155
+
156
+ act(() => {
157
+ // fireAll(), not `.latest` — the scroll-progress signal now also
158
+ // observes the Viewport, so "latest" no longer reliably refers to
159
+ // this effect's own observer.
160
+ MockResizeObserver.fireAll();
161
+ });
162
+
163
+ expect(screen.getByTestId("slide-0").style.scrollMarginInlineEnd).toBe(
164
+ "120px",
165
+ );
166
+ });
167
+ });
@@ -0,0 +1,518 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+ import { createRef } from "react";
3
+
4
+ import { MockResizeObserver } from "../../test/resizeObserverPolyfill";
5
+ import { Carousel } from "../index.ts";
6
+ import type { CarouselImperativeApi } from "../index.ts";
7
+
8
+ function fixture(
9
+ apiRef: React.Ref<CarouselImperativeApi>,
10
+ props: { orientation?: "horizontal" | "vertical" } = {},
11
+ ) {
12
+ return (
13
+ <Carousel.Root ref={apiRef} ariaLabel="Featured products" {...props}>
14
+ <Carousel.Viewport data-testid="viewport">
15
+ <Carousel.Slide data-testid="slide-0" />
16
+ <Carousel.Slide data-testid="slide-1" />
17
+ <Carousel.Slide data-testid="slide-2" />
18
+ </Carousel.Viewport>
19
+ </Carousel.Root>
20
+ );
21
+ }
22
+
23
+ function mockHorizontalScrollGeometry(
24
+ el: HTMLElement,
25
+ { scrollLeft = 0, scrollWidth = 0, clientWidth = 0 },
26
+ ) {
27
+ Object.defineProperty(el, "scrollLeft", {
28
+ value: scrollLeft,
29
+ configurable: true,
30
+ });
31
+ Object.defineProperty(el, "scrollWidth", {
32
+ value: scrollWidth,
33
+ configurable: true,
34
+ });
35
+ Object.defineProperty(el, "clientWidth", {
36
+ value: clientWidth,
37
+ configurable: true,
38
+ });
39
+ }
40
+
41
+ function mockVerticalScrollGeometry(
42
+ el: HTMLElement,
43
+ { scrollTop = 0, scrollHeight = 0, clientHeight = 0 },
44
+ ) {
45
+ Object.defineProperty(el, "scrollTop", {
46
+ value: scrollTop,
47
+ configurable: true,
48
+ });
49
+ Object.defineProperty(el, "scrollHeight", {
50
+ value: scrollHeight,
51
+ configurable: true,
52
+ });
53
+ Object.defineProperty(el, "clientHeight", {
54
+ value: clientHeight,
55
+ configurable: true,
56
+ });
57
+ }
58
+
59
+ // The scroll/resize-driven recompute is rAF-batched (the mount-time call
60
+ // bypasses it, staying synchronous). Capture the scheduled callback instead
61
+ // of letting real timers run it, so each test decides exactly when a
62
+ // pending frame flushes.
63
+ let pendingFrame: (() => void) | null = null;
64
+ const cancelledFrameIds: number[] = [];
65
+ const requestAnimationFrameSpy = vi.fn((cb: () => void) => {
66
+ pendingFrame = cb;
67
+ return 1;
68
+ });
69
+
70
+ function flushFrame() {
71
+ const cb = pendingFrame;
72
+ pendingFrame = null;
73
+ cb?.();
74
+ }
75
+
76
+ beforeEach(() => {
77
+ pendingFrame = null;
78
+ cancelledFrameIds.length = 0;
79
+ requestAnimationFrameSpy.mockClear();
80
+ vi.stubGlobal("requestAnimationFrame", requestAnimationFrameSpy);
81
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => {
82
+ cancelledFrameIds.push(id);
83
+ });
84
+ });
85
+
86
+ afterEach(() => {
87
+ vi.unstubAllGlobals();
88
+ });
89
+
90
+ describe("Carousel imperative getScrollProgress", () => {
91
+ it("returns 0 when there is no scrollable overflow", () => {
92
+ const ref = createRef<CarouselImperativeApi>();
93
+ render(fixture(ref));
94
+
95
+ expect(ref.current!.getScrollProgress()).toBe(0);
96
+ });
97
+
98
+ it("reflects scrollLeft/scrollWidth/clientWidth after a scroll event (horizontal)", () => {
99
+ const ref = createRef<CarouselImperativeApi>();
100
+ render(fixture(ref));
101
+ const viewport = screen.getByTestId("viewport");
102
+
103
+ mockHorizontalScrollGeometry(viewport, {
104
+ scrollLeft: 150,
105
+ scrollWidth: 900,
106
+ clientWidth: 300,
107
+ });
108
+
109
+ act(() => {
110
+ viewport.dispatchEvent(new Event("scroll"));
111
+ });
112
+ act(() => {
113
+ flushFrame();
114
+ });
115
+
116
+ // maxScroll = 900 - 300 = 600; 150 / 600 = 0.25
117
+ expect(ref.current!.getScrollProgress()).toBe(0.25);
118
+ });
119
+
120
+ it("reflects scrollTop/scrollHeight/clientHeight when orientation is vertical", () => {
121
+ const ref = createRef<CarouselImperativeApi>();
122
+ render(fixture(ref, { orientation: "vertical" }));
123
+ const viewport = screen.getByTestId("viewport");
124
+
125
+ mockVerticalScrollGeometry(viewport, {
126
+ scrollTop: 100,
127
+ scrollHeight: 500,
128
+ clientHeight: 200,
129
+ });
130
+
131
+ act(() => {
132
+ viewport.dispatchEvent(new Event("scroll"));
133
+ });
134
+ act(() => {
135
+ flushFrame();
136
+ });
137
+
138
+ // maxScroll = 500 - 200 = 300; 100 / 300 = 1/3
139
+ expect(ref.current!.getScrollProgress()).toBeCloseTo(1 / 3);
140
+ });
141
+
142
+ it("returns 0 when maxScroll is not positive (no overflow to scroll)", () => {
143
+ const ref = createRef<CarouselImperativeApi>();
144
+ render(fixture(ref));
145
+ const viewport = screen.getByTestId("viewport");
146
+
147
+ mockHorizontalScrollGeometry(viewport, {
148
+ scrollLeft: 0,
149
+ scrollWidth: 300,
150
+ clientWidth: 300,
151
+ });
152
+
153
+ act(() => {
154
+ viewport.dispatchEvent(new Event("scroll"));
155
+ });
156
+ act(() => {
157
+ flushFrame();
158
+ });
159
+
160
+ expect(ref.current!.getScrollProgress()).toBe(0);
161
+ });
162
+
163
+ it("clamps to 1 when scrollLeft exceeds maxScroll", () => {
164
+ const ref = createRef<CarouselImperativeApi>();
165
+ render(fixture(ref));
166
+ const viewport = screen.getByTestId("viewport");
167
+
168
+ mockHorizontalScrollGeometry(viewport, {
169
+ scrollLeft: 9999,
170
+ scrollWidth: 900,
171
+ clientWidth: 300,
172
+ });
173
+
174
+ act(() => {
175
+ viewport.dispatchEvent(new Event("scroll"));
176
+ });
177
+ act(() => {
178
+ flushFrame();
179
+ });
180
+
181
+ expect(ref.current!.getScrollProgress()).toBe(1);
182
+ });
183
+
184
+ it("treats a negative scrollLeft (the standardized RTL convention) as forward progress via Math.abs", () => {
185
+ const ref = createRef<CarouselImperativeApi>();
186
+ render(fixture(ref));
187
+ const viewport = screen.getByTestId("viewport");
188
+
189
+ mockHorizontalScrollGeometry(viewport, {
190
+ scrollLeft: -450,
191
+ scrollWidth: 900,
192
+ clientWidth: 300,
193
+ });
194
+
195
+ act(() => {
196
+ viewport.dispatchEvent(new Event("scroll"));
197
+ });
198
+ act(() => {
199
+ flushFrame();
200
+ });
201
+
202
+ // maxScroll = 600; |-450| / 600 = 0.75
203
+ expect(ref.current!.getScrollProgress()).toBe(0.75);
204
+ });
205
+
206
+ it("mirrors the value onto the viewport's --carousel-progress custom property", () => {
207
+ const ref = createRef<CarouselImperativeApi>();
208
+ render(fixture(ref));
209
+ const viewport = screen.getByTestId("viewport");
210
+
211
+ mockHorizontalScrollGeometry(viewport, {
212
+ scrollLeft: 300,
213
+ scrollWidth: 900,
214
+ clientWidth: 300,
215
+ });
216
+
217
+ act(() => {
218
+ viewport.dispatchEvent(new Event("scroll"));
219
+ });
220
+ act(() => {
221
+ flushFrame();
222
+ });
223
+
224
+ expect(viewport.style.getPropertyValue("--carousel-progress")).toBe("0.5");
225
+ });
226
+
227
+ it("computes the value once synchronously on mount, before any scroll event", () => {
228
+ const ref = createRef<CarouselImperativeApi>();
229
+ render(fixture(ref));
230
+ const viewport = screen.getByTestId("viewport");
231
+
232
+ // Geometry mocked with defineProperty before render would be racier to
233
+ // set up; instead confirm the mount-time call by asserting the CSS var
234
+ // is already present (not just unset) immediately after render, using
235
+ // whatever the default (zero-overflow) geometry resolves to.
236
+ expect(viewport.style.getPropertyValue("--carousel-progress")).toBe("0");
237
+ });
238
+
239
+ it("removes the scroll listener on unmount", () => {
240
+ const ref = createRef<CarouselImperativeApi>();
241
+ const { unmount } = render(fixture(ref));
242
+ const viewport = screen.getByTestId("viewport");
243
+ const removeSpy = vi.spyOn(viewport, "removeEventListener");
244
+
245
+ unmount();
246
+
247
+ expect(removeSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
248
+ });
249
+ });
250
+
251
+ describe("Carousel scroll-progress rAF batching", () => {
252
+ it("coalesces multiple scroll events within one animation frame into a single recompute", () => {
253
+ const ref = createRef<CarouselImperativeApi>();
254
+ render(fixture(ref));
255
+ const viewport = screen.getByTestId("viewport");
256
+
257
+ // The mount-time call is synchronous (bypasses the rAF gate), so only
258
+ // scroll-driven scheduling should count here.
259
+ requestAnimationFrameSpy.mockClear();
260
+
261
+ act(() => {
262
+ viewport.dispatchEvent(new Event("scroll"));
263
+ viewport.dispatchEvent(new Event("scroll"));
264
+ });
265
+
266
+ expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
267
+ });
268
+
269
+ it("cancels a pending animation frame on unmount", () => {
270
+ const ref = createRef<CarouselImperativeApi>();
271
+ const { unmount } = render(fixture(ref));
272
+ const viewport = screen.getByTestId("viewport");
273
+
274
+ act(() => {
275
+ viewport.dispatchEvent(new Event("scroll"));
276
+ });
277
+
278
+ unmount();
279
+
280
+ expect(cancelledFrameIds).toEqual([1]);
281
+ });
282
+ });
283
+
284
+ describe("Carousel imperative getSlideProgress", () => {
285
+ function mockRects(rectsByTestId: Record<string, Partial<DOMRect>>) {
286
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
287
+ function (this: HTMLElement) {
288
+ const rect = rectsByTestId[this.dataset.testid ?? ""];
289
+ return { left: 0, top: 0, width: 0, height: 0, ...rect } as DOMRect;
290
+ },
291
+ );
292
+ }
293
+
294
+ it("returns 0 for a never-registered/out-of-range index", () => {
295
+ const ref = createRef<CarouselImperativeApi>();
296
+ render(fixture(ref));
297
+
298
+ expect(ref.current!.getSlideProgress(99)).toBe(0);
299
+ });
300
+
301
+ it("returns 0 for a slide centered in the viewport", () => {
302
+ const ref = createRef<CarouselImperativeApi>();
303
+ mockRects({
304
+ viewport: { left: 0, width: 300 },
305
+ "slide-0": { left: 75, width: 150 },
306
+ });
307
+ render(fixture(ref));
308
+
309
+ expect(ref.current!.getSlideProgress(0)).toBe(0);
310
+ });
311
+
312
+ it("is negative when the slide's center sits before the viewport's center", () => {
313
+ const ref = createRef<CarouselImperativeApi>();
314
+ mockRects({
315
+ viewport: { left: 0, width: 300 },
316
+ "slide-0": { left: 0, width: 150 },
317
+ });
318
+ render(fixture(ref));
319
+
320
+ // slideCenter=75, viewportCenter=150, halfSize=150 -> (75-150)/150 = -0.5
321
+ expect(ref.current!.getSlideProgress(0)).toBeCloseTo(-0.5);
322
+ });
323
+
324
+ it("is positive when the slide's center sits after the viewport's center", () => {
325
+ const ref = createRef<CarouselImperativeApi>();
326
+ mockRects({
327
+ viewport: { left: 0, width: 300 },
328
+ "slide-0": { left: 150, width: 150 },
329
+ });
330
+ render(fixture(ref));
331
+
332
+ // slideCenter=225, viewportCenter=150, halfSize=150 -> (225-150)/150 = 0.5
333
+ expect(ref.current!.getSlideProgress(0)).toBeCloseTo(0.5);
334
+ });
335
+
336
+ it("clamps to -1/+1 at or beyond the viewport edge", () => {
337
+ const ref = createRef<CarouselImperativeApi>();
338
+ mockRects({
339
+ viewport: { left: 0, width: 300 },
340
+ "slide-0": { left: -1000, width: 150 },
341
+ "slide-1": { left: 1000, width: 150 },
342
+ });
343
+ render(fixture(ref));
344
+
345
+ expect(ref.current!.getSlideProgress(0)).toBe(-1);
346
+ expect(ref.current!.getSlideProgress(1)).toBe(1);
347
+ });
348
+
349
+ it("uses vertical geometry (top/height) when orientation is vertical", () => {
350
+ const ref = createRef<CarouselImperativeApi>();
351
+ mockRects({
352
+ viewport: { top: 0, height: 300 },
353
+ "slide-0": { top: 150, height: 150 },
354
+ });
355
+ render(fixture(ref, { orientation: "vertical" }));
356
+
357
+ // slideCenter=225, viewportCenter=150, halfSize=150 -> 0.5
358
+ expect(ref.current!.getSlideProgress(0)).toBeCloseTo(0.5);
359
+ });
360
+
361
+ it("returns 0 when the viewport's half-extent is 0 (defensive divide-by-zero guard)", () => {
362
+ const ref = createRef<CarouselImperativeApi>();
363
+ mockRects({
364
+ viewport: { left: 0, width: 0 },
365
+ "slide-0": { left: 50, width: 100 },
366
+ });
367
+ render(fixture(ref));
368
+
369
+ expect(ref.current!.getSlideProgress(0)).toBe(0);
370
+ });
371
+
372
+ it("mirrors each slide's value onto its own --slide-progress custom property", () => {
373
+ const ref = createRef<CarouselImperativeApi>();
374
+ mockRects({
375
+ viewport: { left: 0, width: 300 },
376
+ "slide-0": { left: 150, width: 150 },
377
+ });
378
+ render(fixture(ref));
379
+
380
+ expect(
381
+ screen.getByTestId("slide-0").style.getPropertyValue("--slide-progress"),
382
+ ).toBe("0.5");
383
+ });
384
+
385
+ it("computes slide progress once synchronously on mount, with no scroll/resize event", () => {
386
+ const ref = createRef<CarouselImperativeApi>();
387
+ mockRects({
388
+ viewport: { left: 0, width: 300 },
389
+ "slide-0": { left: 150, width: 150 },
390
+ });
391
+ render(fixture(ref));
392
+
393
+ expect(
394
+ screen.getByTestId("slide-0").style.getPropertyValue("--slide-progress"),
395
+ ).toBe("0.5");
396
+ });
397
+
398
+ it("recomputes both global and per-slide progress when the ResizeObserver fires, without a scroll event", () => {
399
+ const ref = createRef<CarouselImperativeApi>();
400
+ mockRects({
401
+ viewport: { left: 0, width: 300 },
402
+ "slide-0": { left: 75, width: 150 },
403
+ });
404
+ render(fixture(ref));
405
+
406
+ expect(ref.current!.getSlideProgress(0)).toBe(0);
407
+
408
+ mockRects({
409
+ viewport: { left: 0, width: 300 },
410
+ "slide-0": { left: 150, width: 150 },
411
+ });
412
+
413
+ act(() => {
414
+ MockResizeObserver.fireAll();
415
+ });
416
+ act(() => {
417
+ flushFrame();
418
+ });
419
+
420
+ expect(ref.current!.getSlideProgress(0)).toBeCloseTo(0.5);
421
+ });
422
+
423
+ it("disconnects the ResizeObserver on unmount", () => {
424
+ const ref = createRef<CarouselImperativeApi>();
425
+ const { unmount } = render(fixture(ref));
426
+ const disconnectSpy = vi.spyOn(MockResizeObserver.latest!, "disconnect");
427
+
428
+ unmount();
429
+
430
+ expect(disconnectSpy).toHaveBeenCalled();
431
+ });
432
+
433
+ it("does not crash when the slide count shrinks in the same render that changes orientation", () => {
434
+ // Mirrors the IntersectionObserver regression in
435
+ // Carousel.dynamic-slides.test.tsx: a slide can unmount in the same
436
+ // commit that re-runs this effect (orientation is one of its other
437
+ // dependencies) — the removed slide's ref callback deletes it from
438
+ // slidesRef synchronously during the mutation phase, but slideKeys
439
+ // (state, updated via that same callback) hasn't flushed a new render
440
+ // yet, so this effect's own initial recompute() can still see the
441
+ // pre-drop slideKeys while slidesRef already reflects the drop.
442
+ function Harness({
443
+ count,
444
+ orientation,
445
+ }: {
446
+ count: number;
447
+ orientation: "horizontal" | "vertical";
448
+ }) {
449
+ return (
450
+ <Carousel.Root ariaLabel="Featured products" orientation={orientation}>
451
+ <Carousel.Viewport data-testid="viewport">
452
+ {Array.from({ length: count }, (_, index) => (
453
+ <Carousel.Slide key={index} data-testid={`slide-${index}`} />
454
+ ))}
455
+ </Carousel.Viewport>
456
+ </Carousel.Root>
457
+ );
458
+ }
459
+
460
+ const { rerender } = render(
461
+ <Harness count={4} orientation="horizontal" />,
462
+ );
463
+
464
+ expect(() =>
465
+ rerender(<Harness count={2} orientation="vertical" />),
466
+ ).not.toThrow();
467
+ });
468
+ });
469
+
470
+ describe("Carousel scroll-progress freshness across slide-set changes", () => {
471
+ function mockRects(rectsByTestId: Record<string, Partial<DOMRect>>) {
472
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
473
+ function (this: HTMLElement) {
474
+ const rect = rectsByTestId[this.dataset.testid ?? ""];
475
+ return { left: 0, top: 0, width: 0, height: 0, ...rect } as DOMRect;
476
+ },
477
+ );
478
+ }
479
+
480
+ it("recomputes immediately when a leading slide is removed and every later slide's index shifts", () => {
481
+ // Without `slideKeys` in the effect's dependency array, removing
482
+ // slide-a would leave index 0's cached progress pinned to slide-a's
483
+ // stale value even though slide-b now occupies index 0 — this test
484
+ // fails silently (wrong number, not a crash) if that freshness fix
485
+ // regresses.
486
+ mockRects({
487
+ viewport: { left: 0, width: 300 },
488
+ "slide-a": { left: 0, width: 100 }, // center 50 -> progress -2/3
489
+ "slide-b": { left: 100, width: 100 }, // center 150 -> progress 0
490
+ });
491
+
492
+ const ref = createRef<CarouselImperativeApi>();
493
+ const { rerender } = render(
494
+ <Carousel.Root ariaLabel="Featured products" ref={ref}>
495
+ <Carousel.Viewport data-testid="viewport">
496
+ <Carousel.Slide data-testid="slide-a" />
497
+ <Carousel.Slide data-testid="slide-b" />
498
+ </Carousel.Viewport>
499
+ </Carousel.Root>,
500
+ );
501
+
502
+ expect(ref.current!.getSlideProgress(0)).toBeCloseTo(-2 / 3);
503
+ expect(ref.current!.getSlideProgress(1)).toBe(0);
504
+
505
+ rerender(
506
+ <Carousel.Root ariaLabel="Featured products" ref={ref}>
507
+ <Carousel.Viewport data-testid="viewport">
508
+ <Carousel.Slide data-testid="slide-b" />
509
+ </Carousel.Viewport>
510
+ </Carousel.Root>,
511
+ );
512
+
513
+ // slide-b is now index 0 — its own (unchanged) geometry still gives 0,
514
+ // proving the value was recomputed against the new mapping rather than
515
+ // left as slide-a's stale -2/3 at index 0.
516
+ expect(ref.current!.getSlideProgress(0)).toBe(0);
517
+ });
518
+ });