Skip to content

Commit baade3f

Browse files
authored
[MOO-2435]: Build MxOverlay Primitive (#684)
2 parents 0c9724e + 1d65906 commit baade3f

20 files changed

Lines changed: 4317 additions & 2074 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- The enlarged image is now presented through the shared MxOverlay component. Screen readers now treat it as a modal, move focus to the enlarged image when it opens and return focus to the image when it closes.
12+
913
## [3.1.2] - 2026-7-17
1014

1115
### Fixed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "image-native",
33
"widgetName": "Image",
4-
"version": "3.1.2",
4+
"version": "3.2.0",
55
"description": "Display an image and enlarge it on click",
66
"copyright": "© Mendix Technology BV 2022. All rights reserved.",
77
"license": "Apache-2.0",

‎packages/pluggableWidgets/image-native/src/components/ImageComponents.tsx‎

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
1-
import { Dispatch, SetStateAction, FunctionComponent, useState, Fragment, useCallback, useMemo } from "react";
2-
import { Modal, Pressable, LayoutChangeEvent, View, ImageStyle, useWindowDimensions } from "react-native";
1+
import {
2+
Dispatch,
3+
SetStateAction,
4+
FunctionComponent,
5+
RefObject,
6+
useState,
7+
Fragment,
8+
useCallback,
9+
useMemo,
10+
useRef
11+
} from "react";
12+
import { Pressable, LayoutChangeEvent, View, ImageStyle, useWindowDimensions } from "react-native";
313
import { SvgUri } from "react-native-svg";
414
import { extractStyles } from "@mendix/pluggable-widgets-tools";
515
import { OnClickTypeEnum } from "../../typings/ImageProps";
616
import { CustomImageObjectProps, onLayoutSetDimensions, getScaledDimensions } from "../utils/imageUtils";
717
import { DimensionsType, ImageIconSVG } from "./ImageIconSVG";
818
import { DefaultImageStyle } from "../ui/Styles.js";
919
import { DynamicValue } from "mendix";
20+
import { MxOverlay } from "@mendix/piw-native-utils-internal";
21+
22+
// The themed backdrop style is applied on the pressable inside the overlay, so the overlay backdrop stays transparent
23+
const transparentBackdrop = { backgroundColor: "transparent" };
1024

1125
interface AccessibilityProps {
1226
accessible: boolean;
@@ -31,11 +45,13 @@ export interface ImageViewerProps extends ImageViewerBaseProps {
3145
}
3246
interface ImageSmallProps extends ImageViewerBaseProps {
3347
onClick: () => Dispatch<SetStateAction<boolean>> | void;
48+
pressableRef?: RefObject<View | null>;
3449
styles: DefaultImageStyle;
3550
}
3651
interface ImageEnlargedProps extends ImageViewerBaseProps {
3752
visible: boolean;
3853
setEnlarged: Dispatch<SetStateAction<boolean>>;
54+
returnFocusRef?: RefObject<View | null>;
3955
styles: DefaultImageStyle;
4056
}
4157
interface GetImageDimensionsComponentProps extends ImageViewerBaseProps {
@@ -44,6 +60,7 @@ interface GetImageDimensionsComponentProps extends ImageViewerBaseProps {
4460

4561
export const ImageViewer: FunctionComponent<ImageViewerProps> = props => {
4662
const [enlarged, setEnlarged] = useState(false);
63+
const triggerRef = useRef<View>(null);
4764
const {
4865
accessible,
4966
screenReaderCaption,
@@ -73,6 +90,7 @@ export const ImageViewer: FunctionComponent<ImageViewerProps> = props => {
7390
iconSize={iconSize}
7491
initialDimensions={initialDimensions}
7592
onClick={onClickType === "enlarge" ? () => setEnlarged(true) : onClick}
93+
pressableRef={triggerRef}
7694
styles={styles}
7795
/>
7896
<GetImageDimensionsComponent
@@ -91,6 +109,7 @@ export const ImageViewer: FunctionComponent<ImageViewerProps> = props => {
91109
screenReaderHint={screenReaderHint}
92110
visible={enlarged}
93111
setEnlarged={setEnlarged}
112+
returnFocusRef={triggerRef}
94113
source={source}
95114
initialDimensions={initialDimensions}
96115
styles={styles}
@@ -156,6 +175,7 @@ export const ImageSmall: FunctionComponent<ImageSmallProps> = props => {
156175
customHeight,
157176
iconSize,
158177
onClick,
178+
pressableRef,
159179
styles,
160180
name,
161181
accessible,
@@ -181,6 +201,7 @@ export const ImageSmall: FunctionComponent<ImageSmallProps> = props => {
181201

182202
return source.type === "icon" || (initialDimensions?.width && initialDimensions?.height) ? (
183203
<Pressable
204+
ref={pressableRef}
184205
accessible={accessible}
185206
accessibilityLabel={screenReaderCaption?.value}
186207
accessibilityHint={screenReaderHint?.value}
@@ -218,26 +239,28 @@ export const ImageEnlarged: FunctionComponent<ImageEnlargedProps> = props => {
218239
name,
219240
accessible,
220241
screenReaderCaption,
221-
screenReaderHint
242+
screenReaderHint,
243+
returnFocusRef
222244
} = props;
245+
const contentRef = useRef<View>(null);
223246
const [svgProps] = extractStyles(styles.image as ImageStyle, ["width", "height"]);
224247

225248
const { width, height } = useWindowDimensions();
226249
const dimensions = useMemo(() => {
227250
return getScaledDimensions(width, height, initialDimensions);
228251
}, [width, height, initialDimensions]);
229252

230-
return visible && initialDimensions?.width && initialDimensions?.height ? (
231-
<Modal
232-
accessible={accessible}
233-
accessibilityLabel={screenReaderCaption?.value}
234-
accessibilityHint={screenReaderHint?.value}
235-
accessibilityRole="image"
253+
return initialDimensions?.width && initialDimensions?.height ? (
254+
<MxOverlay
236255
visible={visible}
237256
onRequestClose={() => setEnlarged(false)}
238-
onDismiss={() => setEnlarged(false)}
239-
transparent
240-
animationType="fade"
257+
placement="fullscreen"
258+
testID={`${name}$ImageEnlargedOverlay`}
259+
backdropStyle={transparentBackdrop}
260+
respectSafeArea={false}
261+
statusBarTranslucent={false}
262+
initialFocusRef={contentRef}
263+
returnFocusRef={returnFocusRef}
241264
supportedOrientations={[
242265
"portrait",
243266
"portrait-upside-down",
@@ -247,6 +270,11 @@ export const ImageEnlarged: FunctionComponent<ImageEnlargedProps> = props => {
247270
]}
248271
>
249272
<Pressable
273+
ref={contentRef}
274+
accessible={accessible}
275+
accessibilityLabel={screenReaderCaption?.value}
276+
accessibilityHint={screenReaderHint?.value}
277+
accessibilityRole="image"
250278
testID={`${name}$ImageEnlargedPressable`}
251279
onPress={() => setEnlarged(false)}
252280
style={styles.backdrop}
@@ -268,6 +296,6 @@ export const ImageEnlarged: FunctionComponent<ImageEnlargedProps> = props => {
268296
/>
269297
</View>
270298
</Pressable>
271-
</Modal>
299+
</MxOverlay>
272300
) : null;
273301
};

‎packages/pluggableWidgets/image-native/src/components/__tests__/Image.spec.tsx‎

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Text } from "react-native";
1+
import { AccessibilityInfo, Modal, Platform, Text } from "react-native";
22
import { fireEvent, render, act, RenderAPI } from "@testing-library/react-native";
33
import { NativeIcon, NativeImage } from "mendix";
44
import { Style } from "@mendix/piw-native-utils-internal";
@@ -29,6 +29,17 @@ async function fireLayout(image: RenderAPI, name: string, suffix: string): Promi
2929
fireEvent(await findByImageTestId(image, name, suffix), "layout", onLayoutEventData);
3030
}
3131

32+
const mockFindNodeHandle = jest.fn();
33+
jest.mock("react-native/Libraries/ReactNative/RendererProxy", () => {
34+
const actual = jest.requireActual("react-native/Libraries/ReactNative/RendererProxy");
35+
return {
36+
...actual,
37+
// Only intercepted while a test installs an implementation, so other tests keep the real behaviour
38+
findNodeHandle: (node: unknown) =>
39+
mockFindNodeHandle.getMockImplementation() ? mockFindNodeHandle(node) : actual.findNodeHandle(node)
40+
};
41+
});
42+
3243
jest.mock("../../utils/imageUtils", () => {
3344
const actual = jest.requireActual("../../utils/imageUtils") as typeof import("../../utils/imageUtils");
3445

@@ -668,4 +679,146 @@ describe("Widget", () => {
668679
});
669680
});
670681
});
682+
683+
describe("Enlarged overlay", () => {
684+
const originalOS = Platform.OS;
685+
let setFocus: jest.SpyInstance;
686+
let nodeOf: (tag: number) => { props: { testID?: string } };
687+
688+
const openEnlarged = async (props: Partial<ImageProps<Style>> = {}): Promise<RenderAPI> => {
689+
const image = render(<Image {...imageProps} onClickType={"enlarge"} {...props} />);
690+
await flushMicrotasksQueue();
691+
fireEvent.press(await findByImageTestId(image, imageProps.name, "ImageSmallPressable"));
692+
await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");
693+
return image;
694+
};
695+
696+
// Closing plays the exit animation, so fake timers are switched on right before closing to run it to the end
697+
const closeWithFakeTimers = (close: () => void): void => {
698+
jest.useFakeTimers();
699+
act(() => close());
700+
act(() => {
701+
jest.advanceTimersByTime(500);
702+
});
703+
};
704+
705+
beforeEach(() => {
706+
setFocus = jest.spyOn(AccessibilityInfo, "setAccessibilityFocus").mockImplementation(() => undefined);
707+
// Numeric tags (as on a device) that can be mapped back, to see which element got focus
708+
const tags = new Map<unknown, number>();
709+
const nodes = new Map<number, { props: { testID?: string } }>();
710+
mockFindNodeHandle.mockReset().mockImplementation((node: { props: { testID?: string } } | null) => {
711+
if (!node) {
712+
return null;
713+
}
714+
if (!tags.has(node)) {
715+
tags.set(node, tags.size + 1);
716+
nodes.set(tags.size, node);
717+
}
718+
return tags.get(node);
719+
});
720+
nodeOf = tag => nodes.get(tag)!;
721+
});
722+
723+
afterEach(() => {
724+
jest.useRealTimers();
725+
(Platform as { OS: string }).OS = originalOS;
726+
mockFindNodeHandle.mockReset();
727+
jest.restoreAllMocks();
728+
});
729+
730+
it("is not shown before the image is pressed", async () => {
731+
const image = render(<Image {...imageProps} onClickType={"enlarge"} />);
732+
await flushMicrotasksQueue();
733+
734+
expect(image.UNSAFE_queryByType(Modal)).toBeNull();
735+
});
736+
737+
it("shows a transparent modal that keeps every orientation", async () => {
738+
const image = await openEnlarged();
739+
const { props } = image.UNSAFE_getByType(Modal);
740+
741+
expect(props.transparent).toBe(true);
742+
expect(props.statusBarTranslucent).toBe(false);
743+
expect(props.supportedOrientations).toEqual([
744+
"portrait",
745+
"portrait-upside-down",
746+
"landscape",
747+
"landscape-left",
748+
"landscape-right"
749+
]);
750+
});
751+
752+
it("closes when the enlarged image is pressed", async () => {
753+
const image = await openEnlarged();
754+
const enlarged = await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");
755+
756+
closeWithFakeTimers(() => fireEvent.press(enlarged));
757+
758+
expect(image.UNSAFE_queryByType(Modal)).toBeNull();
759+
});
760+
761+
it("closes on the Android back button", async () => {
762+
const image = await openEnlarged();
763+
764+
closeWithFakeTimers(() => image.UNSAFE_getByType(Modal).props.onRequestClose());
765+
766+
expect(image.UNSAFE_queryByType(Modal)).toBeNull();
767+
});
768+
769+
it("exposes the screen reader caption, hint and role on the enlarged image", async () => {
770+
const image = await openEnlarged({
771+
screenReaderCaption: dynamicValue("A caption"),
772+
screenReaderHint: dynamicValue("A hint")
773+
});
774+
const { props } = await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");
775+
776+
expect(props.accessibilityLabel).toBe("A caption");
777+
expect(props.accessibilityHint).toBe("A hint");
778+
expect(props.accessibilityRole).toBe("image");
779+
});
780+
781+
it("marks the overlay as modal on iOS", async () => {
782+
(Platform as { OS: string }).OS = "ios";
783+
const image = await openEnlarged();
784+
const { props } = await findByImageTestId(image, imageProps.name, "ImageEnlargedOverlay-content");
785+
786+
expect(props.accessibilityViewIsModal).toBe(true);
787+
expect(props.importantForAccessibility).toBeUndefined();
788+
});
789+
790+
it("marks the overlay as important for accessibility on Android", async () => {
791+
(Platform as { OS: string }).OS = "android";
792+
const image = await openEnlarged();
793+
const { props } = await findByImageTestId(image, imageProps.name, "ImageEnlargedOverlay-content");
794+
795+
expect(props.importantForAccessibility).toBe("yes");
796+
expect(props.accessibilityViewIsModal).toBeUndefined();
797+
});
798+
799+
it("moves accessibility focus to the enlarged image when it opens", async () => {
800+
const image = await openEnlarged();
801+
802+
fireEvent(
803+
await findByImageTestId(image, imageProps.name, "ImageEnlargedOverlay-content"),
804+
"layout",
805+
onLayoutEventData
806+
);
807+
808+
expect(setFocus).toHaveBeenCalledTimes(1);
809+
expect(nodeOf(setFocus.mock.calls[0][0]).props.testID).toBe(`${imageProps.name}$ImageEnlargedPressable`);
810+
});
811+
812+
it("returns accessibility focus to the small image once it has closed", async () => {
813+
const image = await openEnlarged();
814+
const enlarged = await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");
815+
setFocus.mockClear();
816+
817+
closeWithFakeTimers(() => fireEvent.press(enlarged));
818+
819+
expect(image.UNSAFE_queryByType(Modal)).toBeNull();
820+
expect(setFocus).toHaveBeenCalledTimes(1);
821+
expect(nodeOf(setFocus.mock.calls[0][0]).props.testID).toBe(`${imageProps.name}$ImageSmallPressable`);
822+
});
823+
});
671824
});

0 commit comments

Comments
 (0)