react-native-reanimated-carousel 5.0.0-beta.3 → 5.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -0
  2. package/lib/commonjs/components/ItemLayout.js +1 -1
  3. package/lib/commonjs/components/ItemLayout.js.map +1 -1
  4. package/lib/commonjs/components/Pagination/Basic/PaginationItem.js +1 -1
  5. package/lib/commonjs/components/Pagination/Basic/PaginationItem.js.map +1 -1
  6. package/lib/commonjs/components/Pagination/Basic/index.js +1 -1
  7. package/lib/commonjs/components/Pagination/Basic/index.js.map +1 -1
  8. package/lib/commonjs/components/Pagination/Custom/PaginationItem.js +1 -1
  9. package/lib/commonjs/components/Pagination/Custom/PaginationItem.js.map +1 -1
  10. package/lib/commonjs/components/Pagination/Custom/index.js +1 -1
  11. package/lib/commonjs/components/Pagination/Custom/index.js.map +1 -1
  12. package/lib/commonjs/components/ScrollViewGesture.js +1 -1
  13. package/lib/commonjs/components/ScrollViewGesture.js.map +1 -1
  14. package/lib/commonjs/hooks/useCarouselController.js +1 -1
  15. package/lib/commonjs/hooks/useCarouselController.js.map +1 -1
  16. package/lib/commonjs/hooks/useVisibleRanges.js +1 -1
  17. package/lib/commonjs/hooks/useVisibleRanges.js.map +1 -1
  18. package/lib/commonjs/utils/compute-gesture-translation.js +2 -0
  19. package/lib/commonjs/utils/compute-gesture-translation.js.map +1 -0
  20. package/lib/commonjs/utils/sanitize-animation-style.js +2 -0
  21. package/lib/commonjs/utils/sanitize-animation-style.js.map +1 -0
  22. package/lib/module/components/ItemLayout.js +1 -1
  23. package/lib/module/components/ItemLayout.js.map +1 -1
  24. package/lib/module/components/Pagination/Basic/PaginationItem.js +1 -1
  25. package/lib/module/components/Pagination/Basic/PaginationItem.js.map +1 -1
  26. package/lib/module/components/Pagination/Basic/index.js +1 -1
  27. package/lib/module/components/Pagination/Basic/index.js.map +1 -1
  28. package/lib/module/components/Pagination/Custom/PaginationItem.js +1 -1
  29. package/lib/module/components/Pagination/Custom/PaginationItem.js.map +1 -1
  30. package/lib/module/components/Pagination/Custom/index.js +1 -1
  31. package/lib/module/components/Pagination/Custom/index.js.map +1 -1
  32. package/lib/module/components/ScrollViewGesture.js +1 -1
  33. package/lib/module/components/ScrollViewGesture.js.map +1 -1
  34. package/lib/module/hooks/useCarouselController.js +1 -1
  35. package/lib/module/hooks/useCarouselController.js.map +1 -1
  36. package/lib/module/hooks/useVisibleRanges.js +1 -1
  37. package/lib/module/hooks/useVisibleRanges.js.map +1 -1
  38. package/lib/module/utils/compute-gesture-translation.js +2 -0
  39. package/lib/module/utils/compute-gesture-translation.js.map +1 -0
  40. package/lib/module/utils/sanitize-animation-style.js +2 -0
  41. package/lib/module/utils/sanitize-animation-style.js.map +1 -0
  42. package/lib/typescript/components/ItemLayout.d.ts.map +1 -1
  43. package/lib/typescript/components/Pagination/Basic/PaginationItem.d.ts +10 -1
  44. package/lib/typescript/components/Pagination/Basic/PaginationItem.d.ts.map +1 -1
  45. package/lib/typescript/components/Pagination/Basic/index.d.ts +6 -1
  46. package/lib/typescript/components/Pagination/Basic/index.d.ts.map +1 -1
  47. package/lib/typescript/components/Pagination/Custom/PaginationItem.d.ts +10 -0
  48. package/lib/typescript/components/Pagination/Custom/PaginationItem.d.ts.map +1 -1
  49. package/lib/typescript/components/Pagination/Custom/index.d.ts +6 -1
  50. package/lib/typescript/components/Pagination/Custom/index.d.ts.map +1 -1
  51. package/lib/typescript/components/ScrollViewGesture.d.ts.map +1 -1
  52. package/lib/typescript/hooks/useCarouselController.d.ts.map +1 -1
  53. package/lib/typescript/hooks/useVisibleRanges.d.ts.map +1 -1
  54. package/lib/typescript/utils/compute-gesture-translation.d.ts +9 -0
  55. package/lib/typescript/utils/compute-gesture-translation.d.ts.map +1 -0
  56. package/lib/typescript/utils/sanitize-animation-style.d.ts +3 -0
  57. package/lib/typescript/utils/sanitize-animation-style.d.ts.map +1 -0
  58. package/package.json +5 -2
  59. package/src/components/Carousel.test.tsx +86 -0
  60. package/src/components/ItemLayout.tsx +2 -1
  61. package/src/components/Pagination/Basic/PaginationItem.tsx +23 -5
  62. package/src/components/Pagination/Basic/index.tsx +21 -2
  63. package/src/components/Pagination/Custom/PaginationItem.tsx +23 -4
  64. package/src/components/Pagination/Custom/index.tsx +21 -2
  65. package/src/components/Pagination/Pagination.test.tsx +73 -4
  66. package/src/components/ScrollViewGesture.tsx +11 -16
  67. package/src/hooks/useCarouselController.test.tsx +93 -6
  68. package/src/hooks/useCarouselController.tsx +23 -25
  69. package/src/hooks/useVisibleRanges.test.tsx +39 -0
  70. package/src/hooks/useVisibleRanges.tsx +14 -3
  71. package/src/utils/compute-gesture-translation.test.ts +70 -0
  72. package/src/utils/compute-gesture-translation.ts +29 -0
  73. package/src/utils/sanitize-animation-style.test.ts +40 -0
  74. package/src/utils/sanitize-animation-style.ts +20 -0
@@ -13,6 +13,25 @@ import Carousel from "./Carousel";
13
13
 
14
14
  import type { TCarouselProps } from "../types";
15
15
 
16
+ // Suppress the "measure() cannot be used with Jest" warning from Reanimated.
17
+ // The measure export is non-configurable so it cannot be spied on or mocked
18
+ // at the module level. The code in ScrollViewGesture already handles the null
19
+ // return gracefully (measurement?.width || 0). We filter at the Reanimated
20
+ // logger level which is more reliable than intercepting console.warn.
21
+ {
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ const cfg = (global as any).__reanimatedLoggerConfig as
24
+ | { logFunction: (data: { level: number; message: string }) => void }
25
+ | undefined;
26
+ if (cfg) {
27
+ const _origLog = cfg.logFunction;
28
+ cfg.logFunction = (data) => {
29
+ if (data.message.includes("measure() cannot be used with Jest")) return;
30
+ _origLog(data);
31
+ };
32
+ }
33
+ }
34
+
16
35
  jest.setTimeout(1000 * 12);
17
36
 
18
37
  const mockPan = jest.fn();
@@ -832,6 +851,73 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
832
851
  pushExpect(1);
833
852
  });
834
853
 
854
+ it("`overscrollEnabled` false should clamp right overdrag at the first page in non-loop mode", async () => {
855
+ const handlerOffset = { current: 0 };
856
+ const maxObservedPositiveOffset = { current: 0 };
857
+
858
+ const Wrapper: FC<Partial<TCarouselProps<string>>> = React.forwardRef((customProps, ref) => {
859
+ const progressAnimVal = useSharedValue(0);
860
+ const mockHandlerOffset = useSharedValue(handlerOffset.current);
861
+ const defaultRenderItem = ({
862
+ item,
863
+ index,
864
+ }: {
865
+ item: string;
866
+ index: number;
867
+ }) => (
868
+ <Animated.View
869
+ testID={`carousel-item-${index}`}
870
+ style={{ width: slideWidth, height: slideHeight }}
871
+ >
872
+ {item}
873
+ </Animated.View>
874
+ );
875
+ const { renderItem = defaultRenderItem, ...defaultProps } = createDefaultProps(
876
+ progressAnimVal,
877
+ customProps
878
+ );
879
+
880
+ useDerivedValue(() => {
881
+ handlerOffset.current = mockHandlerOffset.value;
882
+ maxObservedPositiveOffset.current = Math.max(
883
+ maxObservedPositiveOffset.current,
884
+ mockHandlerOffset.value
885
+ );
886
+ }, [mockHandlerOffset]);
887
+
888
+ return (
889
+ <Carousel
890
+ {...defaultProps}
891
+ defaultScrollOffsetValue={mockHandlerOffset}
892
+ renderItem={renderItem}
893
+ ref={ref}
894
+ />
895
+ );
896
+ });
897
+
898
+ const { getByTestId } = render(
899
+ <Wrapper
900
+ loop={false}
901
+ overscrollEnabled={false}
902
+ pagingEnabled={false}
903
+ style={{ width: slideWidth, height: slideHeight }}
904
+ />
905
+ );
906
+ await verifyInitialRender(getByTestId);
907
+
908
+ fireGestureHandler<PanGesture>(getByGestureTestId(gestureTestId), [
909
+ { state: State.BEGAN, translationX: 0, velocityX: slideWidth },
910
+ ]);
911
+
912
+ fireGestureHandler<PanGesture>(getByGestureTestId(gestureTestId), [
913
+ { state: State.ACTIVE, translationX: slideWidth * 0.6, velocityX: slideWidth },
914
+ ]);
915
+
916
+ await waitFor(() => {
917
+ expect(maxObservedPositiveOffset.current).toBe(0);
918
+ });
919
+ });
920
+
835
921
  it("should keep correct page after left overscroll at first page when calling next() or scrollTo()", async () => {
836
922
  const handlerOffset = { current: 0 };
837
923
  let nextSlide: ((opts?: { animated?: boolean }) => void) | undefined;
@@ -15,6 +15,7 @@ import type { IVisibleRanges } from "../hooks/useVisibleRanges";
15
15
  import type { ILayoutConfig } from "../layouts/stack";
16
16
  import { useGlobalState } from "../store";
17
17
  import type { TCarouselProps } from "../types";
18
+ import { sanitizeAnimationStyle } from "../utils/sanitize-animation-style";
18
19
 
19
20
  export type TAnimationStyle = NonNullable<TCarouselProps["customAnimation"]>;
20
21
 
@@ -116,7 +117,7 @@ export const ItemLayout: React.FC<{
116
117
  }, [x, size]);
117
118
  const animatedStyle = useAnimatedStyle<ViewStyle>(() => {
118
119
  const safeSize = size || 1;
119
- return animationStyle(x.value / safeSize, index);
120
+ return sanitizeAnimationStyle(animationStyle(x.value / safeSize, index));
120
121
  }, [animationStyle, index, x, size]);
121
122
 
122
123
  // TODO: For dynamic dimension in the future
@@ -1,6 +1,6 @@
1
1
  import type { PropsWithChildren } from "react";
2
2
  import React, { useState } from "react";
3
- import type { ViewStyle } from "react-native";
3
+ import type { AccessibilityRole, AccessibilityState, ViewStyle } from "react-native";
4
4
  import { Pressable, View } from "react-native";
5
5
  import type { SharedValue } from "react-native-reanimated";
6
6
  import Animated, {
@@ -16,6 +16,13 @@ export type DotStyle = Omit<ViewStyle, "width" | "height"> & {
16
16
  height?: number;
17
17
  };
18
18
 
19
+ export type PaginationItemAccessibilityOverrides = {
20
+ accessibilityLabel?: string;
21
+ accessibilityHint?: string;
22
+ accessibilityRole?: AccessibilityRole;
23
+ accessibilityState?: AccessibilityState;
24
+ };
25
+
19
26
  export const PaginationItem: React.FC<
20
27
  PropsWithChildren<{
21
28
  index: number;
@@ -27,6 +34,9 @@ export const PaginationItem: React.FC<
27
34
  activeDotStyle?: DotStyle;
28
35
  onPress: () => void;
29
36
  accessibilityLabel?: string;
37
+ accessibilityHint?: string;
38
+ accessibilityRole?: AccessibilityRole;
39
+ accessibilityState?: AccessibilityState;
30
40
  }>
31
41
  > = (props) => {
32
42
  const {
@@ -40,6 +50,9 @@ export const PaginationItem: React.FC<
40
50
  children,
41
51
  onPress,
42
52
  accessibilityLabel,
53
+ accessibilityHint,
54
+ accessibilityRole,
55
+ accessibilityState,
43
56
  } = props;
44
57
 
45
58
  const defaultDotSize = 10;
@@ -62,6 +75,11 @@ export const PaginationItem: React.FC<
62
75
  const height = sizes.height;
63
76
 
64
77
  const [isSelected, setIsSelected] = useState(false);
78
+ const resolvedAccessibilityLabel = accessibilityLabel ?? `Slide ${index + 1} of ${count}`;
79
+ const resolvedAccessibilityHint =
80
+ accessibilityHint ?? (isSelected ? "" : `Go to ${resolvedAccessibilityLabel}`);
81
+ const resolvedAccessibilityRole = accessibilityRole ?? "button";
82
+ const resolvedAccessibilityState = accessibilityState ?? { selected: isSelected };
65
83
 
66
84
  useAnimatedReaction(
67
85
  () => animValue.value,
@@ -93,10 +111,10 @@ export const PaginationItem: React.FC<
93
111
  return (
94
112
  <Pressable
95
113
  onPress={onPress}
96
- accessibilityLabel={accessibilityLabel}
97
- accessibilityRole="button"
98
- accessibilityHint={isSelected ? "" : `Go to ${accessibilityLabel}`}
99
- accessibilityState={{ selected: isSelected }}
114
+ accessibilityLabel={resolvedAccessibilityLabel}
115
+ accessibilityRole={resolvedAccessibilityRole}
116
+ accessibilityHint={resolvedAccessibilityHint}
117
+ accessibilityState={resolvedAccessibilityState}
100
118
  >
101
119
  <View
102
120
  style={[
@@ -2,7 +2,7 @@ import { type StyleProp, View, type ViewStyle } from "react-native";
2
2
  import type { SharedValue } from "react-native-reanimated";
3
3
 
4
4
  import React from "react";
5
- import type { DotStyle } from "./PaginationItem";
5
+ import type { DotStyle, PaginationItemAccessibilityOverrides } from "./PaginationItem";
6
6
  import { PaginationItem } from "./PaginationItem";
7
7
 
8
8
  export interface BasicProps<T> {
@@ -16,6 +16,14 @@ export interface BasicProps<T> {
16
16
  size?: number;
17
17
  onPress?: (index: number) => void;
18
18
  carouselName?: string;
19
+ /**
20
+ * Optional accessibility overrides for each pagination item.
21
+ * Use this to fully control screen reader announcements per dot.
22
+ */
23
+ paginationItemAccessibility?: (
24
+ index: number,
25
+ length: number
26
+ ) => PaginationItemAccessibilityOverrides;
19
27
  }
20
28
 
21
29
  export const Basic = <T extends {}>(props: BasicProps<T>) => {
@@ -30,6 +38,7 @@ export const Basic = <T extends {}>(props: BasicProps<T>) => {
30
38
  renderItem,
31
39
  onPress,
32
40
  carouselName,
41
+ paginationItemAccessibility,
33
42
  } = props;
34
43
 
35
44
  if (
@@ -57,6 +66,11 @@ export const Basic = <T extends {}>(props: BasicProps<T>) => {
57
66
  ]}
58
67
  >
59
68
  {data.map((item, index) => {
69
+ const defaultAccessibilityLabel = carouselName
70
+ ? `Slide ${index + 1} of ${data.length} - ${carouselName}`
71
+ : `Slide ${index + 1} of ${data.length}`;
72
+ const accessibilityOverrides = paginationItemAccessibility?.(index, data.length) ?? {};
73
+
60
74
  return (
61
75
  <PaginationItem
62
76
  key={index}
@@ -68,7 +82,12 @@ export const Basic = <T extends {}>(props: BasicProps<T>) => {
68
82
  horizontal={!horizontal}
69
83
  activeDotStyle={activeDotStyle}
70
84
  onPress={() => onPress?.(index)}
71
- accessibilityLabel={`Slide ${index + 1} of ${data.length} - ${carouselName}`}
85
+ accessibilityLabel={
86
+ accessibilityOverrides.accessibilityLabel ?? defaultAccessibilityLabel
87
+ }
88
+ accessibilityHint={accessibilityOverrides.accessibilityHint}
89
+ accessibilityRole={accessibilityOverrides.accessibilityRole}
90
+ accessibilityState={accessibilityOverrides.accessibilityState}
72
91
  >
73
92
  {renderItem?.(item, index)}
74
93
  </PaginationItem>
@@ -1,5 +1,6 @@
1
1
  import type { PropsWithChildren } from "react";
2
2
  import React, { useState } from "react";
3
+ import type { AccessibilityRole, AccessibilityState } from "react-native";
3
4
  import { Pressable } from "react-native";
4
5
  import type { ImageStyle, TextStyle, ViewStyle } from "react-native";
5
6
  import type { SharedValue } from "react-native-reanimated";
@@ -16,6 +17,13 @@ import { scheduleOnRN } from "react-native-worklets";
16
17
 
17
18
  type AnimatedDefaultStyle = ViewStyle | ImageStyle | TextStyle;
18
19
 
20
+ export type PaginationItemAccessibilityOverrides = {
21
+ accessibilityLabel?: string;
22
+ accessibilityHint?: string;
23
+ accessibilityRole?: AccessibilityRole;
24
+ accessibilityState?: AccessibilityState;
25
+ };
26
+
19
27
  export type DotStyle = Omit<ViewStyle, "width" | "height" | "backgroundColor" | "borderRadius"> & {
20
28
  width?: number;
21
29
  height?: number;
@@ -39,6 +47,9 @@ export const PaginationItem: React.FC<
39
47
  length: number
40
48
  ) => AnimatedDefaultStyle;
41
49
  accessibilityLabel?: string;
50
+ accessibilityHint?: string;
51
+ accessibilityRole?: AccessibilityRole;
52
+ accessibilityState?: AccessibilityState;
42
53
  }>
43
54
  > = (props) => {
44
55
  const defaultDotSize = 10;
@@ -54,6 +65,9 @@ export const PaginationItem: React.FC<
54
65
  customReanimatedStyle,
55
66
  onPress,
56
67
  accessibilityLabel,
68
+ accessibilityHint,
69
+ accessibilityRole,
70
+ accessibilityState,
57
71
  } = props;
58
72
  const customReanimatedStyleRef = useSharedValue<AnimatedDefaultStyle>({});
59
73
  const handleCustomAnimation = (progress: number) => {
@@ -61,6 +75,11 @@ export const PaginationItem: React.FC<
61
75
  };
62
76
 
63
77
  const [isSelected, setIsSelected] = useState(false);
78
+ const resolvedAccessibilityLabel = accessibilityLabel ?? `Slide ${index + 1} of ${count}`;
79
+ const resolvedAccessibilityHint =
80
+ accessibilityHint ?? (isSelected ? "" : `Go to ${resolvedAccessibilityLabel}`);
81
+ const resolvedAccessibilityRole = accessibilityRole ?? "button";
82
+ const resolvedAccessibilityState = accessibilityState ?? { selected: isSelected };
64
83
 
65
84
  useDerivedValue(() => {
66
85
  scheduleOnRN(handleCustomAnimation, animValue?.value);
@@ -121,10 +140,10 @@ export const PaginationItem: React.FC<
121
140
  return (
122
141
  <Pressable
123
142
  onPress={onPress}
124
- accessibilityLabel={accessibilityLabel}
125
- accessibilityRole="button"
126
- accessibilityHint={isSelected ? "" : `Go to ${accessibilityLabel}`}
127
- accessibilityState={{ selected: isSelected }}
143
+ accessibilityLabel={resolvedAccessibilityLabel}
144
+ accessibilityRole={resolvedAccessibilityRole}
145
+ accessibilityHint={resolvedAccessibilityHint}
146
+ accessibilityState={resolvedAccessibilityState}
128
147
  >
129
148
  <Animated.View
130
149
  style={[
@@ -3,7 +3,7 @@ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
3
3
  import { View } from "react-native";
4
4
  import type { SharedValue } from "react-native-reanimated";
5
5
 
6
- import type { DotStyle } from "./PaginationItem";
6
+ import type { DotStyle, PaginationItemAccessibilityOverrides } from "./PaginationItem";
7
7
  import { PaginationItem } from "./PaginationItem";
8
8
 
9
9
  export interface ShapeProps<T extends {}> {
@@ -18,6 +18,14 @@ export interface ShapeProps<T extends {}> {
18
18
  onPress?: (index: number) => void;
19
19
  customReanimatedStyle?: (progress: number, index: number, length: number) => AnimatedDefaultStyle;
20
20
  carouselName?: string;
21
+ /**
22
+ * Optional accessibility overrides for each pagination item.
23
+ * Use this to fully control screen reader announcements per dot.
24
+ */
25
+ paginationItemAccessibility?: (
26
+ index: number,
27
+ length: number
28
+ ) => PaginationItemAccessibilityOverrides;
21
29
  }
22
30
 
23
31
  type AnimatedDefaultStyle = ViewStyle | ImageStyle | TextStyle;
@@ -35,6 +43,7 @@ export const Custom = <T extends {}>(props: ShapeProps<T>) => {
35
43
  onPress,
36
44
  customReanimatedStyle,
37
45
  carouselName,
46
+ paginationItemAccessibility,
38
47
  } = props;
39
48
 
40
49
  if (
@@ -69,6 +78,11 @@ export const Custom = <T extends {}>(props: ShapeProps<T>) => {
69
78
  ]}
70
79
  >
71
80
  {data.map((item, index) => {
81
+ const defaultAccessibilityLabel = carouselName
82
+ ? `Slide ${index + 1} of ${data.length} - ${carouselName}`
83
+ : `Slide ${index + 1} of ${data.length}`;
84
+ const accessibilityOverrides = paginationItemAccessibility?.(index, data.length) ?? {};
85
+
72
86
  return (
73
87
  <PaginationItem
74
88
  key={index}
@@ -81,7 +95,12 @@ export const Custom = <T extends {}>(props: ShapeProps<T>) => {
81
95
  activeDotStyle={activeDotStyle}
82
96
  customReanimatedStyle={customReanimatedStyle}
83
97
  onPress={() => onPress?.(index)}
84
- accessibilityLabel={`Slide ${index + 1} of ${data.length} - ${carouselName}`}
98
+ accessibilityLabel={
99
+ accessibilityOverrides.accessibilityLabel ?? defaultAccessibilityLabel
100
+ }
101
+ accessibilityHint={accessibilityOverrides.accessibilityHint}
102
+ accessibilityRole={accessibilityOverrides.accessibilityRole}
103
+ accessibilityState={accessibilityOverrides.accessibilityState}
85
104
  >
86
105
  {renderItem?.(item, index)}
87
106
  </PaginationItem>
@@ -1,7 +1,6 @@
1
- import { fireEvent, render } from "@testing-library/react-native";
1
+ import { act, fireEvent, render } from "@testing-library/react-native";
2
2
  import { View } from "react-native";
3
3
  import { useSharedValue } from "react-native-reanimated";
4
- import { act } from "react-test-renderer";
5
4
 
6
5
  import { Pagination } from ".";
7
6
 
@@ -9,6 +8,16 @@ jest.mock("react-native-worklets", () => ({
9
8
  scheduleOnRN: (fn: (...args: unknown[]) => void, ...args: unknown[]) => fn(...args),
10
9
  }));
11
10
 
11
+ // Use fake timers so the reanimated mapper's mockedRequestAnimationFrame
12
+ // (a setTimeout-based RAF) doesn't fire outside of act().
13
+ beforeEach(() => {
14
+ jest.useFakeTimers();
15
+ });
16
+
17
+ afterEach(() => {
18
+ jest.useRealTimers();
19
+ });
20
+
12
21
  describe("Pagination.Basic", () => {
13
22
  it("throws when size/dot width/height are non-number", () => {
14
23
  const Test = () => {
@@ -41,7 +50,9 @@ describe("Pagination.Basic", () => {
41
50
  };
42
51
 
43
52
  const { UNSAFE_root } = render(<Test />);
44
- await act(async () => {});
53
+ await act(async () => {
54
+ jest.advanceTimersByTime(100);
55
+ });
45
56
 
46
57
  const buttons = UNSAFE_root.findAll(
47
58
  (node) =>
@@ -58,9 +69,36 @@ describe("Pagination.Basic", () => {
58
69
 
59
70
  await act(async () => {
60
71
  fireEvent.press(uniqueByLabel[1]);
72
+ jest.advanceTimersByTime(100);
61
73
  });
62
74
  expect(handlePress).toHaveBeenCalledWith(1);
63
75
  });
76
+
77
+ it("does not include undefined in default accessibilityLabel when carouselName is omitted", async () => {
78
+ const Test = () => {
79
+ const progress = useSharedValue(0);
80
+ return <Pagination.Basic data={[1, 2]} progress={progress} />;
81
+ };
82
+
83
+ const { UNSAFE_root } = render(<Test />);
84
+ await act(async () => {
85
+ jest.advanceTimersByTime(100);
86
+ });
87
+
88
+ const buttons = UNSAFE_root.findAll(
89
+ (node) =>
90
+ node?.props?.accessibilityRole === "button" &&
91
+ typeof node?.props?.accessibilityLabel === "string" &&
92
+ node.props.accessibilityLabel.startsWith("Slide ")
93
+ );
94
+
95
+ const uniqueByLabel = Array.from(
96
+ new Map(buttons.map((b) => [b.props.accessibilityLabel, b])).values()
97
+ );
98
+
99
+ expect(uniqueByLabel[0].props.accessibilityLabel).toBe("Slide 1 of 2");
100
+ expect(uniqueByLabel[0].props.accessibilityLabel).not.toContain("undefined");
101
+ });
64
102
  });
65
103
 
66
104
  describe("Pagination.Custom", () => {
@@ -95,7 +133,9 @@ describe("Pagination.Custom", () => {
95
133
  };
96
134
 
97
135
  const { UNSAFE_root } = render(<Test />);
98
- await act(async () => {});
136
+ await act(async () => {
137
+ jest.advanceTimersByTime(100);
138
+ });
99
139
 
100
140
  // @ts-expect-error
101
141
  const container = UNSAFE_root.findByType(View);
@@ -106,4 +146,33 @@ describe("Pagination.Custom", () => {
106
146
  expect(flattened.minWidth).toBe(16);
107
147
  expect(flattened.minHeight).toBe(20);
108
148
  });
149
+
150
+ it("allows overriding pagination item accessibility props", async () => {
151
+ const Test = () => {
152
+ const progress = useSharedValue(0);
153
+ return (
154
+ <Pagination.Custom
155
+ data={[1, 2]}
156
+ progress={progress}
157
+ paginationItemAccessibility={(index: number, length: number) => ({
158
+ accessibilityLabel: `Page ${index + 1}/${length}`,
159
+ accessibilityHint: `Jump to ${index + 1}`,
160
+ accessibilityRole: "link",
161
+ })}
162
+ />
163
+ );
164
+ };
165
+
166
+ const { UNSAFE_root } = render(<Test />);
167
+ await act(async () => {
168
+ jest.advanceTimersByTime(100);
169
+ });
170
+
171
+ const item = UNSAFE_root.find(
172
+ (node) =>
173
+ node?.props?.accessibilityLabel === "Page 1/2" && node?.props?.accessibilityRole === "link"
174
+ );
175
+
176
+ expect(item.props.accessibilityHint).toBe("Jump to 1");
177
+ });
109
178
  });
@@ -22,6 +22,7 @@ import { Easing } from "../constants";
22
22
  import { usePanGestureProxy } from "../hooks/usePanGestureProxy";
23
23
  import { useGlobalState } from "../store";
24
24
  import type { WithTimingAnimation } from "../types";
25
+ import { computeGestureTranslation } from "../utils/compute-gesture-translation";
25
26
  import { dealWithAnimation } from "../utils/deal-with-animation";
26
27
 
27
28
  interface Props {
@@ -207,8 +208,7 @@ const IScrollViewGesture: React.FC<PropsWithChildren<Props>> = (props) => {
207
208
  function withProcessTranslation(translation: number) {
208
209
  if (!loop && !overscrollEnabled) {
209
210
  const limit = getLimit();
210
- const sign = Math.sign(translation);
211
- return sign * Math.max(0, Math.min(limit, Math.abs(translation)));
211
+ return Math.min(0, Math.max(-limit, translation));
212
212
  }
213
213
 
214
214
  return translation;
@@ -296,8 +296,7 @@ const IScrollViewGesture: React.FC<PropsWithChildren<Props>> = (props) => {
296
296
 
297
297
  if (!loop && !overscrollEnabled) {
298
298
  const limit = getLimit();
299
- const sign = Math.sign(translation);
300
- return sign * Math.max(0, Math.min(limit, Math.abs(translation)));
299
+ return Math.min(0, Math.max(-limit, translation));
301
300
  }
302
301
 
303
302
  return translation;
@@ -370,18 +369,14 @@ const IScrollViewGesture: React.FC<PropsWithChildren<Props>> = (props) => {
370
369
  if (fixedDirection === "negative") panTranslation = -Math.abs(panTranslation);
371
370
  else if (fixedDirection === "positive") panTranslation = +Math.abs(panTranslation);
372
371
 
373
- if (!loop) {
374
- if (translation.value > 0 || translation.value < -max.value) {
375
- const boundary = translation.value > 0 ? 0 : -max.value;
376
- const fixed = boundary - panOffset.value;
377
- const dynamic = panTranslation - fixed;
378
- translation.value = boundary + dynamic * 0.5;
379
- return;
380
- }
381
- }
382
-
383
- const translationValue = panOffset.value + panTranslation;
384
- translation.value = translationValue;
372
+ translation.value = computeGestureTranslation({
373
+ loop,
374
+ overscrollEnabled: overscrollEnabled ?? true,
375
+ currentTranslation: translation.value,
376
+ panOffset: panOffset.value,
377
+ panTranslation,
378
+ max: max.value,
379
+ });
385
380
  },
386
381
  [
387
382
  isHorizontal,
@@ -228,6 +228,76 @@ describe("useCarouselController", () => {
228
228
  expect(mockHandlerOffset.value).toBe(-900); // size * 3
229
229
  });
230
230
 
231
+ it("should keep negative offsets when scrollTo() moves backward in non-loop mode", () => {
232
+ const { result } = renderHook(
233
+ () =>
234
+ useCarouselController({
235
+ ...defaultProps,
236
+ loop: false,
237
+ }),
238
+ { wrapper }
239
+ );
240
+
241
+ act(() => {
242
+ result.current.scrollTo({ index: 3, animated: false });
243
+ });
244
+ expect(mockHandlerOffset.value).toBe(-900);
245
+
246
+ act(() => {
247
+ result.current.scrollTo({ index: 2, animated: false });
248
+ });
249
+ expect(mockHandlerOffset.value).toBe(-600);
250
+
251
+ act(() => {
252
+ result.current.scrollTo({ index: 1, animated: false });
253
+ });
254
+ expect(mockHandlerOffset.value).toBe(-300);
255
+ });
256
+
257
+ it("should keep negative offsets when animated backward scrollTo() runs in non-loop mode", () => {
258
+ const onFinished = jest.fn();
259
+ const { result } = renderHook(
260
+ () =>
261
+ useCarouselController({
262
+ ...defaultProps,
263
+ loop: false,
264
+ }),
265
+ { wrapper }
266
+ );
267
+
268
+ act(() => {
269
+ result.current.scrollTo({ index: 3, animated: true });
270
+ });
271
+ expect(mockHandlerOffset.value).toBe(-900);
272
+
273
+ act(() => {
274
+ result.current.scrollTo({ index: 2, animated: true, onFinished });
275
+ });
276
+ expect(mockHandlerOffset.value).toBe(-600);
277
+ expect(onFinished).toHaveBeenCalled();
278
+ });
279
+
280
+ it("should map scrollTo({ index: 0 }) to zero offset from end in non-loop mode", () => {
281
+ const { result } = renderHook(
282
+ () =>
283
+ useCarouselController({
284
+ ...defaultProps,
285
+ loop: false,
286
+ }),
287
+ { wrapper }
288
+ );
289
+
290
+ act(() => {
291
+ result.current.scrollTo({ index: 4, animated: false });
292
+ });
293
+ expect(mockHandlerOffset.value).toBe(-1200);
294
+
295
+ act(() => {
296
+ result.current.scrollTo({ index: 0, animated: false });
297
+ });
298
+ expect(Math.abs(mockHandlerOffset.value)).toBe(0);
299
+ });
300
+
231
301
  it("should handle animation callbacks", () => {
232
302
  const onFinished = jest.fn();
233
303
  const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper });
@@ -1004,13 +1074,31 @@ describe("useCarouselController edge cases and uncovered lines", () => {
1004
1074
  expect(typeof currentIndex).toBe("number");
1005
1075
  });
1006
1076
 
1007
- it("should handle fixed direction in scrollTo", () => {
1077
+ it("should handle positive fixed direction in scrollTo", () => {
1078
+ const { result } = renderHook(
1079
+ () =>
1080
+ useCarouselController({
1081
+ ...defaultProps,
1082
+ loop: true,
1083
+ fixedDirection: "positive",
1084
+ }),
1085
+ { wrapper }
1086
+ );
1087
+
1088
+ act(() => {
1089
+ result.current.scrollTo({ index: 3, animated: false });
1090
+ });
1091
+
1092
+ expect(mockHandlerOffset.value).toBe(900); // size * 3
1093
+ });
1094
+
1095
+ it("should handle negative fixed direction in scrollTo", () => {
1008
1096
  const { result } = renderHook(
1009
1097
  () =>
1010
1098
  useCarouselController({
1011
1099
  ...defaultProps,
1012
1100
  loop: true,
1013
- fixedDirection: 1,
1101
+ fixedDirection: "negative",
1014
1102
  }),
1015
1103
  { wrapper }
1016
1104
  );
@@ -1022,7 +1110,7 @@ describe("useCarouselController edge cases and uncovered lines", () => {
1022
1110
  expect(mockHandlerOffset.value).toBe(-900); // size * 3
1023
1111
  });
1024
1112
 
1025
- it("should handle complex loop calculations in scrollTo", () => {
1113
+ it("should handle scrollTo when loop position is close to next cycle", () => {
1026
1114
  const { result } = renderHook(
1027
1115
  () =>
1028
1116
  useCarouselController({
@@ -1032,14 +1120,13 @@ describe("useCarouselController edge cases and uncovered lines", () => {
1032
1120
  { wrapper }
1033
1121
  );
1034
1122
 
1035
- // Set to a high offset to test loop boundary calculations
1036
- mockHandlerOffset.value = -1800; // 6 * size, beyond data length
1123
+ mockHandlerOffset.value = -900; // 3 * size, more than half-way through current cycle
1037
1124
 
1038
1125
  act(() => {
1039
1126
  result.current.scrollTo({ index: 1, animated: false });
1040
1127
  });
1041
1128
 
1042
- expect(typeof mockHandlerOffset.value).toBe("number");
1129
+ expect(mockHandlerOffset.value).toBe(-1800);
1043
1130
  });
1044
1131
 
1045
1132
  it("should get shared index correctly", () => {