fix(app): close the modal you opened, not whatever is on top (#1851)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:31:14 +02:00
1 parent b0e882645e
commit ac6fcc8c40
4 files changed
+125 -46

No files matched your search

@@ -9,7 +9,6 @@ type LayerType = 'modal' | 'popout' | 'contextmenu';
export interface Layer { export interface Layer {
type: LayerType; type: LayerType;
key: string | PopoutKey; key: string | PopoutKey;
timestamp: number;
onClose?: () => void; onClose?: () => void;
} }
@@ -34,21 +33,16 @@ class LayerManager {
if (event.key !== 'Escape') return; if (event.key !== 'Escape') return;
const topLayer = this.getTopLayer(); const topLayer = this.getTopLayer();
if (!topLayer) return; if (!topLayer) return;
if (topLayer.type !== 'popout' && !topLayer.onClose) return;
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
if (topLayer.type === 'modal') { if (topLayer.type === 'popout') {
if (topLayer.onClose) {
topLayer.onClose();
} else {
ModalCommands.pop();
}
} else if (topLayer.type === 'popout') {
topLayer.onClose?.(); topLayer.onClose?.();
this.removeLayer('popout', topLayer.key); this.removeLayer('popout', topLayer.key);
PopoutCommands.close(topLayer.key); PopoutCommands.close(topLayer.key);
} else if (topLayer.type === 'contextmenu') { return;
topLayer.onClose?.();
} }
topLayer.onClose?.();
}; };
addLayer(type: LayerType, key: string | PopoutKey, onClose?: () => void) { addLayer(type: LayerType, key: string | PopoutKey, onClose?: () => void) {
@@ -56,7 +50,6 @@ class LayerManager {
this.layers.push({ this.layers.push({
type, type,
key, key,
timestamp: Date.now(),
onClose, onClose,
}); });
} }
@@ -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 { getModal(ownerDocument: Document = document): ModalEntry | undefined {
return this.modals.findLast((modal) => modal.ownerDocument === ownerDocument); return this.modals.findLast((modal) => modal.ownerDocument === ownerDocument);
} }
@@ -10,10 +10,12 @@ export interface ModalStackContextValue {
restoreFocusOnClose: boolean; restoreFocusOnClose: boolean;
} }
export const ModalStackContext = createContext<ModalStackContextValue>({ export const UNSTACKED_MODAL_CONTEXT: ModalStackContextValue = {
stackIndex: 0, stackIndex: 0,
isVisible: true, isVisible: true,
needsBackdrop: true, needsBackdrop: true,
isTopmost: true, isTopmost: true,
restoreFocusOnClose: true, restoreFocusOnClose: true,
}); };
export const ModalStackContext = createContext<ModalStackContextValue>(UNSTACKED_MODAL_CONTEXT);
+113 -33
View File
@@ -2,13 +2,17 @@
import Accessibility from '@app/features/accessibility/state/Accessibility'; import Accessibility from '@app/features/accessibility/state/Accessibility';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; 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 LayerManager from '@app/features/ui/state/LayerManager';
import MobileLayout from '@app/features/ui/state/MobileLayout'; 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 {useId} from '@floating-ui/react';
import { import {
type ReactNode, type ReactNode,
type RefObject, type RefObject,
useCallback, useCallback,
useContext,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
@@ -16,10 +20,71 @@ import {
useState, useState,
} from 'react'; } 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<Document, BackdropActivationWatcher>();
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 ModalSize = 'medium' | 'small' | 'large' | 'xlarge' | 'fullscreen';
export type LabelSource = 'header' | 'screen-reader'; export type LabelSource = 'header' | 'screen-reader';
export type ModalTransitionPreset = 'default' | 'instant' | 'profile-slide'; export type ModalTransitionPreset = 'default' | 'instant' | 'quick' | 'profile-slide';
export interface ModalProps { export interface ModalProps {
children: ReactNode; children: ReactNode;
@@ -37,6 +102,7 @@ export interface ModalProps {
export interface ModalContextValue { export interface ModalContextValue {
getDefaultLabelId: (source: LabelSource) => string; getDefaultLabelId: (source: LabelSource) => string;
registerLabel: (source: LabelSource, id: string) => () => void; registerLabel: (source: LabelSource, id: string) => () => void;
popOwningModal: () => void;
} }
export interface ModalLogicState { export interface ModalLogicState {
@@ -69,9 +135,12 @@ export function useModalLogic({
const prefersReducedMotion = Accessibility.useReducedMotion; const prefersReducedMotion = Accessibility.useReducedMotion;
const baseLabelId = useId() || 'modal'; const baseLabelId = useId() || 'modal';
const modalKey = useRef(Math.random().toString(36).substring(7)).current; 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<string | null>(null);
const [labelRegistry, setLabelRegistry] = useState<Partial<Record<LabelSource, string>>>({}); const [labelRegistry, setLabelRegistry] = useState<Partial<Record<LabelSource, string>>>({});
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
const backdropContaminatedRef = useRef<boolean>(false);
const registerLabel = useCallback((source: LabelSource, id: string) => { const registerLabel = useCallback((source: LabelSource, id: string) => {
setLabelRegistry((current) => ({...current, [source]: id})); setLabelRegistry((current) => ({...current, [source]: id}));
return () => { return () => {
@@ -90,7 +159,28 @@ export function useModalLogic({
const ids = Object.values(labelRegistry).filter(Boolean); const ids = Object.values(labelRegistry).filter(Boolean);
return ids.length > 0 ? ids.join(' ') : undefined; return ids.length > 0 ? ids.join(' ') : undefined;
}, [labelRegistry]); }, [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(() => { useEffect(() => {
if (typeof queueMicrotask === 'function') { if (typeof queueMicrotask === 'function') {
queueMicrotask(() => setHasMounted(true)); 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.', 'Modal.Root requires either a Modal.Header or Modal.ScreenReaderLabel to provide an accessible label.',
); );
}, [hasMounted, labelledBy]); }, [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( const handleClose = useCallback(
(customOnClose?: () => void) => { (customOnClose?: () => void) => {
if (customOnClose) { if (customOnClose) {
@@ -138,10 +203,25 @@ export function useModalLogic({
} else if (onClose) { } else if (onClose) {
onClose(); onClose();
} else { } 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 { return {
isMobile, isMobile,
@@ -183,7 +263,7 @@ export function useHeaderLogic({
}: Pick<HeaderProps, 'title' | 'onClose' | 'id'> & { }: Pick<HeaderProps, 'title' | 'onClose' | 'id'> & {
modalContextValue: ModalContextValue; modalContextValue: ModalContextValue;
}): HeaderLogicState { }): HeaderLogicState {
const {getDefaultLabelId, registerLabel} = modalContextValue; const {getDefaultLabelId, registerLabel, popOwningModal} = modalContextValue;
const headingId = useMemo(() => id ?? getDefaultLabelId('header'), [getDefaultLabelId, id]); const headingId = useMemo(() => id ?? getDefaultLabelId('header'), [getDefaultLabelId, id]);
const useIsomorphicLayoutEffect = useLayoutEffect; const useIsomorphicLayoutEffect = useLayoutEffect;
useIsomorphicLayoutEffect(() => registerLabel('header', headingId), [headingId, registerLabel]); useIsomorphicLayoutEffect(() => registerLabel('header', headingId), [headingId, registerLabel]);
@@ -191,9 +271,9 @@ export function useHeaderLogic({
if (onClose) { if (onClose) {
onClose(); onClose();
} else { } else {
ModalCommands.pop(); popOwningModal();
} }
}, [onClose]); }, [onClose, popOwningModal]);
return { return {
headingId, headingId,
handleClose, handleClose,