diff --git a/docs/modal-portal-pattern.md b/docs/modal-portal-pattern.md
index 261eca1..8d119ec 100644
--- a/docs/modal-portal-pattern.md
+++ b/docs/modal-portal-pattern.md
@@ -82,6 +82,49 @@ function MyComponent() {
`hideModal(id)` — removes the modal.
`isVisible(id)` — returns whether a modal with that id is registered.
+## Modal Stacking & Z-Index Management
+
+When opening multiple modals simultaneously (e.g., a confirmation dialog on top of a settings selector), the application automatically manages their stacking order and `zIndex` to prevent visual overlapping, touch-through, and focus conflicts:
+
+1. **Auto-assigned Z-Index**:
+ - The `ModalStackManager` class maintains a global stacking order.
+ - The stack manager auto-assigns ascending `zIndex` values starting from `10000` (step size of `10`) based on the modal's stack position.
+ - Portalled modals (rendered via `ModalPortal`) are wrapped in a container that dynamically applies these `zIndex` styles on stack updates.
+
+2. **Top-Most Modal Exclusivity**:
+ - The focus trap of lower modals is automatically paused and is active *only* for the top-most modal, avoiding accessibility and focus fighting.
+ - Dismissal events (backdrop click, Android hardware back button, or Escape key on Web) are handled exclusively by the top-most modal.
+
+### useModalStack Hook
+
+Custom modals that do not use `AccessibleModal` can still coordinate their stacking order:
+
+```tsx
+import { useModalStack } from './ModalStackManager';
+
+function CustomModal({ id, visible, onClose, children }) {
+ const { zIndex, isTop } = useModalStack(id, visible);
+
+ return (
+ {
+ if (isTop) onClose();
+ }}
+ >
+ {
+ if (isTop) onClose();
+ }}
+ >
+ {children}
+
+
+ );
+}
+```
+
## Graceful fallback
`AccessibleModal` uses `useModalPortalSafe` internally, which returns `null` instead of throwing when no provider is present. In that case it falls back to inline rendering. This means existing components work correctly in tests and Storybook without a provider.
diff --git a/src/__tests__/components/ModalStackManager.test.tsx b/src/__tests__/components/ModalStackManager.test.tsx
new file mode 100644
index 0000000..d5430dc
--- /dev/null
+++ b/src/__tests__/components/ModalStackManager.test.tsx
@@ -0,0 +1,131 @@
+import { act, renderHook } from '@testing-library/react-native';
+import { modalStackManager, useModalStack } from '../../components/common/ModalStackManager';
+
+describe('ModalStackManager & useModalStack', () => {
+ beforeEach(() => {
+ // Reset manager stack state before each test
+ modalStackManager.clear();
+ });
+
+ describe('ModalStackManager Class (Core Stacking Logic)', () => {
+ it('should correctly push and pop modals', () => {
+ expect(modalStackManager.getStack()).toEqual([]);
+
+ modalStackManager.push('modal-1');
+ expect(modalStackManager.getStack()).toEqual(['modal-1']);
+
+ modalStackManager.push('modal-2');
+ expect(modalStackManager.getStack()).toEqual(['modal-1', 'modal-2']);
+
+ modalStackManager.pop('modal-1');
+ expect(modalStackManager.getStack()).toEqual(['modal-2']);
+
+ modalStackManager.pop('modal-2');
+ expect(modalStackManager.getStack()).toEqual([]);
+ });
+
+ it('should auto-assign correct z-index values', () => {
+ // Base z-index is 10000, step is 10
+ expect(modalStackManager.getZIndex('unregistered')).toBe(10000);
+
+ modalStackManager.push('modal-1');
+ expect(modalStackManager.getZIndex('modal-1')).toBe(10000);
+
+ modalStackManager.push('modal-2');
+ expect(modalStackManager.getZIndex('modal-1')).toBe(10000);
+ expect(modalStackManager.getZIndex('modal-2')).toBe(10010);
+
+ modalStackManager.push('modal-3');
+ expect(modalStackManager.getZIndex('modal-3')).toBe(10020);
+ });
+
+ it('should correctly identify the top-most modal', () => {
+ expect(modalStackManager.isTop('modal-1')).toBe(false);
+
+ modalStackManager.push('modal-1');
+ expect(modalStackManager.isTop('modal-1')).toBe(true);
+
+ modalStackManager.push('modal-2');
+ expect(modalStackManager.isTop('modal-1')).toBe(false);
+ expect(modalStackManager.isTop('modal-2')).toBe(true);
+ });
+
+ it('should handle duplicate push operations by moving the item to the top', () => {
+ modalStackManager.push('modal-1');
+ modalStackManager.push('modal-2');
+ modalStackManager.push('modal-3');
+ expect(modalStackManager.getStack()).toEqual(['modal-1', 'modal-2', 'modal-3']);
+
+ // Pushing existing 'modal-1' should move it to the top
+ modalStackManager.push('modal-1');
+ expect(modalStackManager.getStack()).toEqual(['modal-2', 'modal-3', 'modal-1']);
+ expect(modalStackManager.isTop('modal-1')).toBe(true);
+ expect(modalStackManager.getZIndex('modal-1')).toBe(10020);
+ expect(modalStackManager.getZIndex('modal-2')).toBe(10000);
+ });
+ });
+
+ describe('useModalStack Hook', () => {
+ it('should register modal when visible and unregister on unmount', () => {
+ const { result, unmount } = renderHook(({ visible }) => useModalStack('hook-modal', visible), {
+ initialProps: { visible: true },
+ });
+
+ expect(result.current.zIndex).toBe(10000);
+ expect(result.current.isTop).toBe(true);
+ expect(modalStackManager.getStack()).toEqual(['hook-modal']);
+
+ unmount();
+ expect(modalStackManager.getStack()).toEqual([]);
+ });
+
+ it('should reactively update when visibility changes', () => {
+ const { result, rerender } = renderHook(({ visible }) => useModalStack('hook-modal', visible), {
+ initialProps: { visible: false },
+ });
+
+ expect(result.current.isTop).toBe(false);
+ expect(modalStackManager.getStack()).toEqual([]);
+
+ rerender({ visible: true });
+ expect(result.current.zIndex).toBe(10000);
+ expect(result.current.isTop).toBe(true);
+ expect(modalStackManager.getStack()).toEqual(['hook-modal']);
+
+ rerender({ visible: false });
+ expect(result.current.isTop).toBe(false);
+ expect(modalStackManager.getStack()).toEqual([]);
+ });
+
+ it('should assign and update z-index and isTop for multiple stacked modals', () => {
+ const { result: modal1 } = renderHook(({ visible }) => useModalStack('modal-a', visible), {
+ initialProps: { visible: true },
+ });
+
+ expect(modal1.current.zIndex).toBe(10000);
+ expect(modal1.current.isTop).toBe(true);
+
+ // Now open modal B
+ const { result: modal2, unmount: unmountB } = renderHook(({ visible }) => useModalStack('modal-b', visible), {
+ initialProps: { visible: true },
+ });
+
+ // Modal B should be top
+ expect(modal2.current.zIndex).toBe(10010);
+ expect(modal2.current.isTop).toBe(true);
+
+ // Modal A should no longer be top
+ expect(modal1.current.zIndex).toBe(10000);
+ expect(modal1.current.isTop).toBe(false);
+
+ // Close modal B
+ act(() => {
+ unmountB();
+ });
+
+ // Modal A should become top again
+ expect(modal1.current.zIndex).toBe(10000);
+ expect(modal1.current.isTop).toBe(true);
+ });
+ });
+});
diff --git a/src/components/common/AccessibleModal.tsx b/src/components/common/AccessibleModal.tsx
index e062800..f6555ba 100644
--- a/src/components/common/AccessibleModal.tsx
+++ b/src/components/common/AccessibleModal.tsx
@@ -11,10 +11,13 @@ import {
} from 'react-native';
import { useModalPortalSafe } from './ModalPortal';
+import { useModalStack } from './ModalStackManager';
import { useFocusRestore } from '../../hooks/useFocusRestore';
import { useFocusTrap } from '../../hooks/useFocusTrap';
interface AccessibleModalProps extends Omit {
+ /** Optional unique identifier for stack tracking and z-index auto-assignment */
+ id?: string;
/** Whether the modal is visible */
visible: boolean;
/** Callback when the modal overlay or request close is triggered */
@@ -48,6 +51,7 @@ interface AccessibleModalProps extends Omit {
* Pass usePortal={false} to render inline (e.g. in tests or outside a provider).
*/
export const AccessibleModal: React.FC = ({
+ id,
visible,
onClose,
accessibilityLabel,
@@ -61,25 +65,41 @@ export const AccessibleModal: React.FC = ({
}) => {
const containerRef = useRef(null);
+ // Stable portal id derived from id or accessibilityLabel
+ const portalId = id || `accessible-modal:${accessibilityLabel}`;
+ const portal = useModalPortalSafe();
+
+ // Stacking z-index and active top status
+ const { zIndex, isTop } = useModalStack(portalId, visible);
+
useFocusRestore(visible, triggerRef);
- const { containerProps } = useFocusTrap(containerRef, visible, {
+
+ // Only trap focus if the modal is visible and is the top-most modal
+ const { containerProps } = useFocusTrap(containerRef, visible && isTop, {
initialFocusRef,
autoFocus: true,
});
- // Stable portal id derived from accessibilityLabel
- const portalId = `accessible-modal:${accessibilityLabel}`;
- const portal = useModalPortalSafe();
-
const modalContent = (
{
+ if (isTop) {
+ onClose();
+ }
+ }}
{...modalProps}
>
-
+ {
+ if (isTop) {
+ onClose();
+ }
+ }}
+ >
= ({
return () => {
portal.hideModal(portalId);
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [visible, usePortal, portal, portalId]);
+ }, [visible, usePortal, portal, portalId, modalContent]);
// When portal is active, the host renders the modal — nothing to render here
if (usePortal && portal) return null;
diff --git a/src/components/common/ModalPortal.tsx b/src/components/common/ModalPortal.tsx
index a315a7f..b31f498 100644
--- a/src/components/common/ModalPortal.tsx
+++ b/src/components/common/ModalPortal.tsx
@@ -24,8 +24,9 @@
* showModal('confirm', hideModal('confirm')} />);
*/
-import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { View } from 'react-native';
+import { modalStackManager } from './ModalStackManager';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -90,6 +91,42 @@ export const ModalPortalProvider: React.FC<{ children: React.ReactNode }> = ({ c
);
};
+// ─── Wrapper ──────────────────────────────────────────────────────────────────
+
+/**
+ * Subscribes to ModalStackManager to dynamically apply the computed z-index
+ * style wrapper to the portal modal child when other modals open or close.
+ */
+const ModalPortalWrapper: React.FC<{ id: string; children: React.ReactNode }> = ({
+ id,
+ children,
+}) => {
+ const [zIndex, setZIndex] = useState(() => modalStackManager.getZIndex(id));
+
+ useEffect(() => {
+ const unsubscribe = modalStackManager.subscribe(() => {
+ setZIndex(modalStackManager.getZIndex(id));
+ });
+ // Immediately sync in case stack changed during subscribe
+ setZIndex(modalStackManager.getZIndex(id));
+ return unsubscribe;
+ }, [id]);
+
+ return (
+
+ {children}
+
+ );
+};
+
// ─── Host ─────────────────────────────────────────────────────────────────────
/**
@@ -104,9 +141,9 @@ const ModalPortalHost: React.FC<{ _modals: ModalEntry[] }> = React.memo(function
return (
<>
{_modals.map(({ id, content }) => (
-
+
{content}
-
+
))}
>
);
diff --git a/src/components/common/ModalStackManager.ts b/src/components/common/ModalStackManager.ts
new file mode 100644
index 0000000..2c42818
--- /dev/null
+++ b/src/components/common/ModalStackManager.ts
@@ -0,0 +1,166 @@
+import { useEffect, useState } from 'react';
+
+export interface UseModalStackResult {
+ zIndex: number;
+ isTop: boolean;
+ stackIndex: number;
+}
+
+/**
+ * ModalStackManager manages the active stack of dialogs/modals.
+ * It is responsible for tracking stack order, checking if a modal is on top,
+ * and auto-assigning unique, ascending z-indices to prevent layering bugs.
+ */
+export class ModalStackManager {
+ private static instance: ModalStackManager;
+ private stack: string[] = [];
+ private listeners: Set<() => void> = new Set();
+ private baseZIndex: number = 10000;
+ private zIndexStep: number = 10;
+
+ private constructor() {}
+
+ public static getInstance(): ModalStackManager {
+ if (!ModalStackManager.instance) {
+ ModalStackManager.instance = new ModalStackManager();
+ }
+ return ModalStackManager.instance;
+ }
+
+ /**
+ * Pushes a modal onto the stack.
+ */
+ public push(id: string): void {
+ const index = this.stack.indexOf(id);
+ if (index !== -1) {
+ // Remove existing to push it to the top of the stack
+ this.stack.splice(index, 1);
+ }
+ this.stack.push(id);
+ this.notify();
+ }
+
+ /**
+ * Removes a modal from the stack.
+ */
+ public pop(id: string): void {
+ const index = this.stack.indexOf(id);
+ if (index !== -1) {
+ this.stack.splice(index, 1);
+ this.notify();
+ }
+ }
+
+ /**
+ * Gets the z-index for a given modal.
+ * If the modal is not in the stack, returns the base z-index.
+ */
+ public getZIndex(id: string): number {
+ const index = this.stack.indexOf(id);
+ if (index === -1) {
+ return this.baseZIndex;
+ }
+ return this.baseZIndex + index * this.zIndexStep;
+ }
+
+ /**
+ * Checks if a given modal is at the top of the stack.
+ */
+ public isTop(id: string): boolean {
+ if (this.stack.length === 0) return false;
+ return this.stack[this.stack.length - 1] === id;
+ }
+
+ /**
+ * Gets the current stack of modal IDs.
+ */
+ public getStack(): string[] {
+ return [...this.stack];
+ }
+
+ /**
+ * Subscribes to stack changes.
+ */
+ public subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ private notify(): void {
+ this.listeners.forEach(listener => {
+ try {
+ listener();
+ } catch (e) {
+ console.error('Error in ModalStackManager listener:', e);
+ }
+ });
+ }
+
+ /**
+ * Clears the stack (primarily for testing purposes).
+ */
+ public clear(): void {
+ this.stack = [];
+ this.notify();
+ }
+}
+
+export const modalStackManager = ModalStackManager.getInstance();
+
+/**
+ * Hook to manage modal stacking. Registers the modal in the stack when visible
+ * and provides the auto-assigned zIndex and active top status.
+ *
+ * @param id Unique identifier for the modal
+ * @param visible Whether the modal is currently visible
+ */
+export function useModalStack(id: string, visible: boolean): UseModalStackResult {
+ const [state, setState] = useState(() => ({
+ zIndex: modalStackManager.getZIndex(id),
+ isTop: modalStackManager.isTop(id),
+ stackIndex: modalStackManager.getStack().indexOf(id),
+ }));
+
+ useEffect(() => {
+ if (visible) {
+ modalStackManager.push(id);
+ } else {
+ modalStackManager.pop(id);
+ }
+
+ return () => {
+ modalStackManager.pop(id);
+ };
+ }, [id, visible]);
+
+ useEffect(() => {
+ if (!visible) {
+ setState({
+ zIndex: modalStackManager.getZIndex(id),
+ isTop: false,
+ stackIndex: -1,
+ });
+ return;
+ }
+
+ const unsubscribe = modalStackManager.subscribe(() => {
+ setState({
+ zIndex: modalStackManager.getZIndex(id),
+ isTop: modalStackManager.isTop(id),
+ stackIndex: modalStackManager.getStack().indexOf(id),
+ });
+ });
+
+ setState({
+ zIndex: modalStackManager.getZIndex(id),
+ isTop: modalStackManager.isTop(id),
+ stackIndex: modalStackManager.getStack().indexOf(id),
+ });
+
+ return unsubscribe;
+ }, [id, visible]);
+
+ return state;
+}