@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
@@ -12,10 +12,16 @@ import {
12
12
 
13
13
  import type {
14
14
  CarouselAutoplay,
15
+ CarouselAutoplayStatus,
15
16
  CarouselContextValue,
17
+ CarouselDragStatus,
16
18
  CarouselIds,
17
19
  CarouselImperativeApi,
20
+ CarouselLoopMode,
21
+ CarouselOrientation,
22
+ CarouselOverscrollStatus,
18
23
  CarouselSnapAlign,
24
+ CarouselSnapType,
19
25
  CarouselTransition,
20
26
  CarouselTranslations,
21
27
  } from "../types";
@@ -29,8 +35,20 @@ const DEFAULT_TRANSLATIONS: Required<CarouselTranslations> = {
29
35
  indicatorLabel: ({ index }) => `Slide ${index}`,
30
36
  startSlideshow: "Start automatic slide show",
31
37
  stopSlideshow: "Stop automatic slide show",
38
+ progressText: ({ page, totalPages }) => `${page + 1} of ${totalPages}`,
32
39
  };
33
40
 
41
+ /**
42
+ * Coerce a consumer-supplied layout count to a safe integer ≥ `min`. The
43
+ * slide count and `slidesPerPage` / `slidesPerMove` come from the consumer,
44
+ * so 0, negative, fractional, or non-finite values must degrade to a sane
45
+ * integer — otherwise the page maths divides by zero (Infinity pages → a
46
+ * thrown RangeError from the indicator map) or the carousel goes inert.
47
+ */
48
+ function clampCount(n: number, min: number): number {
49
+ return Number.isFinite(n) ? Math.max(min, Math.floor(n)) : min;
50
+ }
51
+
34
52
  function resolveAutoplay(autoplay: CarouselAutoplay | undefined): {
35
53
  enabled: boolean;
36
54
  delay: number;
@@ -61,6 +79,9 @@ type UseCarouselRootProps = {
61
79
  onPlayingChange?: (playing: boolean) => void;
62
80
  /** Autoplay configuration — see {@link CarouselAutoplay}. */
63
81
  autoplay?: CarouselAutoplay;
82
+ /** Fires on every autoplay status transition — see
83
+ * {@link CarouselAutoplayStatus}. */
84
+ onAutoplayStatusChange?: (status: CarouselAutoplayStatus) => void;
64
85
  /** Number of slides visible per page. Defaults to `1`. */
65
86
  slidesPerPage?: number;
66
87
  /** Slides advanced per Prev/Next click — `"auto"` (default) is
@@ -76,6 +97,26 @@ type UseCarouselRootProps = {
76
97
  transition?: CarouselTransition;
77
98
  /** Scroll-snap alignment — see {@link CarouselSnapAlign}. */
78
99
  snapAlign?: CarouselSnapAlign;
100
+ /** Scroll/pagination axis — see {@link CarouselOrientation}. */
101
+ orientation?: CarouselOrientation;
102
+ /** Wrap navigation around the ends instead of clamping — `boolean` for
103
+ * ergonomics (`true` = `"wrap"`) or a named mode. Defaults to `false`. */
104
+ loop?: boolean | "wrap" | "infinite";
105
+ /** Opt-in mouse click-and-drag scrolling. */
106
+ allowMouseDrag?: boolean;
107
+ /** Fires on every mouse-drag status transition — see
108
+ * {@link CarouselDragStatus}. */
109
+ onDragStatusChange?: (status: CarouselDragStatus) => void;
110
+ /** Fires whenever the keyboard, wheel, or a mouse drag pushes against a
111
+ * boundary with nowhere further to go — see
112
+ * {@link CarouselOverscrollStatus}. */
113
+ onOverscrollStatusChange?: (status: CarouselOverscrollStatus) => void;
114
+ /** IntersectionObserver visibility threshold(s) for the `isInView`
115
+ * fallback. Defaults to `0.6`. */
116
+ inViewThreshold?: number | number[];
117
+ /** `scroll-snap-type` strictness — see {@link CarouselSnapType}.
118
+ * Defaults to `"mandatory"`. */
119
+ snapType?: CarouselSnapType;
79
120
  };
80
121
 
81
122
  /**
@@ -111,12 +152,20 @@ export function useCarouselRoot(
111
152
  playing,
112
153
  onPlayingChange,
113
154
  autoplay,
155
+ onAutoplayStatusChange,
114
156
  slidesPerPage = 1,
115
157
  slidesPerMove = "auto",
116
158
  translations,
117
159
  ids = EMPTY_IDS,
118
160
  transition = "slide",
119
- snapAlign = "start",
161
+ snapAlign = "center",
162
+ orientation = "horizontal",
163
+ loop = false,
164
+ allowMouseDrag = false,
165
+ onDragStatusChange,
166
+ onOverscrollStatusChange,
167
+ inViewThreshold = 0.6,
168
+ snapType = "mandatory",
120
169
  }: UseCarouselRootProps = {},
121
170
  imperativeRef?: Ref<CarouselImperativeApi>,
122
171
  ) {
@@ -128,21 +177,41 @@ export function useCarouselRoot(
128
177
  const isControlled = page !== undefined;
129
178
  const rawPage = isControlled ? (page as number) : internalPage;
130
179
  const total = slideKeys.length;
180
+ // Resolve the ergonomic boolean/named `loop` prop to a single mode.
181
+ // `true` is sugar for `"wrap"`; `false`/omitted is `"none"`. `"wrap"` and
182
+ // `"infinite"` share the page model below (`looping`), differing only in
183
+ // the scroll/clone layer the styled surface keys off `data-loop`.
184
+ const loopMode: CarouselLoopMode =
185
+ loop === true ? "wrap" : loop === false ? "none" : loop;
186
+ const looping = loopMode !== "none";
187
+ // Guard the layout counts (see clampCount). perPage is an integer ≥ 1; a
188
+ // numeric slidesPerMove is additionally clamped to ≤ perPage so a move can
189
+ // never skip past a page and orphan the slides in the gap. "auto" moves a
190
+ // full page.
191
+ const perPage = clampCount(slidesPerPage, 1);
192
+ // Slides advanced per page step: a full page in "auto" mode, else the numeric
193
+ // move clamped to ≤ perPage so a move can never skip past a page and orphan
194
+ // the slides in the gap.
131
195
  const effectiveSlidesPerMove =
132
- slidesPerMove === "auto" ? slidesPerPage : slidesPerMove;
133
- // "auto" mode keeps the existing ceil(total / slidesPerPage) formula
134
- // and accepts a partial last page; numeric mode uses
135
- // floor((total - slidesPerPage) / slidesPerMove) + 1 so the active
136
- // window is always full. Math.ceil(0 / N) === 0 so the empty-slide
137
- // case still gives totalPages === 0.
196
+ slidesPerMove === "auto"
197
+ ? perPage
198
+ : Math.min(perPage, clampCount(slidesPerMove, 1));
199
+ // Pages step by `effectiveSlidesPerMove` from 0, and the **last page always
200
+ // end-aligns** to `maxOffset` (the track end minus a full page). This is the
201
+ // single offset model for both modes: it guarantees every page is a full
202
+ // window that can start-snap cleanly, so a total that isn't a whole number of
203
+ // pages can't leave a partial last page whose leading slide can't reach the
204
+ // viewport start (which desyncs the active page against the scroll). The
205
+ // formula is identical to `ceil(total / perPage)` when the step is a full
206
+ // page, so the page *count* is unchanged — only the last page's offset shifts
207
+ // back to keep its window full.
208
+ const maxOffset = Math.max(0, total - perPage);
138
209
  const totalPages =
139
210
  total === 0
140
211
  ? 0
141
- : total <= slidesPerPage
212
+ : total <= perPage
142
213
  ? 1
143
- : slidesPerMove === "auto"
144
- ? Math.ceil(total / slidesPerPage)
145
- : Math.floor((total - slidesPerPage) / effectiveSlidesPerMove) + 1;
214
+ : Math.ceil(maxOffset / effectiveSlidesPerMove) + 1;
146
215
 
147
216
  // In controlled mode an out-of-range page prop is a consumer error —
148
217
  // throw loudly so it surfaces during development rather than shipping
@@ -161,6 +230,36 @@ export function useCarouselRoot(
161
230
  ? Math.max(0, Math.min(rawPage, totalPages - 1))
162
231
  : rawPage;
163
232
 
233
+ // Start slide index of the active window: step by move, but the last page
234
+ // end-aligns (clamped to maxOffset) so its window is always full.
235
+ const currentPageOffset = Math.min(
236
+ currentPage * effectiveSlidesPerMove,
237
+ maxOffset,
238
+ );
239
+
240
+ // Inverse of the offset model: which page does a leading slide index belong
241
+ // to? Because the last page end-aligns, the offsets aren't a clean multiple
242
+ // series (the final step is short), so round/floor can't invert them — pick
243
+ // the page whose start offset is nearest the slide. Ties favour the earlier
244
+ // page (`<`). Returns 0 for the empty carousel (the loop runs zero times) —
245
+ // pageForSlideIndex is only called once slides exist anyway.
246
+ const pageForSlideIndex = useCallback(
247
+ (slideIndex: number) => {
248
+ let best = 0;
249
+ let bestDistance = Infinity;
250
+ for (let page = 0; page < totalPages; page++) {
251
+ const offset = Math.min(page * effectiveSlidesPerMove, maxOffset);
252
+ const distance = Math.abs(offset - slideIndex);
253
+ if (distance < bestDistance) {
254
+ bestDistance = distance;
255
+ best = page;
256
+ }
257
+ }
258
+ return best;
259
+ },
260
+ [effectiveSlidesPerMove, maxOffset, totalPages],
261
+ );
262
+
164
263
  const [internalPlaying, setInternalPlaying] = useState(defaultPlaying);
165
264
  const isPlayingControlled = playing !== undefined;
166
265
  const currentPlaying = isPlayingControlled ? playing : internalPlaying;
@@ -189,11 +288,17 @@ export function useCarouselRoot(
189
288
  const suspended =
190
289
  (hovered || focused || touchActive) && !userInitiatedPlayRef.current;
191
290
 
192
- // Boundary derivation: navigation requires at least one page. The
193
- // ends clamp at the last page (which, with slidesPerPage > 1, is
194
- // generally before the last slide).
195
- const canGoPrevious = totalPages > 0 && currentPage > 0;
196
- const canGoNext = totalPages > 0 && currentPage < totalPages - 1;
291
+ // Boundary derivation: navigation requires at least one page. Without
292
+ // loop the ends clamp at the last page (which, with slidesPerPage > 1,
293
+ // is generally before the last slide). With loop, navigation wraps, so
294
+ // both directions stay available as long as there's more than one page
295
+ // to wrap between — a single page has no wrap target.
296
+ const canGoPrevious = looping
297
+ ? totalPages > 1
298
+ : totalPages > 0 && currentPage > 0;
299
+ const canGoNext = looping
300
+ ? totalPages > 1
301
+ : totalPages > 0 && currentPage < totalPages - 1;
197
302
 
198
303
  const registerSlide = useCallback(
199
304
  (key: string, element: HTMLDivElement | null) => {
@@ -214,30 +319,69 @@ export function useCarouselRoot(
214
319
  // once the imperative API or autoplay land; they're added then.
215
320
  const isProgrammaticScrollRef = useRef(false);
216
321
 
217
- const next = useCallback(() => {
218
- if (currentPage >= totalPages - 1) return;
219
- isProgrammaticScrollRef.current = true;
220
- const target = currentPage + 1;
221
- if (isControlled) {
222
- onPageChange?.(target);
223
- } else {
224
- setInternalPage(target);
225
- }
226
- }, [currentPage, totalPages, isControlled, onPageChange]);
227
-
228
- const previous = useCallback(() => {
229
- if (currentPage <= 0) return;
230
- isProgrammaticScrollRef.current = true;
231
- const target = currentPage - 1;
232
- if (isControlled) {
233
- onPageChange?.(target);
234
- } else {
235
- setInternalPage(target);
236
- }
237
- }, [currentPage, isControlled, onPageChange]);
322
+ // One-shot override for the resolved smooth/reduced-motion scrollBehavior,
323
+ // consumed by the Viewport hook's scroll effect on the very next run and
324
+ // reset immediately after (see useCarouselViewport.ts) — so it never
325
+ // outlives the single call that requested it.
326
+ const instantScrollRef = useRef(false);
327
+
328
+ // One-shot hint for the infinite-loop engine (useCarouselLoop), consumed and
329
+ // reset the same way as instantScrollRef above. `next()`/`previous()` are a
330
+ // relative step, so under looping they should always take the wrap
331
+ // shortcut — continuing forward off the last slide onto the clone, not
332
+ // rewinding across the whole strip. `goTo()` (and `scrollToIndex`, which
333
+ // calls it) is an absolute jump to a *specific* page, most visibly from an
334
+ // indicator/thumbnail click — there, the wrap shortcut reads wrong: picking
335
+ // the last thumbnail from the first should visibly travel forward through
336
+ // every slide in between, matching the indicators' own left-to-right
337
+ // reading order, not silently take the one-step-back route around the ring
338
+ // just because it happens to be shorter. See RFC 0018 (loop navigation).
339
+ const directJumpRef = useRef(false);
340
+
341
+ const next = useCallback(
342
+ (instant?: boolean) => {
343
+ // At the last page: clamp (no-op) normally, or wrap to the first when
344
+ // looping (provided there's more than one page to wrap between).
345
+ if (currentPage >= totalPages - 1 && !(looping && totalPages > 1)) return;
346
+ isProgrammaticScrollRef.current = true;
347
+ instantScrollRef.current = !!instant;
348
+ directJumpRef.current = false;
349
+ const target = looping
350
+ ? (currentPage + 1) % totalPages
351
+ : currentPage + 1;
352
+ if (isControlled) {
353
+ onPageChange?.(target);
354
+ } else {
355
+ setInternalPage(target);
356
+ }
357
+ },
358
+ [currentPage, totalPages, looping, isControlled, onPageChange],
359
+ );
360
+
361
+ const previous = useCallback(
362
+ (instant?: boolean) => {
363
+ // At the first page: clamp (no-op) normally, or wrap to the last when
364
+ // looping (provided there's more than one page to wrap between).
365
+ if (currentPage <= 0 && !(looping && totalPages > 1)) return;
366
+ isProgrammaticScrollRef.current = true;
367
+ instantScrollRef.current = !!instant;
368
+ directJumpRef.current = false;
369
+ const target = looping
370
+ ? (currentPage - 1 + totalPages) % totalPages
371
+ : currentPage - 1;
372
+ if (isControlled) {
373
+ onPageChange?.(target);
374
+ } else {
375
+ setInternalPage(target);
376
+ }
377
+ },
378
+ [currentPage, totalPages, looping, isControlled, onPageChange],
379
+ );
238
380
 
239
381
  const goTo = useCallback(
240
- (target: number) => {
382
+ (target: number, instant?: boolean) => {
383
+ instantScrollRef.current = !!instant;
384
+ directJumpRef.current = true;
241
385
  if (isControlled) {
242
386
  onPageChange?.(target);
243
387
  } else {
@@ -247,17 +391,59 @@ export function useCarouselRoot(
247
391
  [isControlled, onPageChange],
248
392
  );
249
393
 
394
+ // Slide-granularity counterpart to page-granularity goTo — maps the
395
+ // target slide index to its containing page via pageForSlideIndex (a
396
+ // multi-slide page's interior slides aren't independently reachable,
397
+ // so this always lands on the page start closest to slideIndex) and
398
+ // defers to the same goTo the trigger components use. Matches Ark UI's
399
+ // scrollToIndex(index, instant?).
400
+ const scrollToIndex = useCallback(
401
+ (slideIndex: number, instant?: boolean) => {
402
+ goTo(pageForSlideIndex(slideIndex), instant);
403
+ },
404
+ [goTo, pageForSlideIndex],
405
+ );
406
+
250
407
  // Autoplay timer. Schedules a single setTimeout per active page; when
251
408
  // next() runs it bumps currentPage, which retriggers the effect with
252
409
  // a fresh timer. canGoNext gates the schedule so autoplay stops at
253
410
  // the last page.
254
411
  // The suspended flag pauses the timer while the user is hovering or
255
412
  // has focus inside the Root, per WCAG 2.2.2.
413
+ // isAutoplayRunningRef edge-triggers onAutoplayStatusChange's
414
+ // "autoplay.start"/"autoplay.stop" pair around a running session — the
415
+ // effect reruns on every page change (autoplayRunning stays true, so no
416
+ // duplicate "start"), and "autoplay" fires once per tick from inside the
417
+ // scheduled callback, right before next() advances the page.
418
+ const isAutoplayRunningRef = useRef(false);
256
419
  useEffect(() => {
257
- if (!autoplayEnabled || !currentPlaying || !canGoNext || suspended) {
420
+ const eligible =
421
+ autoplayEnabled && currentPlaying && canGoNext && !suspended;
422
+ if (!eligible) {
423
+ if (isAutoplayRunningRef.current) {
424
+ isAutoplayRunningRef.current = false;
425
+ onAutoplayStatusChange?.({
426
+ type: "autoplay.stop",
427
+ page: currentPage,
428
+ isPlaying: false,
429
+ });
430
+ }
258
431
  return;
259
432
  }
433
+ if (!isAutoplayRunningRef.current) {
434
+ isAutoplayRunningRef.current = true;
435
+ onAutoplayStatusChange?.({
436
+ type: "autoplay.start",
437
+ page: currentPage,
438
+ isPlaying: true,
439
+ });
440
+ }
260
441
  const id = setTimeout(() => {
442
+ onAutoplayStatusChange?.({
443
+ type: "autoplay",
444
+ page: currentPage,
445
+ isPlaying: true,
446
+ });
261
447
  next();
262
448
  }, autoplayDelay);
263
449
  return () => clearTimeout(id);
@@ -268,6 +454,8 @@ export function useCarouselRoot(
268
454
  suspended,
269
455
  autoplayDelay,
270
456
  next,
457
+ currentPage,
458
+ onAutoplayStatusChange,
271
459
  ]);
272
460
 
273
461
  // Handlers spread onto the Root <section>. mouseEnter / mouseLeave
@@ -348,6 +536,18 @@ export function useCarouselRoot(
348
536
  [currentPage, totalPages],
349
537
  );
350
538
 
539
+ // The same offset formula `currentPageOffset` uses for the active page,
540
+ // computed for every page — the leading slide index each page snaps to.
541
+ // Exposed so a consumer can build a custom progress bar / indicator from
542
+ // the raw offsets instead of re-deriving the end-align math themselves.
543
+ const getPageSnapPoints = useCallback(
544
+ () =>
545
+ Array.from({ length: totalPages }, (_, page) =>
546
+ Math.min(page * effectiveSlidesPerMove, maxOffset),
547
+ ),
548
+ [totalPages, effectiveSlidesPerMove, maxOffset],
549
+ );
550
+
351
551
  // Visibility tracking is a ref (not state) because callers read on
352
552
  // demand via isInView() and the IO callback in useCarouselViewport
353
553
  // mutates many entries per tick — re-rendering on each addition
@@ -362,19 +562,83 @@ export function useCarouselRoot(
362
562
  [],
363
563
  );
364
564
 
565
+ // Same ref-not-state reasoning as visibility tracking above: isDragging()
566
+ // is read on demand, and the Viewport hook's pointermove handler would
567
+ // otherwise re-render on every tick of a drag for no benefit (the DOM
568
+ // data-dragging hook already handles the visual).
569
+ const isDraggingRef = useRef(false);
570
+ const setDragging = useCallback((value: boolean) => {
571
+ isDraggingRef.current = value;
572
+ }, []);
573
+ const isDragging = useCallback(() => isDraggingRef.current, []);
574
+
575
+ // Same ref-not-state reasoning again: isOverscrolling() is read on
576
+ // demand, and set/cleared from inside the Viewport hook's pointermove
577
+ // handler on every tick of a drag-overscroll (the data-overscroll DOM
578
+ // hook already handles the visual).
579
+ const isOverscrollingRef = useRef(false);
580
+ const setOverscrolling = useCallback((value: boolean) => {
581
+ isOverscrollingRef.current = value;
582
+ }, []);
583
+ const isOverscrolling = useCallback(() => isOverscrollingRef.current, []);
584
+
585
+ // Same ref-not-state reasoning again: getScrollProgress()/
586
+ // getSlideProgress() are read on demand, and the Viewport hook's
587
+ // scroll/resize recompute would otherwise re-render on every tick for
588
+ // no benefit (the --carousel-progress/--slide-progress CSS custom
589
+ // properties already handle the visual).
590
+ const scrollProgressRef = useRef(0);
591
+ const setScrollProgress = useCallback((value: number) => {
592
+ scrollProgressRef.current = value;
593
+ }, []);
594
+ const getScrollProgress = useCallback(() => scrollProgressRef.current, []);
595
+
596
+ const slideProgressRef = useRef<Map<number, number>>(new Map());
597
+ const setSlideProgress = useCallback(
598
+ (slideIndex: number, value: number) => {
599
+ slideProgressRef.current.set(slideIndex, value);
600
+ },
601
+ [],
602
+ );
603
+ const getSlideProgress = useCallback(
604
+ (slideIndex: number) => slideProgressRef.current.get(slideIndex) ?? 0,
605
+ [],
606
+ );
607
+
365
608
  useImperativeHandle(
366
609
  imperativeRef,
367
610
  () => ({
368
611
  next,
369
612
  previous,
370
613
  goTo,
614
+ scrollToIndex,
371
615
  play,
372
616
  pause,
373
617
  refresh,
374
618
  getProgress,
375
619
  isInView,
620
+ getPageSnapPoints,
621
+ isDragging,
622
+ isOverscrolling,
623
+ getScrollProgress,
624
+ getSlideProgress,
376
625
  }),
377
- [next, previous, goTo, play, pause, refresh, getProgress, isInView],
626
+ [
627
+ next,
628
+ previous,
629
+ goTo,
630
+ scrollToIndex,
631
+ play,
632
+ pause,
633
+ refresh,
634
+ getProgress,
635
+ isInView,
636
+ getPageSnapPoints,
637
+ isDragging,
638
+ isOverscrolling,
639
+ getScrollProgress,
640
+ getSlideProgress,
641
+ ],
378
642
  );
379
643
 
380
644
  // Reset the user-initiated flag when the playing session ends, so a
@@ -398,8 +662,11 @@ export function useCarouselRoot(
398
662
  registerSlide,
399
663
  slidesRef,
400
664
  slideKeys,
401
- slidesPerPage,
665
+ slidesPerPage: perPage,
402
666
  effectiveSlidesPerMove,
667
+ maxOffset,
668
+ currentPageOffset,
669
+ pageForSlideIndex,
403
670
  totalPages,
404
671
  currentPage,
405
672
  canGoNext,
@@ -415,17 +682,35 @@ export function useCarouselRoot(
415
682
  ids,
416
683
  transition,
417
684
  snapAlign,
685
+ orientation,
686
+ loop: loopMode,
687
+ allowMouseDrag,
688
+ onDragStatusChange,
689
+ onOverscrollStatusChange,
690
+ inViewThreshold,
691
+ snapType,
418
692
  refreshTick,
419
693
  visibleSlideIndicesRef,
420
694
  setSlideInView,
421
695
  isProgrammaticScrollRef,
696
+ instantScrollRef,
697
+ directJumpRef,
698
+ isDraggingRef,
699
+ setDragging,
700
+ isOverscrollingRef,
701
+ setOverscrolling,
702
+ setScrollProgress,
703
+ setSlideProgress,
422
704
  }),
423
705
  [
424
706
  registerSlide,
425
707
  slidesRef,
426
708
  slideKeys,
427
- slidesPerPage,
709
+ perPage,
428
710
  effectiveSlidesPerMove,
711
+ maxOffset,
712
+ currentPageOffset,
713
+ pageForSlideIndex,
429
714
  totalPages,
430
715
  currentPage,
431
716
  canGoNext,
@@ -441,8 +726,19 @@ export function useCarouselRoot(
441
726
  ids,
442
727
  transition,
443
728
  snapAlign,
729
+ orientation,
730
+ loopMode,
731
+ allowMouseDrag,
732
+ onDragStatusChange,
733
+ onOverscrollStatusChange,
734
+ inViewThreshold,
735
+ snapType,
444
736
  refreshTick,
445
737
  setSlideInView,
738
+ setDragging,
739
+ setOverscrolling,
740
+ setScrollProgress,
741
+ setSlideProgress,
446
742
  ],
447
743
  );
448
744
 
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useId } from "react";
2
2
 
3
+ import type { CarouselSnapAlign } from "../types";
3
4
  import { useCarouselContext } from "./useCarouselContext";
4
5
 
5
6
  /**
@@ -12,14 +13,20 @@ import { useCarouselContext } from "./useCarouselContext";
12
13
  * a callback ref (rather than `useEffect`) keeps DOM-attachment and
13
14
  * registration in lockstep, which matters when slides mount or unmount
14
15
  * during a render pass driven by an external state change.
16
+ *
17
+ * `snapAlignOverride` is the slide's own `Carousel.Slide.snapAlign` prop
18
+ * (if set) — it wins over the root's resolved `snapAlign` for this one
19
+ * slide, but only when the slide is actually a valid snap-start position;
20
+ * see the `snapAlign` field below.
15
21
  */
16
- export function useCarouselSlide() {
22
+ export function useCarouselSlide(snapAlignOverride?: CarouselSnapAlign) {
17
23
  const {
18
24
  registerSlide,
19
25
  slideKeys,
20
26
  slidesPerPage,
27
+ currentPageOffset,
21
28
  effectiveSlidesPerMove,
22
- currentPage,
29
+ snapAlign: rootSnapAlign,
23
30
  } = useCarouselContext();
24
31
  const slideKey = useId();
25
32
 
@@ -32,14 +39,42 @@ export function useCarouselSlide() {
32
39
 
33
40
  const index = slideKeys.indexOf(slideKey);
34
41
  const total = slideKeys.length;
35
- // The active "page" covers a window of slidesPerPage slides starting
36
- // at pageOffset = currentPage * effectiveSlidesPerMove. With
37
- // slidesPerMove="auto" this collapses to non-overlapping page-sized
38
- // groups; with a numeric slidesPerMove the windows overlap.
39
- const pageOffset = currentPage * effectiveSlidesPerMove;
42
+ // The active page covers a window of slidesPerPage slides starting at
43
+ // currentPageOffset (computed by the Root — non-overlapping page groups
44
+ // in "auto" mode, overlapping/end-aligned windows with a numeric
45
+ // slidesPerMove).
40
46
  const isActive =
41
- index >= 0 && index >= pageOffset && index < pageOffset + slidesPerPage;
47
+ index >= 0 &&
48
+ index >= currentPageOffset &&
49
+ index < currentPageOffset + slidesPerPage;
42
50
  const state: "active" | "inactive" = isActive ? "active" : "inactive";
43
51
 
44
- return { slideRef, index, total, state };
52
+ // Only a page's *leading* slide is a valid scroll-snap resting position.
53
+ // The Root's page offsets are `min(page * effectiveSlidesPerMove,
54
+ // maxOffset)` for each page — the same set an interior slide (any other
55
+ // index) is never a member of. Letting every slide snap (the old,
56
+ // unconditional CSS default) lets the browser rest scroll on an interior
57
+ // slide when slidesPerPage > 1, which has no valid page mapping: the
58
+ // viewport shows a straddled mix of two pages while currentPage/the
59
+ // indicators still claim one clean page is active. Mirrors the identical
60
+ // offset formula pageForSlideIndex inverts, so the two always agree.
61
+ const maxOffset = Math.max(0, total - slidesPerPage);
62
+ const isSnapStart =
63
+ index >= 0 &&
64
+ (index === maxOffset ||
65
+ (index <= maxOffset && index % effectiveSlidesPerMove === 0));
66
+ // The *value* mirrors the resolved root snapAlign (or this slide's own
67
+ // override, if set), not just a fixed "start" — so the native scroll-snap
68
+ // resting position agrees with where the programmatic scroll (Viewport's
69
+ // centerOffset maths, which reads this same value off the DOM) actually
70
+ // targets. Previously this was hardcoded to "start" regardless of
71
+ // snapAlign, so a user's own swipe/wheel/drag would snap back to the
72
+ // slide's leading edge even under snapAlign="center". Gated on
73
+ // isSnapStart either way — an interior slide of a multi-slide page must
74
+ // never snap, even if it carries its own snapAlign override.
75
+ const snapAlign = isSnapStart
76
+ ? (snapAlignOverride ?? rootSnapAlign)
77
+ : undefined;
78
+
79
+ return { slideRef, index, total, state, snapAlign };
45
80
  }