From 98d2e277bfe9099f42b26668d80311bc9f46e165 Mon Sep 17 00:00:00 2001 From: paufau Date: Mon, 7 Sep 2026 23:51:39 +0400 Subject: [PATCH] feat(web): improve animations behavior --- README.md | 6 +--- eslint.config.js | 10 +----- example/metro.config.js | 16 +++++++-- src/ModalView.web.tsx | 31 +++++----------- src/helpers/animationHelpers.ts | 59 +++++++++++++++++++++++++++++++ src/{ => helpers}/focusHelpers.ts | 0 src/hooks/useFocusTrap.ts | 2 +- src/hooks/useModalAnimation.ts | 50 ++++++++++++++++++++++++++ src/hooks/useRestoreFocus.ts | 2 +- src/types.ts | 4 ++- 10 files changed, 137 insertions(+), 43 deletions(-) create mode 100644 src/helpers/animationHelpers.ts rename src/{ => helpers}/focusHelpers.ts (100%) create mode 100644 src/hooks/useModalAnimation.ts diff --git a/README.md b/README.md index 9d7f166..32a202f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/eslint.config.js b/eslint.config.js index 655d701..93bd08d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, @@ -45,6 +36,7 @@ module.exports = [ // Base JS ...js.configs.recommended.rules, 'no-unused-vars': 'off', + 'no-undef': 'off', // React ...reactPlugin.configs.recommended.rules, diff --git a/example/metro.config.js b/example/metro.config.js index 85c91a0..ad65482 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -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 @@ -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; diff --git a/src/ModalView.web.tsx b/src/ModalView.web.tsx index 808fcd1..09efe10 100644 --- a/src/ModalView.web.tsx +++ b/src/ModalView.web.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useState } from 'react'; +import { useEffect, useId } from 'react'; import type { FC } from 'react'; import { createPortal } from 'react-dom'; @@ -6,6 +6,7 @@ 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'; @@ -43,12 +44,7 @@ export const ModalView: FC = ({ 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) { @@ -68,23 +64,12 @@ export const ModalView: FC = ({ }; }, [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( - + {showBackdrop && ( 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); +} diff --git a/src/focusHelpers.ts b/src/helpers/focusHelpers.ts similarity index 100% rename from src/focusHelpers.ts rename to src/helpers/focusHelpers.ts diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts index 1de83ef..faadf11 100644 --- a/src/hooks/useFocusTrap.ts +++ b/src/hooks/useFocusTrap.ts @@ -7,7 +7,7 @@ import { inertBackground, maybeGetElement, onDocumentFocus, -} from '../focusHelpers'; +} from '../helpers/focusHelpers'; import { useRestoreFocus } from './useRestoreFocus'; export function useFocusTrap(isTopmost: boolean) { diff --git a/src/hooks/useModalAnimation.ts b/src/hooks/useModalAnimation.ts new file mode 100644 index 0000000..d542afa --- /dev/null +++ b/src/hooks/useModalAnimation.ts @@ -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(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 }; +} diff --git a/src/hooks/useRestoreFocus.ts b/src/hooks/useRestoreFocus.ts index 5cf9603..301004b 100644 --- a/src/hooks/useRestoreFocus.ts +++ b/src/hooks/useRestoreFocus.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { maybeGetElement } from '../focusHelpers'; +import { maybeGetElement } from '../helpers/focusHelpers'; export function useRestoreFocus() { useEffect(() => { diff --git a/src/types.ts b/src/types.ts index 6adfab3..068695c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,8 @@ import type { import { DismissalSource } from './ModalView'; +export type AnimationType = 'none' | 'fade' | 'slide'; + export type ModalViewProps = { /** * The content of the modal. @@ -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.