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; +}