mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15: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 {
|
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);
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user