diff --git a/packages/pluggableWidgets/image-native/CHANGELOG.md b/packages/pluggableWidgets/image-native/CHANGELOG.md index 698fe52d7..35eb6f4e3 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. + ## [3.1.2] - 2026-7-17 ### Fixed 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 90477a1c3..38b2d5b0a 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"); @@ -668,4 +679,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 761fc34da..bdc3ca682 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. + ## [4.5.0] - 2026-9-2 ### Fixed 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";