@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
@@ -36,7 +36,7 @@ describe("Carousel scroll sync (user-driven via scrollsnapchange)", () => {
36
36
  );
37
37
  });
38
38
 
39
- it("should derive the page index from floor(slideIndex / slidesPerPage) when slidesPerPage > 1", () => {
39
+ it("should derive the page index from the nearest window start when slidesPerPage > 1", () => {
40
40
  render(
41
41
  <Carousel.Root ariaLabel="Featured products" slidesPerPage={2}>
42
42
  <Carousel.Viewport data-testid="viewport">
@@ -48,7 +48,7 @@ describe("Carousel scroll sync (user-driven via scrollsnapchange)", () => {
48
48
  </Carousel.Root>,
49
49
  );
50
50
 
51
- // Snap target is slide 3 → page = floor(3/2) = 1.
51
+ // Snap target is slide 3; the nearest window start (offsets 0, 2) is page 1.
52
52
  fireScrollSnapChange(
53
53
  screen.getByTestId("viewport"),
54
54
  screen.getByTestId("slide-3"),
@@ -112,6 +112,31 @@ describe("Carousel scroll sync (user-driven via scrollsnapchange)", () => {
112
112
  expect(onPageChange).not.toHaveBeenCalled();
113
113
  });
114
114
 
115
+ it("should ignore a scrollsnapchange event that carries no snap target", () => {
116
+ const onPageChange = vi.fn();
117
+ render(
118
+ <Carousel.Root
119
+ ariaLabel="Featured products"
120
+ page={0}
121
+ onPageChange={onPageChange}
122
+ >
123
+ <Carousel.Viewport data-testid="viewport">
124
+ <Carousel.Slide data-testid="slide-0" />
125
+ <Carousel.Slide data-testid="slide-1" />
126
+ </Carousel.Viewport>
127
+ </Carousel.Root>,
128
+ );
129
+
130
+ const viewport = screen.getByTestId("viewport");
131
+ // A scrollsnapchange with no snapTargetInline (undefined) must be a safe
132
+ // no-op — no page change, no crash.
133
+ act(() => {
134
+ viewport.dispatchEvent(new Event("scrollsnapchange", { bubbles: false }));
135
+ });
136
+
137
+ expect(onPageChange).not.toHaveBeenCalled();
138
+ });
139
+
115
140
  it("should not invoke onPageChange when the snap target is on the already-active page, but should fire it when the page genuinely changes", () => {
116
141
  const onPageChange = vi.fn();
117
142
  function Parent() {
@@ -149,13 +174,12 @@ describe("Carousel scroll sync (user-driven via scrollsnapchange)", () => {
149
174
  expect(onPageChange).toHaveBeenCalledWith(1);
150
175
  });
151
176
 
152
- it("should not scroll the slide into view again after a user-driven scrollsnapchange", () => {
177
+ it("should not scroll the viewport again after a user-driven scrollsnapchange", () => {
153
178
  // Regression: when the user swipes, the browser's CSS scroll-snap
154
179
  // already positions the viewport. The scrollsnapchange event updates
155
180
  // React state, which re-runs the scroll effect. If the effect calls
156
- // scrollIntoView here, two scroll animations fight and the result is
157
- // janky. The scroll effect must be a no-op for user-initiated page
158
- // changes.
181
+ // scrollTo here, two scroll animations fight and the result is janky.
182
+ // The scroll effect must be a no-op for user-initiated page changes.
159
183
  render(
160
184
  <Carousel.Root ariaLabel="Featured products">
161
185
  <Carousel.Viewport data-testid="viewport">
@@ -167,13 +191,10 @@ describe("Carousel scroll sync (user-driven via scrollsnapchange)", () => {
167
191
  );
168
192
 
169
193
  const viewport = screen.getByTestId("viewport");
170
- const scrollIntoViewSpy = vi.spyOn(
171
- screen.getByTestId("slide-1"),
172
- "scrollIntoView",
173
- );
194
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
174
195
 
175
196
  fireScrollSnapChange(viewport, screen.getByTestId("slide-1"));
176
197
 
177
- expect(scrollIntoViewSpy).not.toHaveBeenCalled();
198
+ expect(scrollToSpy).not.toHaveBeenCalled();
178
199
  });
179
200
  });
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
4
4
  import { Carousel } from "../index.ts";
5
5
 
6
6
  describe("Carousel scroll sync (programmatic page change)", () => {
7
- it("should call scrollIntoView on the first slide of the target page when the active page changes", async () => {
7
+ it("should scroll the viewport itself, not the slide's scrollable ancestors (so the page doesn't scroll)", async () => {
8
8
  const user = userEvent.setup();
9
9
  render(
10
10
  <Carousel.Root ariaLabel="Featured products">
@@ -16,17 +16,41 @@ describe("Carousel scroll sync (programmatic page change)", () => {
16
16
  </Carousel.Root>,
17
17
  );
18
18
 
19
- const scrollIntoViewSpy = vi.spyOn(
19
+ // scrollIntoView walks every scrollable ancestor (including the page/window),
20
+ // so it scrolls the whole document when a carousel is off-screen. The viewport
21
+ // must scroll itself instead.
22
+ const viewportScrollTo = vi.spyOn(screen.getByTestId("viewport"), "scrollTo");
23
+ const slideScrollIntoView = vi.spyOn(
20
24
  screen.getByTestId("slide-1"),
21
25
  "scrollIntoView",
22
26
  );
23
27
 
24
28
  await user.click(screen.getByRole("button", { name: "Next" }));
25
29
 
26
- expect(scrollIntoViewSpy).toHaveBeenCalled();
30
+ expect(viewportScrollTo).toHaveBeenCalled();
31
+ expect(slideScrollIntoView).not.toHaveBeenCalled();
27
32
  });
28
33
 
29
- it("should scroll the target slide into view with inline:'start' and block:'nearest' by default", async () => {
34
+ it("should call scrollTo on the viewport for the first slide of the target page when the active page changes", async () => {
35
+ const user = userEvent.setup();
36
+ render(
37
+ <Carousel.Root ariaLabel="Featured products">
38
+ <Carousel.Viewport data-testid="viewport">
39
+ <Carousel.Slide data-testid="slide-0" />
40
+ <Carousel.Slide data-testid="slide-1" />
41
+ </Carousel.Viewport>
42
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
43
+ </Carousel.Root>,
44
+ );
45
+
46
+ const scrollToSpy = vi.spyOn(screen.getByTestId("viewport"), "scrollTo");
47
+
48
+ await user.click(screen.getByRole("button", { name: "Next" }));
49
+
50
+ expect(scrollToSpy).toHaveBeenCalled();
51
+ });
52
+
53
+ it("should centre the target slide in the viewport on the inline axis by default (center)", async () => {
30
54
  const user = userEvent.setup();
31
55
  render(
32
56
  <Carousel.Root ariaLabel="Featured products">
@@ -43,24 +67,29 @@ describe("Carousel scroll sync (programmatic page change)", () => {
43
67
  </Carousel.Root>,
44
68
  );
45
69
 
46
- const scrollIntoViewSpy = vi.spyOn(
47
- screen.getByTestId("slide-2"),
48
- "scrollIntoView",
70
+ // The viewport sits at the origin, is 1000px wide, and the target slide
71
+ // is 320px wide, 640px to its right: 640 - (1000 - 320) / 2 = 300.
72
+ const viewport = screen.getByTestId("viewport");
73
+ Object.defineProperty(viewport, "clientWidth", { value: 1000, configurable: true });
74
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
75
+ vi.spyOn(screen.getByTestId("slide-2"), "getBoundingClientRect").mockReturnValue(
76
+ { left: 640, top: 0, width: 320, height: 180 } as DOMRect,
49
77
  );
50
78
 
51
79
  await user.click(screen.getByRole("button", { name: "Slide 3" }));
52
80
 
53
- expect(scrollIntoViewSpy).toHaveBeenCalledWith({
54
- behavior: "smooth",
55
- inline: "start",
56
- block: "nearest",
57
- });
81
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: 300, behavior: "smooth" });
58
82
  });
59
83
 
60
84
  it("should target the first slide of a multi-slide page", async () => {
61
85
  const user = userEvent.setup();
62
86
  render(
63
- <Carousel.Root ariaLabel="Featured products" slidesPerPage={2}>
87
+ // snapAlign="start" isolates this test's concern (multi-slide page
88
+ // targeting: index = page * slidesPerPage) from the root's default
89
+ // alignment, which centre-aligns and would otherwise shift the
90
+ // expected scroll position for a reason unrelated to what this test
91
+ // is checking.
92
+ <Carousel.Root ariaLabel="Featured products" slidesPerPage={2} snapAlign="start">
64
93
  <Carousel.Viewport data-testid="viewport">
65
94
  <Carousel.Slide data-testid="slide-0" />
66
95
  <Carousel.Slide data-testid="slide-1" />
@@ -72,18 +101,18 @@ describe("Carousel scroll sync (programmatic page change)", () => {
72
101
  );
73
102
 
74
103
  // Page 0 → page 1 means the first visible slide becomes slide 2
75
- // (index = page * slidesPerPage).
76
- const scrollIntoViewSpy = vi.spyOn(
77
- screen.getByTestId("slide-2"),
78
- "scrollIntoView",
104
+ // (index = page * slidesPerPage): its offset is what drives the scroll.
105
+ const scrollToSpy = vi.spyOn(screen.getByTestId("viewport"), "scrollTo");
106
+ vi.spyOn(screen.getByTestId("slide-2"), "getBoundingClientRect").mockReturnValue(
107
+ { left: 500, top: 0, width: 240, height: 180 } as DOMRect,
79
108
  );
80
109
 
81
110
  await user.click(screen.getByRole("button", { name: "Next" }));
82
111
 
83
- expect(scrollIntoViewSpy).toHaveBeenCalled();
112
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: 500, behavior: "smooth" });
84
113
  });
85
114
 
86
- it("should scroll with inline:'center' when snapAlign is 'center'", async () => {
115
+ it("should centre the target slide in the viewport when snapAlign is 'center'", async () => {
87
116
  const user = userEvent.setup();
88
117
  render(
89
118
  <Carousel.Root ariaLabel="Featured products" snapAlign="center">
@@ -95,15 +124,199 @@ describe("Carousel scroll sync (programmatic page change)", () => {
95
124
  </Carousel.Root>,
96
125
  );
97
126
 
98
- const scrollIntoViewSpy = vi.spyOn(
99
- screen.getByTestId("slide-1"),
100
- "scrollIntoView",
127
+ // A 400-wide slide at inline 400 in a 1000-wide viewport centres at
128
+ // 400 - (1000 - 400) / 2 = 100.
129
+ const viewport = screen.getByTestId("viewport");
130
+ Object.defineProperty(viewport, "clientWidth", { value: 1000, configurable: true });
131
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
132
+ vi.spyOn(screen.getByTestId("slide-1"), "getBoundingClientRect").mockReturnValue(
133
+ { left: 400, top: 0, width: 400, height: 180 } as DOMRect,
101
134
  );
102
135
 
103
136
  await user.click(screen.getByRole("button", { name: "Next" }));
104
137
 
105
- expect(scrollIntoViewSpy).toHaveBeenCalledWith(
106
- expect.objectContaining({ inline: "center" }),
138
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: 100, behavior: "smooth" });
139
+ });
140
+
141
+ it("should align the target slide's trailing edge to the viewport when snapAlign is 'end'", async () => {
142
+ const user = userEvent.setup();
143
+ render(
144
+ <Carousel.Root ariaLabel="Featured products" snapAlign="end">
145
+ <Carousel.Viewport data-testid="viewport">
146
+ <Carousel.Slide data-testid="slide-0" />
147
+ <Carousel.Slide data-testid="slide-1" />
148
+ </Carousel.Viewport>
149
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
150
+ </Carousel.Root>,
151
+ );
152
+
153
+ // A 400-wide slide at inline 400 in a 1000-wide viewport end-aligns at
154
+ // 400 - (1000 - 400) = -200 (its trailing edge flush with the
155
+ // viewport's).
156
+ const viewport = screen.getByTestId("viewport");
157
+ Object.defineProperty(viewport, "clientWidth", { value: 1000, configurable: true });
158
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
159
+ vi.spyOn(screen.getByTestId("slide-1"), "getBoundingClientRect").mockReturnValue(
160
+ { left: 400, top: 0, width: 400, height: 180 } as DOMRect,
161
+ );
162
+
163
+ await user.click(screen.getByRole("button", { name: "Next" }));
164
+
165
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: -200, behavior: "smooth" });
166
+ });
167
+
168
+ it("should let a slide's own snapAlign override the root default", async () => {
169
+ const user = userEvent.setup();
170
+ render(
171
+ <Carousel.Root ariaLabel="Featured products">
172
+ <Carousel.Viewport data-testid="viewport">
173
+ <Carousel.Slide data-testid="slide-0" />
174
+ <Carousel.Slide data-testid="slide-1" snapAlign="start" />
175
+ </Carousel.Viewport>
176
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
177
+ </Carousel.Root>,
178
+ );
179
+
180
+ // Root default is "center", but slide-1 opts itself into "start":
181
+ // 400 - 0 = 400 — the alignOffset-zero case, driven by the per-slide
182
+ // prop instead of the root value.
183
+ const viewport = screen.getByTestId("viewport");
184
+ Object.defineProperty(viewport, "clientWidth", { value: 1000, configurable: true });
185
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
186
+ vi.spyOn(screen.getByTestId("slide-1"), "getBoundingClientRect").mockReturnValue(
187
+ { left: 400, top: 0, width: 400, height: 180 } as DOMRect,
107
188
  );
189
+
190
+ await user.click(screen.getByRole("button", { name: "Next" }));
191
+
192
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: 400, behavior: "smooth" });
193
+ });
194
+ });
195
+
196
+ describe("Carousel multi-slide page-span alignment", () => {
197
+ it("should centre the whole multi-slide page, not just its leading slide", async () => {
198
+ const user = userEvent.setup();
199
+ render(
200
+ <Carousel.Root
201
+ ariaLabel="Featured products"
202
+ slidesPerPage={2}
203
+ snapAlign="center"
204
+ >
205
+ <Carousel.Viewport data-testid="viewport">
206
+ <Carousel.Slide data-testid="slide-0" />
207
+ <Carousel.Slide data-testid="slide-1" />
208
+ <Carousel.Slide data-testid="slide-2" />
209
+ <Carousel.Slide data-testid="slide-3" />
210
+ </Carousel.Viewport>
211
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
212
+ </Carousel.Root>,
213
+ );
214
+
215
+ // Page 1's window is [slide-2, slide-3]. The page spans 500 (slide-2's
216
+ // left) to 980 (slide-3's right) = 480 wide, in a 1000-wide viewport:
217
+ // 500 - (1000 - 480) / 2 = 500 - 260 = 240. Using only slide-2's own
218
+ // 240px width (the pre-fix bug) would instead leftover-space against a
219
+ // single slide and undershoot, leaving slide-3 clipped off the trailing
220
+ // edge.
221
+ const viewport = screen.getByTestId("viewport");
222
+ Object.defineProperty(viewport, "clientWidth", {
223
+ value: 1000,
224
+ configurable: true,
225
+ });
226
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
227
+ vi.spyOn(
228
+ screen.getByTestId("slide-2"),
229
+ "getBoundingClientRect",
230
+ ).mockReturnValue({ left: 500, top: 0, width: 240, height: 180 } as DOMRect);
231
+ vi.spyOn(
232
+ screen.getByTestId("slide-3"),
233
+ "getBoundingClientRect",
234
+ ).mockReturnValue({ left: 740, top: 0, width: 240, height: 180 } as DOMRect);
235
+
236
+ await user.click(screen.getByRole("button", { name: "Next" }));
237
+
238
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: 240, behavior: "smooth" });
239
+ });
240
+
241
+ it("should align the whole multi-slide page's trailing edge when snapAlign is 'end'", async () => {
242
+ const user = userEvent.setup();
243
+ render(
244
+ <Carousel.Root
245
+ ariaLabel="Featured products"
246
+ slidesPerPage={2}
247
+ snapAlign="end"
248
+ >
249
+ <Carousel.Viewport data-testid="viewport">
250
+ <Carousel.Slide data-testid="slide-0" />
251
+ <Carousel.Slide data-testid="slide-1" />
252
+ <Carousel.Slide data-testid="slide-2" />
253
+ <Carousel.Slide data-testid="slide-3" />
254
+ </Carousel.Viewport>
255
+ <Carousel.NextTrigger>Next</Carousel.NextTrigger>
256
+ </Carousel.Root>,
257
+ );
258
+
259
+ // Same 480-wide page as above; end-alignment uses the full leftover
260
+ // space (520) rather than halving it: 500 - 520 = -20.
261
+ const viewport = screen.getByTestId("viewport");
262
+ Object.defineProperty(viewport, "clientWidth", {
263
+ value: 1000,
264
+ configurable: true,
265
+ });
266
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
267
+ vi.spyOn(
268
+ screen.getByTestId("slide-2"),
269
+ "getBoundingClientRect",
270
+ ).mockReturnValue({ left: 500, top: 0, width: 240, height: 180 } as DOMRect);
271
+ vi.spyOn(
272
+ screen.getByTestId("slide-3"),
273
+ "getBoundingClientRect",
274
+ ).mockReturnValue({ left: 740, top: 0, width: 240, height: 180 } as DOMRect);
275
+
276
+ await user.click(screen.getByRole("button", { name: "Next" }));
277
+
278
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: -20, behavior: "smooth" });
279
+ });
280
+
281
+ it("should clamp the page span to the actual last slide when slidesPerPage exceeds the slide count", () => {
282
+ // This exercises the *initial-mount* scroll (there's only one page here,
283
+ // so nothing else would trigger a second effect run) — the rect mocks
284
+ // and the scrollTo spy both have to be in place before that first effect
285
+ // fires, so they're attached to the shared prototypes rather than to
286
+ // specific instances found only after render().
287
+ const scrollToSpy = vi
288
+ .spyOn(HTMLElement.prototype, "scrollTo")
289
+ .mockImplementation(() => {});
290
+ const rectsByTestId: Record<string, Partial<DOMRect>> = {
291
+ "slide-0": { left: 0, top: 0, width: 400, height: 180 },
292
+ "slide-1": { left: 400, top: 0, width: 400, height: 180 },
293
+ };
294
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
295
+ function (this: HTMLElement) {
296
+ const rect = rectsByTestId[this.dataset.testid ?? ""];
297
+ return { left: 0, top: 0, width: 0, height: 0, ...rect } as DOMRect;
298
+ },
299
+ );
300
+
301
+ render(
302
+ <Carousel.Root
303
+ ariaLabel="Featured products"
304
+ slidesPerPage={4}
305
+ snapAlign="center"
306
+ >
307
+ <Carousel.Viewport data-testid="viewport">
308
+ <Carousel.Slide data-testid="slide-0" />
309
+ <Carousel.Slide data-testid="slide-1" />
310
+ </Carousel.Viewport>
311
+ </Carousel.Root>,
312
+ );
313
+
314
+ // Only 2 slides exist for a configured slidesPerPage=4 — the naive last-
315
+ // member index (0 + 4 - 1 = 3) doesn't exist, so it must clamp to the
316
+ // real last slide (index 1) instead of looking up a nonexistent one
317
+ // (which would throw). Page span: slide-0's left (0) to slide-1's right
318
+ // (800) = 800 wide; the viewport's own clientWidth is unmocked (jsdom
319
+ // default 0): 0 - (0 - 800) / 2 = 400.
320
+ expect(scrollToSpy).toHaveBeenCalledWith({ left: 400, behavior: "smooth" });
108
321
  });
109
322
  });
@@ -0,0 +1,115 @@
1
+ import { createRef, useRef } from "react";
2
+ import { act, render, screen } from "@testing-library/react";
3
+
4
+ import { Carousel } from "../index.ts";
5
+ import type { CarouselImperativeApi } from "../index.ts";
6
+
7
+ describe("Carousel imperative scrollToIndex", () => {
8
+ it("should jump directly to the page containing the given slide index", () => {
9
+ const ref = createRef<CarouselImperativeApi>();
10
+ render(
11
+ <Carousel.Root ref={ref} ariaLabel="Featured products">
12
+ <Carousel.Viewport>
13
+ <Carousel.Slide data-testid="slide-0" />
14
+ <Carousel.Slide data-testid="slide-1" />
15
+ <Carousel.Slide data-testid="slide-2" />
16
+ </Carousel.Viewport>
17
+ </Carousel.Root>,
18
+ );
19
+
20
+ act(() => {
21
+ ref.current!.scrollToIndex(2);
22
+ });
23
+
24
+ expect(screen.getByTestId("slide-2")).toHaveAttribute(
25
+ "data-state",
26
+ "active",
27
+ );
28
+ });
29
+
30
+ it("should map a slide index to its containing multi-slide page, not just its own page", () => {
31
+ const ref = createRef<CarouselImperativeApi>();
32
+ render(
33
+ <Carousel.Root ref={ref} ariaLabel="Featured products" slidesPerPage={3}>
34
+ <Carousel.Viewport>
35
+ {Array.from({ length: 7 }, (_, index) => (
36
+ <Carousel.Slide key={index} data-testid={`slide-${index}`} />
37
+ ))}
38
+ </Carousel.Viewport>
39
+ </Carousel.Root>,
40
+ );
41
+
42
+ // 7 slides / perPage 3 → pages [0,1,2] [3,4,5] [4,5,6] (the end-aligned
43
+ // last page). Slide index 6 belongs to the last page, whose leading
44
+ // slide is 4 — scrollToIndex(6) should land on that page, not treat 6
45
+ // as its own page start.
46
+ act(() => {
47
+ ref.current!.scrollToIndex(6);
48
+ });
49
+
50
+ expect(screen.getByTestId("slide-4")).toHaveAttribute(
51
+ "data-state",
52
+ "active",
53
+ );
54
+ });
55
+
56
+ it("should scroll with behavior='instant' when scrollToIndex(index, true) is called", () => {
57
+ const ref = createRef<CarouselImperativeApi>();
58
+ render(
59
+ <Carousel.Root ref={ref} ariaLabel="Featured products">
60
+ <Carousel.Viewport data-testid="viewport">
61
+ <Carousel.Slide data-testid="slide-0" />
62
+ <Carousel.Slide data-testid="slide-1" />
63
+ </Carousel.Viewport>
64
+ </Carousel.Root>,
65
+ );
66
+
67
+ const scrollToSpy = vi.spyOn(screen.getByTestId("viewport"), "scrollTo");
68
+
69
+ act(() => {
70
+ ref.current!.scrollToIndex(1, true);
71
+ });
72
+
73
+ expect(scrollToSpy).toHaveBeenCalledWith(
74
+ expect.objectContaining({ behavior: "instant" }),
75
+ );
76
+ });
77
+
78
+ it("should route through onPageChange in controlled mode, like goTo", () => {
79
+ const onPageChange = vi.fn();
80
+ function Parent() {
81
+ const ref = useRef<CarouselImperativeApi>(null);
82
+ return (
83
+ <>
84
+ <button
85
+ type="button"
86
+ onClick={() => ref.current?.scrollToIndex(2)}
87
+ data-testid="external-jump"
88
+ >
89
+ Jump
90
+ </button>
91
+ <Carousel.Root
92
+ ref={ref}
93
+ ariaLabel="Featured products"
94
+ page={0}
95
+ onPageChange={onPageChange}
96
+ >
97
+ <Carousel.Viewport>
98
+ <Carousel.Slide data-testid="slide-0" />
99
+ <Carousel.Slide data-testid="slide-1" />
100
+ <Carousel.Slide data-testid="slide-2" />
101
+ </Carousel.Viewport>
102
+ </Carousel.Root>
103
+ </>
104
+ );
105
+ }
106
+
107
+ render(<Parent />);
108
+
109
+ act(() => {
110
+ screen.getByTestId("external-jump").click();
111
+ });
112
+
113
+ expect(onPageChange).toHaveBeenCalledWith(2);
114
+ });
115
+ });