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