Implemented useListenClickOutside V2 (#3507)

* Implemented useListenClickOutside V2

* Removed lock and implemented a toggle instead
This commit is contained in:
Lucas Bordeau 2024-01-17 16:22:22 +01:00 committed by GitHub
parent 808100fdd5
commit bbfe62df9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 303 additions and 5 deletions

View File

@ -1,4 +1,8 @@
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useEffect } from 'react';
import { MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
export const MultipleObjectRecordOnClickOutsideEffect = ({
containerRef,
@ -7,6 +11,21 @@ export const MultipleObjectRecordOnClickOutsideEffect = ({
containerRef: React.RefObject<HTMLDivElement>;
onClickOutside: () => void;
}) => {
const { useListenClickOutside } = useClickOutsideListener(
MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID,
);
const { toggleClickOutsideListener: toggleRightDrawerClickOustideListener } =
useClickOutsideListener(RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID);
useEffect(() => {
toggleRightDrawerClickOustideListener(false);
return () => {
toggleRightDrawerClickOustideListener(true);
};
}, [toggleRightDrawerClickOustideListener]);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {

View File

@ -0,0 +1,2 @@
export const MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID =
'multi-object-record-click-outside-listener';

View File

@ -5,11 +5,10 @@ import { motion } from 'framer-motion';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
ClickOutsideMode,
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { ClickOutsideMode } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from '~/utils/isDefined';
@ -53,6 +52,10 @@ export const RightDrawer = () => {
const rightDrawerRef = useRef<HTMLDivElement>(null);
const { useListenClickOutside } = useClickOutsideListener(
RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID,
);
useListenClickOutside({
refs: [rightDrawerRef],
callback: () => closeRightDrawer(),

View File

@ -0,0 +1,2 @@
export const RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID =
'right-drawer-click-outside-listener';

View File

@ -0,0 +1,23 @@
import { clickOutsideListenerIsActivatedStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedStateScopeMap';
import { clickOutsideListenerIsMouseDownInsideStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideStateScopeMap';
import { lockedListenerIdState } from '@/ui/utilities/pointer-event/states/lockedListenerIdState';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { getState } from '@/ui/utilities/recoil-scope/utils/getState';
export const useClickOustideListenerStates = (componentId: string) => {
// TODO: improve typing
const scopeId = getScopeIdFromComponentId(componentId) ?? '';
return {
scopeId,
getClickOutsideListenerIsMouseDownInsideState: getState(
clickOutsideListenerIsMouseDownInsideStateScopeMap,
scopeId,
),
getClickOutsideListenerIsActivatedState: getState(
clickOutsideListenerIsActivatedStateScopeMap,
scopeId,
),
lockedListenerIdState,
};
};

View File

@ -0,0 +1,45 @@
import { useRecoilCallback } from 'recoil';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
import {
ClickOutsideListenerProps,
useListenClickOutsideV2,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
export const useClickOutsideListener = (componentId: string) => {
// TODO: improve typing
const scopeId = getScopeIdFromComponentId(componentId) ?? '';
const { getClickOutsideListenerIsActivatedState } =
useClickOustideListenerStates(componentId);
const useListenClickOutside = <T extends Element>({
callback,
refs,
enabled,
mode,
}: Omit<ClickOutsideListenerProps<T>, 'listenerId'>) => {
return useListenClickOutsideV2({
listenerId: componentId,
refs,
callback,
enabled,
mode,
});
};
const toggleClickOutsideListener = useRecoilCallback(
({ set }) =>
(activated: boolean) => {
set(getClickOutsideListenerIsActivatedState(), activated);
},
[getClickOutsideListenerIsActivatedState],
);
return {
scopeId,
useListenClickOutside,
toggleClickOutsideListener,
};
};

View File

@ -0,0 +1,184 @@
import React, { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
export enum ClickOutsideMode {
comparePixels = 'comparePixels',
compareHTMLRef = 'compareHTMLRef',
}
export type ClickOutsideListenerProps<T extends Element> = {
refs: Array<React.RefObject<T>>;
callback: (event: MouseEvent | TouchEvent) => void;
mode?: ClickOutsideMode;
listenerId: string;
enabled?: boolean;
};
export const useListenClickOutsideV2 = <T extends Element>({
refs,
callback,
mode = ClickOutsideMode.compareHTMLRef,
listenerId,
enabled = true,
}: ClickOutsideListenerProps<T>) => {
const {
getClickOutsideListenerIsMouseDownInsideState,
getClickOutsideListenerIsActivatedState,
} = useClickOustideListenerStates(listenerId);
const handleMouseDown = useRecoilCallback(
({ snapshot, set }) =>
(event: MouseEvent | TouchEvent) => {
const clickOutsideListenerIsActivated = snapshot
.getLoadable(getClickOutsideListenerIsActivatedState())
.getValue();
const isListening = clickOutsideListenerIsActivated && enabled;
if (!isListening) {
return;
}
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
set(
getClickOutsideListenerIsMouseDownInsideState(),
clickedOnAtLeastOneRef,
);
}
if (mode === ClickOutsideMode.comparePixels) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => {
if (!ref.current) {
return false;
}
const { x, y, width, height } =
ref.current.getBoundingClientRect();
const clientX =
'clientX' in event
? event.clientX
: event.changedTouches[0].clientX;
const clientY =
'clientY' in event
? event.clientY
: event.changedTouches[0].clientY;
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
return false;
}
return true;
});
set(
getClickOutsideListenerIsMouseDownInsideState(),
clickedOnAtLeastOneRef,
);
}
},
[
mode,
refs,
getClickOutsideListenerIsMouseDownInsideState,
enabled,
getClickOutsideListenerIsActivatedState,
],
);
const handleClickOutside = useRecoilCallback(
({ snapshot }) =>
(event: MouseEvent | TouchEvent) => {
const isMouseDownInside = snapshot
.getLoadable(getClickOutsideListenerIsMouseDownInsideState())
.getValue();
if (mode === ClickOutsideMode.compareHTMLRef) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (!clickedOnAtLeastOneRef && !isMouseDownInside) {
callback(event);
}
}
if (mode === ClickOutsideMode.comparePixels) {
const clickedOnAtLeastOneRef = refs
.filter((ref) => !!ref.current)
.some((ref) => {
if (!ref.current) {
return false;
}
const { x, y, width, height } =
ref.current.getBoundingClientRect();
const clientX =
'clientX' in event
? event.clientX
: event.changedTouches[0].clientX;
const clientY =
'clientY' in event
? event.clientY
: event.changedTouches[0].clientY;
if (
clientX < x ||
clientX > x + width ||
clientY < y ||
clientY > y + height
) {
return false;
}
return true;
});
if (!clickedOnAtLeastOneRef && !isMouseDownInside) {
callback(event);
}
}
},
[mode, refs, callback, getClickOutsideListenerIsMouseDownInsideState],
);
useEffect(() => {
document.addEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.addEventListener('click', handleClickOutside, { capture: true });
document.addEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.addEventListener('touchend', handleClickOutside, {
capture: true,
});
return () => {
document.removeEventListener('mousedown', handleMouseDown, {
capture: true,
});
document.removeEventListener('click', handleClickOutside, {
capture: true,
});
document.removeEventListener('touchstart', handleMouseDown, {
capture: true,
});
document.removeEventListener('touchend', handleClickOutside, {
capture: true,
});
};
}, [refs, callback, mode, handleClickOutside, handleMouseDown]);
};

View File

@ -0,0 +1,7 @@
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export const clickOutsideListenerIsActivatedStateScopeMap =
createStateScopeMap<boolean>({
key: 'clickOutsideListenerIsActivatedStateScopeMap',
defaultValue: true,
});

View File

@ -0,0 +1,7 @@
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export const clickOutsideListenerIsMouseDownInsideStateScopeMap =
createStateScopeMap<boolean>({
key: 'clickOutsideListenerIsMouseDownInsideStateScopeMap',
defaultValue: false,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const lockedListenerIdState = atom<string | null>({
key: 'lockedListenerIdState',
default: null,
});