Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/modal-portal-pattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Modal
visible={visible}
onRequestClose={() => {
if (isTop) onClose();
}}
>
<Pressable
style={{ position: 'absolute', inset: 0, zIndex }}
onPress={() => {
if (isTop) onClose();
}}
>
{children}
</Pressable>
</Modal>
);
}
```

## 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.
Expand Down
131 changes: 131 additions & 0 deletions src/__tests__/components/ModalStackManager.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
37 changes: 28 additions & 9 deletions src/components/common/AccessibleModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModalProps, 'visible'> {
/** 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 */
Expand Down Expand Up @@ -48,6 +51,7 @@ interface AccessibleModalProps extends Omit<ModalProps, 'visible'> {
* Pass usePortal={false} to render inline (e.g. in tests or outside a provider).
*/
export const AccessibleModal: React.FC<AccessibleModalProps> = ({
id,
visible,
onClose,
accessibilityLabel,
Expand All @@ -61,25 +65,41 @@ export const AccessibleModal: React.FC<AccessibleModalProps> = ({
}) => {
const containerRef = useRef<View>(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 = (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
onRequestClose={() => {
if (isTop) {
onClose();
}
}}
{...modalProps}
>
<Pressable style={[styles.overlay, overlayStyle]} onPress={onClose}>
<Pressable
style={[styles.overlay, overlayStyle, { zIndex }]}
onPress={() => {
if (isTop) {
onClose();
}
}}
>
<Pressable
ref={containerRef}
style={[styles.content, containerStyle]}
Expand All @@ -106,8 +126,7 @@ export const AccessibleModal: React.FC<AccessibleModalProps> = ({
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;
Expand Down
43 changes: 40 additions & 3 deletions src/components/common/ModalPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
* showModal('confirm', <ConfirmDialog onClose={() => 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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 (
<View
collapsable={false}
style={{
position: 'absolute',
width: 0,
height: 0,
zIndex,
}}
>
{children}
</View>
);
};

// ─── Host ─────────────────────────────────────────────────────────────────────

/**
Expand All @@ -104,9 +141,9 @@ const ModalPortalHost: React.FC<{ _modals: ModalEntry[] }> = React.memo(function
return (
<>
{_modals.map(({ id, content }) => (
<View key={id} collapsable={false} style={{ position: 'absolute', width: 0, height: 0 }}>
<ModalPortalWrapper key={id} id={id}>
{content}
</View>
</ModalPortalWrapper>
))}
</>
);
Expand Down
Loading
Loading