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
6 changes: 1 addition & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,8 @@ I would love if you would let me know what you are missing in the library. _Toge

## Roadmap

- Align web modal behavior and API with mobile
- play exit animations (fade-out / slide-out) on dismiss
- focus trap, body scroll-lock, and `aria-modal` for accessibility
- guard `document.body` access for SSR (Next.js / server rendering)
- Guard `document.body` access for SSR (Next.js / server rendering)
- Drop old architecture support & deprecated props
- Change versioning to {LIB}.{RN_VERSION}.{PATCH}
- Create separate documentation page

## Troubleshooting
Expand Down
10 changes: 1 addition & 9 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ module.exports = [
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
globals: {
__DEV__: 'readonly',
document: 'readonly',
require: 'readonly',
console: 'readonly',
KeyboardEvent: 'readonly',
HTMLElement: 'readonly',
Element: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
Expand All @@ -45,6 +36,7 @@ module.exports = [
// Base JS
...js.configs.recommended.rules,
'no-unused-vars': 'off',
'no-undef': 'off',

// React
...reactPlugin.configs.recommended.rules,
Expand Down
16 changes: 13 additions & 3 deletions example/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ const config = getDefaultConfig(projectRoot);

config.watchFolders = [projectRoot, path.resolve(root, 'src')];

config.resolver.extraNodeModules = {
'react-native-multiple-modals': path.resolve(root, 'src'),
};
config.resolver.extraNodeModules = {};

// Force the library's peer deps to resolve from the example's node_modules, and
// block the root copies so imports originating in `../src` don't pick up the
Expand All @@ -36,4 +34,16 @@ config.resolver.blockList = [
...blocks,
];

const libName = pak.name;
const libSrc = path.resolve(root, 'src');
const libPrefix = `${libName}/`;

config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === libName || moduleName.startsWith(libPrefix)) {
const rest = moduleName.slice(libName.length);
return context.resolveRequest(context, path.join(libSrc, rest || 'index'), platform);
}
return context.resolveRequest(context, moduleName, platform);
};

module.exports = config;
31 changes: 8 additions & 23 deletions src/ModalView.web.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useEffect, useId, useMemo, useState } from 'react';
import { useEffect, useId } from 'react';
import type { FC } from 'react';

import { createPortal } from 'react-dom';
import { StyleSheet, View, Pressable } from 'react-native';

import { FocusBracket } from './FocusBracket';
import { useFocusTrap } from './hooks/useFocusTrap';
import { useModalAnimation } from './hooks/useModalAnimation';
import { useModalStack } from './hooks/useModalStack';
import type { ModalViewProps } from './types';

Expand Down Expand Up @@ -43,12 +44,7 @@ export const ModalView: FC<ModalViewWebProps> = ({
const { isTopmost } = useModalStack(currentModalId);

const contentRef = useFocusTrap(isTopmost);

const [isOpen, setIsOpen] = useState(false);

useEffect(() => {
setIsOpen(true);
}, []);
const { setContainerRef, animatedStyle } = useModalAnimation(animationType);

useEffect(() => {
if (!isTopmost || !onRequestDismiss) {
Expand All @@ -68,23 +64,12 @@ export const ModalView: FC<ModalViewWebProps> = ({
};
}, [isTopmost, onRequestDismiss]);

const animatedStyle = useMemo(() => {
switch (animationType) {
case 'fade':
return { opacity: isOpen ? 1 : 0, transition: 'opacity 0.3s' };
case 'slide':
return {
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
opacity: isOpen ? 1 : 0,
transition: 'transform 0.3s, opacity 0.3s',
};
default:
return {};
}
}, [animationType, isOpen]);

return createPortal(
<View pointerEvents='box-none' style={styles.container}>
<View
ref={setContainerRef}
pointerEvents='box-none'
style={styles.container}
>
{showBackdrop && (
<BackdropPressableComponent
accessibilityLabel={backdropAccessibilityLabel}
Expand Down
59 changes: 59 additions & 0 deletions src/helpers/animationHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { maybeGetElement } from './focusHelpers';
import type { AnimationType } from '../types';

const EXIT_DURATION_MS = 300;

const EXIT_ANIMATION_OPTIONS: KeyframeAnimationOptions = {
duration: EXIT_DURATION_MS,
easing: 'ease',
fill: 'forwards',
};

export function runAfterGuaranteedRender(callback: () => void) {
let animationFrame2: number | undefined;

const animationFrame1 = requestAnimationFrame(() => {
animationFrame2 = requestAnimationFrame(callback);
});

return () => {
cancelAnimationFrame(animationFrame1);
cancelAnimationFrame(animationFrame2);
};
}

export function playExitAnimation(
node: HTMLElement | null,
animationType: AnimationType,
) {
if (!node || animationType === 'none') {
return;
}

const clone = maybeGetElement(node.cloneNode(true));

if (!clone) {
return;
}

clone.style.pointerEvents = 'none';
document.body.appendChild(clone);

const fadeAnimation = clone.animate(
{ opacity: [1, 0] },
EXIT_ANIMATION_OPTIONS,
);

if (animationType === 'slide') {
const content = maybeGetElement(clone.lastElementChild);

content?.animate(
{ transform: ['translateY(0)', 'translateY(100%)'] },
EXIT_ANIMATION_OPTIONS,
);
}

const remove = () => clone.remove();

fadeAnimation.finished.then(remove, remove);
}
File renamed without changes.
2 changes: 1 addition & 1 deletion src/hooks/useFocusTrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
inertBackground,
maybeGetElement,
onDocumentFocus,
} from '../focusHelpers';
} from '../helpers/focusHelpers';
import { useRestoreFocus } from './useRestoreFocus';

export function useFocusTrap(isTopmost: boolean) {
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/useModalAnimation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import {
playExitAnimation,
runAfterGuaranteedRender,
} from '../helpers/animationHelpers';
import { maybeGetElement } from '../helpers/focusHelpers';
import type { AnimationType } from '../types';

export function useModalAnimation(animationType: AnimationType) {
const [isVisible, setVisibility] = useState(animationType === 'none');

const containerRef = useRef<HTMLElement | null>(null);
const latestAnimationType = useRef(animationType);
latestAnimationType.current = animationType;

const setContainerRef = useCallback((node: unknown) => {
containerRef.current = maybeGetElement(node);
}, []);

useEffect(() => {
const cancelEnteringAnimation = runAfterGuaranteedRender(() =>
setVisibility(true),
);

const modalContent = containerRef.current;

return () => {
cancelEnteringAnimation();
playExitAnimation(modalContent, latestAnimationType.current);
};
}, []);

const animatedStyle = useMemo(() => {
switch (animationType) {
case 'fade':
return { opacity: isVisible ? 1 : 0, transition: 'opacity 0.3s' };
case 'slide':
return {
transform: isVisible ? 'translateY(0)' : 'translateY(100%)',
opacity: isVisible ? 1 : 0,
transition: 'transform 0.3s, opacity 0.3s',
};
default:
return {};
}
}, [animationType, isVisible]);

return { setContainerRef, animatedStyle };
}
2 changes: 1 addition & 1 deletion src/hooks/useRestoreFocus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from 'react';

import { maybeGetElement } from '../focusHelpers';
import { maybeGetElement } from '../helpers/focusHelpers';

export function useRestoreFocus() {
useEffect(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {

import { DismissalSource } from './ModalView';

export type AnimationType = 'none' | 'fade' | 'slide';

export type ModalViewProps = {
/**
* The content of the modal.
Expand Down Expand Up @@ -63,7 +65,7 @@ export type ModalViewProps = {
* Can be 'none', 'fade', or 'slide'.
* Defaults to 'none'.
*/
animationType?: 'none' | 'fade' | 'slide';
animationType?: AnimationType;

/**
* Whether to show the backdrop behind the modal.
Expand Down