From 25efd1ed80f37b58ca5027e0ef6883f805d081b2 Mon Sep 17 00:00:00 2001 From: Srirang Kalantri Date: Wed, 14 Oct 2026 14:43:44 +0530 Subject: [PATCH 1/5] feat: build mxoverlay primitive --- .../intro-screen-native/CHANGELOG.md | 4 + .../intro-screen-native/src/IntroScreen.tsx | 19 +- .../IntroScreen.notch.spec.tsx.snap | 2033 ++++++++++------- .../__snapshots__/IntroScreen.spec.tsx.snap | 2033 ++++++++++------- .../piw-native-utils-internal/package.json | 3 + .../src/components/MxOverlay.tsx | 214 ++ .../components/__tests__/MxOverlay.spec.tsx | 203 ++ .../piw-native-utils-internal/src/index.ts | 1 + 8 files changed, 2919 insertions(+), 1591 deletions(-) create mode 100644 packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx create mode 100644 packages/tools/piw-native-utils-internal/src/components/__tests__/MxOverlay.spec.tsx 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/src/IntroScreen.tsx b/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx index e861673a3..b3686999f 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,16 @@ 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__/__snapshots__/IntroScreen.notch.spec.tsx.snap b/packages/pluggableWidgets/intro-screen-native/src/__tests__/__snapshots__/IntroScreen.notch.spec.tsx.snap index 34bfbcb4c..5c1d5d18d 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,241 @@ 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`] = ` + + + + + + + + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Intro Screen renders with active slide attribute 1`] = ` + + + + + + + + + + + + + + + + + + + + + + `; -exports[`Intro Screen renders with active slide attribute 1`] = ` +exports[`Intro Screen renders with async storage identifier 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - - - - - - - -`; - -exports[`Intro Screen renders with async storage identifier 1`] = ` - - - - - - - - - + testId="icon" + /> - - - - - - - - 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..24faddd4f 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,241 @@ 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`] = ` + + + + + + + + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Intro Screen renders with active slide attribute 1`] = ` + + + + + + + + + + + + + + + + + + + + + + `; -exports[`Intro Screen renders with active slide attribute 1`] = ` +exports[`Intro Screen renders with async storage identifier 1`] = ` + + + - - - - + - + + /> - + + + + + - + - - - - - - - - - - - -`; - -exports[`Intro Screen renders with async storage identifier 1`] = ` - - - - - - - - - + testId="icon" + /> - - - - - - - - diff --git a/packages/tools/piw-native-utils-internal/package.json b/packages/tools/piw-native-utils-internal/package.json index 563db5e6c..ae2810485 100644 --- a/packages/tools/piw-native-utils-internal/package.json +++ b/packages/tools/piw-native-utils-internal/package.json @@ -24,6 +24,9 @@ "test": "pluggable-widgets-tools test:unit:native", "postinstall": "pnpm build" }, + "peerDependencies": { + "react-native-safe-area-context": "5.7.0" + }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*", "rimraf": "^6.1.2" 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..0929d5f41 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx @@ -0,0 +1,214 @@ +import { + Component, + ReactElement, + ReactNode, + RefObject, + useCallback, + useContext, + useEffect, + useRef, + useState +} from "react"; +import { + AccessibilityInfo, + Animated, + findNodeHandle, + Modal, + 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; + /** 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; +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, + 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); + + 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); + moveFocusTo(returnFocusRef); + } + }; + + 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..2cbf0b301 --- /dev/null +++ b/packages/tools/piw-native-utils-internal/src/components/__tests__/MxOverlay.spec.tsx @@ -0,0 +1,203 @@ +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); + }); + }); + + 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 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"; From b687bdd051b6f0d10b64fb380ee35de27540bf69 Mon Sep 17 00:00:00 2001 From: Srirang Kalantri Date: Wed, 14 Oct 2026 14:53:37 +0530 Subject: [PATCH 2/5] fix: pnpm lock --- pnpm-lock.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b310e2ab..52e48f5ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,6 +903,10 @@ importers: version: 11.12.0(patch_hash=6c126ccd843c0d2171a692235aff82990be64a7edbd70905cc90482bfd5c4389)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@types/babel__core@7.20.5)(@types/node@20.19.41)(jest-util@30.4.1)(jiti@2.6.1)(picomatch@4.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(tslib@2.8.1) packages/tools/piw-native-utils-internal: + dependencies: + react-native-safe-area-context: + specifier: 5.7.0 + version: 5.7.0(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.12.0 From 86b293dd17eee67010be84a558b687cd40096339 Mon Sep 17 00:00:00 2001 From: Srirang Kalantri Date: Wed, 14 Oct 2026 16:21:25 +0530 Subject: [PATCH 3/5] fix: long path issues --- packages/tools/piw-native-utils-internal/package.json | 3 --- pnpm-lock.yaml | 4 ---- 2 files changed, 7 deletions(-) diff --git a/packages/tools/piw-native-utils-internal/package.json b/packages/tools/piw-native-utils-internal/package.json index ae2810485..563db5e6c 100644 --- a/packages/tools/piw-native-utils-internal/package.json +++ b/packages/tools/piw-native-utils-internal/package.json @@ -24,9 +24,6 @@ "test": "pluggable-widgets-tools test:unit:native", "postinstall": "pnpm build" }, - "peerDependencies": { - "react-native-safe-area-context": "5.7.0" - }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*", "rimraf": "^6.1.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52e48f5ef..2b310e2ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,10 +903,6 @@ importers: version: 11.12.0(patch_hash=6c126ccd843c0d2171a692235aff82990be64a7edbd70905cc90482bfd5c4389)(@jest/transform@30.3.0)(@jest/types@30.4.1)(@types/babel__core@7.20.5)(@types/node@20.19.41)(jest-util@30.4.1)(jiti@2.6.1)(picomatch@4.0.4)(react-dom@19.2.3(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(tslib@2.8.1) packages/tools/piw-native-utils-internal: - dependencies: - react-native-safe-area-context: - specifier: 5.7.0 - version: 5.7.0(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) devDependencies: '@mendix/pluggable-widgets-tools': specifier: 11.12.0 From 0311168aacd9ea810a07c83febd71f75269ce29f Mon Sep 17 00:00:00 2001 From: Srirang Kalantri Date: Thu, 15 Oct 2026 19:10:29 +0530 Subject: [PATCH 4/5] fix: orientation and transparency --- .../intro-screen-native/CHANGELOG.md | 2 +- .../intro-screen-native/package.json | 2 +- .../intro-screen-native/src/IntroScreen.tsx | 2 + .../src/__tests__/IntroScreen.spec.tsx | 30 +++++++++- .../IntroScreen.notch.spec.tsx.snap | 35 ++++++++++-- .../__snapshots__/IntroScreen.spec.tsx.snap | 35 ++++++++++-- .../intro-screen-native/src/package.xml | 2 +- .../src/components/MxOverlay.tsx | 28 +++++++++- .../components/__tests__/MxOverlay.spec.tsx | 56 +++++++++++++++++++ 9 files changed, 176 insertions(+), 16 deletions(-) diff --git a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md index f10057940..787afb5df 100644 --- a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md +++ b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this widget will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [4.5.1] - 2026-10-15 ### Changed 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 b3686999f..0bed8ea0e 100644 --- a/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx +++ b/packages/pluggableWidgets/intro-screen-native/src/IntroScreen.tsx @@ -75,6 +75,8 @@ export function IntroScreen(props: IntroScreenProps): JSX.Elem { 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 5c1d5d18d..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 @@ -5,7 +5,12 @@ exports[`Intro Screen renders 1`] = ` animationType="none" onRequestClose={[Function]} statusBarTranslucent={false} - transparent={true} + supportedOrientations={ + [ + "portrait", + ] + } + transparent={false} visible={true} > - + diff --git a/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx b/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx index 0929d5f41..e363079f7 100644 --- a/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx +++ b/packages/tools/piw-native-utils-internal/src/components/MxOverlay.tsx @@ -14,6 +14,7 @@ import { Animated, findNodeHandle, Modal, + ModalProps, Platform, Pressable, StyleProp, @@ -43,6 +44,10 @@ export interface MxOverlayProps { 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. */ @@ -53,6 +58,13 @@ export interface MxOverlayProps { 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, @@ -80,6 +92,8 @@ export function MxOverlay(props: MxOverlayProps): ReactElement | null { returnFocusRef, animationDuration = DEFAULT_ANIMATION_DURATION, respectSafeArea = true, + transparent = true, + supportedOrientations = ALL_ORIENTATIONS, statusBarTranslucent = true, testID, children @@ -90,6 +104,16 @@ export function MxOverlay(props: MxOverlayProps): ReactElement | null { 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) { @@ -101,7 +125,6 @@ export function MxOverlay(props: MxOverlayProps): ReactElement | null { const finish = (): void => { if (!visible) { setMounted(false); - moveFocusTo(returnFocusRef); } }; @@ -155,8 +178,9 @@ export function MxOverlay(props: MxOverlayProps): ReactElement | null { return ( 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 index 2cbf0b301..091949794 100644 --- 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 @@ -110,6 +110,42 @@ describe.each(["ios", "android"] as const)("MxOverlay on %s", os => { 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", () => { @@ -163,6 +199,26 @@ describe.each(["ios", "android"] as const)("MxOverlay on %s", os => { ).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 }); From 1946684051088562937b0281f529c7f5f8881222 Mon Sep 17 00:00:00 2001 From: Srirang Kalantri Date: Fri, 16 Oct 2026 17:05:25 +0530 Subject: [PATCH 5/5] fix: changelog --- packages/pluggableWidgets/intro-screen-native/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md index 787afb5df..f10057940 100644 --- a/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md +++ b/packages/pluggableWidgets/intro-screen-native/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this widget will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [4.5.1] - 2026-10-15 +## [Unreleased] ### Changed