Skip to content

Commit 59e748d

Browse files
[MOO-2509] - Replace third-party carousel with own implementation (#677)
2 parents 3e50e79 + 3e0f2b3 commit 59e748d

11 files changed

Lines changed: 841 additions & 228 deletions

File tree

‎package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@
106106
},
107107
"patchedDependencies": {
108108
"react-native-gesture-handler@2.31.2": "patches/react-native-gesture-handler+2.31.2.patch",
109-
"react-native-snap-carousel@3.9.1": "patches/react-native-snap-carousel+3.9.1.patch",
110109
"@mendix/pluggable-widgets-tools@11.12.0": "patches/@mendix__pluggable-widgets-tools@11.12.0.patch"
111110
}
112111
},

‎packages/pluggableWidgets/carousel-native/CHANGELOG.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
### Changed
1010

11-
- We updated deprecated-react-native-prop-types to 4.2.3.
12-
- We pinned react-native-snap-carousel to 3.9.1.
11+
- We replaced react-native-snap-carousel with a built-in carousel component, removing the dependency on it and on deprecated-react-native-prop-types.
12+
13+
### Fixed
14+
15+
- Carousel is now accessible: TalkBack and VoiceOver users can jump straight to a slide via its pagination dot, or step through slides one at a time; only the active slide is ever read out, instead of all of them at once.
1316

1417
## [3.1.0] - 2024-12-3
1518

‎packages/pluggableWidgets/carousel-native/package.json‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "carousel-native",
33
"widgetName": "Carousel",
4-
"version": "3.3.0",
4+
"version": "4.0.0",
55
"license": "Apache-2.0",
66
"repository": {
77
"type": "git",
@@ -20,12 +20,9 @@
2020
},
2121
"dependencies": {
2222
"@mendix/piw-native-utils-internal": "*",
23-
"@mendix/piw-utils-internal": "*",
24-
"deprecated-react-native-prop-types": "4.2.3",
25-
"react-native-snap-carousel": "3.9.1"
23+
"@mendix/piw-utils-internal": "*"
2624
},
2725
"devDependencies": {
28-
"@mendix/pluggable-widgets-tools": "*",
29-
"@types/react-native-snap-carousel": "^3.7.4"
26+
"@mendix/pluggable-widgets-tools": "*"
3027
}
3128
}

‎packages/pluggableWidgets/carousel-native/src/Carousel.tsx‎

Lines changed: 224 additions & 56 deletions
Large diffs are not rendered by default.

‎packages/pluggableWidgets/carousel-native/src/__tests__/Carousel.spec.tsx‎

Lines changed: 237 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CarouselProps } from "../../typings/CarouselProps";
22
import { CarouselStyle, defaultCarouselStyle } from "../ui/styles";
3-
import { render } from "@testing-library/react-native";
4-
import { Text, View } from "react-native";
3+
import { fireEvent, render } from "@testing-library/react-native";
4+
import { FlatList, Text, View } from "react-native";
55
import { buildWidgetValue, ListValueBuilder } from "@mendix/piw-utils-internal";
66
import { Carousel } from "../Carousel";
77

@@ -24,29 +24,246 @@ describe("Carousel", () => {
2424
};
2525
});
2626

27-
/* TODO: enzyme or react-native-testing-lib still not allowing mocking states with hooks
28-
Thus we can't snapshot test the actual carousel
29-
*/
27+
// Carousel.tsx renders nothing but a loading indicator until it has measured its own width via onLayout, so
28+
// every test beyond "renders loading" needs to fire that event first, same as Gallery's tests do.
29+
const fireContainerLayout = (
30+
carousel: ReturnType<typeof render>,
31+
layout: { width: number; height: number }
32+
): void => {
33+
fireEvent(carousel.getByTestId(defaultProps.name), "layout", {
34+
nativeEvent: { layout: { x: 0, y: 0, ...layout } }
35+
});
36+
};
3037

3138
it("renders loading", () => {
3239
expect(render(<Carousel {...defaultProps} />).toJSON()).toMatchSnapshot();
3340
});
3441

35-
// it("renders without pagination", () => {
36-
// expect(render(<Carousel {...defaultProps} showPagination={false} />).toJSON()).toMatchSnapshot();
37-
// });
38-
//
39-
// it("renders full width", () => {
40-
// expect(render(<Carousel {...defaultProps} layout={"fullWidth"} />).toJSON()).toMatchSnapshot();
41-
// });
42-
//
43-
// it("renders numbered pagination if item size is more than 5", () => {
44-
// const props = {
45-
// ...defaultProps,
46-
// contentSource: listValueBuilder.withAmountOfItems(6)
47-
// };
48-
// expect(render(<Carousel {...props} />).toJSON()).toMatchSnapshot();
49-
// });
42+
it("renders cards with dot pagination once it has measured its width", () => {
43+
const carousel = render(<Carousel {...defaultProps} />);
44+
fireContainerLayout(carousel, { width: 300, height: 150 });
45+
46+
expect(carousel.getByTestId("carousel$carousel")).toBeDefined();
47+
expect(carousel.getAllByRole("button")).toHaveLength(2);
48+
expect(carousel.getByTestId("carousel$pagination")).toBeDefined();
49+
});
50+
51+
it("renders without pagination", () => {
52+
const carousel = render(<Carousel {...defaultProps} showPagination={false} />);
53+
fireContainerLayout(carousel, { width: 300, height: 150 });
54+
55+
expect(carousel.queryByTestId("carousel$pagination")).toBeNull();
56+
});
57+
58+
it("renders full width", () => {
59+
const carousel = render(<Carousel {...defaultProps} layout="fullWidth" />);
60+
fireContainerLayout(carousel, { width: 300, height: 150 });
61+
62+
expect(carousel.toJSON()).toMatchSnapshot();
63+
});
64+
65+
it("renders numbered pagination if item count is more than 5", () => {
66+
const carousel = render(<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />);
67+
fireContainerLayout(carousel, { width: 300, height: 150 });
68+
69+
// The "N/M" text itself is hidden from screen readers (see the accessibility describe block below); the
70+
// count still has to be shown somewhere, so it's asserted via the pagination container's own a11y label.
71+
const pagination = carousel.getByTestId("carousel$pagination");
72+
expect(pagination.props.accessibilityLabel).toBe("Slide 1 of 6");
73+
expect(carousel.queryAllByRole("button")).toHaveLength(0);
74+
});
75+
76+
it("tracks the active slide once the list settles on it", () => {
77+
const carousel = render(<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />);
78+
fireContainerLayout(carousel, { width: 300, height: 150 });
79+
// Card layout sizes each item to 70% of the measured width.
80+
const itemWidth = 300 * 0.7;
81+
82+
fireEvent(carousel.getByTestId("carousel$carousel"), "momentumScrollEnd", {
83+
nativeEvent: { contentOffset: { x: itemWidth } }
84+
});
85+
86+
expect(carousel.getByTestId("carousel$pagination").props.accessibilityLabel).toBe("Slide 2 of 6");
87+
});
88+
89+
it("scrolls to the slide behind a tapped dot", () => {
90+
const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined);
91+
const carousel = render(<Carousel {...defaultProps} />);
92+
fireContainerLayout(carousel, { width: 300, height: 150 });
93+
94+
fireEvent.press(carousel.getAllByRole("button")[1]);
95+
96+
expect(scrollToIndex).toHaveBeenCalledWith({ index: 1, animated: true });
97+
scrollToIndex.mockRestore();
98+
});
99+
100+
describe("short drags", () => {
101+
// Card layout sizes each item to 70% of the measured width.
102+
const itemWidth = 300 * 0.7;
103+
104+
const drag = (carousel: ReturnType<typeof render>, from: number, to: number): void => {
105+
const list = carousel.getByTestId("carousel$carousel");
106+
fireEvent(list, "scrollBeginDrag", { nativeEvent: { contentOffset: { x: from } } });
107+
fireEvent(list, "scrollEndDrag", { nativeEvent: { contentOffset: { x: to } } });
108+
};
109+
110+
it("steps to the neighbouring slide once a drag passes the threshold, even short of half a card", () => {
111+
const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined);
112+
const carousel = render(
113+
<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />
114+
);
115+
fireContainerLayout(carousel, { width: 300, height: 150 });
116+
117+
drag(carousel, 0, 40);
118+
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, animated: true });
119+
expect(carousel.getByTestId("carousel$pagination").props.accessibilityLabel).toBe("Slide 2 of 6");
120+
121+
drag(carousel, itemWidth, itemWidth - 40);
122+
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 0, animated: true });
123+
expect(carousel.getByTestId("carousel$pagination").props.accessibilityLabel).toBe("Slide 1 of 6");
124+
scrollToIndex.mockRestore();
125+
});
126+
127+
it("leaves the slide alone for a drag under the threshold or past either end", () => {
128+
const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined);
129+
const carousel = render(<Carousel {...defaultProps} />);
130+
fireContainerLayout(carousel, { width: 300, height: 150 });
131+
132+
drag(carousel, 0, 10);
133+
drag(carousel, 0, -40);
134+
drag(carousel, itemWidth, itemWidth + 40);
135+
136+
expect(scrollToIndex).not.toHaveBeenCalled();
137+
scrollToIndex.mockRestore();
138+
});
139+
});
140+
141+
describe("accessibility", () => {
142+
it("labels each dot by its slide number, with the active one marked selected", () => {
143+
const carousel = render(<Carousel {...defaultProps} />);
144+
fireContainerLayout(carousel, { width: 300, height: 150 });
145+
146+
const dots = carousel.getAllByRole("button");
147+
expect(dots.map(dot => dot.props.accessibilityLabel)).toEqual(["Go to slide 1", "Go to slide 2"]);
148+
expect(dots[0].props.accessibilityState).toEqual({ selected: true });
149+
expect(dots[1].props.accessibilityState).toEqual({ selected: false });
150+
});
151+
152+
it("hides every slide but the active one from screen readers", () => {
153+
const carousel = render(<Carousel {...defaultProps} />);
154+
fireContainerLayout(carousel, { width: 300, height: 150 });
155+
156+
// Hidden elements are excluded by default - as a screen reader would skip them too - so finding the
157+
// (intentionally) hidden one back has to opt in.
158+
const active = carousel.getByTestId("carousel$content$0");
159+
const inactive = carousel.getByTestId("carousel$content$1", { includeHiddenElements: true });
160+
expect(active.props.accessibilityElementsHidden).toBe(false);
161+
expect(active.props.importantForAccessibility).toBe("auto");
162+
expect(inactive.props.accessibilityElementsHidden).toBe(true);
163+
expect(inactive.props.importantForAccessibility).toBe("no-hide-descendants");
164+
});
165+
166+
it("moves the hidden-from-screen-readers slide once the active one changes", () => {
167+
const carousel = render(
168+
<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />
169+
);
170+
fireContainerLayout(carousel, { width: 300, height: 150 });
171+
const itemWidth = 300 * 0.7;
172+
173+
fireEvent(carousel.getByTestId("carousel$carousel"), "momentumScrollEnd", {
174+
nativeEvent: { contentOffset: { x: itemWidth } }
175+
});
176+
177+
expect(
178+
carousel.getByTestId("carousel$content$0", { includeHiddenElements: true }).props
179+
.accessibilityElementsHidden
180+
).toBe(true);
181+
expect(carousel.getByTestId("carousel$content$1").props.accessibilityElementsHidden).toBe(false);
182+
});
183+
184+
it("keeps the last slide reachable when the datasource shrinks under the active one", () => {
185+
const carousel = render(
186+
<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />
187+
);
188+
fireContainerLayout(carousel, { width: 300, height: 150 });
189+
const itemWidth = 300 * 0.7;
190+
191+
fireEvent(carousel.getByTestId("carousel$carousel"), "momentumScrollEnd", {
192+
nativeEvent: { contentOffset: { x: 4 * itemWidth } }
193+
});
194+
carousel.rerender(<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(2)} />);
195+
196+
expect(carousel.getByTestId("carousel$content$1").props.accessibilityElementsHidden).toBe(false);
197+
const dots = carousel.getAllByRole("button");
198+
expect(dots[1].props.accessibilityState).toEqual({ selected: true });
199+
});
200+
201+
it("steps forward and backward on a TalkBack/VoiceOver increment or decrement action", () => {
202+
const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined);
203+
const carousel = render(
204+
<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />
205+
);
206+
fireContainerLayout(carousel, { width: 300, height: 150 });
207+
const itemWidth = 300 * 0.7;
208+
const list = carousel.getByTestId("carousel$carousel");
209+
// scrollToIndex is mocked, so nothing actually moves the list; each step settles it manually at
210+
// the index the previous action asked for, same as a real scroll settling before the next gesture.
211+
const step = (actionName: "increment" | "decrement"): void => {
212+
const callsBefore = scrollToIndex.mock.calls.length;
213+
fireEvent(carousel.getByTestId("carousel$pagination"), "accessibilityAction", {
214+
nativeEvent: { actionName }
215+
});
216+
// Already at that end: the action was a no-op, so there is nothing to settle.
217+
if (scrollToIndex.mock.calls.length === callsBefore) {
218+
return;
219+
}
220+
const index = scrollToIndex.mock.lastCall![0].index as number;
221+
fireEvent(list, "momentumScrollEnd", { nativeEvent: { contentOffset: { x: index * itemWidth } } });
222+
};
223+
224+
step("increment");
225+
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, animated: true });
226+
227+
step("decrement");
228+
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 0, animated: true });
229+
230+
scrollToIndex.mockRestore();
231+
});
232+
233+
it("does not step past either end of the carousel", () => {
234+
const scrollToIndex = jest.spyOn(FlatList.prototype, "scrollToIndex").mockImplementation(() => undefined);
235+
const carousel = render(
236+
<Carousel {...defaultProps} contentSource={listValueBuilder.withAmountOfItems(6)} />
237+
);
238+
fireContainerLayout(carousel, { width: 300, height: 150 });
239+
const itemWidth = 300 * 0.7;
240+
const list = carousel.getByTestId("carousel$carousel");
241+
const step = (actionName: "increment" | "decrement"): void => {
242+
const callsBefore = scrollToIndex.mock.calls.length;
243+
fireEvent(carousel.getByTestId("carousel$pagination"), "accessibilityAction", {
244+
nativeEvent: { actionName }
245+
});
246+
// Already at that end: the action was a no-op, so there is nothing to settle.
247+
if (scrollToIndex.mock.calls.length === callsBefore) {
248+
return;
249+
}
250+
const index = scrollToIndex.mock.lastCall![0].index as number;
251+
fireEvent(list, "momentumScrollEnd", { nativeEvent: { contentOffset: { x: index * itemWidth } } });
252+
};
253+
254+
// Already on the first slide: decrementing has nowhere to go, so the no-op scroll is skipped.
255+
step("decrement");
256+
expect(scrollToIndex).not.toHaveBeenCalled();
257+
258+
// Incrementing past the last slide (index 5 of 6) stays put rather than running off the end.
259+
for (let i = 0; i < 6; i++) {
260+
step("increment");
261+
}
262+
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 5, animated: true });
263+
264+
scrollToIndex.mockRestore();
265+
});
266+
});
50267

51268
it("preserves expected style key set", () => {
52269
const keys = Object.keys(defaultCarouselStyle).sort();

0 commit comments

Comments
 (0)