Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/image-native/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Changed

- 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.

### Fixed

- We fixed an issue that caused images from entities to not render on Android in Online Synchronization mode
Expand Down
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/image-native/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "image-native",
"widgetName": "Image",
"version": "3.1.2",
"version": "3.2.0",
"description": "Display an image and enlarge it on click",
"copyright": "© Mendix Technology BV 2022. All rights reserved.",
"license": "Apache-2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { Dispatch, SetStateAction, FunctionComponent, useState, Fragment, useCallback, useMemo } from "react";
import { Modal, Pressable, LayoutChangeEvent, View, ImageStyle, useWindowDimensions } from "react-native";
import {
Dispatch,
SetStateAction,
FunctionComponent,
RefObject,
useState,
Fragment,
useCallback,
useMemo,
useRef
} from "react";
import { Pressable, LayoutChangeEvent, View, ImageStyle, useWindowDimensions } from "react-native";
import { SvgUri } from "react-native-svg";
import { extractStyles } from "@mendix/pluggable-widgets-tools";
import { OnClickTypeEnum } from "../../typings/ImageProps";
import { CustomImageObjectProps, onLayoutSetDimensions, getScaledDimensions } from "../utils/imageUtils";
import { DimensionsType, ImageIconSVG } from "./ImageIconSVG";
import { DefaultImageStyle } from "../ui/Styles.js";
import { DynamicValue } from "mendix";
import { MxOverlay } from "@mendix/piw-native-utils-internal";

// The themed backdrop style is applied on the pressable inside the overlay, so the overlay backdrop stays transparent
const transparentBackdrop = { backgroundColor: "transparent" };

interface AccessibilityProps {
accessible: boolean;
Expand All @@ -31,11 +45,13 @@ export interface ImageViewerProps extends ImageViewerBaseProps {
}
interface ImageSmallProps extends ImageViewerBaseProps {
onClick: () => Dispatch<SetStateAction<boolean>> | void;
pressableRef?: RefObject<View | null>;
styles: DefaultImageStyle;
}
interface ImageEnlargedProps extends ImageViewerBaseProps {
visible: boolean;
setEnlarged: Dispatch<SetStateAction<boolean>>;
returnFocusRef?: RefObject<View | null>;
styles: DefaultImageStyle;
}
interface GetImageDimensionsComponentProps extends ImageViewerBaseProps {
Expand All @@ -44,6 +60,7 @@ interface GetImageDimensionsComponentProps extends ImageViewerBaseProps {

export const ImageViewer: FunctionComponent<ImageViewerProps> = props => {
const [enlarged, setEnlarged] = useState(false);
const triggerRef = useRef<View>(null);
const {
accessible,
screenReaderCaption,
Expand Down Expand Up @@ -73,6 +90,7 @@ export const ImageViewer: FunctionComponent<ImageViewerProps> = props => {
iconSize={iconSize}
initialDimensions={initialDimensions}
onClick={onClickType === "enlarge" ? () => setEnlarged(true) : onClick}
pressableRef={triggerRef}
styles={styles}
/>
<GetImageDimensionsComponent
Expand All @@ -91,6 +109,7 @@ export const ImageViewer: FunctionComponent<ImageViewerProps> = props => {
screenReaderHint={screenReaderHint}
visible={enlarged}
setEnlarged={setEnlarged}
returnFocusRef={triggerRef}
source={source}
initialDimensions={initialDimensions}
styles={styles}
Expand Down Expand Up @@ -156,6 +175,7 @@ export const ImageSmall: FunctionComponent<ImageSmallProps> = props => {
customHeight,
iconSize,
onClick,
pressableRef,
styles,
name,
accessible,
Expand All @@ -181,6 +201,7 @@ export const ImageSmall: FunctionComponent<ImageSmallProps> = props => {

return source.type === "icon" || (initialDimensions?.width && initialDimensions?.height) ? (
<Pressable
ref={pressableRef}
accessible={accessible}
accessibilityLabel={screenReaderCaption?.value}
accessibilityHint={screenReaderHint?.value}
Expand Down Expand Up @@ -218,26 +239,28 @@ export const ImageEnlarged: FunctionComponent<ImageEnlargedProps> = props => {
name,
accessible,
screenReaderCaption,
screenReaderHint
screenReaderHint,
returnFocusRef
} = props;
const contentRef = useRef<View>(null);
const [svgProps] = extractStyles(styles.image as ImageStyle, ["width", "height"]);

const { width, height } = useWindowDimensions();
const dimensions = useMemo(() => {
return getScaledDimensions(width, height, initialDimensions);
}, [width, height, initialDimensions]);

return visible && initialDimensions?.width && initialDimensions?.height ? (
<Modal
accessible={accessible}
accessibilityLabel={screenReaderCaption?.value}
accessibilityHint={screenReaderHint?.value}
accessibilityRole="image"
return initialDimensions?.width && initialDimensions?.height ? (
<MxOverlay
visible={visible}
onRequestClose={() => setEnlarged(false)}
onDismiss={() => setEnlarged(false)}
transparent
animationType="fade"
placement="fullscreen"
testID={`${name}$ImageEnlargedOverlay`}
backdropStyle={transparentBackdrop}
respectSafeArea={false}
statusBarTranslucent={false}
initialFocusRef={contentRef}
returnFocusRef={returnFocusRef}
supportedOrientations={[
"portrait",
"portrait-upside-down",
Expand All @@ -247,6 +270,11 @@ export const ImageEnlarged: FunctionComponent<ImageEnlargedProps> = props => {
]}
>
<Pressable
ref={contentRef}
accessible={accessible}
accessibilityLabel={screenReaderCaption?.value}
accessibilityHint={screenReaderHint?.value}
accessibilityRole="image"
testID={`${name}$ImageEnlargedPressable`}
onPress={() => setEnlarged(false)}
style={styles.backdrop}
Expand All @@ -268,6 +296,6 @@ export const ImageEnlarged: FunctionComponent<ImageEnlargedProps> = props => {
/>
</View>
</Pressable>
</Modal>
</MxOverlay>
) : null;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Text } from "react-native";
import { AccessibilityInfo, Modal, Platform, Text } from "react-native";
import { fireEvent, render, act, RenderAPI } from "@testing-library/react-native";
import { NativeIcon, NativeImage } from "mendix";
import { Style } from "@mendix/piw-native-utils-internal";
Expand Down Expand Up @@ -29,6 +29,17 @@ async function fireLayout(image: RenderAPI, name: string, suffix: string): Promi
fireEvent(await findByImageTestId(image, name, suffix), "layout", onLayoutEventData);
}

const mockFindNodeHandle = jest.fn();
jest.mock("react-native/Libraries/ReactNative/RendererProxy", () => {
const actual = jest.requireActual("react-native/Libraries/ReactNative/RendererProxy");
return {
...actual,
// Only intercepted while a test installs an implementation, so other tests keep the real behaviour
findNodeHandle: (node: unknown) =>
mockFindNodeHandle.getMockImplementation() ? mockFindNodeHandle(node) : actual.findNodeHandle(node)
};
});

jest.mock("../../utils/imageUtils", () => {
const actual = jest.requireActual("../../utils/imageUtils") as typeof import("../../utils/imageUtils");

Expand Down Expand Up @@ -667,4 +678,146 @@ describe("Widget", () => {
});
});
});

describe("Enlarged overlay", () => {
const originalOS = Platform.OS;
let setFocus: jest.SpyInstance;
let nodeOf: (tag: number) => { props: { testID?: string } };

const openEnlarged = async (props: Partial<ImageProps<Style>> = {}): Promise<RenderAPI> => {
const image = render(<Image {...imageProps} onClickType={"enlarge"} {...props} />);
await flushMicrotasksQueue();
fireEvent.press(await findByImageTestId(image, imageProps.name, "ImageSmallPressable"));
await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");
return image;
};

// Closing plays the exit animation, so fake timers are switched on right before closing to run it to the end
const closeWithFakeTimers = (close: () => void): void => {
jest.useFakeTimers();
act(() => close());
act(() => {
jest.advanceTimersByTime(500);
});
};

beforeEach(() => {
setFocus = jest.spyOn(AccessibilityInfo, "setAccessibilityFocus").mockImplementation(() => undefined);
// Numeric tags (as on a device) that can be mapped back, to see which element got focus
const tags = new Map<unknown, number>();
const nodes = new Map<number, { props: { testID?: string } }>();
mockFindNodeHandle.mockReset().mockImplementation((node: { props: { testID?: string } } | null) => {
if (!node) {
return null;
}
if (!tags.has(node)) {
tags.set(node, tags.size + 1);
nodes.set(tags.size, node);
}
return tags.get(node);
});
nodeOf = tag => nodes.get(tag)!;
});

afterEach(() => {
jest.useRealTimers();
(Platform as { OS: string }).OS = originalOS;
mockFindNodeHandle.mockReset();
jest.restoreAllMocks();
});

it("is not shown before the image is pressed", async () => {
const image = render(<Image {...imageProps} onClickType={"enlarge"} />);
await flushMicrotasksQueue();

expect(image.UNSAFE_queryByType(Modal)).toBeNull();
});

it("shows a transparent modal that keeps every orientation", async () => {
const image = await openEnlarged();
const { props } = image.UNSAFE_getByType(Modal);

expect(props.transparent).toBe(true);
expect(props.statusBarTranslucent).toBe(false);
expect(props.supportedOrientations).toEqual([
"portrait",
"portrait-upside-down",
"landscape",
"landscape-left",
"landscape-right"
]);
});

it("closes when the enlarged image is pressed", async () => {
const image = await openEnlarged();
const enlarged = await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");

closeWithFakeTimers(() => fireEvent.press(enlarged));

expect(image.UNSAFE_queryByType(Modal)).toBeNull();
});

it("closes on the Android back button", async () => {
const image = await openEnlarged();

closeWithFakeTimers(() => image.UNSAFE_getByType(Modal).props.onRequestClose());

expect(image.UNSAFE_queryByType(Modal)).toBeNull();
});

it("exposes the screen reader caption, hint and role on the enlarged image", async () => {
const image = await openEnlarged({
screenReaderCaption: dynamicValue("A caption"),
screenReaderHint: dynamicValue("A hint")
});
const { props } = await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");

expect(props.accessibilityLabel).toBe("A caption");
expect(props.accessibilityHint).toBe("A hint");
expect(props.accessibilityRole).toBe("image");
});

it("marks the overlay as modal on iOS", async () => {
(Platform as { OS: string }).OS = "ios";
const image = await openEnlarged();
const { props } = await findByImageTestId(image, imageProps.name, "ImageEnlargedOverlay-content");

expect(props.accessibilityViewIsModal).toBe(true);
expect(props.importantForAccessibility).toBeUndefined();
});

it("marks the overlay as important for accessibility on Android", async () => {
(Platform as { OS: string }).OS = "android";
const image = await openEnlarged();
const { props } = await findByImageTestId(image, imageProps.name, "ImageEnlargedOverlay-content");

expect(props.importantForAccessibility).toBe("yes");
expect(props.accessibilityViewIsModal).toBeUndefined();
});

it("moves accessibility focus to the enlarged image when it opens", async () => {
const image = await openEnlarged();

fireEvent(
await findByImageTestId(image, imageProps.name, "ImageEnlargedOverlay-content"),
"layout",
onLayoutEventData
);

expect(setFocus).toHaveBeenCalledTimes(1);
expect(nodeOf(setFocus.mock.calls[0][0]).props.testID).toBe(`${imageProps.name}$ImageEnlargedPressable`);
});

it("returns accessibility focus to the small image once it has closed", async () => {
const image = await openEnlarged();
const enlarged = await findByImageTestId(image, imageProps.name, "ImageEnlargedPressable");
setFocus.mockClear();

closeWithFakeTimers(() => fireEvent.press(enlarged));

expect(image.UNSAFE_queryByType(Modal)).toBeNull();
expect(setFocus).toHaveBeenCalledTimes(1);
expect(nodeOf(setFocus.mock.calls[0][0]).props.testID).toBe(`${imageProps.name}$ImageSmallPressable`);
});
});
});
Loading
Loading