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..660bf12eb 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,387 +2,246 @@ 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,214 +966,308 @@ exports[`Intro Screen renders with 2 bottom button 1`] = ` exports[`Intro Screen renders with active slide attribute 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - + + + + + @@ -902,214 +1278,308 @@ 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..4793777cd 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,387 +2,246 @@ 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,214 +966,308 @@ exports[`Intro Screen renders with 2 bottom button 1`] = ` exports[`Intro Screen renders with active slide attribute 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - + + + + + @@ -902,214 +1278,308 @@ 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/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..e363079f7 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx @@ -0,0 +1,238 @@ +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; + } + + const translateY = progress.interpolate({ + inputRange: [0, 1], + outputRange: [SLIDE_DISTANCE[placement], 0] + }); + + // 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..091949794 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/__tests__/MxOverlay.spec.tsx @@ -0,0 +1,259 @@ +import { createRef, ReactElement } from "react"; +import { AccessibilityInfo, Modal, Platform, Text, View } from "react-native"; +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"]); + }); + + 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 b51a411d7..054ab10f2 100644 --- a/packages/tools/piw-native-utils-internal/src/index.ts +++ b/packages/tools/piw-native-utils-internal/src/index.ts @@ -1,4 +1,5 @@ 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";