diff --git a/README.md b/README.md index 5b5049c..9d7f166 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,9 @@ 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 -- Build CI verification against nightly RN + - 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) - Drop old architecture support & deprecated props - Change versioning to {LIB}.{RN_VERSION}.{PATCH} - Create separate documentation page diff --git a/eslint.config.js b/eslint.config.js index 9aa1cb1..fd0c9ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,7 @@ module.exports = [ document: 'readonly', require: 'readonly', console: 'readonly', + KeyboardEvent: 'readonly', }, }, plugins: { diff --git a/src/ModalView.web.tsx b/src/ModalView.web.tsx index f3849bf..d685919 100644 --- a/src/ModalView.web.tsx +++ b/src/ModalView.web.tsx @@ -1,11 +1,10 @@ -import { useMemo } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import type { FC } from 'react'; import { createPortal } from 'react-dom'; import { StyleSheet, View, Pressable } from 'react-native'; -import { useID } from './hooks/useID'; -import { useModalRegistry } from './hooks/useModalRegistry'; +import { useModalStack } from './hooks/useModalStack'; import type { ModalViewProps } from './types'; export type ModalViewWebProps = Omit< @@ -19,6 +18,8 @@ const backdropAccessibilityLabel = 'Backdrop'; const backdropAccessibilityHint = 'Double-tap to close the modal'; const defaultBackdropColor = 'rgba(0, 0, 0, 0.3)'; +const MODAL_Z_INDEX = 10000; // react-native-web's default renders at zIndex 9999 + export enum DismissalSource { BackButton = 'BackButton', Backdrop = 'Backdrop', @@ -35,38 +36,56 @@ export const ModalView: FC = ({ backdropColor = defaultBackdropColor, animationType = 'none', }) => { - const currentModalId = useID(modalId); - const { modals, isBackdropVisible } = useModalRegistry(currentModalId); - const modalIsOpen = modals.has(currentModalId); + const reactId = useId(); + const currentModalId = modalId ?? reactId; + const { isTopmost } = useModalStack(currentModalId); - const animatedStyle = useMemo(() => { - if (animationType === 'fade') { - return { - opacity: modalIsOpen ? 1 : 0, - transition: 'opacity 0.3s', - }; - } - if (animationType === 'slide') { - return { - transform: modalIsOpen ? 'translateY(0)' : 'translateY(100%)', - opacity: modalIsOpen ? 1 : 0, - transition: 'transform 0.3s, opacity 0.3s', - }; + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + setIsOpen(true); + }, []); + + useEffect(() => { + if (!isTopmost || !onRequestDismiss) { + return; } - return {}; - }, [animationType, modalIsOpen]); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onRequestDismiss(DismissalSource.BackButton); + } + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [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 && ( onRequestDismiss?.(DismissalSource.Backdrop)} > {renderBackdrop ? ( @@ -95,9 +114,6 @@ const styles = StyleSheet.create({ opacity: 1, alignSelf: 'stretch', }, - backdropHidden: { - opacity: 0, - }, flex: { flex: 1, }, @@ -108,7 +124,7 @@ const styles = StyleSheet.create({ position: 'absolute', zIndex: 1, }, - backdropContainer: { + container: { bottom: 0, display: 'flex', flexDirection: 'column', @@ -116,6 +132,6 @@ const styles = StyleSheet.create({ position: 'fixed', right: 0, top: 0, - zIndex: 0, + zIndex: MODAL_Z_INDEX, }, } as const); diff --git a/src/hooks/useID.ts b/src/hooks/useID.ts deleted file mode 100644 index 777957b..0000000 --- a/src/hooks/useID.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect, useRef } from 'react'; - -const getRandomId = (): string => Math.random().toString(36); - -export const useID = (defaultID?: string): string => { - const idRef = useRef(defaultID || getRandomId()); - - useEffect(() => { - return () => { - idRef.current = defaultID || getRandomId(); - }; - }, [defaultID]); - - return idRef.current; -}; diff --git a/src/hooks/useModalRegistry.ts b/src/hooks/useModalRegistry.ts deleted file mode 100644 index 959b028..0000000 --- a/src/hooks/useModalRegistry.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useState, useEffect } from 'react'; - -const currentModals = new Set(); - -export function useModalRegistry(modalId: string) { - const [modals, setModals] = useState>(new Set(currentModals)); - - useEffect(() => { - currentModals.add(modalId); - setModals(new Set(currentModals)); - return () => { - currentModals.delete(modalId); - setModals(new Set(currentModals)); - }; - }, [modalId]); - - return { - modals, - isBackdropVisible: modals.size > 0 && Array.from(modals)[0] === modalId, - }; -} diff --git a/src/hooks/useModalStack.ts b/src/hooks/useModalStack.ts new file mode 100644 index 0000000..ebf67aa --- /dev/null +++ b/src/hooks/useModalStack.ts @@ -0,0 +1,32 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +let stack: string[] = []; +const listeners = new Set<() => void>(); + +const emit = () => listeners.forEach(listener => listener()); + +const subscribe = (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +}; + +export function useModalStack(id: string) { + const getIsTopmost = () => stack[stack.length - 1] === id; + + const isTopmost = useSyncExternalStore(subscribe, getIsTopmost, getIsTopmost); + + useEffect(() => { + stack = [...stack, id]; + emit(); + + return () => { + stack = stack.filter(entry => entry !== id); + emit(); + }; + }, [id]); + + return { isTopmost }; +}