diff --git a/fluxer_app/src/features/ui/state/LayerManager.ts b/fluxer_app/src/features/ui/state/LayerManager.ts index c3571ded5..012be0ec5 100644 --- a/fluxer_app/src/features/ui/state/LayerManager.ts +++ b/fluxer_app/src/features/ui/state/LayerManager.ts @@ -9,7 +9,6 @@ type LayerType = 'modal' | 'popout' | 'contextmenu'; export interface Layer { type: LayerType; key: string | PopoutKey; - timestamp: number; onClose?: () => void; } @@ -34,21 +33,16 @@ class LayerManager { if (event.key !== 'Escape') return; const topLayer = this.getTopLayer(); if (!topLayer) return; + if (topLayer.type !== 'popout' && !topLayer.onClose) return; event.preventDefault(); event.stopImmediatePropagation(); - if (topLayer.type === 'modal') { - if (topLayer.onClose) { - topLayer.onClose(); - } else { - ModalCommands.pop(); - } - } else if (topLayer.type === 'popout') { + if (topLayer.type === 'popout') { topLayer.onClose?.(); this.removeLayer('popout', topLayer.key); PopoutCommands.close(topLayer.key); - } else if (topLayer.type === 'contextmenu') { - topLayer.onClose?.(); + return; } + topLayer.onClose?.(); }; addLayer(type: LayerType, key: string | PopoutKey, onClose?: () => void) { @@ -56,7 +50,6 @@ class LayerManager { this.layers.push({ type, key, - timestamp: Date.now(), onClose, }); } diff --git a/fluxer_app/src/features/ui/state/Modal.ts b/fluxer_app/src/features/ui/state/Modal.ts index 106d16f46..a449c3984 100644 --- a/fluxer_app/src/features/ui/state/Modal.ts +++ b/fluxer_app/src/features/ui/state/Modal.ts @@ -200,6 +200,10 @@ class ModalState { }); } + getKeyAtStackIndex(stackIndex: number, ownerDocument: Document = document): string | undefined { + return this.modals.filter((modal) => modal.ownerDocument === ownerDocument)[stackIndex]?.key; + } + getModal(ownerDocument: Document = document): ModalEntry | undefined { return this.modals.findLast((modal) => modal.ownerDocument === ownerDocument); } diff --git a/fluxer_app/src/features/ui/utils/ModalStackContext.ts b/fluxer_app/src/features/ui/utils/ModalStackContext.ts index d2cdf7cc6..8c48d9ae9 100644 --- a/fluxer_app/src/features/ui/utils/ModalStackContext.ts +++ b/fluxer_app/src/features/ui/utils/ModalStackContext.ts @@ -10,10 +10,12 @@ export interface ModalStackContextValue { restoreFocusOnClose: boolean; } -export const ModalStackContext = createContext({ +export const UNSTACKED_MODAL_CONTEXT: ModalStackContextValue = { stackIndex: 0, isVisible: true, needsBackdrop: true, isTopmost: true, restoreFocusOnClose: true, -}); +}; + +export const ModalStackContext = createContext(UNSTACKED_MODAL_CONTEXT); diff --git a/fluxer_app/src/features/ui/utils/ModalUtils.ts b/fluxer_app/src/features/ui/utils/ModalUtils.ts index 9d89dd639..87a4f745b 100644 --- a/fluxer_app/src/features/ui/utils/ModalUtils.ts +++ b/fluxer_app/src/features/ui/utils/ModalUtils.ts @@ -2,13 +2,17 @@ import Accessibility from '@app/features/accessibility/state/Accessibility'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; +import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext'; import LayerManager from '@app/features/ui/state/LayerManager'; import MobileLayout from '@app/features/ui/state/MobileLayout'; +import ModalState from '@app/features/ui/state/Modal'; +import {ModalStackContext, UNSTACKED_MODAL_CONTEXT} from '@app/features/ui/utils/ModalStackContext'; import {useId} from '@floating-ui/react'; import { type ReactNode, type RefObject, useCallback, + useContext, useEffect, useLayoutEffect, useMemo, @@ -16,10 +20,71 @@ import { useState, } from 'react'; +interface BackdropActivationWatcher { + watcherCount: number; + pressTarget: EventTarget | null; + isRepeatActivation: boolean; + isPressTargetDetached: boolean; + handlePress: (event: Event) => void; + handleClickActivation: (event: Event) => void; +} + +const backdropActivationWatchers = new WeakMap(); + +function watchBackdropActivation(ownerDocument: Document): () => void { + let watcher = backdropActivationWatchers.get(ownerDocument); + if (!watcher) { + const created: BackdropActivationWatcher = { + watcherCount: 0, + pressTarget: null, + isRepeatActivation: false, + isPressTargetDetached: false, + handlePress: (event: Event) => { + created.pressTarget = event.target; + }, + handleClickActivation: (event: Event) => { + const {pressTarget} = created; + const activationTarget = event.target; + created.isRepeatActivation = (event as MouseEvent).detail > 1; + created.isPressTargetDetached = + pressTarget instanceof Node && activationTarget instanceof Node && !activationTarget.contains(pressTarget); + created.pressTarget = null; + }, + }; + ownerDocument.addEventListener('pointerdown', created.handlePress, true); + ownerDocument.addEventListener('mousedown', created.handlePress, true); + ownerDocument.addEventListener('touchstart', created.handlePress, true); + ownerDocument.addEventListener('click', created.handleClickActivation, true); + backdropActivationWatchers.set(ownerDocument, created); + watcher = created; + } + const activeWatcher = watcher; + activeWatcher.watcherCount += 1; + return () => { + activeWatcher.watcherCount -= 1; + if (activeWatcher.watcherCount > 0) { + return; + } + ownerDocument.removeEventListener('pointerdown', activeWatcher.handlePress, true); + ownerDocument.removeEventListener('mousedown', activeWatcher.handlePress, true); + ownerDocument.removeEventListener('touchstart', activeWatcher.handlePress, true); + ownerDocument.removeEventListener('click', activeWatcher.handleClickActivation, true); + backdropActivationWatchers.delete(ownerDocument); + }; +} + +function isBackdropActivationCarriedOver(ownerDocument: Document): boolean { + const watcher = backdropActivationWatchers.get(ownerDocument); + if (!watcher) { + return false; + } + return watcher.isRepeatActivation || watcher.isPressTargetDetached; +} + export type ModalSize = 'medium' | 'small' | 'large' | 'xlarge' | 'fullscreen'; export type LabelSource = 'header' | 'screen-reader'; -export type ModalTransitionPreset = 'default' | 'instant' | 'profile-slide'; +export type ModalTransitionPreset = 'default' | 'instant' | 'quick' | 'profile-slide'; export interface ModalProps { children: ReactNode; @@ -37,6 +102,7 @@ export interface ModalProps { export interface ModalContextValue { getDefaultLabelId: (source: LabelSource) => string; registerLabel: (source: LabelSource, id: string) => () => void; + popOwningModal: () => void; } export interface ModalLogicState { @@ -69,9 +135,12 @@ export function useModalLogic({ const prefersReducedMotion = Accessibility.useReducedMotion; const baseLabelId = useId() || 'modal'; const modalKey = useRef(Math.random().toString(36).substring(7)).current; + const stackPlacement = useContext(ModalStackContext); + const portalHost = usePortalHost(); + const ownerDocument = portalHost?.ownerDocument ?? document; + const stackEntryKeyRef = useRef(null); const [labelRegistry, setLabelRegistry] = useState>>({}); const [hasMounted, setHasMounted] = useState(false); - const backdropContaminatedRef = useRef(false); const registerLabel = useCallback((source: LabelSource, id: string) => { setLabelRegistry((current) => ({...current, [source]: id})); return () => { @@ -90,7 +159,28 @@ export function useModalLogic({ const ids = Object.values(labelRegistry).filter(Boolean); return ids.length > 0 ? ids.join(' ') : undefined; }, [labelRegistry]); - const modalContextValue = useMemo(() => ({getDefaultLabelId, registerLabel}), [getDefaultLabelId, registerLabel]); + useLayoutEffect(() => { + if (stackPlacement === UNSTACKED_MODAL_CONTEXT) { + return; + } + const resolvedKey = ModalState.getKeyAtStackIndex(stackPlacement.stackIndex, ownerDocument); + if (resolvedKey != null) { + stackEntryKeyRef.current = resolvedKey; + } + }, [ownerDocument, stackPlacement]); + useEffect(() => watchBackdropActivation(ownerDocument), [ownerDocument]); + const popOwningModal = useCallback(() => { + const stackEntryKey = stackEntryKeyRef.current; + if (stackEntryKey != null) { + ModalCommands.popWithKey(stackEntryKey); + return; + } + ModalCommands.pop(); + }, []); + const modalContextValue = useMemo( + () => ({getDefaultLabelId, registerLabel, popOwningModal}), + [getDefaultLabelId, popOwningModal, registerLabel], + ); useEffect(() => { if (typeof queueMicrotask === 'function') { queueMicrotask(() => setHasMounted(true)); @@ -106,31 +196,6 @@ export function useModalLogic({ 'Modal.Root requires either a Modal.Header or Modal.ScreenReaderLabel to provide an accessible label.', ); }, [hasMounted, labelledBy]); - useEffect(() => { - LayerManager.addLayer('modal', modalKey, onClose); - return () => { - LayerManager.removeLayer('modal', modalKey); - }; - }, [onClose, modalKey]); - const handleBackdropClick = useCallback( - (customOnClose?: () => void) => { - if (backdropContaminatedRef.current) { - return; - } - backdropContaminatedRef.current = true; - setTimeout(() => { - backdropContaminatedRef.current = false; - }, 100); - if (customOnClose) { - customOnClose(); - } else if (onClose) { - onClose(); - } else { - ModalCommands.pop(); - } - }, - [onClose], - ); const handleClose = useCallback( (customOnClose?: () => void) => { if (customOnClose) { @@ -138,10 +203,25 @@ export function useModalLogic({ } else if (onClose) { onClose(); } else { - ModalCommands.pop(); + popOwningModal(); } }, - [onClose], + [onClose, popOwningModal], + ); + useEffect(() => { + LayerManager.addLayer('modal', modalKey, handleClose); + return () => { + LayerManager.removeLayer('modal', modalKey); + }; + }, [handleClose, modalKey]); + const handleBackdropClick = useCallback( + (customOnClose?: () => void) => { + if (isBackdropActivationCarriedOver(ownerDocument)) { + return; + } + handleClose(customOnClose); + }, + [handleClose, ownerDocument], ); return { isMobile, @@ -183,7 +263,7 @@ export function useHeaderLogic({ }: Pick & { modalContextValue: ModalContextValue; }): HeaderLogicState { - const {getDefaultLabelId, registerLabel} = modalContextValue; + const {getDefaultLabelId, registerLabel, popOwningModal} = modalContextValue; const headingId = useMemo(() => id ?? getDefaultLabelId('header'), [getDefaultLabelId, id]); const useIsomorphicLayoutEffect = useLayoutEffect; useIsomorphicLayoutEffect(() => registerLabel('header', headingId), [headingId, registerLabel]); @@ -191,9 +271,9 @@ export function useHeaderLogic({ if (onClose) { onClose(); } else { - ModalCommands.pop(); + popOwningModal(); } - }, [onClose]); + }, [onClose, popOwningModal]); return { headingId, handleClose,