@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.
- package/README.md +4 -0
- package/package.json +6 -1
- package/src/AccessibleIcon/AccessibleIcon.tsx +32 -9
- package/src/AccessibleIcon/__tests__/AccessibleIcon.test.tsx +5 -0
- package/src/AccessibleIcon/types.ts +15 -2
- package/src/Accordion/Accordion.tsx +25 -4
- package/src/Accordion/__tests__/Accordion.asChild.test.tsx +46 -0
- package/src/Accordion/__tests__/Accordion.basic-rendering.test.tsx +15 -0
- package/src/Accordion/__tests__/Accordion.disabled-items.test.tsx +42 -0
- package/src/Accordion/__tests__/Accordion.error-handling.test.tsx +33 -0
- package/src/Accordion/hooks/useAccordionRoot.ts +44 -7
- package/src/Accordion/hooks/useAccordionTrigger.ts +9 -1
- package/src/Accordion/types.ts +72 -21
- package/src/Alert/Alert.tsx +36 -7
- package/src/Alert/__tests__/Alert.test.tsx +5 -0
- package/src/Alert/types.ts +18 -2
- package/src/Avatar/Avatar.tsx +99 -15
- package/src/Avatar/__tests__/Avatar.basic-rendering.test.tsx +10 -0
- package/src/Avatar/__tests__/Avatar.fallback.test.tsx +30 -0
- package/src/Avatar/__tests__/Avatar.image-loading.test.tsx +35 -0
- package/src/Avatar/hooks/useAvatarImage.ts +16 -0
- package/src/Avatar/types.ts +15 -3
- package/src/Breadcrumb/Breadcrumb.tsx +136 -18
- package/src/Breadcrumb/README.md +47 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.basic-rendering.test.tsx +11 -0
- package/src/Breadcrumb/__tests__/Breadcrumb.ellipsis.test.tsx +110 -0
- package/src/Breadcrumb/types.ts +73 -7
- package/src/Button/Button.tsx +2 -0
- package/src/Button/__tests__/Button.basic-rendering.test.tsx +6 -0
- package/src/Button/types.ts +21 -0
- package/src/Carousel/Carousel.tsx +263 -15
- package/src/Carousel/README.md +749 -72
- package/src/Carousel/__tests__/Carousel.autoplay-status.test.tsx +239 -0
- package/src/Carousel/__tests__/Carousel.drag-status.test.tsx +292 -0
- package/src/Carousel/__tests__/Carousel.dynamic-slides.test.tsx +38 -0
- package/src/Carousel/__tests__/Carousel.indicators.test.tsx +101 -0
- package/src/Carousel/__tests__/Carousel.infinite.test.tsx +1453 -0
- package/src/Carousel/__tests__/Carousel.instant-scroll.test.tsx +124 -0
- package/src/Carousel/__tests__/Carousel.intersection-observer.test.tsx +88 -0
- package/src/Carousel/__tests__/Carousel.loop.test.tsx +163 -0
- package/src/Carousel/__tests__/Carousel.loopEngine.test.ts +77 -0
- package/src/Carousel/__tests__/Carousel.mouse-drag.test.tsx +504 -0
- package/src/Carousel/__tests__/Carousel.multi-slide-bounds.test.tsx +91 -0
- package/src/Carousel/__tests__/Carousel.orientation.test.tsx +207 -0
- package/src/Carousel/__tests__/Carousel.overscroll.test.tsx +532 -0
- package/src/Carousel/__tests__/Carousel.page-snap-points.test.tsx +50 -0
- package/src/Carousel/__tests__/Carousel.progress-text.test.tsx +76 -0
- package/src/Carousel/__tests__/Carousel.reduced-motion.test.tsx +3 -6
- package/src/Carousel/__tests__/Carousel.refresh-progress.test.tsx +2 -5
- package/src/Carousel/__tests__/Carousel.scroll-margin.test.tsx +167 -0
- package/src/Carousel/__tests__/Carousel.scroll-progress.test.tsx +518 -0
- package/src/Carousel/__tests__/Carousel.scroll-snap-change.test.tsx +32 -11
- package/src/Carousel/__tests__/Carousel.scroll-sync.test.tsx +237 -24
- package/src/Carousel/__tests__/Carousel.scroll-to-index.test.tsx +115 -0
- package/src/Carousel/__tests__/Carousel.slide-snap.test.tsx +172 -0
- package/src/Carousel/__tests__/Carousel.slides-per-move.test.tsx +179 -1
- package/src/Carousel/__tests__/Carousel.slides-per-page.test.tsx +91 -7
- package/src/Carousel/__tests__/Carousel.snap-type.test.tsx +35 -0
- package/src/Carousel/__tests__/Carousel.transition-modes.test.tsx +30 -5
- package/src/Carousel/__tests__/Carousel.translations.test.tsx +23 -0
- package/src/Carousel/__tests__/Carousel.wheel.test.tsx +160 -0
- package/src/Carousel/hooks/index.ts +1 -0
- package/src/Carousel/hooks/useCarouselLoop.ts +869 -0
- package/src/Carousel/hooks/useCarouselRoot.ts +338 -42
- package/src/Carousel/hooks/useCarouselSlide.ts +44 -9
- package/src/Carousel/hooks/useCarouselViewport.ts +649 -46
- package/src/Carousel/loopEngine.ts +54 -0
- package/src/Carousel/types.ts +457 -35
- package/src/Checkbox/Checkbox.tsx +9 -1
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +14 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +4 -0
- package/src/Checkbox/types.ts +19 -0
- package/src/CheckboxCard/CheckboxCard.tsx +13 -4
- package/src/CheckboxCard/__tests__/CheckboxCard.basic-rendering.test.tsx +7 -0
- package/src/CheckboxCard/types.ts +65 -17
- package/src/Collapsible/Collapsible.tsx +49 -3
- package/src/Collapsible/README.md +40 -0
- package/src/Collapsible/__tests__/Collapsible.asChild.test.tsx +89 -0
- package/src/Collapsible/__tests__/Collapsible.basic-rendering.test.tsx +27 -0
- package/src/Collapsible/__tests__/Collapsible.collapsedHeight.test.tsx +140 -0
- package/src/Collapsible/hooks/useCollapsibleTrigger.ts +3 -1
- package/src/Collapsible/types.ts +89 -22
- package/src/ContextMenu/ContextMenu.tsx +270 -4
- package/src/ContextMenu/types.ts +148 -6
- package/src/DirectionProvider/DirectionProvider.tsx +41 -9
- package/src/DirectionProvider/__tests__/DirectionProvider.test.tsx +5 -0
- package/src/DirectionProvider/types.ts +28 -3
- package/src/Divider/Divider.tsx +24 -10
- package/src/Divider/__tests__/Divider.test.tsx +5 -0
- package/src/Divider/types.ts +10 -3
- package/src/Drawer/Drawer.tsx +315 -0
- package/src/Drawer/README.md +124 -0
- package/src/Drawer/__tests__/Drawer.behavior.test.tsx +100 -0
- package/src/Drawer/__tests__/Drawer.focus-trap.test.tsx +55 -0
- package/src/Drawer/__tests__/Drawer.rendering.test.tsx +111 -0
- package/src/Drawer/__tests__/Drawer.side.test.tsx +69 -0
- package/src/Drawer/__tests__/dialog-polyfill.ts +39 -0
- package/src/Drawer/index.ts +2 -0
- package/src/Drawer/types.ts +66 -0
- package/src/Dropdown/Dropdown.tsx +117 -0
- package/src/Dropdown/types.ts +241 -11
- package/src/EmptyState/EmptyState.tsx +74 -4
- package/src/EmptyState/__tests__/EmptyState.Actions.test.tsx +7 -2
- package/src/EmptyState/__tests__/EmptyState.Description.test.tsx +3 -1
- package/src/EmptyState/__tests__/EmptyState.Root.test.tsx +10 -0
- package/src/EmptyState/__tests__/EmptyState.Title.test.tsx +3 -1
- package/src/EmptyState/types.ts +49 -6
- package/src/Field/Field.tsx +23 -0
- package/src/Field/__tests__/Field.basic-rendering.test.tsx +11 -0
- package/src/Field/__tests__/Field.error-handling.test.tsx +23 -0
- package/src/Field/__tests__/Field.state-cascade.test.tsx +21 -0
- package/src/Field/hooks/__tests__/useFieldProps.test.tsx +104 -9
- package/src/Field/types.ts +60 -12
- package/src/Fieldset/Fieldset.tsx +54 -18
- package/src/Fieldset/__tests__/Fieldset.basic-rendering.test.tsx +9 -0
- package/src/Fieldset/types.ts +20 -2
- package/src/Input/Input.tsx +14 -9
- package/src/Input/__tests__/Input.basic-rendering.test.tsx +5 -0
- package/src/Input/types.ts +19 -2
- package/src/InputGroup/InputGroup.tsx +11 -3
- package/src/InputGroup/__tests__/InputGroup.basic-rendering.test.tsx +14 -0
- package/src/InputGroup/types.ts +26 -5
- package/src/MillerColumns/MillerColumns.tsx +86 -19
- package/src/MillerColumns/types.ts +83 -27
- package/src/Modal/Modal.tsx +31 -4
- package/src/Modal/README.md +19 -10
- package/src/Modal/__tests__/Modal.focus-trap.test.tsx +162 -0
- package/src/Modal/hooks/useModalContent.ts +32 -0
- package/src/Modal/types.ts +110 -29
- package/src/NavigationMenu/NavigationMenu.tsx +890 -0
- package/src/NavigationMenu/NavigationMenuContext.ts +67 -0
- package/src/NavigationMenu/README.md +355 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.anchor-positioning.test.tsx +132 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.asChild.test.tsx +73 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.basic-rendering.test.tsx +92 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.contract.test.tsx +45 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.controlled-state.test.tsx +134 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.error-handling.test.tsx +62 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.fixtures.tsx +79 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.forceMount.test.tsx +52 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.hover-interaction.test.tsx +254 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.indicator.test.tsx +252 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-interaction.test.tsx +294 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.keyboard-navigation.test.tsx +126 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.link.test.tsx +139 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.motion.test.tsx +568 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.mouse-interaction.test.tsx +76 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.uncontrolled-state.test.tsx +30 -0
- package/src/NavigationMenu/__tests__/NavigationMenu.viewport.test.tsx +113 -0
- package/src/NavigationMenu/constants.ts +5 -0
- package/src/NavigationMenu/hooks/index.ts +7 -0
- package/src/NavigationMenu/hooks/useNavigationMenuEntry.ts +33 -0
- package/src/NavigationMenu/hooks/useNavigationMenuIndicator.ts +70 -0
- package/src/NavigationMenu/hooks/useNavigationMenuLink.ts +72 -0
- package/src/NavigationMenu/hooks/useNavigationMenuRoot.ts +301 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTopLevelEntry.ts +65 -0
- package/src/NavigationMenu/hooks/useNavigationMenuTrigger.ts +158 -0
- package/src/NavigationMenu/hooks/useNavigationMenuViewport.ts +101 -0
- package/src/NavigationMenu/index.ts +3 -0
- package/src/NavigationMenu/types.ts +259 -0
- package/src/NavigationMenu/utils.ts +89 -0
- package/src/Popover/Popover.tsx +427 -0
- package/src/Popover/PopoverContext.ts +13 -0
- package/src/Popover/README.md +106 -0
- package/src/Popover/__tests__/Popover.anchor.test.tsx +38 -0
- package/src/Popover/__tests__/Popover.asChild.test.tsx +160 -0
- package/src/Popover/__tests__/Popover.basic-rendering.test.tsx +106 -0
- package/src/Popover/__tests__/Popover.click-outside.test.tsx +149 -0
- package/src/Popover/__tests__/Popover.close.test.tsx +70 -0
- package/src/Popover/__tests__/Popover.content.test.tsx +58 -0
- package/src/Popover/__tests__/Popover.controlled-state.test.tsx +45 -0
- package/src/Popover/__tests__/Popover.error-handling.test.tsx +51 -0
- package/src/Popover/__tests__/Popover.focus.test.tsx +43 -0
- package/src/Popover/__tests__/Popover.keyboard-interaction.test.tsx +71 -0
- package/src/Popover/__tests__/Popover.mouse-interaction.test.tsx +69 -0
- package/src/Popover/__tests__/Popover.uncontrolled-state.test.tsx +44 -0
- package/src/Popover/hooks/index.ts +3 -0
- package/src/Popover/hooks/usePopoverContent.ts +98 -0
- package/src/Popover/hooks/usePopoverRoot.ts +84 -0
- package/src/Popover/hooks/usePopoverTrigger.ts +33 -0
- package/src/Popover/index.ts +2 -0
- package/src/Popover/types.ts +197 -0
- package/src/Portal/Portal.tsx +50 -10
- package/src/Portal/__tests__/Portal.basic-rendering.test.tsx +5 -0
- package/src/Portal/types.ts +5 -0
- package/src/Progress/Progress.tsx +33 -1
- package/src/Progress/__tests__/Progress.basic-rendering.test.tsx +10 -0
- package/src/Progress/__tests__/Progress.value.test.tsx +21 -0
- package/src/Progress/types.ts +20 -4
- package/src/Radio/Radio.tsx +9 -1
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +14 -0
- package/src/Radio/hooks/useRadioRoot.ts +3 -0
- package/src/Radio/types.ts +20 -0
- package/src/RadioCard/RadioCard.tsx +11 -0
- package/src/RadioCard/__tests__/RadioCard.basic-rendering.test.tsx +8 -0
- package/src/RadioCard/__tests__/RadioCard.disabled-items.test.tsx +27 -0
- package/src/RadioCard/__tests__/RadioCard.keyboard-interaction.test.tsx +53 -0
- package/src/RadioCard/__tests__/RadioCard.ref-forwarding.test.tsx +54 -0
- package/src/RadioCard/__tests__/RadioCard.uncontrolled-state.test.tsx +21 -0
- package/src/RadioCard/hooks/useRadioCardRoot.ts +11 -0
- package/src/RadioCard/types.ts +95 -22
- package/src/RadioGroup/RadioGroup.tsx +11 -0
- package/src/RadioGroup/__tests__/RadioGroup.basic-rendering.test.tsx +8 -0
- package/src/RadioGroup/__tests__/RadioGroup.disabled.test.tsx +27 -0
- package/src/RadioGroup/__tests__/RadioGroup.ref-forwarding.test.tsx +29 -0
- package/src/RadioGroup/__tests__/RadioGroup.roving-tabindex.test.tsx +31 -0
- package/src/RadioGroup/hooks/useRadioGroupRoot.ts +11 -0
- package/src/RadioGroup/types.ts +73 -9
- package/src/SegmentedControl/README.md +154 -0
- package/src/SegmentedControl/SegmentedControl.tsx +309 -0
- package/src/SegmentedControl/SegmentedControlContext.ts +28 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.asChild.test.tsx +50 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.basic-rendering.test.tsx +100 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.controlled-state.test.tsx +107 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled-keydown-guards.test.tsx +62 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.disabled.test.tsx +151 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.error-handling.test.tsx +17 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.keyboard-interaction.test.tsx +117 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.orientation.test.tsx +54 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.reading-direction.test.tsx +56 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.ref-forwarding.test.tsx +45 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.roving-tabindex.test.tsx +104 -0
- package/src/SegmentedControl/__tests__/SegmentedControl.uncontrolled-state.test.tsx +94 -0
- package/src/SegmentedControl/hooks/index.ts +2 -0
- package/src/SegmentedControl/hooks/useSegmentedControlContext.ts +1 -0
- package/src/SegmentedControl/hooks/useSegmentedControlRoot.ts +86 -0
- package/src/SegmentedControl/index.ts +2 -0
- package/src/SegmentedControl/types.ts +138 -0
- package/src/Select/README.md +122 -157
- package/src/Select/Select.tsx +595 -68
- package/src/Select/SelectContext.ts +38 -0
- package/src/Select/SelectItemIndicatorContext.ts +29 -0
- package/src/Select/__tests__/Select.asChild.test.tsx +2 -2
- package/src/Select/__tests__/Select.basic-rendering.test.tsx +22 -3
- package/src/Select/__tests__/Select.controlled-state.test.tsx +6 -6
- package/src/Select/__tests__/Select.data-attributes.test.tsx +4 -4
- package/src/Select/__tests__/Select.field-integration.test.tsx +11 -11
- package/src/Select/__tests__/Select.form.test.tsx +38 -0
- package/src/Select/__tests__/Select.group.test.tsx +6 -6
- package/src/Select/__tests__/Select.keyboard-edge.test.tsx +95 -0
- package/src/Select/__tests__/Select.keyboard.test.tsx +124 -0
- package/src/Select/__tests__/Select.mutation-hardening.test.tsx +317 -0
- package/src/Select/__tests__/Select.native-item.test.tsx +30 -0
- package/src/Select/__tests__/Select.open-close.test.tsx +57 -0
- package/src/Select/__tests__/Select.placeholder.test.tsx +4 -4
- package/src/Select/__tests__/Select.rich-asChild.test.tsx +60 -0
- package/src/Select/__tests__/Select.rich-mode.test.tsx +16 -0
- package/src/Select/__tests__/Select.selection.test.tsx +104 -0
- package/src/Select/__tests__/Select.separator.test.tsx +71 -0
- package/src/Select/__tests__/Select.strict-context.test.tsx +36 -0
- package/src/Select/__tests__/Select.typeahead.test.tsx +156 -0
- package/src/Select/__tests__/Select.value-mirror.test.tsx +240 -0
- package/src/Select/constants.ts +8 -0
- package/src/Select/hooks/index.ts +2 -0
- package/src/Select/hooks/useSelectContent.ts +149 -0
- package/src/Select/hooks/useSelectRoot.ts +149 -0
- package/src/Select/types.ts +199 -31
- package/src/SkipNav/SkipNav.tsx +69 -29
- package/src/SkipNav/__tests__/SkipNav.basic-rendering.test.tsx +6 -0
- package/src/SkipNav/types.ts +17 -9
- package/src/Slider/Slider.tsx +33 -1
- package/src/Slider/SliderContext.ts +28 -1
- package/src/Slider/types.ts +157 -9
- package/src/Slot/Slot.tsx +94 -19
- package/src/Slot/__tests__/Slot.test.tsx +110 -0
- package/src/Slot/composeEventHandlers.ts +26 -2
- package/src/Slot/types.ts +16 -0
- package/src/Status/Status.tsx +36 -15
- package/src/Status/__tests__/Status.test.tsx +5 -0
- package/src/Status/types.ts +12 -1
- package/src/Switch/Switch.tsx +15 -2
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +7 -0
- package/src/Switch/hooks/useSwitchRoot.ts +3 -0
- package/src/Switch/types.ts +49 -22
- package/src/Table/Table.tsx +44 -10
- package/src/Table/types.ts +66 -17
- package/src/Tabs/Tabs.tsx +8 -0
- package/src/Tabs/types.ts +29 -4
- package/src/Textarea/Textarea.tsx +9 -1
- package/src/Textarea/__tests__/Textarea.basic-rendering.test.tsx +5 -0
- package/src/Textarea/types.ts +20 -3
- package/src/Toggle/Toggle.tsx +51 -13
- package/src/Toggle/__tests__/Toggle.basic-rendering.test.tsx +5 -0
- package/src/Toggle/types.ts +54 -4
- package/src/ToggleGroup/ToggleGroup.tsx +14 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.asChild.test.tsx +20 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.basic-rendering.test.tsx +9 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.dynamic-items.test.tsx +70 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.error-handling.test.tsx +23 -0
- package/src/ToggleGroup/__tests__/ToggleGroup.keyboard-interaction.test.tsx +23 -1
- package/src/ToggleGroup/__tests__/ToggleGroup.ref-forwarding.test.tsx +58 -0
- package/src/ToggleGroup/hooks/useToggleGroupRoot.ts +21 -1
- package/src/ToggleGroup/types.ts +55 -2
- package/src/Tooltip/Tooltip.tsx +98 -20
- package/src/Tooltip/hooks/__tests__/useTooltipProvider.test.tsx +40 -0
- package/src/Tooltip/hooks/useTooltipProvider.ts +13 -1
- package/src/Tooltip/types.ts +89 -8
- package/src/Tree/Tree.tsx +187 -33
- package/src/Tree/types.ts +54 -12
- package/src/VisuallyHidden/VisuallyHidden.tsx +33 -8
- package/src/VisuallyHidden/__tests__/VisuallyHidden.test.tsx +15 -1
- package/src/VisuallyHidden/types.ts +11 -1
- package/src/index.ts +4 -0
- package/src/test/intersectionObserverPolyfill.ts +7 -0
- package/src/test/resizeObserverPolyfill.ts +80 -0
- package/src/utils/focusable.ts +15 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { render, act, fireEvent } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { MockResizeObserver } from "../../test/resizeObserverPolyfill";
|
|
6
|
+
import { Carousel } from "../index.ts";
|
|
7
|
+
import type { CarouselImperativeApi } from "../index.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dispatch a pointer event with a controlled `timeStamp` (jsdom's is not
|
|
11
|
+
* settable via fireEvent props) so drag velocity is deterministic. clientX and
|
|
12
|
+
* clientY both carry `client` so the same helper drives either axis.
|
|
13
|
+
*/
|
|
14
|
+
function pointer(
|
|
15
|
+
target: Element,
|
|
16
|
+
type: string,
|
|
17
|
+
{
|
|
18
|
+
client,
|
|
19
|
+
time = 0,
|
|
20
|
+
id = 1,
|
|
21
|
+
pointerType = "touch",
|
|
22
|
+
}: { client: number; time?: number; id?: number; pointerType?: string },
|
|
23
|
+
) {
|
|
24
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
25
|
+
Object.assign(event, {
|
|
26
|
+
clientX: client,
|
|
27
|
+
clientY: client,
|
|
28
|
+
pointerId: id,
|
|
29
|
+
pointerType,
|
|
30
|
+
});
|
|
31
|
+
Object.defineProperty(event, "timeStamp", {
|
|
32
|
+
configurable: true,
|
|
33
|
+
value: time,
|
|
34
|
+
});
|
|
35
|
+
act(() => {
|
|
36
|
+
fireEvent(target, event);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fire the track's `transitionend` (jsdom runs no transitions, so the glide
|
|
42
|
+
* settle that triggers the re-base is simulated). Carries `propertyName:
|
|
43
|
+
* "transform"` — the engine ignores any other property.
|
|
44
|
+
*/
|
|
45
|
+
function fireEnd(track: Element, propertyName = "transform") {
|
|
46
|
+
const event = new Event("transitionend", { bubbles: true });
|
|
47
|
+
Object.assign(event, { propertyName });
|
|
48
|
+
act(() => {
|
|
49
|
+
fireEvent(track, event);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The infinite transform engine (RFC 0018 / useCarouselLoop). Real geometry is
|
|
54
|
+
// browser-only (jsdom lays nothing out), so these mock the layout the engine
|
|
55
|
+
// reads (offsetLeft/Top, offsetWidth/Height, clientWidth/Height) and assert the
|
|
56
|
+
// resulting track / per-slide transforms and the CSS transition that drives the
|
|
57
|
+
// glide. Pixel behaviour and smoothness are verified in Playwright.
|
|
58
|
+
|
|
59
|
+
const STRIDE = 100; // slide size + gap
|
|
60
|
+
const SLIDE = 100;
|
|
61
|
+
const VIEWPORT = 100; // → centre align offset 0, so transforms read cleanly
|
|
62
|
+
|
|
63
|
+
function defineGeometry() {
|
|
64
|
+
// A slide's layout position is its index × stride; every slide is SLIDE wide;
|
|
65
|
+
// the viewport is VIEWPORT wide. Defined on the prototype so both axes read
|
|
66
|
+
// the same numbers (horizontal uses left/width, vertical top/height).
|
|
67
|
+
const indexPos = function (this: HTMLElement) {
|
|
68
|
+
const index = this.getAttribute?.("data-index");
|
|
69
|
+
return index != null ? Number(index) * STRIDE : 0;
|
|
70
|
+
};
|
|
71
|
+
const slideSize = function (this: HTMLElement) {
|
|
72
|
+
return this.hasAttribute?.("data-carousel-slide") ? SLIDE : 0;
|
|
73
|
+
};
|
|
74
|
+
// The engine measures the TRACK's own content box (peek/padding is the
|
|
75
|
+
// viewport's, kept out of the maths), so the harness sizes the track.
|
|
76
|
+
const viewportSize = function (this: HTMLElement) {
|
|
77
|
+
return this.hasAttribute?.("data-carousel-track") ? VIEWPORT : 0;
|
|
78
|
+
};
|
|
79
|
+
for (const [prop, get] of [
|
|
80
|
+
["offsetLeft", indexPos],
|
|
81
|
+
["offsetTop", indexPos],
|
|
82
|
+
["offsetWidth", slideSize],
|
|
83
|
+
["offsetHeight", slideSize],
|
|
84
|
+
["clientWidth", viewportSize],
|
|
85
|
+
["clientHeight", viewportSize],
|
|
86
|
+
] as const) {
|
|
87
|
+
Object.defineProperty(HTMLElement.prototype, prop, {
|
|
88
|
+
configurable: true,
|
|
89
|
+
get,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// The engine measures via getBoundingClientRect (sub-pixel). Compose the same
|
|
93
|
+
// numbers into a rect: a slide starts at index × stride and is SLIDE big; the
|
|
94
|
+
// track starts at 0 and is VIEWPORT big (its own content box). jsdom applies no
|
|
95
|
+
// transform, so the mock is the pure layout the engine reads via differences.
|
|
96
|
+
stdRect();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Install a getBoundingClientRect that derives left/top/width/height from a
|
|
100
|
+
// per-element { start, size } (both axes share the numbers, as the offsets do).
|
|
101
|
+
function defineRect(
|
|
102
|
+
layout: (this: HTMLElement) => { start: number; size: number },
|
|
103
|
+
) {
|
|
104
|
+
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
|
105
|
+
configurable: true,
|
|
106
|
+
value: function (this: HTMLElement) {
|
|
107
|
+
const { start, size } = layout.call(this);
|
|
108
|
+
return {
|
|
109
|
+
left: start,
|
|
110
|
+
top: start,
|
|
111
|
+
right: start + size,
|
|
112
|
+
bottom: start + size,
|
|
113
|
+
width: size,
|
|
114
|
+
height: size,
|
|
115
|
+
x: start,
|
|
116
|
+
y: start,
|
|
117
|
+
toJSON() {},
|
|
118
|
+
} as DOMRect;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The standard rect layout, with per-test overrides: the track (and, by
|
|
124
|
+
// default, the viewport around it — coincident with no peek/padding) is
|
|
125
|
+
// `trackSize` wide, a slide is SLIDE, and a real slide (data-index) starts at
|
|
126
|
+
// `startFor(i)` (default index × stride; negate for RTL, offset for the clone
|
|
127
|
+
// buffer, zero to collapse the stride). Clones/other elements start at 0.
|
|
128
|
+
function stdRect({
|
|
129
|
+
trackSize = VIEWPORT,
|
|
130
|
+
viewportSize = trackSize,
|
|
131
|
+
startFor = (i: number) => i * STRIDE,
|
|
132
|
+
}: {
|
|
133
|
+
trackSize?: number;
|
|
134
|
+
viewportSize?: number;
|
|
135
|
+
startFor?: (index: number) => number;
|
|
136
|
+
} = {}) {
|
|
137
|
+
defineRect(function (this: HTMLElement) {
|
|
138
|
+
const raw = this.getAttribute?.("data-index");
|
|
139
|
+
const index = raw != null ? Number(raw) : null;
|
|
140
|
+
const isTrack = this.hasAttribute?.("data-carousel-track");
|
|
141
|
+
const isViewport = this.hasAttribute?.("data-carousel-viewport");
|
|
142
|
+
const isSlide = this.hasAttribute?.("data-carousel-slide");
|
|
143
|
+
return {
|
|
144
|
+
start: index != null ? startFor(index) : 0,
|
|
145
|
+
size: isTrack
|
|
146
|
+
? trackSize
|
|
147
|
+
: isViewport
|
|
148
|
+
? viewportSize
|
|
149
|
+
: isSlide
|
|
150
|
+
? SLIDE
|
|
151
|
+
: 0,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
defineGeometry();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
vi.restoreAllMocks();
|
|
162
|
+
vi.unstubAllGlobals();
|
|
163
|
+
for (const prop of [
|
|
164
|
+
"offsetLeft",
|
|
165
|
+
"offsetTop",
|
|
166
|
+
"offsetWidth",
|
|
167
|
+
"offsetHeight",
|
|
168
|
+
"clientWidth",
|
|
169
|
+
"clientHeight",
|
|
170
|
+
"getBoundingClientRect",
|
|
171
|
+
]) {
|
|
172
|
+
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[prop];
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
function renderInfinite(
|
|
177
|
+
props: Partial<{
|
|
178
|
+
loop: "infinite" | "wrap";
|
|
179
|
+
orientation: "horizontal" | "vertical";
|
|
180
|
+
snapAlign: "start" | "center" | "end";
|
|
181
|
+
defaultPage: number;
|
|
182
|
+
count: number;
|
|
183
|
+
allowMouseDrag: boolean;
|
|
184
|
+
slidesPerPage: number;
|
|
185
|
+
apiRef: React.Ref<CarouselImperativeApi>;
|
|
186
|
+
}> = {},
|
|
187
|
+
) {
|
|
188
|
+
const {
|
|
189
|
+
loop = "infinite",
|
|
190
|
+
orientation = "horizontal",
|
|
191
|
+
snapAlign = "center",
|
|
192
|
+
defaultPage = 0,
|
|
193
|
+
count = 4,
|
|
194
|
+
allowMouseDrag = false,
|
|
195
|
+
slidesPerPage = 1,
|
|
196
|
+
apiRef,
|
|
197
|
+
} = props;
|
|
198
|
+
const result = render(
|
|
199
|
+
<Carousel.Root
|
|
200
|
+
ref={apiRef}
|
|
201
|
+
ariaLabel="Featured products"
|
|
202
|
+
loop={loop}
|
|
203
|
+
orientation={orientation}
|
|
204
|
+
snapAlign={snapAlign}
|
|
205
|
+
defaultPage={defaultPage}
|
|
206
|
+
allowMouseDrag={allowMouseDrag}
|
|
207
|
+
slidesPerPage={slidesPerPage}
|
|
208
|
+
>
|
|
209
|
+
<Carousel.Viewport data-testid="viewport">
|
|
210
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
211
|
+
<Carousel.Slide key={i} data-testid={`slide-${i}`} />
|
|
212
|
+
))}
|
|
213
|
+
</Carousel.Viewport>
|
|
214
|
+
<Carousel.PreviousTrigger>Previous</Carousel.PreviousTrigger>
|
|
215
|
+
<Carousel.NextTrigger>Next</Carousel.NextTrigger>
|
|
216
|
+
</Carousel.Root>,
|
|
217
|
+
);
|
|
218
|
+
const track = result.container.querySelector<HTMLElement>(
|
|
219
|
+
"[data-carousel-track]",
|
|
220
|
+
);
|
|
221
|
+
return { ...result, track };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
describe("Carousel infinite — transform engine", () => {
|
|
225
|
+
it("wraps the real slides in a track for infinite, and not for other modes", () => {
|
|
226
|
+
const infinite = renderInfinite({ loop: "infinite" });
|
|
227
|
+
expect(infinite.track).not.toBeNull();
|
|
228
|
+
// The real slides carry a data-index (the clones have it stripped).
|
|
229
|
+
expect(
|
|
230
|
+
infinite.track!.querySelectorAll("[data-carousel-slide][data-index]"),
|
|
231
|
+
).toHaveLength(4);
|
|
232
|
+
|
|
233
|
+
const wrap = renderInfinite({ loop: "wrap" });
|
|
234
|
+
expect(wrap.track).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("flanks the real slides with a full period of aria-hidden, inert clones", () => {
|
|
238
|
+
const { track } = renderInfinite({ count: 4 });
|
|
239
|
+
const head = track!.querySelector('[data-carousel-clones="head"]')!;
|
|
240
|
+
const tail = track!.querySelector('[data-carousel-clones="tail"]')!;
|
|
241
|
+
// One period each side → a periodic strip whose every seam is painted DOM.
|
|
242
|
+
expect(head.children).toHaveLength(4);
|
|
243
|
+
expect(tail.children).toHaveLength(4);
|
|
244
|
+
const clone = head.children[0] as HTMLElement;
|
|
245
|
+
expect(clone.getAttribute("aria-hidden")).toBe("true");
|
|
246
|
+
expect(clone.hasAttribute("inert")).toBe(true);
|
|
247
|
+
expect(clone.hasAttribute("data-carousel-clone")).toBe(true);
|
|
248
|
+
// A clone is presentational only — no index/state that would double-count.
|
|
249
|
+
expect(clone.hasAttribute("data-index")).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("rebuilds clones when a slide's own content changes, not just its count", async () => {
|
|
253
|
+
// The builder toggles a slide's *content* (gradient <-> picture) without
|
|
254
|
+
// changing the slide count/keys — the clone-rebuild effect's deps
|
|
255
|
+
// (isInfinite, slideKeys, refreshTick) don't fire for that, so a stale
|
|
256
|
+
// clone kept showing the OLD content until something else happened to
|
|
257
|
+
// rebuild it. Crossing the seam then briefly shows the stale clone before
|
|
258
|
+
// the settle re-base swaps in the fresh real slide at the same pixels —
|
|
259
|
+
// "I still see the gradient momentarily, then it quickly changes to a
|
|
260
|
+
// picture." Same key, same count, only the children differ.
|
|
261
|
+
function App({ label }: { label: string }) {
|
|
262
|
+
return (
|
|
263
|
+
<Carousel.Root ariaLabel="Featured" loop="infinite">
|
|
264
|
+
<Carousel.Viewport>
|
|
265
|
+
{[0, 1, 2, 3].map((i) => (
|
|
266
|
+
<Carousel.Slide key={i}>{label}</Carousel.Slide>
|
|
267
|
+
))}
|
|
268
|
+
</Carousel.Viewport>
|
|
269
|
+
</Carousel.Root>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const { container, rerender } = render(<App label="gradient" />);
|
|
273
|
+
const head = container.querySelector('[data-carousel-clones="head"]')!;
|
|
274
|
+
expect(head.children[0]!.textContent).toBe("gradient");
|
|
275
|
+
|
|
276
|
+
rerender(<App label="picture" />);
|
|
277
|
+
// MutationObserver callbacks fire as a microtask, not synchronously
|
|
278
|
+
// within rerender() — flush one before asserting the clone caught up.
|
|
279
|
+
await act(async () => {
|
|
280
|
+
await Promise.resolve();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(head.children[0]!.textContent).toBe("picture");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("does not rebuild clones for the engine's own visibility/--slide-progress writes", async () => {
|
|
287
|
+
// paint() writes `visibility` and `--slide-progress` onto every real
|
|
288
|
+
// slide's inline style on every navigation, drag pointermove, and rAF
|
|
289
|
+
// tick of an animated glide. The clone-content observer must not mistake
|
|
290
|
+
// that continuous bookkeeping for a genuine consumer change, or it
|
|
291
|
+
// thrashes rebuildClones() (full clone-strip teardown/rebuild) dozens of
|
|
292
|
+
// times a second — exactly the DOM churn that made images flicker/blank
|
|
293
|
+
// during a drag or glide. rebuildClones() replaces every clone with a
|
|
294
|
+
// brand-new node (`replaceChildren` + fresh `cloneNode`s), so a rebuild
|
|
295
|
+
// is observable as a change of node *identity* at `head.children[0]` —
|
|
296
|
+
// comparing the node's own attributes to itself would prove nothing,
|
|
297
|
+
// since a detached node's attributes never change after the fact.
|
|
298
|
+
render(
|
|
299
|
+
<Carousel.Root ariaLabel="Featured" loop="infinite">
|
|
300
|
+
<Carousel.Viewport>
|
|
301
|
+
{[0, 1, 2, 3].map((i) => (
|
|
302
|
+
<Carousel.Slide key={i}>{i}</Carousel.Slide>
|
|
303
|
+
))}
|
|
304
|
+
</Carousel.Viewport>
|
|
305
|
+
</Carousel.Root>,
|
|
306
|
+
);
|
|
307
|
+
const head = document.querySelector('[data-carousel-clones="head"]')!;
|
|
308
|
+
const realSlide = document.querySelector(
|
|
309
|
+
'[data-carousel-slide][data-index="0"]',
|
|
310
|
+
) as HTMLElement;
|
|
311
|
+
const cloneBefore = head.children[0];
|
|
312
|
+
|
|
313
|
+
realSlide.style.visibility = "hidden";
|
|
314
|
+
realSlide.style.setProperty("--slide-progress", "0.987654");
|
|
315
|
+
await act(async () => {
|
|
316
|
+
await Promise.resolve();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(head.children[0]).toBe(cloneBefore);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("does not rebuild clones when a real slide's style attribute is cleared entirely", async () => {
|
|
323
|
+
// Exercises the other half of the before/after style comparison: the
|
|
324
|
+
// *current* side reading `null` (no style attribute at all) rather than
|
|
325
|
+
// the *old* side. A plain slide's only inline style is the engine's own
|
|
326
|
+
// visibility/--slide-progress, so removing the whole attribute strips to
|
|
327
|
+
// the same "nothing" on both sides — still not a genuine content change.
|
|
328
|
+
render(
|
|
329
|
+
<Carousel.Root ariaLabel="Featured" loop="infinite">
|
|
330
|
+
<Carousel.Viewport>
|
|
331
|
+
{[0, 1, 2, 3].map((i) => (
|
|
332
|
+
<Carousel.Slide key={i}>{i}</Carousel.Slide>
|
|
333
|
+
))}
|
|
334
|
+
</Carousel.Viewport>
|
|
335
|
+
</Carousel.Root>,
|
|
336
|
+
);
|
|
337
|
+
const head = document.querySelector('[data-carousel-clones="head"]')!;
|
|
338
|
+
const realSlide = document.querySelector(
|
|
339
|
+
'[data-carousel-slide][data-index="0"]',
|
|
340
|
+
) as HTMLElement;
|
|
341
|
+
const cloneBefore = head.children[0];
|
|
342
|
+
|
|
343
|
+
realSlide.removeAttribute("style");
|
|
344
|
+
await act(async () => {
|
|
345
|
+
await Promise.resolve();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(head.children[0]).toBe(cloneBefore);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("still rebuilds clones when a slide's own inline style changes for real", async () => {
|
|
352
|
+
// The reported bug used an inline-style content swap specifically (the
|
|
353
|
+
// builder's gradient <-> picture toggle sets `style={{ background }}`
|
|
354
|
+
// directly on the slide) — stripping the engine's own visibility/
|
|
355
|
+
// --slide-progress properties out of the comparison (above) must not
|
|
356
|
+
// also blind the observer to this genuine case.
|
|
357
|
+
function App({ background }: { background: string }) {
|
|
358
|
+
return (
|
|
359
|
+
<Carousel.Root ariaLabel="Featured" loop="infinite">
|
|
360
|
+
<Carousel.Viewport>
|
|
361
|
+
{[0, 1, 2, 3].map((i) => (
|
|
362
|
+
<Carousel.Slide key={i} style={{ background }} />
|
|
363
|
+
))}
|
|
364
|
+
</Carousel.Viewport>
|
|
365
|
+
</Carousel.Root>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const { container, rerender } = render(<App background="red" />);
|
|
369
|
+
const head = container.querySelector('[data-carousel-clones="head"]')!;
|
|
370
|
+
expect((head.children[0] as HTMLElement).style.background).toBe("red");
|
|
371
|
+
|
|
372
|
+
rerender(<App background="blue" />);
|
|
373
|
+
await act(async () => {
|
|
374
|
+
await Promise.resolve();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
expect((head.children[0] as HTMLElement).style.background).toBe("blue");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("rebuilds clones when a slide's own class (or any other attribute) changes", async () => {
|
|
381
|
+
// Only "style" needs the finer before/after check (the engine and a
|
|
382
|
+
// consumer both write it) and only data-state/data-index are routine
|
|
383
|
+
// per-navigation noise — every other attribute a consumer might set
|
|
384
|
+
// (className, aria-*, a data-* the app owns) still means genuinely new
|
|
385
|
+
// clone content and must still trigger a rebuild.
|
|
386
|
+
render(
|
|
387
|
+
<Carousel.Root ariaLabel="Featured" loop="infinite">
|
|
388
|
+
<Carousel.Viewport>
|
|
389
|
+
{[0, 1, 2, 3].map((i) => (
|
|
390
|
+
<Carousel.Slide key={i}>{i}</Carousel.Slide>
|
|
391
|
+
))}
|
|
392
|
+
</Carousel.Viewport>
|
|
393
|
+
</Carousel.Root>,
|
|
394
|
+
);
|
|
395
|
+
const head = document.querySelector('[data-carousel-clones="head"]')!;
|
|
396
|
+
const realSlide = document.querySelector(
|
|
397
|
+
'[data-carousel-slide][data-index="0"]',
|
|
398
|
+
) as HTMLElement;
|
|
399
|
+
const cloneBefore = head.children[0];
|
|
400
|
+
|
|
401
|
+
realSlide.className = "featured-slide";
|
|
402
|
+
await act(async () => {
|
|
403
|
+
await Promise.resolve();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(head.children[0]).not.toBe(cloneBefore);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("keeps the offset within one period under rapid navigation (no run-off)", async () => {
|
|
410
|
+
// Rapid Next clicks retarget the transition before it ends, so the settle
|
|
411
|
+
// re-base never fires; without a start-of-glide re-base the offset accumulates
|
|
412
|
+
// (+100 per click) and the track translates right off the one-period clone
|
|
413
|
+
// buffer into unpainted space — a blank until you pause. Six Nexts on a 4-slide
|
|
414
|
+
// loop must stay bounded to one period, not reach −600.
|
|
415
|
+
const user = userEvent.setup();
|
|
416
|
+
const { track, getByRole } = renderInfinite();
|
|
417
|
+
const next = getByRole("button", { name: "Next" });
|
|
418
|
+
for (let i = 0; i < 6; i++) await user.click(next);
|
|
419
|
+
// 6 Nexts, re-based each glide: offset cycles 100,200,300,400→0,100,200 → −200.
|
|
420
|
+
expect(track!.style.transform).toBe("translate(-200px, 0px)");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("subtracts the first real slide's position so the clone buffer is offset out", () => {
|
|
424
|
+
// In a real browser the leading clone buffer pushes real slide 0 to
|
|
425
|
+
// offsetLeft = bufferWidth (measured track-relative). Simulate that: real
|
|
426
|
+
// slides sit at (index + 4) × stride, so basePos = 400. The track transform
|
|
427
|
+
// must subtract it (align − basePos − offset) so real slide 0 still rests at
|
|
428
|
+
// `align`, not shoved off by the buffer.
|
|
429
|
+
stdRect({ startFor: (i) => (i + 4) * STRIDE });
|
|
430
|
+
const { track } = renderInfinite({ snapAlign: "center" });
|
|
431
|
+
// align 0, basePos 400, offset 0 → translate(0 − 400 − 0) = −400.
|
|
432
|
+
expect(track!.style.transform).toBe("translate(-400px, 0px)");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("positions the track on the active page at rest, instantly (no glide on load)", () => {
|
|
436
|
+
const { track } = renderInfinite({ snapAlign: "center" });
|
|
437
|
+
// align (VIEWPORT−SLIDE)/2 = 0, offset 0 → translate3d(0). First paint is
|
|
438
|
+
// instant, so no transition.
|
|
439
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
440
|
+
expect(track!.style.transition).toBe("none");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("glides forward one stride via a CSS transition when Next advances a page", async () => {
|
|
444
|
+
const user = userEvent.setup();
|
|
445
|
+
const { track, getByRole } = renderInfinite();
|
|
446
|
+
|
|
447
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
448
|
+
|
|
449
|
+
// Page 0 → 1: offset 1×stride = 100 → translate3d(align − 100) = −100, driven
|
|
450
|
+
// by a transform transition (the smooth, GPU-composited glide).
|
|
451
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
452
|
+
expect(track!.style.transition).toContain("transform");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("drives the glide with the CSS custom properties when they are set", () => {
|
|
456
|
+
// The engine reads --primitiv-carousel-glide-duration / -easing off the track
|
|
457
|
+
// (a consumer sets them via a --glide-* preset or their own CSS) and builds the
|
|
458
|
+
// transform transition from them, so glide timing and easing are customisable —
|
|
459
|
+
// only for the infinite loop, which owns its JS transition (native modes glide
|
|
460
|
+
// via browser scroll). Spy on getComputedStyle, delegating everything but the
|
|
461
|
+
// two glide props to the real one.
|
|
462
|
+
const real = window.getComputedStyle.bind(window);
|
|
463
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
|
|
464
|
+
const style = real(el);
|
|
465
|
+
return new Proxy(style, {
|
|
466
|
+
get(target, prop) {
|
|
467
|
+
if (prop === "getPropertyValue")
|
|
468
|
+
return (name: string) =>
|
|
469
|
+
name === "--primitiv-carousel-glide-duration"
|
|
470
|
+
? "250ms"
|
|
471
|
+
: name === "--primitiv-carousel-glide-easing"
|
|
472
|
+
? "ease-in"
|
|
473
|
+
: target.getPropertyValue(name);
|
|
474
|
+
const value = Reflect.get(target, prop);
|
|
475
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
476
|
+
},
|
|
477
|
+
}) as CSSStyleDeclaration;
|
|
478
|
+
});
|
|
479
|
+
const { track, getByRole } = renderInfinite();
|
|
480
|
+
|
|
481
|
+
fireEvent.click(getByRole("button", { name: "Next" }));
|
|
482
|
+
|
|
483
|
+
expect(track!.style.transition).toBe("transform 250ms ease-in");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("defaults the glide to 500ms ease-out when no custom properties are set", () => {
|
|
487
|
+
// Fallback for the headless package with no registry stylesheet: the built-in
|
|
488
|
+
// glide matches the default (`medium`) the stylesheet would otherwise supply.
|
|
489
|
+
const { track, getByRole } = renderInfinite();
|
|
490
|
+
|
|
491
|
+
fireEvent.click(getByRole("button", { name: "Next" }));
|
|
492
|
+
|
|
493
|
+
expect(track!.style.transition).toBe(
|
|
494
|
+
"transform 500ms cubic-bezier(0, 0, 0.2, 1)",
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("wraps the SHORT way from the last page to the first, then re-bases", async () => {
|
|
499
|
+
const user = userEvent.setup();
|
|
500
|
+
// 4 slides, start on the last. The re-base normalizes the mount to offset 300
|
|
501
|
+
// (real slide 3 at its own position) → translate3d(−300).
|
|
502
|
+
const { track, getByRole } = renderInfinite({ defaultPage: 3 });
|
|
503
|
+
expect(track!.style.transform).toBe("translate(-300px, 0px)");
|
|
504
|
+
|
|
505
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
506
|
+
|
|
507
|
+
// 3 → 0 is +1 forward: offset 300 → 400 (onto the clone of slide 0), a single
|
|
508
|
+
// step onward, not a rewind. The glide animates to the clone…
|
|
509
|
+
expect(track!.style.transform).toBe("translate(-400px, 0px)");
|
|
510
|
+
expect(track!.style.transition).toContain("transform");
|
|
511
|
+
|
|
512
|
+
// …and once it settles the track re-bases by one period to the REAL slide 0
|
|
513
|
+
// at the identical pixels (offset 0), instantly and invisibly.
|
|
514
|
+
fireEnd(track!);
|
|
515
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
516
|
+
expect(track!.style.transition).toBe("none");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("travels the LITERAL way for a direct goTo jump, not the ring's wrap shortcut", () => {
|
|
520
|
+
// Clicking the LAST indicator/thumbnail from the first should visibly pass
|
|
521
|
+
// through every slide in between, matching the indicators' own reading
|
|
522
|
+
// order — unlike Next/Previous, a direct jump has no "wrap" to shortcut.
|
|
523
|
+
// 4 slides, start on the first (offset 0). shortestStep(0, 3, 4) would take
|
|
524
|
+
// the ring's short way (a single step BACKWARD, onto the tail clone); the
|
|
525
|
+
// literal distance is +3 forward instead.
|
|
526
|
+
const ref = createRef<CarouselImperativeApi>();
|
|
527
|
+
const { track } = renderInfinite({ apiRef: ref });
|
|
528
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
529
|
+
|
|
530
|
+
act(() => {
|
|
531
|
+
ref.current!.goTo(3);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// offset 0 → 300 (real slide 3's own position): +3 forward, not the -1
|
|
535
|
+
// wrap shortcut (which would have read translate(100px, 0px)).
|
|
536
|
+
expect(track!.style.transform).toBe("translate(-300px, 0px)");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("travels the LITERAL way in reverse too — last page back to the first", () => {
|
|
540
|
+
// The reverse of the above: starting on the last page and jumping to the
|
|
541
|
+
// first should travel BACKWARD through every slide, not take the +1
|
|
542
|
+
// wrap-forward shortcut onto the head clone.
|
|
543
|
+
const ref = createRef<CarouselImperativeApi>();
|
|
544
|
+
const { track } = renderInfinite({ apiRef: ref, defaultPage: 3 });
|
|
545
|
+
expect(track!.style.transform).toBe("translate(-300px, 0px)");
|
|
546
|
+
|
|
547
|
+
act(() => {
|
|
548
|
+
ref.current!.goTo(0);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// offset 300 → 0: -3 backward, not the +1 wrap shortcut (which would have
|
|
552
|
+
// read translate(-400px, 0px), the clone-of-0 pixel).
|
|
553
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("still wraps the SHORT way for next()/previous(), even after a direct goTo", () => {
|
|
557
|
+
// The one-shot directJumpRef must not leak past the single navigation that
|
|
558
|
+
// set it — a goTo() followed by a plain Next should go straight back to
|
|
559
|
+
// using the wrap shortcut.
|
|
560
|
+
const ref = createRef<CarouselImperativeApi>();
|
|
561
|
+
const { track, getByRole } = renderInfinite({
|
|
562
|
+
apiRef: ref,
|
|
563
|
+
defaultPage: 3,
|
|
564
|
+
});
|
|
565
|
+
expect(track!.style.transform).toBe("translate(-300px, 0px)");
|
|
566
|
+
|
|
567
|
+
act(() => {
|
|
568
|
+
ref.current!.goTo(0);
|
|
569
|
+
});
|
|
570
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
571
|
+
fireEnd(track!);
|
|
572
|
+
|
|
573
|
+
fireEvent.click(getByRole("button", { name: "Previous" }));
|
|
574
|
+
|
|
575
|
+
// 0 → 3 the short way is -1 (onto the tail clone at offset -100), not the
|
|
576
|
+
// literal -3 a leaked directJumpRef would have produced.
|
|
577
|
+
expect(track!.style.transform).toBe("translate(100px, 0px)");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("travels the literal way for a direct jump under slidesPerPage > 1 too", () => {
|
|
581
|
+
// The literal-jump step is `currentPageOffset - logical` — both already
|
|
582
|
+
// real SLIDE indices, not page indices, so the fix should compose with
|
|
583
|
+
// slidesPerPage's own page->slide-index mapping (effectiveSlidesPerMove)
|
|
584
|
+
// for free. Verifying it, not assuming it: 8 slides, 2 per page (pages
|
|
585
|
+
// lead at slide index 0, 2, 4, 6). goTo(3) (last page, slide index 6)
|
|
586
|
+
// from page 0 (slide index 0) — the ring's short way, shortestStep(0, 6,
|
|
587
|
+
// 8), is -2 (backward onto the tail clone); the literal distance is +6
|
|
588
|
+
// forward instead.
|
|
589
|
+
// align shifts to -50 here (a 2-slide page span of 200 vs. the 100px
|
|
590
|
+
// track — (100 − 200) / 2 — unlike the single-slide tests above, where
|
|
591
|
+
// align is 0), so every transform below carries that same constant -50
|
|
592
|
+
// offset; only the *difference* the jump produces is the point.
|
|
593
|
+
const ref = createRef<CarouselImperativeApi>();
|
|
594
|
+
const { track } = renderInfinite({
|
|
595
|
+
apiRef: ref,
|
|
596
|
+
count: 8,
|
|
597
|
+
slidesPerPage: 2,
|
|
598
|
+
});
|
|
599
|
+
expect(track!.style.transform).toBe("translate(-50px, 0px)");
|
|
600
|
+
|
|
601
|
+
act(() => {
|
|
602
|
+
ref.current!.goTo(3);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// offset 0 → 600: +6 slides forward, not the -2 wrap shortcut (which
|
|
606
|
+
// would have read translate(-50 + 200, 0px) = translate(150px, 0px)).
|
|
607
|
+
expect(track!.style.transform).toBe("translate(-650px, 0px)");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("never gives a real slide its own transform (all ride the track layer)", () => {
|
|
611
|
+
// The seam is filled by clones, not by shifting individual slides, so no real
|
|
612
|
+
// slide ever carries a transform — the whole strip paints into the track's one
|
|
613
|
+
// compositor layer (an off-screen per-slide layer is the iOS white-flash).
|
|
614
|
+
const { track } = renderInfinite({ defaultPage: 0 });
|
|
615
|
+
const realSlides = track!.querySelectorAll<HTMLElement>(
|
|
616
|
+
"[data-carousel-slide][data-index]",
|
|
617
|
+
);
|
|
618
|
+
expect(realSlides).toHaveLength(4);
|
|
619
|
+
for (const slide of realSlides) expect(slide.style.transform).toBe("");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("windows the painted set — hides slides far from the viewport at rest", () => {
|
|
623
|
+
// The clone strip is far wider than the screen; iOS rasterises a wide
|
|
624
|
+
// composited layer in tiles on demand, blanking the entering edge for a
|
|
625
|
+
// frame. Windowing paints only the on-screen slides (plus a viewport of
|
|
626
|
+
// slack) and `visibility: hidden`s the rest, so the composited surface is
|
|
627
|
+
// ~one screen wide — a hidden slide is still laid out (measured, registered,
|
|
628
|
+
// interactive when shown) but contributes nothing to rasterise.
|
|
629
|
+
const { track } = renderInfinite({ count: 4 });
|
|
630
|
+
const real = (i: number) =>
|
|
631
|
+
track!.querySelector<HTMLElement>(
|
|
632
|
+
`[data-carousel-slide][data-index="${i}"]`,
|
|
633
|
+
)!;
|
|
634
|
+
// At rest (offset 0, viewport 100) slide 0 is on-screen and slide 2 is
|
|
635
|
+
// within the one-viewport margin; slide 3 (three strides right) is beyond
|
|
636
|
+
// it → hidden.
|
|
637
|
+
expect(real(0).style.visibility).toBe("");
|
|
638
|
+
expect(real(2).style.visibility).toBe("");
|
|
639
|
+
expect(real(3).style.visibility).toBe("hidden");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("keeps --slide-progress live across a navigation, the way native scroll mode does", async () => {
|
|
643
|
+
// Parallax (and any other consumer of the "Continuous scroll progress"
|
|
644
|
+
// signal) reads --slide-progress. Native mode's effect (useCarouselViewport)
|
|
645
|
+
// recomputes it from real scroll position — a `scroll` event fires on every
|
|
646
|
+
// native scroll step. Under infinite, nothing ever scrolls (the engine
|
|
647
|
+
// translates the TRACK via CSS transform instead), so that effect's
|
|
648
|
+
// listeners never fire again after its one synchronous read on mount: the
|
|
649
|
+
// signal is coincidentally correct at rest (mount happens to run after the
|
|
650
|
+
// engine's own first paint) but then FROZEN forever — parallax would never
|
|
651
|
+
// visibly drift on any subsequent navigation. Click Next and confirm the
|
|
652
|
+
// newly-active slide (1) becomes centred (0) and the previously-active one
|
|
653
|
+
// (0) moves off-centre (1) — this is what would stay stale without the
|
|
654
|
+
// engine driving its own --slide-progress.
|
|
655
|
+
const user = userEvent.setup();
|
|
656
|
+
const { track, getByRole } = renderInfinite({ count: 4 });
|
|
657
|
+
const real = (i: number) =>
|
|
658
|
+
track!.querySelector<HTMLElement>(
|
|
659
|
+
`[data-carousel-slide][data-index="${i}"]`,
|
|
660
|
+
)!;
|
|
661
|
+
expect(real(0).style.getPropertyValue("--slide-progress")).toBe("0");
|
|
662
|
+
|
|
663
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
664
|
+
|
|
665
|
+
expect(real(1).style.getPropertyValue("--slide-progress")).toBe("0");
|
|
666
|
+
expect(real(0).style.getPropertyValue("--slide-progress")).toBe("-1");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("defends --slide-progress against a zero-size track (divide-by-zero guard)", () => {
|
|
670
|
+
// Mirrors the native scroll-progress signal's own defensive guard
|
|
671
|
+
// (Carousel.scroll-progress.test.tsx) for the same reason: a 0 track (and,
|
|
672
|
+
// by default, viewport) size — nothing measured yet — would otherwise
|
|
673
|
+
// divide by zero. Covers both paint()'s analytic guard and the live
|
|
674
|
+
// ticker's own copy of it (triggered by an animated glide).
|
|
675
|
+
stdRect({ trackSize: 0 });
|
|
676
|
+
vi.useFakeTimers();
|
|
677
|
+
try {
|
|
678
|
+
const { track, getByRole } = renderInfinite({ count: 4 });
|
|
679
|
+
const real0 = track!.querySelector<HTMLElement>(
|
|
680
|
+
'[data-carousel-slide][data-index="0"]',
|
|
681
|
+
)!;
|
|
682
|
+
expect(real0.style.getPropertyValue("--slide-progress")).toBe("0");
|
|
683
|
+
|
|
684
|
+
fireEvent.click(getByRole("button", { name: "Next" }));
|
|
685
|
+
expect(() => vi.advanceTimersToNextFrame()).not.toThrow();
|
|
686
|
+
expect(real0.style.getPropertyValue("--slide-progress")).toBe("0");
|
|
687
|
+
} finally {
|
|
688
|
+
vi.useRealTimers();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("live-updates --slide-progress on the block axis for a vertical infinite loop", () => {
|
|
693
|
+
// Same live ticker as the horizontal case, just the block-axis branch of
|
|
694
|
+
// its viewport-center / slide-center math.
|
|
695
|
+
vi.useFakeTimers();
|
|
696
|
+
try {
|
|
697
|
+
const { track, getByRole } = renderInfinite({
|
|
698
|
+
count: 4,
|
|
699
|
+
orientation: "vertical",
|
|
700
|
+
});
|
|
701
|
+
const real0 = track!.querySelector<HTMLElement>(
|
|
702
|
+
'[data-carousel-slide][data-index="0"]',
|
|
703
|
+
)!;
|
|
704
|
+
|
|
705
|
+
fireEvent.click(getByRole("button", { name: "Next" }));
|
|
706
|
+
expect(() => vi.advanceTimersToNextFrame()).not.toThrow();
|
|
707
|
+
expect(real0.style.getPropertyValue("--slide-progress")).toBe("0");
|
|
708
|
+
} finally {
|
|
709
|
+
vi.useRealTimers();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("live-updates --slide-progress every frame while an animated glide is in flight, then stops on settle", () => {
|
|
714
|
+
// Unlike paint()'s analytic value (set once, correct only for the settled
|
|
715
|
+
// target), a button-driven glide's *visual* position is mid-transition for
|
|
716
|
+
// its whole duration — parallax content needs to track that continuously or
|
|
717
|
+
// it snaps to the end value instead of drifting alongside the slide. The
|
|
718
|
+
// engine runs a rAF ticker for exactly the animated-glide duration, reading
|
|
719
|
+
// live positions each frame. jsdom applies no real transform (this harness's
|
|
720
|
+
// mocked rects are transform-invariant by design, like every other geometry
|
|
721
|
+
// mock in this file), so the ticker's *value* can't be distinguished from
|
|
722
|
+
// paint()'s here — device/Playwright confirms the visual drift — but its
|
|
723
|
+
// code path (including the zero-guard) still runs and must not throw, and it
|
|
724
|
+
// must stop itself once the transition ends (an indefinitely-running ticker
|
|
725
|
+
// would burn a frame's work forever).
|
|
726
|
+
vi.useFakeTimers();
|
|
727
|
+
try {
|
|
728
|
+
const { track, getByRole } = renderInfinite({ count: 4 });
|
|
729
|
+
const real0 = track!.querySelector<HTMLElement>(
|
|
730
|
+
'[data-carousel-slide][data-index="0"]',
|
|
731
|
+
)!;
|
|
732
|
+
const cancelSpy = vi.spyOn(window, "cancelAnimationFrame");
|
|
733
|
+
|
|
734
|
+
fireEvent.click(getByRole("button", { name: "Next" }));
|
|
735
|
+
expect(() => vi.advanceTimersToNextFrame()).not.toThrow();
|
|
736
|
+
expect(real0.style.getPropertyValue("--slide-progress")).toBe("0");
|
|
737
|
+
|
|
738
|
+
// The settle (transitionend) stops the ticker.
|
|
739
|
+
fireEnd(track!);
|
|
740
|
+
expect(cancelSpy).toHaveBeenCalled();
|
|
741
|
+
} finally {
|
|
742
|
+
vi.useRealTimers();
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("strips ids from the cloned subtree so no id is duplicated", () => {
|
|
747
|
+
const { container } = render(
|
|
748
|
+
<Carousel.Root ariaLabel="Featured" loop="infinite">
|
|
749
|
+
<Carousel.Viewport>
|
|
750
|
+
<Carousel.Slide>
|
|
751
|
+
<span id="deep-link">link</span>
|
|
752
|
+
</Carousel.Slide>
|
|
753
|
+
<Carousel.Slide />
|
|
754
|
+
</Carousel.Viewport>
|
|
755
|
+
</Carousel.Root>,
|
|
756
|
+
);
|
|
757
|
+
// The real subtree keeps its id; the clones (a full period each side) must not
|
|
758
|
+
// duplicate it — exactly one #deep-link in the whole tree.
|
|
759
|
+
expect(container.querySelectorAll("#deep-link")).toHaveLength(1);
|
|
760
|
+
const head = container.querySelector('[data-carousel-clones="head"]')!;
|
|
761
|
+
expect(head.querySelector("#deep-link")).toBeNull();
|
|
762
|
+
expect(head.querySelector("span")).not.toBeNull(); // content still cloned
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("re-bases only when the settled offset is out of the real range", async () => {
|
|
766
|
+
const user = userEvent.setup();
|
|
767
|
+
const { track, getByRole } = renderInfinite();
|
|
768
|
+
|
|
769
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
770
|
+
// Offset 100 is already within [0, 400): the settle is a no-op, the transform
|
|
771
|
+
// stays put (and the transition flips to none on the instant no-op repaint…
|
|
772
|
+
// which doesn't run — the guard returns early, so the transition is untouched).
|
|
773
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
774
|
+
fireEnd(track!);
|
|
775
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("ignores a transitionend for a property other than transform", async () => {
|
|
779
|
+
const user = userEvent.setup();
|
|
780
|
+
// defaultPage 3 rests at offset 300; Next glides to 400 (a clone) awaiting a
|
|
781
|
+
// transform settle to re-base. A non-transform transitionend must not re-base.
|
|
782
|
+
const { track, getByRole } = renderInfinite({ defaultPage: 3 });
|
|
783
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
784
|
+
expect(track!.style.transform).toBe("translate(-400px, 0px)");
|
|
785
|
+
|
|
786
|
+
fireEnd(track!, "opacity");
|
|
787
|
+
expect(track!.style.transform).toBe("translate(-400px, 0px)");
|
|
788
|
+
// The real transform settle still re-bases.
|
|
789
|
+
fireEnd(track!, "transform");
|
|
790
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("ignores a transform settle when there is nothing to measure", () => {
|
|
794
|
+
// One slide → measure() bails, so a settle can't re-base (nothing to loop).
|
|
795
|
+
const { track } = renderInfinite({ count: 1 });
|
|
796
|
+
expect(() => fireEnd(track!)).not.toThrow();
|
|
797
|
+
expect(track!.style.transform).toBe("");
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("jumps instantly with no transition under reduced motion", async () => {
|
|
801
|
+
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
|
802
|
+
const user = userEvent.setup();
|
|
803
|
+
const { track, getByRole } = renderInfinite();
|
|
804
|
+
|
|
805
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
806
|
+
|
|
807
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
808
|
+
expect(track!.style.transition).toBe("none");
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("drives the block axis for a vertical infinite loop", async () => {
|
|
812
|
+
const user = userEvent.setup();
|
|
813
|
+
const { track, getByRole } = renderInfinite({ orientation: "vertical" });
|
|
814
|
+
|
|
815
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
816
|
+
|
|
817
|
+
expect(track!.style.transform).toBe("translate(0px, -100px)");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("advances a whole page (not one slide) for multi-slide infinite", async () => {
|
|
821
|
+
// 6 slides, 2 per page → pages lead at slide 0, 2, 4. Next must glide to the
|
|
822
|
+
// next page's leading slide (index 2 = two strides), not one slide: the engine
|
|
823
|
+
// has to drive off the page's leading slide index, not the page number.
|
|
824
|
+
// The track holds the whole 2-slide page (span = stride + slide = 200), so it's
|
|
825
|
+
// sized to 200 and the page rests flush (align 0) instead of one slide centred.
|
|
826
|
+
stdRect({ trackSize: 200 });
|
|
827
|
+
const user = userEvent.setup();
|
|
828
|
+
const { track, getByRole } = renderInfinite({ count: 6, slidesPerPage: 2 });
|
|
829
|
+
|
|
830
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
831
|
+
|
|
832
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
833
|
+
|
|
834
|
+
// Page 0 → 1: leading slide 0 → 2, a two-stride glide (a full page), not −100.
|
|
835
|
+
expect(track!.style.transform).toBe("translate(-200px, 0px)");
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("glides a multi-slide page back as a unit, no per-slide transform", async () => {
|
|
839
|
+
// 6 slides, 2-up. Page forward to the last page then Previous back to page 1:
|
|
840
|
+
// the whole track glides as one unit (offset 400 → 200) and every real slide
|
|
841
|
+
// stays untransformed — the seam is filled by clones, so a trailing slide can
|
|
842
|
+
// never be yanked off-screen ("disappear as the group moves").
|
|
843
|
+
stdRect({ trackSize: 200 });
|
|
844
|
+
const user = userEvent.setup();
|
|
845
|
+
const { track, getByRole } = renderInfinite({ count: 6, slidesPerPage: 2 });
|
|
846
|
+
|
|
847
|
+
await user.click(getByRole("button", { name: "Next" })); // page 1 (offset 200)
|
|
848
|
+
await user.click(getByRole("button", { name: "Next" })); // page 2 (offset 400)
|
|
849
|
+
await user.click(getByRole("button", { name: "Previous" })); // back to page 1
|
|
850
|
+
|
|
851
|
+
// Page 2 → 1: the track glides back one page (offset 400 → 200)…
|
|
852
|
+
expect(track!.style.transform).toBe("translate(-200px, 0px)");
|
|
853
|
+
// …and no real slide carries a transform of its own.
|
|
854
|
+
for (const slide of track!.querySelectorAll<HTMLElement>(
|
|
855
|
+
"[data-carousel-slide][data-index]",
|
|
856
|
+
)) {
|
|
857
|
+
expect(slide.style.transform).toBe("");
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("glides a wide (4-up) page as a unit for any N-up count", async () => {
|
|
862
|
+
// 9 slides, 4-up. A full backward page (page 1 → 0) glides the whole track and
|
|
863
|
+
// leaves every real slide untransformed — the clone strip makes the wide-page
|
|
864
|
+
// seam contiguous, so there's no antipode a trailing slide could teleport past.
|
|
865
|
+
stdRect({ trackSize: 400 });
|
|
866
|
+
const user = userEvent.setup();
|
|
867
|
+
const { track, getByRole } = renderInfinite({ count: 9, slidesPerPage: 4 });
|
|
868
|
+
|
|
869
|
+
await user.click(getByRole("button", { name: "Next" })); // page 1 (offset 400)
|
|
870
|
+
await user.click(getByRole("button", { name: "Previous" })); // back to page 0
|
|
871
|
+
|
|
872
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
873
|
+
for (const slide of track!.querySelectorAll<HTMLElement>(
|
|
874
|
+
"[data-carousel-slide][data-index]",
|
|
875
|
+
)) {
|
|
876
|
+
expect(slide.style.transform).toBe("");
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("lands a deep multi-slide page exactly (sub-pixel, no accumulated rounding)", () => {
|
|
881
|
+
// offsetLeft/Width round to whole pixels; over a page several strides in that
|
|
882
|
+
// rounding accumulates into a visible edge misalignment (one slide clipped by
|
|
883
|
+
// the viewport, a gap at the other edge — the multi-slide bug). Fractional
|
|
884
|
+
// geometry: slide 265.5 + gap 8 = stride 273.5, track 539 (a 2-up page). On the
|
|
885
|
+
// LAST page (leading slide 4) the track must land at exactly 4 × 273.5, which a
|
|
886
|
+
// whole-pixel stride (273 or 274) would miss by pixels.
|
|
887
|
+
defineRect(function (this: HTMLElement) {
|
|
888
|
+
const raw = this.getAttribute?.("data-index");
|
|
889
|
+
const isTrack = this.hasAttribute?.("data-carousel-track");
|
|
890
|
+
const isSlide = this.hasAttribute?.("data-carousel-slide");
|
|
891
|
+
return {
|
|
892
|
+
start: raw != null ? Number(raw) * 273.5 : 0,
|
|
893
|
+
size: isTrack ? 539 : isSlide ? 265.5 : 0,
|
|
894
|
+
};
|
|
895
|
+
});
|
|
896
|
+
const { track } = renderInfinite({
|
|
897
|
+
count: 6,
|
|
898
|
+
slidesPerPage: 2,
|
|
899
|
+
defaultPage: 2,
|
|
900
|
+
});
|
|
901
|
+
// align (539 − pageSpan 539)/2 = 0, basePos 0, offset 4 × 273.5 = 1094.
|
|
902
|
+
expect(track!.style.transform).toBe("translate(-1094px, 0px)");
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("mirrors the inline direction under RTL (negative stride)", async () => {
|
|
906
|
+
// RTL reverses the flex row, so slide i's physical offsetLeft DECREASES with
|
|
907
|
+
// i (slide 0 is rightmost). The engine reads the negative stride as dir = −1
|
|
908
|
+
// rather than bailing, and mirrors the track translate.
|
|
909
|
+
stdRect({ startFor: (i) => -i * STRIDE });
|
|
910
|
+
const user = userEvent.setup();
|
|
911
|
+
const { track, getByRole } = renderInfinite();
|
|
912
|
+
|
|
913
|
+
// Page 0 rests at 0 (align 0), same as LTR.
|
|
914
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
915
|
+
|
|
916
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
917
|
+
|
|
918
|
+
// Next advances the inline-forward slide, which in RTL translates the track to
|
|
919
|
+
// the RIGHT (+100) — the mirror of LTR's −100, still one step, no rewind.
|
|
920
|
+
expect(track!.style.transform).toBe("translate(100px, 0px)");
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("offsets the track for start and end alignment", () => {
|
|
924
|
+
// Widen the track so alignment is visible: redefine its clientWidth to 300.
|
|
925
|
+
stdRect({ trackSize: 300 });
|
|
926
|
+
const start = renderInfinite({ snapAlign: "start" });
|
|
927
|
+
// start align = 0 → translate(0).
|
|
928
|
+
expect(start.track!.style.transform).toBe("translate(0px, 0px)");
|
|
929
|
+
|
|
930
|
+
const end = renderInfinite({ snapAlign: "end" });
|
|
931
|
+
// end align = viewport(300) − slide(100) = 200 → translate(200).
|
|
932
|
+
expect(end.track!.style.transform).toBe("translate(200px, 0px)");
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it("mirrors start/end alignment under RTL (reading start is the right edge)", () => {
|
|
936
|
+
// RTL: negative stride (dir −1) and a 300-wide track. The engine ignores a
|
|
937
|
+
// slide's absolute offsetLeft (only its sign/magnitude), so the align term must
|
|
938
|
+
// be dir-mirrored: reading "start" is the RIGHT edge (leading slide flush right,
|
|
939
|
+
// trackShift W−slide = 200) and "end" is the LEFT edge (trackShift 0) — the
|
|
940
|
+
// mirror of LTR. An unmirrored align swaps them.
|
|
941
|
+
stdRect({ trackSize: 300, startFor: (i) => -i * STRIDE });
|
|
942
|
+
const start = renderInfinite({ snapAlign: "start" });
|
|
943
|
+
// Reading start = right: leading slide flush right → translate(300 − 100).
|
|
944
|
+
expect(start.track!.style.transform).toBe("translate(200px, 0px)");
|
|
945
|
+
|
|
946
|
+
const end = renderInfinite({ snapAlign: "end" });
|
|
947
|
+
// Reading end = left: leading slide flush left → translate(0).
|
|
948
|
+
expect(end.track!.style.transform).toBe("translate(0px, 0px)");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("aligns against the track's own box, not the padded viewport (peek)", () => {
|
|
952
|
+
// Under peek the viewport is padded and the track is inset + narrowed to the
|
|
953
|
+
// slide (100). The engine measures the TRACK's own rect, so it aligns within
|
|
954
|
+
// the track (100) and can't double-count the peek — it never reads the padded
|
|
955
|
+
// viewport box at all.
|
|
956
|
+
stdRect({ trackSize: 100 });
|
|
957
|
+
const { track } = renderInfinite({ snapAlign: "center" });
|
|
958
|
+
// align = (track 100 − slide 100) / 2 = 0 → no shift. (Measuring the padded
|
|
959
|
+
// viewport 140 would read +20 and push the slide out of centre.)
|
|
960
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it("does nothing when there are too few slides to loop", () => {
|
|
964
|
+
const { track } = renderInfinite({ count: 1 });
|
|
965
|
+
// measure() bails (count < 2) — the track renders but gets no transform.
|
|
966
|
+
expect(track!.style.transform).toBe("");
|
|
967
|
+
// A resize is a no-op too — the observer's re-home bails on the same measure.
|
|
968
|
+
expect(() =>
|
|
969
|
+
act(() => {
|
|
970
|
+
MockResizeObserver.fireAll();
|
|
971
|
+
}),
|
|
972
|
+
).not.toThrow();
|
|
973
|
+
expect(track!.style.transform).toBe("");
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("does not loop when slides report a zero stride (not yet laid out)", () => {
|
|
977
|
+
// Collapse every slide onto position 0 → stride 0 → measure bails.
|
|
978
|
+
stdRect({ startFor: () => 0 });
|
|
979
|
+
const { track } = renderInfinite();
|
|
980
|
+
expect(track!.style.transform).toBe("");
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("re-measures and re-homes on a resize with the fresh geometry", () => {
|
|
984
|
+
// The infinite engine gets no React signal for a CSS-driven size change (peek,
|
|
985
|
+
// ratio, density, a container resize), so a ResizeObserver re-homes it. Mount on
|
|
986
|
+
// page 2 at stride 100 (rests at −200); grow the stride to 150 and fire the
|
|
987
|
+
// observer — it must re-home page 2 at the NEW stride (−300), not stay stale.
|
|
988
|
+
const { track } = renderInfinite({ defaultPage: 2 });
|
|
989
|
+
expect(track!.style.transform).toBe("translate(-200px, 0px)");
|
|
990
|
+
|
|
991
|
+
stdRect({ startFor: (i) => i * 150 });
|
|
992
|
+
act(() => {
|
|
993
|
+
MockResizeObserver.fireAll();
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
expect(track!.style.transform).toBe("translate(-300px, 0px)");
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("ignores a spurious resize fire mid-glide instead of interrupting it", async () => {
|
|
1000
|
+
// Real browsers fire a ResizeObserver callback once automatically right after
|
|
1001
|
+
// `.observe()`, even with no actual size change (spec: report the initial
|
|
1002
|
+
// size) — a mock only replicates on an explicit `.fireAll()`, but a device
|
|
1003
|
+
// still gets this for free. An unconditional rehome would abort whatever the
|
|
1004
|
+
// track is doing: mid-glide it kills the CSS transition, snaps to the rest
|
|
1005
|
+
// position, and collapses the paint window to a point — hiding the slide
|
|
1006
|
+
// still mid-transition ("current slide vanishes on Prev"). A same-geometry
|
|
1007
|
+
// resize must be a no-op instead.
|
|
1008
|
+
const user = userEvent.setup();
|
|
1009
|
+
const { track, getByRole } = renderInfinite();
|
|
1010
|
+
|
|
1011
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
1012
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
1013
|
+
expect(track!.style.transition).toContain("transform");
|
|
1014
|
+
|
|
1015
|
+
act(() => {
|
|
1016
|
+
MockResizeObserver.fireAll();
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// The in-flight glide is untouched — not reset to the instant rest position.
|
|
1020
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
1021
|
+
expect(track!.style.transition).toContain("transform");
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("does not throw when the loop mode switches at runtime (slides reparent)", () => {
|
|
1025
|
+
// Switching loop reparents the slides into / out of the transform track, so
|
|
1026
|
+
// they unmount + re-register in the same commit that re-runs the native-scroll
|
|
1027
|
+
// effects — the key is still in slideKeys but its element is momentarily out of
|
|
1028
|
+
// slidesRef. Those effects must skip the orphaned key, not read
|
|
1029
|
+
// getBoundingClientRect on `undefined` (white-screen crash in the builder).
|
|
1030
|
+
function App({ loop }: { loop?: "infinite" | "wrap" }) {
|
|
1031
|
+
return (
|
|
1032
|
+
<Carousel.Root ariaLabel="Featured" loop={loop}>
|
|
1033
|
+
<Carousel.Viewport>
|
|
1034
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
1035
|
+
<Carousel.Slide key={i} />
|
|
1036
|
+
))}
|
|
1037
|
+
</Carousel.Viewport>
|
|
1038
|
+
</Carousel.Root>
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const { rerender } = render(<App loop="infinite" />);
|
|
1042
|
+
expect(() => rerender(<App loop="wrap" />)).not.toThrow();
|
|
1043
|
+
expect(() => rerender(<App loop={undefined} />)).not.toThrow();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("re-homes the track without erroring when unmounted mid-interaction", async () => {
|
|
1047
|
+
const user = userEvent.setup();
|
|
1048
|
+
const { getByRole, unmount } = renderInfinite();
|
|
1049
|
+
await user.click(getByRole("button", { name: "Next" }));
|
|
1050
|
+
expect(() => act(() => unmount())).not.toThrow();
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
describe("Carousel infinite — drag + fling", () => {
|
|
1055
|
+
it("follows the pointer 1:1 while dragging, with no transition", () => {
|
|
1056
|
+
// The repaint itself coalesces to one per animation frame (touch can
|
|
1057
|
+
// report pointermove faster than the display refreshes), so a test
|
|
1058
|
+
// checking the *painted* result needs to advance to that frame — offsetRef
|
|
1059
|
+
// itself still updates synchronously (asserted separately below).
|
|
1060
|
+
vi.useFakeTimers();
|
|
1061
|
+
try {
|
|
1062
|
+
const { track, getByTestId } = renderInfinite();
|
|
1063
|
+
const viewport = getByTestId("viewport");
|
|
1064
|
+
|
|
1065
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1066
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1067
|
+
vi.advanceTimersToNextFrame();
|
|
1068
|
+
|
|
1069
|
+
// offset = startOffset(0) − (140 − 200) = 60 → track translate(align − 60).
|
|
1070
|
+
// Dragging paints with no transition so the track tracks the finger.
|
|
1071
|
+
expect(track!.style.transform).toBe("translate(-60px, 0px)");
|
|
1072
|
+
expect(track!.style.transition).toBe("none");
|
|
1073
|
+
} finally {
|
|
1074
|
+
vi.useRealTimers();
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("coalesces several pointermoves within the same frame into a single paint", () => {
|
|
1079
|
+
// The whole point of deferring to rAF: two moves that both land before
|
|
1080
|
+
// the browser's next frame should produce ONE repaint reflecting the
|
|
1081
|
+
// LATEST position, not two repaints (or a stale one showing the first).
|
|
1082
|
+
vi.useFakeTimers();
|
|
1083
|
+
try {
|
|
1084
|
+
const { track, getByTestId } = renderInfinite();
|
|
1085
|
+
const viewport = getByTestId("viewport");
|
|
1086
|
+
|
|
1087
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1088
|
+
pointer(viewport, "pointermove", { client: 170, time: 50 });
|
|
1089
|
+
// No frame has run yet — still showing the pre-drag position.
|
|
1090
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
1091
|
+
|
|
1092
|
+
// A second move, same (unflushed) frame.
|
|
1093
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1094
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
1095
|
+
|
|
1096
|
+
vi.advanceTimersToNextFrame();
|
|
1097
|
+
|
|
1098
|
+
// The single coalesced paint jumps straight to the latest offset (60),
|
|
1099
|
+
// skipping the intermediate one (30) entirely.
|
|
1100
|
+
expect(track!.style.transform).toBe("translate(-60px, 0px)");
|
|
1101
|
+
} finally {
|
|
1102
|
+
vi.useRealTimers();
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("settles back to the current slide when the drag stays under half a stride", () => {
|
|
1107
|
+
// Also covers flushDragFrame's no-op branch: the coalesced repaint is
|
|
1108
|
+
// flushed by advancing to its frame *before* release, so onPointerUp's
|
|
1109
|
+
// own flush call finds nothing pending.
|
|
1110
|
+
vi.useFakeTimers();
|
|
1111
|
+
try {
|
|
1112
|
+
const { track, getByTestId } = renderInfinite();
|
|
1113
|
+
const viewport = getByTestId("viewport");
|
|
1114
|
+
|
|
1115
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1116
|
+
pointer(viewport, "pointermove", { client: 180, time: 100 });
|
|
1117
|
+
// A second move at the same point zeroes the release velocity.
|
|
1118
|
+
pointer(viewport, "pointermove", { client: 180, time: 200 });
|
|
1119
|
+
vi.advanceTimersToNextFrame();
|
|
1120
|
+
pointer(viewport, "pointerup", { client: 180, time: 220 });
|
|
1121
|
+
|
|
1122
|
+
// offset 20, velocity 0 → snap to the nearest stride (0). Back home, animated.
|
|
1123
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
1124
|
+
expect(track!.style.transition).toContain("transform");
|
|
1125
|
+
} finally {
|
|
1126
|
+
vi.useRealTimers();
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it("advances a slide when the drag passes the half-stride point", () => {
|
|
1131
|
+
const { track, getByTestId } = renderInfinite();
|
|
1132
|
+
const viewport = getByTestId("viewport");
|
|
1133
|
+
|
|
1134
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1135
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1136
|
+
pointer(viewport, "pointermove", { client: 140, time: 200 });
|
|
1137
|
+
pointer(viewport, "pointerup", { client: 140, time: 220 });
|
|
1138
|
+
|
|
1139
|
+
// offset 60, velocity 0 → snaps forward to stride 100 (page 1).
|
|
1140
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("flings forward when a quick flick carries the projected distance past half a stride", () => {
|
|
1144
|
+
const { track, getByTestId } = renderInfinite();
|
|
1145
|
+
const viewport = getByTestId("viewport");
|
|
1146
|
+
|
|
1147
|
+
// Truthy timestamps: React's SyntheticEvent does `nativeEvent.timeStamp ||
|
|
1148
|
+
// Date.now()`, so a falsy 0 on the first event would be replaced by the real
|
|
1149
|
+
// clock and skew the first elapsed — this gesture depends on real velocity.
|
|
1150
|
+
pointer(viewport, "pointerdown", { client: 200, time: 1000 });
|
|
1151
|
+
// Only 30px of travel (would settle home) but at 0.6px/ms.
|
|
1152
|
+
pointer(viewport, "pointermove", { client: 170, time: 1050 });
|
|
1153
|
+
pointer(viewport, "pointerup", { client: 170, time: 1050 });
|
|
1154
|
+
|
|
1155
|
+
// flingTarget(30, 0.6, 120, 100) = snap(30 + 72) = 100 → the flick advances.
|
|
1156
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it("keeps a seam-crossing fling's own glide animated, instead of an instant snap", () => {
|
|
1160
|
+
// A fling's release drives the track directly via its own glideTo() call
|
|
1161
|
+
// (which may deliberately target a clone position — the same "animate
|
|
1162
|
+
// onto the clone, rebase on settle" pattern a click-driven glide uses),
|
|
1163
|
+
// then separately calls goTo() to sync `currentPage`. That goTo() is a
|
|
1164
|
+
// state update, and the SAME positioning effect that drives Next/Previous
|
|
1165
|
+
// also reacts to it — its own boundGlideStart() rebases *before*
|
|
1166
|
+
// computing anything, and unlike a click (which never lands off the
|
|
1167
|
+
// clone buffer until the fling's own target does), a fling crossing the
|
|
1168
|
+
// seam usually lands EXACTLY one trackLength past a real position — so
|
|
1169
|
+
// without a way to tell the effect "this navigation is already handled",
|
|
1170
|
+
// the rebase fires for real and instantly snaps the just-started
|
|
1171
|
+
// animated glide to its settled position before the browser renders a
|
|
1172
|
+
// single frame of it. Reported by a human tester as "the release/settle
|
|
1173
|
+
// snaps abruptly" on touch swipes crossing the seam specifically.
|
|
1174
|
+
// 8 slides, resting on the last (offset 700, trackLength 800). Drag
|
|
1175
|
+
// forward with a quick, deterministic flick: flingTarget(760, 1.2, 120,
|
|
1176
|
+
// 100) = snap(760 + 144) = 900 — one trackLength (800) past a real
|
|
1177
|
+
// position, so normalizeOffset(900, 800) = 100 !== 900: the rebase would
|
|
1178
|
+
// fire for real if nothing stopped it.
|
|
1179
|
+
const { track, getByTestId } = renderInfinite({ count: 8, defaultPage: 7 });
|
|
1180
|
+
const viewport = getByTestId("viewport");
|
|
1181
|
+
|
|
1182
|
+
pointer(viewport, "pointerdown", { client: 200, time: 1000 });
|
|
1183
|
+
pointer(viewport, "pointermove", { client: 140, time: 1050 });
|
|
1184
|
+
pointer(viewport, "pointerup", { client: 140, time: 1050 });
|
|
1185
|
+
|
|
1186
|
+
// The fling's true target (900), still animated — not an instant snap to
|
|
1187
|
+
// the rebased equivalent (100).
|
|
1188
|
+
expect(track!.style.transform).toBe("translate(-900px, 0px)");
|
|
1189
|
+
expect(track!.style.transition).toContain("transform");
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("keeps a seam-crossing fling animated under slidesPerPage > 1 too", () => {
|
|
1193
|
+
// The fling snaps to a PAGE boundary (pageStride = stride *
|
|
1194
|
+
// effectiveSlidesPerMove), not a single slide — verifying the same
|
|
1195
|
+
// rebase-collision fix composes with that coarser granularity, not just
|
|
1196
|
+
// the single-slide case above. 8 slides, 2 per page (pageStride 200),
|
|
1197
|
+
// resting on the last page (slide index 6, offset 600, trackLength 800).
|
|
1198
|
+
// flingTarget(660, 1.2, 120, 200) = snap(660 + 144) = 800 — exactly
|
|
1199
|
+
// trackLength, so normalizeOffset(800, 800) = 0 !== 800: the rebase
|
|
1200
|
+
// would fire for real if nothing stopped it, same as the single-slide
|
|
1201
|
+
// case, just landing on a different boundary (a whole page-stride out).
|
|
1202
|
+
// align is -50 here (a 2-slide page span of 200 vs. the 100px track),
|
|
1203
|
+
// so every transform below carries that same constant offset.
|
|
1204
|
+
const { track, getByTestId } = renderInfinite({
|
|
1205
|
+
count: 8,
|
|
1206
|
+
slidesPerPage: 2,
|
|
1207
|
+
defaultPage: 3,
|
|
1208
|
+
});
|
|
1209
|
+
const viewport = getByTestId("viewport");
|
|
1210
|
+
|
|
1211
|
+
pointer(viewport, "pointerdown", { client: 200, time: 1000 });
|
|
1212
|
+
pointer(viewport, "pointermove", { client: 140, time: 1050 });
|
|
1213
|
+
pointer(viewport, "pointerup", { client: 140, time: 1050 });
|
|
1214
|
+
|
|
1215
|
+
// The fling's true target (800), still animated — not an instant snap
|
|
1216
|
+
// to the rebased equivalent (0, which would have read translate(-50px,
|
|
1217
|
+
// 0px)).
|
|
1218
|
+
expect(track!.style.transform).toBe("translate(-850px, 0px)");
|
|
1219
|
+
expect(track!.style.transition).toContain("transform");
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it("treats a press that never crosses the threshold as a tap (no move, no glide)", () => {
|
|
1223
|
+
const { track, getByTestId } = renderInfinite();
|
|
1224
|
+
const viewport = getByTestId("viewport");
|
|
1225
|
+
|
|
1226
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1227
|
+
pointer(viewport, "pointermove", { client: 202, time: 50 }); // 2px < 3px
|
|
1228
|
+
pointer(viewport, "pointerup", { client: 202, time: 60 });
|
|
1229
|
+
|
|
1230
|
+
// Never became a drag: the track stays put and no fling runs.
|
|
1231
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it("ignores mouse drags unless allowMouseDrag is set", () => {
|
|
1235
|
+
const { track, getByTestId } = renderInfinite({ allowMouseDrag: false });
|
|
1236
|
+
const viewport = getByTestId("viewport");
|
|
1237
|
+
|
|
1238
|
+
pointer(viewport, "pointerdown", {
|
|
1239
|
+
client: 200,
|
|
1240
|
+
time: 0,
|
|
1241
|
+
pointerType: "mouse",
|
|
1242
|
+
});
|
|
1243
|
+
pointer(viewport, "pointermove", {
|
|
1244
|
+
client: 120,
|
|
1245
|
+
time: 100,
|
|
1246
|
+
pointerType: "mouse",
|
|
1247
|
+
});
|
|
1248
|
+
pointer(viewport, "pointerup", {
|
|
1249
|
+
client: 120,
|
|
1250
|
+
time: 120,
|
|
1251
|
+
pointerType: "mouse",
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// pointerdown bailed on the mouse gate, so no drag state was ever created.
|
|
1255
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it("drags with the mouse when allowMouseDrag is set", () => {
|
|
1259
|
+
vi.useFakeTimers();
|
|
1260
|
+
try {
|
|
1261
|
+
const { track, getByTestId } = renderInfinite({ allowMouseDrag: true });
|
|
1262
|
+
const viewport = getByTestId("viewport");
|
|
1263
|
+
|
|
1264
|
+
pointer(viewport, "pointerdown", {
|
|
1265
|
+
client: 200,
|
|
1266
|
+
time: 0,
|
|
1267
|
+
pointerType: "mouse",
|
|
1268
|
+
});
|
|
1269
|
+
pointer(viewport, "pointermove", {
|
|
1270
|
+
client: 140,
|
|
1271
|
+
time: 100,
|
|
1272
|
+
pointerType: "mouse",
|
|
1273
|
+
});
|
|
1274
|
+
vi.advanceTimersToNextFrame();
|
|
1275
|
+
|
|
1276
|
+
expect(track!.style.transform).toBe("translate(-60px, 0px)");
|
|
1277
|
+
} finally {
|
|
1278
|
+
vi.useRealTimers();
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it("drags along the block axis for a vertical loop", () => {
|
|
1283
|
+
vi.useFakeTimers();
|
|
1284
|
+
try {
|
|
1285
|
+
const { track, getByTestId } = renderInfinite({
|
|
1286
|
+
orientation: "vertical",
|
|
1287
|
+
});
|
|
1288
|
+
const viewport = getByTestId("viewport");
|
|
1289
|
+
|
|
1290
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1291
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1292
|
+
vi.advanceTimersToNextFrame();
|
|
1293
|
+
|
|
1294
|
+
// Vertical reads clientY (the helper sets both), so the block axis moves.
|
|
1295
|
+
expect(track!.style.transform).toBe("translate(0px, -60px)");
|
|
1296
|
+
} finally {
|
|
1297
|
+
vi.useRealTimers();
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it("skips the velocity update when two moves share a timestamp", () => {
|
|
1302
|
+
vi.useFakeTimers();
|
|
1303
|
+
try {
|
|
1304
|
+
const { track, getByTestId } = renderInfinite();
|
|
1305
|
+
const viewport = getByTestId("viewport");
|
|
1306
|
+
|
|
1307
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1308
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1309
|
+
// Same timestamp → elapsed 0 → velocity keeps its prior value, no divide.
|
|
1310
|
+
pointer(viewport, "pointermove", { client: 100, time: 100 });
|
|
1311
|
+
vi.advanceTimersToNextFrame();
|
|
1312
|
+
|
|
1313
|
+
// The move still repositions (offset 100) even though velocity wasn't updated.
|
|
1314
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
1315
|
+
} finally {
|
|
1316
|
+
vi.useRealTimers();
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it("sweeps the wrap frame's window instead of just the post-wrap point", () => {
|
|
1321
|
+
// A drag that crosses the loop seam wraps offsetRef by a whole trackLength
|
|
1322
|
+
// in a single pointermove (normalizeOffset) — a discontinuity in the raw
|
|
1323
|
+
// number, even though the finger only moved a normal few px. Painting that
|
|
1324
|
+
// frame with the post-wrap value alone (the pre-fix behaviour) computes a
|
|
1325
|
+
// window around only where the drag ended up, not where it came from —
|
|
1326
|
+
// the same "entering edge blanks for a frame" risk the click-glide's own
|
|
1327
|
+
// swept window already exists to avoid, just reached here by a drag's
|
|
1328
|
+
// continuous wrap instead of an animated transition.
|
|
1329
|
+
// 8 slides, starting on the last (rest offset 700, trackLength 800).
|
|
1330
|
+
// Dragging forward past the end: move 1 lands just before the wrap
|
|
1331
|
+
// (raw 790), move 2 crosses it (raw 810 -> wraps to 10). A real slide
|
|
1332
|
+
// sitting near the middle of the strip (index 4, edge 400) is far
|
|
1333
|
+
// outside a window centred on the post-wrap value (10) alone, but falls
|
|
1334
|
+
// inside the swept range [790, 10] the fix passes to paint() instead.
|
|
1335
|
+
vi.useFakeTimers();
|
|
1336
|
+
try {
|
|
1337
|
+
const { getByTestId, container } = renderInfinite({
|
|
1338
|
+
count: 8,
|
|
1339
|
+
defaultPage: 7,
|
|
1340
|
+
});
|
|
1341
|
+
const viewport = getByTestId("viewport");
|
|
1342
|
+
|
|
1343
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1344
|
+
pointer(viewport, "pointermove", { client: 110, time: 50 });
|
|
1345
|
+
pointer(viewport, "pointermove", { client: 90, time: 100 });
|
|
1346
|
+
vi.advanceTimersToNextFrame();
|
|
1347
|
+
|
|
1348
|
+
const midSlide = container.querySelector<HTMLElement>(
|
|
1349
|
+
'[data-carousel-slide][data-index="4"]',
|
|
1350
|
+
)!;
|
|
1351
|
+
expect(midSlide.style.visibility).toBe("");
|
|
1352
|
+
} finally {
|
|
1353
|
+
vi.useRealTimers();
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it("ignores pointer events from a second, different pointer", () => {
|
|
1358
|
+
vi.useFakeTimers();
|
|
1359
|
+
try {
|
|
1360
|
+
const { track, getByTestId } = renderInfinite();
|
|
1361
|
+
const viewport = getByTestId("viewport");
|
|
1362
|
+
|
|
1363
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0, id: 1 });
|
|
1364
|
+
// A different pointerId must not steer or end the active drag.
|
|
1365
|
+
pointer(viewport, "pointermove", { client: 500, time: 50, id: 2 });
|
|
1366
|
+
pointer(viewport, "pointerup", { client: 500, time: 60, id: 2 });
|
|
1367
|
+
// The original pointer still drives it.
|
|
1368
|
+
pointer(viewport, "pointermove", { client: 140, time: 100, id: 1 });
|
|
1369
|
+
vi.advanceTimersToNextFrame();
|
|
1370
|
+
|
|
1371
|
+
expect(track!.style.transform).toBe("translate(-60px, 0px)");
|
|
1372
|
+
} finally {
|
|
1373
|
+
vi.useRealTimers();
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
it("ignores a move with no active drag", () => {
|
|
1378
|
+
const { track, getByTestId } = renderInfinite();
|
|
1379
|
+
const viewport = getByTestId("viewport");
|
|
1380
|
+
|
|
1381
|
+
// A move with no preceding pointerdown is a no-op (nothing to steer).
|
|
1382
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1383
|
+
|
|
1384
|
+
expect(track!.style.transform).toBe("translate(0px, 0px)");
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("does not start a drag when there is nothing to loop", () => {
|
|
1388
|
+
const { track, getByTestId } = renderInfinite({ count: 1 });
|
|
1389
|
+
const viewport = getByTestId("viewport");
|
|
1390
|
+
|
|
1391
|
+
// measure() bails (count < 2), so pointerdown records no drag state.
|
|
1392
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1393
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1394
|
+
|
|
1395
|
+
expect(track!.style.transform).toBe("");
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
it("snaps a multi-slide fling to the nearest PAGE, not the nearest slide", () => {
|
|
1399
|
+
// 6 slides, 2 per page → pages lead at slide 0, 2, 4 (page stride = 200). A
|
|
1400
|
+
// drag of 120px is past the half-PAGE point (100) so it should advance a whole
|
|
1401
|
+
// page to offset 200. Snapping to the nearest SLIDE instead lands on slide 1
|
|
1402
|
+
// (offset 100) mid-page, whose page is 0, so the page effect then jerks back to
|
|
1403
|
+
// offset 0 — the two-step this fixes.
|
|
1404
|
+
stdRect({ trackSize: 200 });
|
|
1405
|
+
const { track, getByTestId } = renderInfinite({
|
|
1406
|
+
count: 6,
|
|
1407
|
+
slidesPerPage: 2,
|
|
1408
|
+
});
|
|
1409
|
+
const viewport = getByTestId("viewport");
|
|
1410
|
+
|
|
1411
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1412
|
+
pointer(viewport, "pointermove", { client: 80, time: 100 }); // offset 120
|
|
1413
|
+
pointer(viewport, "pointermove", { client: 80, time: 200 }); // zero velocity
|
|
1414
|
+
pointer(viewport, "pointerup", { client: 80, time: 220 });
|
|
1415
|
+
|
|
1416
|
+
// Page-snap → offset 200 (page 1 lead), a single glide. Slide-snap would land
|
|
1417
|
+
// on offset 100 then bounce back to 0.
|
|
1418
|
+
expect(track!.style.transform).toBe("translate(-200px, 0px)");
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it("ends a drag cleanly on pointercancel", () => {
|
|
1422
|
+
const { track, getByTestId } = renderInfinite();
|
|
1423
|
+
const viewport = getByTestId("viewport");
|
|
1424
|
+
|
|
1425
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1426
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1427
|
+
pointer(viewport, "pointermove", { client: 140, time: 200 });
|
|
1428
|
+
// Cancel resolves the fling the same way a release does.
|
|
1429
|
+
pointer(viewport, "pointercancel", { client: 140, time: 220 });
|
|
1430
|
+
|
|
1431
|
+
expect(track!.style.transform).toBe("translate(-100px, 0px)");
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it("cancels a pending coalesced repaint on unmount", () => {
|
|
1435
|
+
// Otherwise a still-scheduled rAF would fire against a detached track
|
|
1436
|
+
// once the component (and its DOM) are gone.
|
|
1437
|
+
vi.useFakeTimers();
|
|
1438
|
+
try {
|
|
1439
|
+
const { getByTestId, unmount } = renderInfinite();
|
|
1440
|
+
const viewport = getByTestId("viewport");
|
|
1441
|
+
const cancelSpy = vi.spyOn(window, "cancelAnimationFrame");
|
|
1442
|
+
|
|
1443
|
+
pointer(viewport, "pointerdown", { client: 200, time: 0 });
|
|
1444
|
+
pointer(viewport, "pointermove", { client: 140, time: 100 });
|
|
1445
|
+
// Left pending on purpose — no advanceTimersToNextFrame() before unmount.
|
|
1446
|
+
|
|
1447
|
+
expect(() => act(() => unmount())).not.toThrow();
|
|
1448
|
+
expect(cancelSpy).toHaveBeenCalled();
|
|
1449
|
+
} finally {
|
|
1450
|
+
vi.useRealTimers();
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
});
|