mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): close the modal you opened, not whatever is on top (#1851)
This commit is contained in:
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 {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ export interface ModalStackContextValue {
|
||||
restoreFocusOnClose: boolean;
|
||||
}
|
||||
|
||||
export const ModalStackContext = createContext<ModalStackContextValue>({
|
||||
export const UNSTACKED_MODAL_CONTEXT: ModalStackContextValue = {
|
||||
stackIndex: 0,
|
||||
isVisible: true,
|
||||
needsBackdrop: true,
|
||||
isTopmost: true,
|
||||
restoreFocusOnClose: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const ModalStackContext = createContext<ModalStackContextValue>(UNSTACKED_MODAL_CONTEXT);
|
||||
@@ -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<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 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<string | null>(null);
|
||||
const [labelRegistry, setLabelRegistry] = useState<Partial<Record<LabelSource, string>>>({});
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const backdropContaminatedRef = useRef<boolean>(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<HeaderProps, 'title' | 'onClose' | 'id'> & {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user