diff --git a/packages/pluggableWidgets/image-native/CHANGELOG.md b/packages/pluggableWidgets/image-native/CHANGELOG.md index c58e080fc..69e105a13 100644 --- a/packages/pluggableWidgets/image-native/CHANGELOG.md +++ b/packages/pluggableWidgets/image-native/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/image-native/package.json b/packages/pluggableWidgets/image-native/package.json index 4694db80f..9fba70cf1 100644 --- a/packages/pluggableWidgets/image-native/package.json +++ b/packages/pluggableWidgets/image-native/package.json @@ -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", diff --git a/packages/pluggableWidgets/image-native/src/components/ImageComponents.tsx b/packages/pluggableWidgets/image-native/src/components/ImageComponents.tsx index 42ad4690d..5b50fc3df 100644 --- a/packages/pluggableWidgets/image-native/src/components/ImageComponents.tsx +++ b/packages/pluggableWidgets/image-native/src/components/ImageComponents.tsx @@ -1,5 +1,15 @@ -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"; @@ -7,6 +17,10 @@ import { CustomImageObjectProps, onLayoutSetDimensions, getScaledDimensions } fr 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; @@ -31,11 +45,13 @@ export interface ImageViewerProps extends ImageViewerBaseProps { } interface ImageSmallProps extends ImageViewerBaseProps { onClick: () => Dispatch> | void; + pressableRef?: RefObject; styles: DefaultImageStyle; } interface ImageEnlargedProps extends ImageViewerBaseProps { visible: boolean; setEnlarged: Dispatch>; + returnFocusRef?: RefObject; styles: DefaultImageStyle; } interface GetImageDimensionsComponentProps extends ImageViewerBaseProps { @@ -44,6 +60,7 @@ interface GetImageDimensionsComponentProps extends ImageViewerBaseProps { export const ImageViewer: FunctionComponent = props => { const [enlarged, setEnlarged] = useState(false); + const triggerRef = useRef(null); const { accessible, screenReaderCaption, @@ -73,6 +90,7 @@ export const ImageViewer: FunctionComponent = props => { iconSize={iconSize} initialDimensions={initialDimensions} onClick={onClickType === "enlarge" ? () => setEnlarged(true) : onClick} + pressableRef={triggerRef} styles={styles} /> = props => { screenReaderHint={screenReaderHint} visible={enlarged} setEnlarged={setEnlarged} + returnFocusRef={triggerRef} source={source} initialDimensions={initialDimensions} styles={styles} @@ -156,6 +175,7 @@ export const ImageSmall: FunctionComponent = props => { customHeight, iconSize, onClick, + pressableRef, styles, name, accessible, @@ -181,6 +201,7 @@ export const ImageSmall: FunctionComponent = props => { return source.type === "icon" || (initialDimensions?.width && initialDimensions?.height) ? ( = props => { name, accessible, screenReaderCaption, - screenReaderHint + screenReaderHint, + returnFocusRef } = props; + const contentRef = useRef(null); const [svgProps] = extractStyles(styles.image as ImageStyle, ["width", "height"]); const { width, height } = useWindowDimensions(); @@ -227,17 +250,17 @@ export const ImageEnlarged: FunctionComponent = props => { return getScaledDimensions(width, height, initialDimensions); }, [width, height, initialDimensions]); - return visible && initialDimensions?.width && initialDimensions?.height ? ( - 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", @@ -247,6 +270,11 @@ export const ImageEnlarged: FunctionComponent = props => { ]} > setEnlarged(false)} style={styles.backdrop} @@ -268,6 +296,6 @@ export const ImageEnlarged: FunctionComponent = props => { /> - + ) : null; }; diff --git a/packages/pluggableWidgets/image-native/src/components/__tests__/Image.spec.tsx b/packages/pluggableWidgets/image-native/src/components/__tests__/Image.spec.tsx index cec1beb64..8b26d7a33 100644 --- a/packages/pluggableWidgets/image-native/src/components/__tests__/Image.spec.tsx +++ b/packages/pluggableWidgets/image-native/src/components/__tests__/Image.spec.tsx @@ -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"; @@ -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"); @@ -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> = {}): Promise => { + const image = render(); + 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(); + const nodes = new Map(); + 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(); + 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`); + }); + }); }); diff --git a/packages/pluggableWidgets/image-native/src/components/__tests__/__snapshots__/Image.spec.tsx.snap b/packages/pluggableWidgets/image-native/src/components/__tests__/__snapshots__/Image.spec.tsx.snap index 8f98e0a94..74dcc50a6 100644 --- a/packages/pluggableWidgets/image-native/src/components/__tests__/__snapshots__/Image.spec.tsx.snap +++ b/packages/pluggableWidgets/image-native/src/components/__tests__/__snapshots__/Image.spec.tsx.snap @@ -328,11 +328,9 @@ exports[`Widget Dynamic Image SVG renders the structure inside a modal 1`] = ` , - + + + + - - - - + testID="Image1$SvgUri" + uri="file:///this/is/a/fake/path.svg" + vbHeight={9999} + vbWidth={9999} + version={1.1} + width={750} + x="0px" + xmlSpace="preserve" + xmlns="http://www.w3.org/2000/svg" + xmlnsXlink="http://www.w3.org/1999/xlink" + y="0px" + > + + + + + + , @@ -1119,11 +1207,9 @@ exports[`Widget Dynamic Image renders the structure inside a modal 1`] = ` , + + - + + + > + + + + @@ -1672,11 +1848,9 @@ exports[`Widget Icon type: image/staticImage renders the structure inside a moda , + + - + + + > + + + + @@ -2438,11 +2702,9 @@ exports[`Widget Static Image SVG renders the structure inside a modal 1`] = ` , - + + + + + " - xmlSpace="preserve" - xmlns="http://www.w3.org/2000/svg" - xmlnsXlink="http://www.w3.org/1999/xlink" - y="0px" - > - - + - - + > + + + + + , @@ -3241,11 +3593,9 @@ exports[`Widget Static Image renders the structure inside a modal 1`] = ` , + + - + + + > + + + + diff --git a/packages/pluggableWidgets/image-native/src/package.xml b/packages/pluggableWidgets/image-native/src/package.xml index 3b1116e40..563994a63 100644 --- a/packages/pluggableWidgets/image-native/src/package.xml +++ b/packages/pluggableWidgets/image-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md index 92d910ebc..f10057940 100644 --- a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md +++ b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- The IntroScreen is now presented through the shared MxOverlay component. Its appearance and behaviour, including blocking the Android back button, are unchanged. + ### Fixed - We fixed an issue where the IntroScreen did not show the slide set by the active slide attribute, and where swiping between slides did not work reliably on slower Android devices. diff --git a/packages/pluggableWidgets/intro-screen-native/package.json b/packages/pluggableWidgets/intro-screen-native/package.json index a9ee26879..23901140a 100644 --- a/packages/pluggableWidgets/intro-screen-native/package.json +++ b/packages/pluggableWidgets/intro-screen-native/package.json @@ -1,7 +1,7 @@ { "name": "intro-screen-native", "widgetName": "IntroScreen", - "version": "4.5.0", + "version": "4.6.0", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx b/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx index e861673a3..0bed8ea0e 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx @@ -1,12 +1,16 @@ import { useCallback, useEffect, useState, JSX } from "react"; import { defaultWelcomeScreenStyle, IntroScreenStyle } from "./ui/Styles"; import { IntroScreenProps } from "../typings/IntroScreenProps"; -import { Modal, View } from "react-native"; +import { View } from "react-native"; import { DynamicValue, ValueStatus } from "mendix"; import { SwipeableContainer } from "./SwipeableContainer"; import AsyncStorage from "@react-native-async-storage/async-storage"; import deepmerge from "deepmerge"; import { executeAction } from "@mendix/piw-utils-internal"; +import { MxOverlay } from "@mendix/piw-native-utils-internal"; + +// The popup container draws its own dimmed background, so the overlay backdrop must not add a second one +const transparentBackdrop = { backgroundColor: "transparent" }; export function IntroScreen(props: IntroScreenProps): JSX.Element { const [visible, setVisible] = useState(false); @@ -68,7 +72,18 @@ export function IntroScreen(props: IntroScreenProps): JSX.Elem const showNextDone = props.buttonPattern !== "none"; return ( - + ): JSX.Elem hideIndicatorLastSlide={props.hideIndicatorLastSlide} /> - + ); } diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx b/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx index 643f80d43..123875a91 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/IntroScreen.spec.tsx @@ -2,7 +2,7 @@ import { render, act, fireEvent, RenderAPI } from "@testing-library/react-native import { IntroScreen } from "../IntroScreen"; import { IntroScreenProps } from "../../typings/IntroScreenProps"; import { IntroScreenStyle } from "../ui/Styles"; -import { View } from "react-native"; +import { Modal, View } from "react-native"; import { EditableValueBuilder } from "@mendix/piw-utils-internal"; import { Big } from "big.js"; @@ -54,6 +54,34 @@ describe("Intro Screen", () => { expect(component.toJSON()).toMatchSnapshot(); }); + it("renders an opaque modal in fullscreen mode", () => { + const component = render(); + + expect(component.UNSAFE_getByType(Modal).props.transparent).toBe(false); + }); + + it("renders a transparent modal in popup mode", () => { + const component = render(); + + expect(component.UNSAFE_getByType(Modal).props.transparent).toBe(true); + }); + + it("stays in portrait on iOS", () => { + const component = render(); + + expect(component.UNSAFE_getByType(Modal).props.supportedOrientations).toEqual(["portrait"]); + }); + + it("keeps the intro screen open when the Android back button is pressed", () => { + const component = render(); + + act(() => { + component.UNSAFE_getByType(Modal).props.onRequestClose(); + }); + + expect(component.UNSAFE_queryByType(Modal)).not.toBeNull(); + }); + it("renders with 1 bottom button", () => { const component = render( diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap index 34bfbcb4c..599e5a00e 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap @@ -2,239 +2,88 @@ exports[`Intro Screen renders 1`] = ` - - - - - - - - - - - - - - - - - - - - - + /> - - -`; - -exports[`Intro Screen renders with 1 bottom button 1`] = ` - - - - - - + - + + /> - + + + + + - + - - - - - + `; -exports[`Intro Screen renders with 2 bottom button 1`] = ` +exports[`Intro Screen renders with 1 bottom button 1`] = ` + + + - - - + + + + + + + + + + + + + + + - + + + - + - - - + + +`; + +exports[`Intro Screen renders with 2 bottom button 1`] = ` + + + + + + + + - + + + + + + + + + + + + + + + + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + focusable={true} + nativeBackgroundAndroid={ + { + "attribute": "selectableItemBackground", + "rippleRadius": undefined, + "type": "ThemeAttrAndroid", + } + } + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "flexDirection": "row", + "justifyContent": "center", + "paddingVertical": 12, + }, + {}, + ] + } + testID="intro-screen-notch-test$buttonDone" + > + + + + @@ -684,21 +945,88 @@ exports[`Intro Screen renders with 2 bottom button 1`] = ` exports[`Intro Screen renders with active slide attribute 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - + + + + + @@ -902,214 +1250,301 @@ exports[`Intro Screen renders with active slide attribute 1`] = ` exports[`Intro Screen renders with async storage identifier 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - + + + + + diff --git a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap index 4518971a9..e51ea1455 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap +++ b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.spec.tsx.snap @@ -2,239 +2,88 @@ exports[`Intro Screen renders 1`] = ` - - - - - - - - - - - - - - - - - - - - - + /> - - -`; - -exports[`Intro Screen renders with 1 bottom button 1`] = ` - - - - - - + - + + /> - + + + + + - + - - - - - + `; -exports[`Intro Screen renders with 2 bottom button 1`] = ` +exports[`Intro Screen renders with 1 bottom button 1`] = ` + + + - - - + + + + + + + + + + + + + + + - + + + - + - - - + + +`; + +exports[`Intro Screen renders with 2 bottom button 1`] = ` + + + + + + + + - + + + + + + + + + + + + + + + + accessibilityState={ + { + "busy": undefined, + "checked": undefined, + "disabled": undefined, + "expanded": undefined, + "selected": undefined, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + focusable={true} + nativeBackgroundAndroid={ + { + "attribute": "selectableItemBackground", + "rippleRadius": undefined, + "type": "ThemeAttrAndroid", + } + } + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "flexDirection": "row", + "justifyContent": "center", + "paddingVertical": 12, + }, + {}, + ] + } + testID="intro-screen-test$buttonDone" + > + + + + @@ -684,21 +945,88 @@ exports[`Intro Screen renders with 2 bottom button 1`] = ` exports[`Intro Screen renders with active slide attribute 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - + + + + + @@ -902,214 +1250,301 @@ exports[`Intro Screen renders with active slide attribute 1`] = ` exports[`Intro Screen renders with async storage identifier 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - + + + + + diff --git a/packages/pluggableWidgets/intro-screen-native/src/package.xml b/packages/pluggableWidgets/intro-screen-native/src/package.xml index a19bb9f76..e0e227e8b 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/package.xml +++ b/packages/pluggableWidgets/intro-screen-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/video-player-native/CHANGELOG.md b/packages/pluggableWidgets/video-player-native/CHANGELOG.md index 7d55efd9a..485b4f2e9 100644 --- a/packages/pluggableWidgets/video-player-native/CHANGELOG.md +++ b/packages/pluggableWidgets/video-player-native/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We added mendix-native as a dependency to provide platform utilities. +### Changed + +- The Android fullscreen video is now presented through the shared MxOverlay component. Screen readers now treat it as a modal, and focus returns to the fullscreen button when you leave fullscreen. + ## [6.4.1] - 2026-6-10 ### Changed diff --git a/packages/pluggableWidgets/video-player-native/src/VideoPlayer.tsx b/packages/pluggableWidgets/video-player-native/src/VideoPlayer.tsx index f94ec2021..30b65b147 100644 --- a/packages/pluggableWidgets/video-player-native/src/VideoPlayer.tsx +++ b/packages/pluggableWidgets/video-player-native/src/VideoPlayer.tsx @@ -1,4 +1,4 @@ -import { flattenStyles } from "@mendix/piw-native-utils-internal"; +import { flattenStyles, MxOverlay } from "@mendix/piw-native-utils-internal"; import { AndroidNavigationBar } from "mendix-native"; import { ReactElement, useEffect, useRef, useState, Fragment, useCallback, useMemo } from "react"; import { @@ -9,7 +9,6 @@ import { TouchableOpacity, View, Appearance, - Modal, Pressable } from "react-native"; import Video, { OnProgressData, ReactVideoProps, VideoRef } from "react-native-video"; @@ -51,6 +50,7 @@ export function VideoPlayer(props: VideoPlayerProps): ReactElement { const timeoutRef = useRef(null); const playerRef = useRef(null); const fullScreenPlayerRef = useRef(null); + const fullScreenButtonRef = useRef(null); const [status, setStatus] = useState(StatusEnum.NOT_READY); const [fullScreen, setFullScreen] = useState(false); const [showControls, setShowControls] = useState(false); @@ -117,11 +117,16 @@ export function VideoPlayer(props: VideoPlayerProps): ReactElement { return ( {isAndroid && ( - fullScreenHandler(false)} + placement="fullscreen" + transparent={false} + respectSafeArea={false} + animationDuration={0} + dismissOnBackdropPress={false} + returnFocusRef={fullScreenButtonRef} testID="fullscreen-modal" - statusBarTranslucent > @@ -156,7 +161,7 @@ export function VideoPlayer(props: VideoPlayerProps): ReactElement { )} {errorText} - + )} {status === StatusEnum.LOADING && } @@ -179,6 +184,7 @@ export function VideoPlayer(props: VideoPlayerProps): ReactElement { )} {isAndroid && showControls && ( fullScreenHandler(true)} style={styles.controlBtnContainerStyle} testID="btn-fullscreen" diff --git a/packages/pluggableWidgets/video-player-native/src/__tests__/VideoPlayer.spec.tsx b/packages/pluggableWidgets/video-player-native/src/__tests__/VideoPlayer.spec.tsx index 77c52c9e8..cf0d10823 100644 --- a/packages/pluggableWidgets/video-player-native/src/__tests__/VideoPlayer.spec.tsx +++ b/packages/pluggableWidgets/video-player-native/src/__tests__/VideoPlayer.spec.tsx @@ -1,6 +1,6 @@ import { dynamicValue } from "@mendix/piw-utils-internal"; -import { Modal, View } from "react-native"; -import { fireEvent, render, waitFor } from "@testing-library/react-native"; +import { AccessibilityInfo, Modal, Platform, StatusBar, View } from "react-native"; +import { act, fireEvent, render, waitFor } from "@testing-library/react-native"; import { ReactVideoProps } from "react-native-video"; import { VideoPlayer } from "../VideoPlayer"; @@ -9,6 +9,16 @@ import { VideoStyle } from "../ui/Styles"; jest.useFakeTimers(); jest.mock("react-native-video", () => "Video"); +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) + }; +}); // The real module resolves the MxNavigation TurboModule at import time, which is unavailable under Jest. jest.mock("mendix-native", () => ({ AndroidNavigationBar: { isActive: true, height: 24 } @@ -119,5 +129,121 @@ describe("VideoPlayer", () => { expect(modal.props.visible).toBe(true); }); }); + + describe("fullscreen overlay", () => { + let setHidden: jest.SpyInstance; + let setFocus: jest.SpyInstance; + + beforeEach(() => { + setHidden = jest.spyOn(StatusBar, "setHidden").mockImplementation(() => undefined); + setFocus = jest.spyOn(AccessibilityInfo, "setAccessibilityFocus").mockImplementation(() => undefined); + }); + + afterEach(() => { + mockFindNodeHandle.mockReset(); + jest.restoreAllMocks(); + }); + + const openFullScreen = (): ReturnType => { + const component = render(); + fireEvent.press(component.getByTestId("btn-fullscreen")); + return component; + }; + + it("is not rendered until fullscreen is requested", () => { + const component = render(); + + expect(component.UNSAFE_queryByType(Modal)).toBeNull(); + expect(component.queryByTestId("btn-fullscreen-exit")).toBeNull(); + }); + + it("opens an opaque modal that draws under the status bar", () => { + const component = openFullScreen(); + const { props } = component.UNSAFE_getByType(Modal); + + expect(props.transparent).toBe(false); + expect(props.statusBarTranslucent).toBe(true); + expect(component.getByTestId("fullscreen-modal")).toBeTruthy(); + }); + + it("hides the status bar when entering fullscreen", () => { + openFullScreen(); + + expect(setHidden).toHaveBeenCalledWith(true); + }); + + it("leaves fullscreen and restores the status bar with the exit button", () => { + const component = openFullScreen(); + + fireEvent.press(component.getByTestId("btn-fullscreen-exit")); + + expect(component.UNSAFE_queryByType(Modal)).toBeNull(); + expect(setHidden).toHaveBeenLastCalledWith(false); + }); + + it("leaves fullscreen and restores the status bar with the Android back button", () => { + const component = openFullScreen(); + + act(() => component.UNSAFE_getByType(Modal).props.onRequestClose()); + + expect(component.UNSAFE_queryByType(Modal)).toBeNull(); + expect(setHidden).toHaveBeenLastCalledWith(false); + }); + + it("stays in fullscreen when the area around the video is pressed", () => { + const component = openFullScreen(); + + fireEvent.press(component.getByTestId("fullscreen-modal-backdrop", { includeHiddenElements: true })); + + expect(component.UNSAFE_queryByType(Modal)).not.toBeNull(); + }); + + it("uses the Android modal semantics for the fullscreen overlay", () => { + const component = openFullScreen(); + const { props } = component.getByTestId("fullscreen-modal-content"); + + expect(props.importantForAccessibility).toBe("yes"); + expect(props.accessibilityViewIsModal).toBeUndefined(); + }); + + it("returns accessibility focus to the fullscreen button after leaving fullscreen", () => { + const tags = new Map(); + const nodes = new Map(); + mockFindNodeHandle.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); + }); + const component = openFullScreen(); + setFocus.mockClear(); + + fireEvent.press(component.getByTestId("btn-fullscreen-exit")); + + expect(setFocus).toHaveBeenCalledTimes(1); + expect(nodes.get(setFocus.mock.calls[0][0])?.props.testID).toBe("btn-fullscreen"); + }); + }); + + describe("on iOS", () => { + const androidOS = Platform.OS; + + afterEach(() => { + (Platform as { OS: string }).OS = androidOS; + }); + + it("does not render the fullscreen overlay or button", () => { + (Platform as { OS: string }).OS = "ios"; + + const component = render(); + + expect(component.UNSAFE_queryByType(Modal)).toBeNull(); + expect(component.queryByTestId("btn-fullscreen")).toBeNull(); + }); + }); }); }); diff --git a/packages/pluggableWidgets/video-player-native/src/ui/Styles.ts b/packages/pluggableWidgets/video-player-native/src/ui/Styles.ts index c66dae01b..e36880972 100644 --- a/packages/pluggableWidgets/video-player-native/src/ui/Styles.ts +++ b/packages/pluggableWidgets/video-player-native/src/ui/Styles.ts @@ -32,6 +32,7 @@ export const defaultVideoStyle: VideoStyle = { color: "white" }, fullScreenVideoPlayer: { + flex: 1, justifyContent: "center", backgroundColor: "black", margin: 0 diff --git a/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx b/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx new file mode 100644 index 000000000..e1f3abafc --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx @@ -0,0 +1,249 @@ +import { + Component, + ReactElement, + ReactNode, + RefObject, + useCallback, + useContext, + useEffect, + useRef, + useState +} from "react"; +import { + AccessibilityInfo, + Animated, + findNodeHandle, + Modal, + ModalProps, + Platform, + Pressable, + StyleProp, + StyleSheet, + View, + ViewStyle +} from "react-native"; +import { SafeAreaInsetsContext } from "react-native-safe-area-context"; + +export type MxOverlayPlacement = "center" | "bottom" | "fullscreen" | "positioned"; + +export interface MxOverlayProps { + visible: boolean; + /** Called when the overlay asks to be dismissed (Android back press, backdrop press). */ + onRequestClose?: () => void; + /** When false, Android back is swallowed and nothing is dismissed. Defaults to true. */ + dismissOnBackPress?: boolean; + /** When false, pressing the backdrop does nothing. Defaults to true. */ + dismissOnBackdropPress?: boolean; + placement?: MxOverlayPlacement; + /** Offset of the content from the top-left corner, used with placement "positioned". */ + position?: { top: number; left: number }; + backdropStyle?: StyleProp; + /** Element that receives accessibility focus when the overlay opens. Defaults to the overlay content. */ + initialFocusRef?: RefObject; + /** Element that receives accessibility focus once the overlay has closed, typically the trigger. */ + returnFocusRef?: RefObject; + /** Enter/exit duration in ms. 0 disables the animation. Defaults to 200. */ + animationDuration?: number; + /** Render a transparent modal so what is behind shows through. When false the platform provides an opaque background. Defaults to true. */ + transparent?: boolean; + /** Orientations the overlay may rotate to (iOS only). Defaults to all four, so the device orientation is kept. */ + supportedOrientations?: ModalProps["supportedOrientations"]; + /** Draw the overlay under the Android status bar. Defaults to true. */ + statusBarTranslucent?: boolean; + /** Pad the content by the safe-area insets. Defaults to true. */ + respectSafeArea?: boolean; + testID?: string; + children?: ReactNode; +} + +const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 }; +const DEFAULT_ANIMATION_DURATION = 200; +// iOS Modal defaults to portrait only, so list every orientation to keep the device orientation while it is open +const ALL_ORIENTATIONS: ModalProps["supportedOrientations"] = [ + "portrait", + "portrait-upside-down", + "landscape-left", + "landscape-right" +]; +const SLIDE_DISTANCE: Record = { + center: 16, + positioned: 16, + bottom: 64, + fullscreen: 0 +}; + +function moveFocusTo(ref?: RefObject): void { + const node = ref?.current ? findNodeHandle(ref.current) : null; + if (node) { + AccessibilityInfo.setAccessibilityFocus(node); + } +} + +export function MxOverlay(props: MxOverlayProps): ReactElement | null { + const { + visible, + onRequestClose, + dismissOnBackPress = true, + dismissOnBackdropPress = true, + placement = "center", + position, + backdropStyle, + initialFocusRef, + returnFocusRef, + animationDuration = DEFAULT_ANIMATION_DURATION, + respectSafeArea = true, + transparent = true, + supportedOrientations = ALL_ORIENTATIONS, + statusBarTranslucent = true, + testID, + children + } = props; + + const insets = useContext(SafeAreaInsetsContext) ?? ZERO_INSETS; + const [mounted, setMounted] = useState(visible); + const [progress] = useState(() => new Animated.Value(visible ? 1 : 0)); + const contentRef = useRef(null); + const focusMoved = useRef(false); + const wasMounted = useRef(mounted); + + // Restore focus in its own effect: it must run after the Modal has actually unmounted, otherwise + // the still-visible overlay (accessibilityViewIsModal) hides the element that should get focus. + useEffect(() => { + if (wasMounted.current && !mounted) { + moveFocusTo(returnFocusRef); + } + wasMounted.current = mounted; + }, [mounted, returnFocusRef]); + + useEffect(() => { + if (visible) { + focusMoved.current = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- Keeps the modal mounted until the exit animation ends + setMounted(true); + } + + const finish = (): void => { + if (!visible) { + setMounted(false); + } + }; + + if (!visible && !mounted) { + return; + } + if (animationDuration <= 0) { + progress.setValue(visible ? 1 : 0); + finish(); + return; + } + Animated.timing(progress, { + toValue: visible ? 1 : 0, + duration: animationDuration, + useNativeDriver: true + }).start(({ finished }) => finished && finish()); + }, [visible]); + + // On Android the native Modal consumes the hardware back press and reports it through onRequestClose, + // so it never reaches BackHandler or navigation. Handling it here covers both dismissing and swallowing. + const handleBackPress = useCallback((): void => { + if (dismissOnBackPress) { + onRequestClose?.(); + } + }, [dismissOnBackPress, onRequestClose]); + + const handleContentLayout = useCallback((): void => { + if (!focusMoved.current) { + focusMoved.current = true; + moveFocusTo(initialFocusRef ?? contentRef); + } + }, [initialFocusRef]); + + if (!visible && !mounted) { + return null; + } + + // Without an animation the views keep no opacity or transform at all, so content that does not cope well with + // an animated parent (e.g. an Android SurfaceView) is left alone. The component type stays the same either way, + // so children are never remounted when the duration changes. + const animated = animationDuration > 0; + const backdropAnimation = animated ? { opacity: progress } : undefined; + const contentAnimation = animated + ? { + opacity: progress, + transform: [ + { + translateY: progress.interpolate({ + inputRange: [0, 1], + outputRange: [SLIDE_DISTANCE[placement], 0] + }) + } + ] + } + : undefined; + + // iOS needs accessibilityViewIsModal to hide what is behind the overlay; Android relies on importantForAccessibility. + const modalSemantics = + Platform.OS === "ios" ? { accessibilityViewIsModal: true } : { importantForAccessibility: "yes" as const }; + + const safeAreaPadding: ViewStyle | undefined = respectSafeArea + ? { paddingTop: insets.top, paddingRight: insets.right, paddingBottom: insets.bottom, paddingLeft: insets.left } + : undefined; + + return ( + + + + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1 + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)" + }, + fullscreenContent: { + flex: 1 + } +}); + +const placementStyles = StyleSheet.create({ + center: { justifyContent: "center", alignItems: "center" }, + bottom: { justifyContent: "flex-end" }, + fullscreen: {}, + positioned: {} +}); diff --git a/packages/tools/piw-native-utils-internal/src/components/__tests__/MxOverlay.spec.tsx b/packages/tools/piw-native-utils-internal/src/components/__tests__/MxOverlay.spec.tsx new file mode 100644 index 000000000..6545adf14 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/__tests__/MxOverlay.spec.tsx @@ -0,0 +1,287 @@ +import { createRef, ReactElement } from "react"; +import { AccessibilityInfo, Modal, Platform, StyleSheet, Text, View } from "react-native"; +import { ReactTestInstance } from "react-test-renderer"; +import { act, fireEvent, render } from "@testing-library/react-native"; +import { MxOverlay, MxOverlayProps } from "../MxOverlay"; + +const mockFindNodeHandle = jest.fn(); +jest.mock("react-native/Libraries/ReactNative/RendererProxy", () => ({ + ...jest.requireActual("react-native/Libraries/ReactNative/RendererProxy"), + findNodeHandle: (node: unknown) => mockFindNodeHandle(node) +})); + +const setPlatform = (os: "ios" | "android"): void => { + (Platform as { OS: string }).OS = os; +}; + +const renderOverlay = ( + props: Partial = {} +): ReturnType & { onRequestClose: jest.Mock } => { + const onRequestClose = jest.fn(); + const utils = render( + + content + + ); + return { ...utils, onRequestClose }; +}; + +describe.each(["ios", "android"] as const)("MxOverlay on %s", os => { + const originalOS = Platform.OS; + let setFocus: jest.SpyInstance; + + beforeEach(() => { + setPlatform(os); + setFocus = jest.spyOn(AccessibilityInfo, "setAccessibilityFocus").mockImplementation(() => undefined); + mockFindNodeHandle.mockReset().mockReturnValue(42); + }); + + afterEach(() => { + (Platform as { OS: string }).OS = originalOS; + jest.restoreAllMocks(); + }); + + describe("modal semantics", () => { + it("marks the content as modal for the current platform only", () => { + const { getByTestId } = renderOverlay(); + const props = getByTestId("overlay-content").props; + + if (os === "ios") { + expect(props.accessibilityViewIsModal).toBe(true); + expect(props.importantForAccessibility).toBeUndefined(); + } else { + expect(props.importantForAccessibility).toBe("yes"); + expect(props.accessibilityViewIsModal).toBeUndefined(); + } + }); + + it("hides the backdrop from accessibility", () => { + const { getByTestId } = renderOverlay(); + + expect(getByTestId("overlay-backdrop", { includeHiddenElements: true }).props.accessible).toBe(false); + }); + }); + + describe("focus", () => { + const layout = (getByTestId: (id: string) => any): void => { + fireEvent(getByTestId("overlay-content"), "layout", { nativeEvent: { layout: {} } }); + }; + + it("moves focus into the overlay once it has laid out", () => { + const { getByTestId } = renderOverlay(); + expect(setFocus).not.toHaveBeenCalled(); + + layout(getByTestId); + layout(getByTestId); + + expect(setFocus).toHaveBeenCalledTimes(1); + expect(setFocus).toHaveBeenCalledWith(42); + }); + + it("moves focus to initialFocusRef when provided", () => { + const initialFocusRef = createRef(); + mockFindNodeHandle.mockImplementation(node => (node === initialFocusRef.current ? 7 : 42)); + const { getByTestId } = render( + + + + ); + + layout(getByTestId); + + expect(setFocus).toHaveBeenCalledWith(7); + }); + + it("restores focus to returnFocusRef after closing", () => { + const returnFocusRef = createRef(); + mockFindNodeHandle.mockImplementation(node => (node === returnFocusRef.current ? 9 : 42)); + const element = (visible: boolean): ReactElement => ( + <> + + + content + + + ); + const { rerender } = render(element(true)); + setFocus.mockClear(); + + rerender(element(false)); + + expect(setFocus).toHaveBeenCalledTimes(1); + expect(setFocus).toHaveBeenCalledWith(9); + }); + + it("restores focus only after the modal has unmounted", () => { + const returnFocusRef = createRef(); + mockFindNodeHandle.mockImplementation(node => (node === returnFocusRef.current ? 9 : 42)); + const rendered: { current?: ReturnType } = {}; + const modalPresentWhenFocused: boolean[] = []; + setFocus.mockImplementation(() => { + modalPresentWhenFocused.push(Boolean(rendered.current?.UNSAFE_queryByType(Modal))); + }); + const element = (visible: boolean): ReactElement => ( + <> + + + content + + + ); + rendered.current = render(element(true)); + setFocus.mockClear(); + modalPresentWhenFocused.length = 0; + + rendered.current.rerender(element(false)); + + expect(modalPresentWhenFocused).toEqual([false]); + }); + + it("does not move focus when it was never shown", () => { + const returnFocusRef = createRef(); + render( + + content + + ); + + expect(setFocus).not.toHaveBeenCalled(); + }); + }); + + describe("back button", () => { + const pressBack = (utils: ReturnType): void => { + act(() => { + utils.UNSAFE_getByType(Modal).props.onRequestClose(); + }); + }; + + it("dismisses on back press by default", () => { + const utils = renderOverlay(); + + pressBack(utils); + + expect(utils.onRequestClose).toHaveBeenCalledTimes(1); + }); + + it("swallows back press without dismissing when dismissOnBackPress is false", () => { + const utils = renderOverlay({ dismissOnBackPress: false }); + + expect(typeof utils.UNSAFE_getByType(Modal).props.onRequestClose).toBe("function"); + pressBack(utils); + + expect(utils.onRequestClose).not.toHaveBeenCalled(); + }); + }); + + describe("backdrop", () => { + it("dismisses on backdrop press by default", () => { + const { getByTestId, onRequestClose } = renderOverlay(); + + fireEvent.press(getByTestId("overlay-backdrop", { includeHiddenElements: true })); + + expect(onRequestClose).toHaveBeenCalledTimes(1); + }); + + it("ignores backdrop press when dismissOnBackdropPress is false", () => { + const { getByTestId, onRequestClose } = renderOverlay({ dismissOnBackdropPress: false }); + + fireEvent.press(getByTestId("overlay-backdrop", { includeHiddenElements: true })); + + expect(onRequestClose).not.toHaveBeenCalled(); + }); + }); + + describe("visibility and animation", () => { + it("passes statusBarTranslucent through to the modal", () => { + expect(renderOverlay().UNSAFE_getByType(Modal).props.statusBarTranslucent).toBe(true); + expect( + renderOverlay({ statusBarTranslucent: false }).UNSAFE_getByType(Modal).props.statusBarTranslucent + ).toBe(false); + }); + + it("renders a transparent modal by default and an opaque one when transparent is false", () => { + expect(renderOverlay().UNSAFE_getByType(Modal).props.transparent).toBe(true); + expect(renderOverlay({ transparent: false }).UNSAFE_getByType(Modal).props.transparent).toBe(false); + }); + + it("supports every orientation so the device orientation is kept on iOS", () => { + expect(renderOverlay().UNSAFE_getByType(Modal).props.supportedOrientations).toEqual([ + "portrait", + "portrait-upside-down", + "landscape-left", + "landscape-right" + ]); + }); + + it("uses the supportedOrientations passed by the caller", () => { + const utils = renderOverlay({ supportedOrientations: ["portrait"] }); + + expect(utils.UNSAFE_getByType(Modal).props.supportedOrientations).toEqual(["portrait"]); + }); + + describe("animated styles", () => { + const flattenedStyles = (props: Partial): { content: any; backdrop: any } => { + const { getByTestId } = renderOverlay(props); + const backdropView = getByTestId("overlay-backdrop", { includeHiddenElements: true }).children[0]; + return { + content: StyleSheet.flatten(getByTestId("overlay-content").props.style), + backdrop: StyleSheet.flatten((backdropView as ReactTestInstance).props.style) + }; + }; + + it("leaves out opacity and transform when there is no animation", () => { + const { content, backdrop } = flattenedStyles({ animationDuration: 0 }); + + expect(content.opacity).toBeUndefined(); + expect(content.transform).toBeUndefined(); + expect(backdrop.opacity).toBeUndefined(); + }); + + it("animates opacity and transform when there is an animation", () => { + const { content, backdrop } = flattenedStyles({ animationDuration: 200 }); + + expect(content.opacity).toBeDefined(); + expect(content.transform).toBeDefined(); + expect(backdrop.opacity).toBeDefined(); + }); + }); + + it("renders nothing while hidden", () => { + const { queryByTestId } = renderOverlay({ visible: false }); + + expect(queryByTestId("overlay")).toBeNull(); + }); + + it("stays mounted until the exit animation finishes", () => { + jest.useFakeTimers(); + try { + const element = (visible: boolean): ReactElement => ( + + content + + ); + const { rerender, queryByTestId } = render(element(true)); + act(() => { + jest.advanceTimersByTime(250); + }); + + rerender(element(false)); + expect(queryByTestId("overlay")).not.toBeNull(); + + act(() => { + jest.advanceTimersByTime(250); + }); + expect(queryByTestId("overlay")).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + + it.each(["center", "bottom", "fullscreen", "positioned"] as const)("renders %s placement", placement => { + const { getByText } = renderOverlay({ placement, position: { top: 10, left: 20 } }); + + expect(getByText("content")).toBeTruthy(); + }); + }); +}); diff --git a/packages/tools/piw-native-utils-internal/src/index.ts b/packages/tools/piw-native-utils-internal/src/index.ts index e8e614b80..5ffd78bff 100644 --- a/packages/tools/piw-native-utils-internal/src/index.ts +++ b/packages/tools/piw-native-utils-internal/src/index.ts @@ -1,6 +1,7 @@ export { flattenStyles, Style } from "./styles"; export { only, exclude } from "./objectFilters"; export { available, toNumber, unavailable } from "./properties"; +export { MxOverlay, MxOverlayProps, MxOverlayPlacement } from "./components/MxOverlay"; export { Slider, SliderProps, RangeSlider, RangeSliderProps, RangeSliderValue } from "./components/Slider"; export { Bar, BarProps } from "./components/Bar"; export { Arc, ArcProps, makeArcPath } from "./components/Arc";