Skip to content

Commit 9542e9f

Browse files
committed
fix: add mxoverlay to image native
1 parent 782d171 commit 9542e9f

6 files changed

Lines changed: 1080 additions & 455 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
### Fixed
1014

1115
- We fixed an issue that caused images from entities to not render on Android in Online Synchronization mode

‎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

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

0 commit comments

Comments
 (0)