Skip to content

Commit 3d5ef4c

Browse files
Merge pull request #740 from Agbasimere/feature/modal-stacking-z-index
feat: implement efficient modal stacking and automatic z-index management
2 parents 913dd40 + 5b1188f commit 3d5ef4c

5 files changed

Lines changed: 408 additions & 12 deletions

File tree

docs/modal-portal-pattern.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,49 @@ function MyComponent() {
8282
`hideModal(id)` — removes the modal.
8383
`isVisible(id)` — returns whether a modal with that id is registered.
8484

85+
## Modal Stacking & Z-Index Management
86+
87+
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:
88+
89+
1. **Auto-assigned Z-Index**:
90+
- The `ModalStackManager` class maintains a global stacking order.
91+
- The stack manager auto-assigns ascending `zIndex` values starting from `10000` (step size of `10`) based on the modal's stack position.
92+
- Portalled modals (rendered via `ModalPortal`) are wrapped in a container that dynamically applies these `zIndex` styles on stack updates.
93+
94+
2. **Top-Most Modal Exclusivity**:
95+
- The focus trap of lower modals is automatically paused and is active *only* for the top-most modal, avoiding accessibility and focus fighting.
96+
- Dismissal events (backdrop click, Android hardware back button, or Escape key on Web) are handled exclusively by the top-most modal.
97+
98+
### useModalStack Hook
99+
100+
Custom modals that do not use `AccessibleModal` can still coordinate their stacking order:
101+
102+
```tsx
103+
import { useModalStack } from './ModalStackManager';
104+
105+
function CustomModal({ id, visible, onClose, children }) {
106+
const { zIndex, isTop } = useModalStack(id, visible);
107+
108+
return (
109+
<Modal
110+
visible={visible}
111+
onRequestClose={() => {
112+
if (isTop) onClose();
113+
}}
114+
>
115+
<Pressable
116+
style={{ position: 'absolute', inset: 0, zIndex }}
117+
onPress={() => {
118+
if (isTop) onClose();
119+
}}
120+
>
121+
{children}
122+
</Pressable>
123+
</Modal>
124+
);
125+
}
126+
```
127+
85128
## Graceful fallback
86129

87130
`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.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { act, renderHook } from '@testing-library/react-native';
2+
import { modalStackManager, useModalStack } from '../../components/common/ModalStackManager';
3+
4+
describe('ModalStackManager & useModalStack', () => {
5+
beforeEach(() => {
6+
// Reset manager stack state before each test
7+
modalStackManager.clear();
8+
});
9+
10+
describe('ModalStackManager Class (Core Stacking Logic)', () => {
11+
it('should correctly push and pop modals', () => {
12+
expect(modalStackManager.getStack()).toEqual([]);
13+
14+
modalStackManager.push('modal-1');
15+
expect(modalStackManager.getStack()).toEqual(['modal-1']);
16+
17+
modalStackManager.push('modal-2');
18+
expect(modalStackManager.getStack()).toEqual(['modal-1', 'modal-2']);
19+
20+
modalStackManager.pop('modal-1');
21+
expect(modalStackManager.getStack()).toEqual(['modal-2']);
22+
23+
modalStackManager.pop('modal-2');
24+
expect(modalStackManager.getStack()).toEqual([]);
25+
});
26+
27+
it('should auto-assign correct z-index values', () => {
28+
// Base z-index is 10000, step is 10
29+
expect(modalStackManager.getZIndex('unregistered')).toBe(10000);
30+
31+
modalStackManager.push('modal-1');
32+
expect(modalStackManager.getZIndex('modal-1')).toBe(10000);
33+
34+
modalStackManager.push('modal-2');
35+
expect(modalStackManager.getZIndex('modal-1')).toBe(10000);
36+
expect(modalStackManager.getZIndex('modal-2')).toBe(10010);
37+
38+
modalStackManager.push('modal-3');
39+
expect(modalStackManager.getZIndex('modal-3')).toBe(10020);
40+
});
41+
42+
it('should correctly identify the top-most modal', () => {
43+
expect(modalStackManager.isTop('modal-1')).toBe(false);
44+
45+
modalStackManager.push('modal-1');
46+
expect(modalStackManager.isTop('modal-1')).toBe(true);
47+
48+
modalStackManager.push('modal-2');
49+
expect(modalStackManager.isTop('modal-1')).toBe(false);
50+
expect(modalStackManager.isTop('modal-2')).toBe(true);
51+
});
52+
53+
it('should handle duplicate push operations by moving the item to the top', () => {
54+
modalStackManager.push('modal-1');
55+
modalStackManager.push('modal-2');
56+
modalStackManager.push('modal-3');
57+
expect(modalStackManager.getStack()).toEqual(['modal-1', 'modal-2', 'modal-3']);
58+
59+
// Pushing existing 'modal-1' should move it to the top
60+
modalStackManager.push('modal-1');
61+
expect(modalStackManager.getStack()).toEqual(['modal-2', 'modal-3', 'modal-1']);
62+
expect(modalStackManager.isTop('modal-1')).toBe(true);
63+
expect(modalStackManager.getZIndex('modal-1')).toBe(10020);
64+
expect(modalStackManager.getZIndex('modal-2')).toBe(10000);
65+
});
66+
});
67+
68+
describe('useModalStack Hook', () => {
69+
it('should register modal when visible and unregister on unmount', () => {
70+
const { result, unmount } = renderHook(({ visible }) => useModalStack('hook-modal', visible), {
71+
initialProps: { visible: true },
72+
});
73+
74+
expect(result.current.zIndex).toBe(10000);
75+
expect(result.current.isTop).toBe(true);
76+
expect(modalStackManager.getStack()).toEqual(['hook-modal']);
77+
78+
unmount();
79+
expect(modalStackManager.getStack()).toEqual([]);
80+
});
81+
82+
it('should reactively update when visibility changes', () => {
83+
const { result, rerender } = renderHook(({ visible }) => useModalStack('hook-modal', visible), {
84+
initialProps: { visible: false },
85+
});
86+
87+
expect(result.current.isTop).toBe(false);
88+
expect(modalStackManager.getStack()).toEqual([]);
89+
90+
rerender({ visible: true });
91+
expect(result.current.zIndex).toBe(10000);
92+
expect(result.current.isTop).toBe(true);
93+
expect(modalStackManager.getStack()).toEqual(['hook-modal']);
94+
95+
rerender({ visible: false });
96+
expect(result.current.isTop).toBe(false);
97+
expect(modalStackManager.getStack()).toEqual([]);
98+
});
99+
100+
it('should assign and update z-index and isTop for multiple stacked modals', () => {
101+
const { result: modal1 } = renderHook(({ visible }) => useModalStack('modal-a', visible), {
102+
initialProps: { visible: true },
103+
});
104+
105+
expect(modal1.current.zIndex).toBe(10000);
106+
expect(modal1.current.isTop).toBe(true);
107+
108+
// Now open modal B
109+
const { result: modal2, unmount: unmountB } = renderHook(({ visible }) => useModalStack('modal-b', visible), {
110+
initialProps: { visible: true },
111+
});
112+
113+
// Modal B should be top
114+
expect(modal2.current.zIndex).toBe(10010);
115+
expect(modal2.current.isTop).toBe(true);
116+
117+
// Modal A should no longer be top
118+
expect(modal1.current.zIndex).toBe(10000);
119+
expect(modal1.current.isTop).toBe(false);
120+
121+
// Close modal B
122+
act(() => {
123+
unmountB();
124+
});
125+
126+
// Modal A should become top again
127+
expect(modal1.current.zIndex).toBe(10000);
128+
expect(modal1.current.isTop).toBe(true);
129+
});
130+
});
131+
});

src/components/common/AccessibleModal.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import {
1111
} from 'react-native';
1212

1313
import { useModalPortalSafe } from './ModalPortal';
14+
import { useModalStack } from './ModalStackManager';
1415
import { useFocusRestore } from '../../hooks/useFocusRestore';
1516
import { useFocusTrap } from '../../hooks/useFocusTrap';
1617

1718
interface AccessibleModalProps extends Omit<ModalProps, 'visible'> {
19+
/** Optional unique identifier for stack tracking and z-index auto-assignment */
20+
id?: string;
1821
/** Whether the modal is visible */
1922
visible: boolean;
2023
/** Callback when the modal overlay or request close is triggered */
@@ -48,6 +51,7 @@ interface AccessibleModalProps extends Omit<ModalProps, 'visible'> {
4851
* Pass usePortal={false} to render inline (e.g. in tests or outside a provider).
4952
*/
5053
export const AccessibleModal: React.FC<AccessibleModalProps> = ({
54+
id,
5155
visible,
5256
onClose,
5357
accessibilityLabel,
@@ -61,25 +65,41 @@ export const AccessibleModal: React.FC<AccessibleModalProps> = ({
6165
}) => {
6266
const containerRef = useRef<View>(null);
6367

68+
// Stable portal id derived from id or accessibilityLabel
69+
const portalId = id || `accessible-modal:${accessibilityLabel}`;
70+
const portal = useModalPortalSafe();
71+
72+
// Stacking z-index and active top status
73+
const { zIndex, isTop } = useModalStack(portalId, visible);
74+
6475
useFocusRestore(visible, triggerRef);
65-
const { containerProps } = useFocusTrap(containerRef, visible, {
76+
77+
// Only trap focus if the modal is visible and is the top-most modal
78+
const { containerProps } = useFocusTrap(containerRef, visible && isTop, {
6679
initialFocusRef,
6780
autoFocus: true,
6881
});
6982

70-
// Stable portal id derived from accessibilityLabel
71-
const portalId = `accessible-modal:${accessibilityLabel}`;
72-
const portal = useModalPortalSafe();
73-
7483
const modalContent = (
7584
<Modal
7685
transparent
7786
animationType="fade"
7887
visible={visible}
79-
onRequestClose={onClose}
88+
onRequestClose={() => {
89+
if (isTop) {
90+
onClose();
91+
}
92+
}}
8093
{...modalProps}
8194
>
82-
<Pressable style={[styles.overlay, overlayStyle]} onPress={onClose}>
95+
<Pressable
96+
style={[styles.overlay, overlayStyle, { zIndex }]}
97+
onPress={() => {
98+
if (isTop) {
99+
onClose();
100+
}
101+
}}
102+
>
83103
<Pressable
84104
ref={containerRef}
85105
style={[styles.content, containerStyle]}
@@ -106,8 +126,7 @@ export const AccessibleModal: React.FC<AccessibleModalProps> = ({
106126
return () => {
107127
portal.hideModal(portalId);
108128
};
109-
// eslint-disable-next-line react-hooks/exhaustive-deps
110-
}, [visible, usePortal, portal, portalId]);
129+
}, [visible, usePortal, portal, portalId, modalContent]);
111130

112131
// When portal is active, the host renders the modal — nothing to render here
113132
if (usePortal && portal) return null;

src/components/common/ModalPortal.tsx

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
* showModal('confirm', <ConfirmDialog onClose={() => hideModal('confirm')} />);
2525
*/
2626

27-
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
27+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
2828
import { View } from 'react-native';
29+
import { modalStackManager } from './ModalStackManager';
2930

3031
// ─── Types ────────────────────────────────────────────────────────────────────
3132

@@ -90,6 +91,42 @@ export const ModalPortalProvider: React.FC<{ children: React.ReactNode }> = ({ c
9091
);
9192
};
9293

94+
// ─── Wrapper ──────────────────────────────────────────────────────────────────
95+
96+
/**
97+
* Subscribes to ModalStackManager to dynamically apply the computed z-index
98+
* style wrapper to the portal modal child when other modals open or close.
99+
*/
100+
const ModalPortalWrapper: React.FC<{ id: string; children: React.ReactNode }> = ({
101+
id,
102+
children,
103+
}) => {
104+
const [zIndex, setZIndex] = useState(() => modalStackManager.getZIndex(id));
105+
106+
useEffect(() => {
107+
const unsubscribe = modalStackManager.subscribe(() => {
108+
setZIndex(modalStackManager.getZIndex(id));
109+
});
110+
// Immediately sync in case stack changed during subscribe
111+
setZIndex(modalStackManager.getZIndex(id));
112+
return unsubscribe;
113+
}, [id]);
114+
115+
return (
116+
<View
117+
collapsable={false}
118+
style={{
119+
position: 'absolute',
120+
width: 0,
121+
height: 0,
122+
zIndex,
123+
}}
124+
>
125+
{children}
126+
</View>
127+
);
128+
};
129+
93130
// ─── Host ─────────────────────────────────────────────────────────────────────
94131

95132
/**
@@ -104,9 +141,9 @@ const ModalPortalHost: React.FC<{ _modals: ModalEntry[] }> = React.memo(function
104141
return (
105142
<>
106143
{_modals.map(({ id, content }) => (
107-
<View key={id} collapsable={false} style={{ position: 'absolute', width: 0, height: 0 }}>
144+
<ModalPortalWrapper key={id} id={id}>
108145
{content}
109-
</View>
146+
</ModalPortalWrapper>
110147
))}
111148
</>
112149
);

0 commit comments

Comments
 (0)