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.
- package/README.md +2 -0
- package/lib/commonjs/components/ItemLayout.js +1 -1
- package/lib/commonjs/components/ItemLayout.js.map +1 -1
- package/lib/commonjs/components/Pagination/Basic/PaginationItem.js +1 -1
- package/lib/commonjs/components/Pagination/Basic/PaginationItem.js.map +1 -1
- package/lib/commonjs/components/Pagination/Basic/index.js +1 -1
- package/lib/commonjs/components/Pagination/Basic/index.js.map +1 -1
- package/lib/commonjs/components/Pagination/Custom/PaginationItem.js +1 -1
- package/lib/commonjs/components/Pagination/Custom/PaginationItem.js.map +1 -1
- package/lib/commonjs/components/Pagination/Custom/index.js +1 -1
- package/lib/commonjs/components/Pagination/Custom/index.js.map +1 -1
- package/lib/commonjs/components/ScrollViewGesture.js +1 -1
- package/lib/commonjs/components/ScrollViewGesture.js.map +1 -1
- package/lib/commonjs/hooks/useCarouselController.js +1 -1
- package/lib/commonjs/hooks/useCarouselController.js.map +1 -1
- package/lib/commonjs/hooks/useVisibleRanges.js +1 -1
- package/lib/commonjs/hooks/useVisibleRanges.js.map +1 -1
- package/lib/commonjs/utils/compute-gesture-translation.js +2 -0
- package/lib/commonjs/utils/compute-gesture-translation.js.map +1 -0
- package/lib/commonjs/utils/sanitize-animation-style.js +2 -0
- package/lib/commonjs/utils/sanitize-animation-style.js.map +1 -0
- package/lib/module/components/ItemLayout.js +1 -1
- package/lib/module/components/ItemLayout.js.map +1 -1
- package/lib/module/components/Pagination/Basic/PaginationItem.js +1 -1
- package/lib/module/components/Pagination/Basic/PaginationItem.js.map +1 -1
- package/lib/module/components/Pagination/Basic/index.js +1 -1
- package/lib/module/components/Pagination/Basic/index.js.map +1 -1
- package/lib/module/components/Pagination/Custom/PaginationItem.js +1 -1
- package/lib/module/components/Pagination/Custom/PaginationItem.js.map +1 -1
- package/lib/module/components/Pagination/Custom/index.js +1 -1
- package/lib/module/components/Pagination/Custom/index.js.map +1 -1
- package/lib/module/components/ScrollViewGesture.js +1 -1
- package/lib/module/components/ScrollViewGesture.js.map +1 -1
- package/lib/module/hooks/useCarouselController.js +1 -1
- package/lib/module/hooks/useCarouselController.js.map +1 -1
- package/lib/module/hooks/useVisibleRanges.js +1 -1
- package/lib/module/hooks/useVisibleRanges.js.map +1 -1
- package/lib/module/utils/compute-gesture-translation.js +2 -0
- package/lib/module/utils/compute-gesture-translation.js.map +1 -0
- package/lib/module/utils/sanitize-animation-style.js +2 -0
- package/lib/module/utils/sanitize-animation-style.js.map +1 -0
- package/lib/typescript/components/ItemLayout.d.ts.map +1 -1
- package/lib/typescript/components/Pagination/Basic/PaginationItem.d.ts +10 -1
- package/lib/typescript/components/Pagination/Basic/PaginationItem.d.ts.map +1 -1
- package/lib/typescript/components/Pagination/Basic/index.d.ts +6 -1
- package/lib/typescript/components/Pagination/Basic/index.d.ts.map +1 -1
- package/lib/typescript/components/Pagination/Custom/PaginationItem.d.ts +10 -0
- package/lib/typescript/components/Pagination/Custom/PaginationItem.d.ts.map +1 -1
- package/lib/typescript/components/Pagination/Custom/index.d.ts +6 -1
- package/lib/typescript/components/Pagination/Custom/index.d.ts.map +1 -1
- package/lib/typescript/components/ScrollViewGesture.d.ts.map +1 -1
- package/lib/typescript/hooks/useCarouselController.d.ts.map +1 -1
- package/lib/typescript/hooks/useVisibleRanges.d.ts.map +1 -1
- package/lib/typescript/utils/compute-gesture-translation.d.ts +9 -0
- package/lib/typescript/utils/compute-gesture-translation.d.ts.map +1 -0
- package/lib/typescript/utils/sanitize-animation-style.d.ts +3 -0
- package/lib/typescript/utils/sanitize-animation-style.d.ts.map +1 -0
- package/package.json +5 -2
- package/src/components/Carousel.test.tsx +86 -0
- package/src/components/ItemLayout.tsx +2 -1
- package/src/components/Pagination/Basic/PaginationItem.tsx +23 -5
- package/src/components/Pagination/Basic/index.tsx +21 -2
- package/src/components/Pagination/Custom/PaginationItem.tsx +23 -4
- package/src/components/Pagination/Custom/index.tsx +21 -2
- package/src/components/Pagination/Pagination.test.tsx +73 -4
- package/src/components/ScrollViewGesture.tsx +11 -16
- package/src/hooks/useCarouselController.test.tsx +93 -6
- package/src/hooks/useCarouselController.tsx +23 -25
- package/src/hooks/useVisibleRanges.test.tsx +39 -0
- package/src/hooks/useVisibleRanges.tsx +14 -3
- package/src/utils/compute-gesture-translation.test.ts +70 -0
- package/src/utils/compute-gesture-translation.ts +29 -0
- package/src/utils/sanitize-animation-style.test.ts +40 -0
- 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={
|
|
97
|
-
accessibilityRole=
|
|
98
|
-
accessibilityHint={
|
|
99
|
-
accessibilityState={
|
|
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={
|
|
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={
|
|
125
|
-
accessibilityRole=
|
|
126
|
-
accessibilityHint={
|
|
127
|
-
accessibilityState={
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
1129
|
+
expect(mockHandlerOffset.value).toBe(-1800);
|
|
1043
1130
|
});
|
|
1044
1131
|
|
|
1045
1132
|
it("should get shared index correctly", () => {
|