Skip to content

Commit 577870a

Browse files
fix: intro screen
1 parent 34482c8 commit 577870a

3 files changed

Lines changed: 151 additions & 3 deletions

File tree

packages/pluggableWidgets/intro-screen-native/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
99
### Fixed
1010

1111
- We fixed an issue where reopening a page containing the intro screen would show the first slide instead of the slide referenced by the active slide attribute, and would report a slide change that the user did not make.
12+
- We fixed an issue where the initial slide was scrolled to with an animation, which briefly left the slide content unavailable to screen readers.
1213

1314
## [4.4.1] - 2026-6-10
1415

packages/pluggableWidgets/intro-screen-native/src/SwipeableContainer.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,12 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
8383
);
8484

8585
const goToSlide = useCallback(
86-
(pageNum: number) => {
86+
(pageNum: number, animated = true) => {
8787
setActiveIndex(pageNum);
8888
if (flashList && flashList.current) {
8989
flashList.current.scrollToOffset({
90-
offset: rtlSafeIndex(pageNum) * width
90+
offset: rtlSafeIndex(pageNum) * width,
91+
animated
9192
});
9293
}
9394
},
@@ -104,7 +105,10 @@ export const SwipeableContainer = (props: SwipeableContainerProps): ReactElement
104105
// can sit at offset 0 while activeIndex says otherwise.
105106
if (!hasAppliedInitialScroll.current) {
106107
hasAppliedInitialScroll.current = true;
107-
goToSlide(slide);
108+
// Jump without animation: activeIndex is applied immediately, and it drives which
109+
// slide is exposed to accessibility. An animated scroll would leave the exposed
110+
// slide off-screen until the animation lands.
111+
goToSlide(slide, false);
108112
} else if (slide !== activeIndex) {
109113
goToSlide(slide);
110114
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { render, fireEvent, act } from "@testing-library/react-native";
2+
import { View } from "react-native";
3+
import { EditableValueBuilder } from "@mendix/piw-utils-internal";
4+
import { EditableValue } from "mendix";
5+
import { Big } from "big.js";
6+
import { SwipeableContainer } from "../SwipeableContainer";
7+
import { defaultWelcomeScreenStyle } from "../ui/Styles";
8+
9+
jest.mock("react-native-device-info", () => ({
10+
hasNotch: jest.fn(),
11+
getDeviceId: jest.fn().mockReturnValue("")
12+
}));
13+
14+
const WIDTH = 400;
15+
16+
// Capture the scroll calls the widget issues, while leaving the real FlashList in place so
17+
// layout and momentum-scroll events still behave normally.
18+
const scrollCalls: Array<{ offset: number; animated?: boolean }> = [];
19+
20+
jest.mock("@shopify/flash-list", () => {
21+
const actual = jest.requireActual("@shopify/flash-list");
22+
// eslint-disable-next-line @typescript-eslint/no-var-requires
23+
const react = require("react");
24+
const Wrapped = react.forwardRef((props: any, ref: any) => {
25+
const inner = react.useRef(null);
26+
react.useImperativeHandle(ref, () => ({
27+
scrollToOffset: (params: any) => {
28+
(globalThis as any).__introScreenScrollCalls.push(params);
29+
(inner.current as any)?.scrollToOffset?.(params);
30+
}
31+
}));
32+
return react.createElement(actual.FlashList, { ...props, ref: inner });
33+
});
34+
return { ...actual, FlashList: Wrapped };
35+
});
36+
37+
(globalThis as any).__introScreenScrollCalls = scrollCalls;
38+
39+
type Setup = ReturnType<typeof render> & {
40+
activeSlide: EditableValue<Big>;
41+
onSlideChange: jest.Mock;
42+
scrollCalls: Array<{ offset: number; animated?: boolean }>;
43+
};
44+
45+
function setup(activeSlideValue: number): Setup {
46+
const activeSlide = new EditableValueBuilder<Big>().withValue(new Big(activeSlideValue)).build();
47+
const onSlideChange = jest.fn();
48+
const slides = [1, 2, 3].map(i => ({ name: `Page ${i}`, content: <View /> }));
49+
50+
scrollCalls.length = 0;
51+
52+
const utils = render(
53+
<SwipeableContainer
54+
testID="intro"
55+
slides={slides as any}
56+
onDone={jest.fn()}
57+
onSkip={jest.fn()}
58+
onSlideChange={onSlideChange}
59+
bottomButton={false}
60+
numberOfButtons={2}
61+
showSkipButton
62+
showNextButton
63+
showPreviousButton
64+
showDoneButton
65+
hidePagination={false}
66+
hideIndicatorLastSlide={false}
67+
styles={defaultWelcomeScreenStyle}
68+
activeSlide={activeSlide}
69+
/>
70+
);
71+
72+
// Simulate layout so `width` becomes known, exactly as on a real device.
73+
act(() => {
74+
fireEvent(utils.getByTestId("intro"), "layout", {
75+
nativeEvent: { layout: { width: WIDTH, height: 800 } }
76+
});
77+
});
78+
79+
return { ...utils, activeSlide, onSlideChange, scrollCalls };
80+
}
81+
82+
describe("SwipeableContainer remount behaviour", () => {
83+
it("positions the initial slide without animation", () => {
84+
const { scrollCalls } = setup(2);
85+
86+
expect(scrollCalls.length).toBeGreaterThan(0);
87+
expect(scrollCalls[0]).toEqual({ offset: WIDTH, animated: false });
88+
});
89+
90+
it("writes the next slide when Next is pressed after remounting on slide 2", () => {
91+
const { getByTestId, activeSlide } = setup(2);
92+
93+
fireEvent.press(getByTestId("intro$buttonNext"));
94+
95+
expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3));
96+
});
97+
98+
it("does not report a slide change for the programmatic initial scroll", () => {
99+
const { getByTestId, onSlideChange, activeSlide } = setup(2);
100+
101+
// The forced initial scroll settles and reports its resting offset.
102+
act(() => {
103+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
104+
nativeEvent: { contentOffset: { x: WIDTH } }
105+
});
106+
});
107+
108+
expect(onSlideChange).not.toHaveBeenCalled();
109+
expect(activeSlide.setValue).not.toHaveBeenCalled();
110+
});
111+
112+
it("still writes the next slide when a stale scroll settles before Next is pressed", () => {
113+
const { getByTestId, activeSlide } = setup(2);
114+
115+
act(() => {
116+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
117+
nativeEvent: { contentOffset: { x: WIDTH } }
118+
});
119+
});
120+
121+
fireEvent.press(getByTestId("intro$buttonNext"));
122+
123+
expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3));
124+
});
125+
126+
it("ignores a stale programmatic scroll that reports the pre-scroll offset", () => {
127+
const { getByTestId, activeSlide, onSlideChange } = setup(2);
128+
129+
// The list reports offset 0 — the position it held before the programmatic scroll to
130+
// slide 2 settled. Trusting it would desync activeIndex from what is on screen.
131+
act(() => {
132+
fireEvent(getByTestId("intro"), "momentumScrollEnd", {
133+
nativeEvent: { contentOffset: { x: 0 } }
134+
});
135+
});
136+
137+
expect(onSlideChange).not.toHaveBeenCalled();
138+
139+
fireEvent.press(getByTestId("intro$buttonNext"));
140+
141+
expect(activeSlide.setValue).toHaveBeenCalledWith(new Big(3));
142+
});
143+
});

0 commit comments

Comments
 (0)