Про "погружение-всплытие"
Направление распространения события в методе addEventListener задается с помощью третьего аргумента. По умолчанию события «всплывают» (идут от целевого элемента вверх к корню document), но вы можете переключить обработчик на фазу «погружения» (захвата), когда событие идет сверху вниз.
Почему это бывает важно
Иногда Yandex навешивает свои обработчики, которые Вы можете отменить процедурой e.stopPropagation() в своем обработчике - в случае если будете использовать настройку по умолчанию { capture: false }
import {
useState, useEffect, useRef, useCallback
} from 'react';
type TProps = {
initialIsActive: boolean;
mode: 'toggle' | 'stupid' | 'external-control';
onOutsideClick?: (ps: {
reset: () => void;
close: () => void;
toggle: () => void;
}) => void;
onInsideClick?: (ps: {
reset: () => void;
close: () => void;
open: () => void;
toggle: () => void;
}) => void;
}
export const useOutsideClick = <T extends HTMLElement>({
initialIsActive, mode, onOutsideClick,
}: TProps) => {
const [isInside, setIsInside] = useState(initialIsActive);
const ref = useRef<T>(null);
const reset = useCallback(() => setIsInside(initialIsActive), [setIsInside, initialIsActive]);
const toggle = useCallback(() => setIsInside((s) => !s), [setIsInside]);
const close = useCallback(() => setIsInside(false), [setIsInside]);
const open = useCallback(() => setIsInside(true), [setIsInside]);
const handleClick = useCallback((e: MouseEvent) => {
const isOutsideClicked = !!ref.current && !ref.current.contains((e.target as T));
switch (mode) {
case 'stupid':
setIsInside(!isOutsideClicked);
break;
case 'toggle':
if (isOutsideClicked) {
setIsInside(false);
} else {
setIsInside((s) => !s);
}
break;
case 'external-control':
if (isOutsideClicked && onOutsideClick) {
onOutsideClick({
reset, toggle, close
});
}
break;
}
}, [mode, onOutsideClick, reset, toggle, close]);
useEffect(() => {
document.addEventListener('click', handleClick, { capture: true });
return () => {
document.removeEventListener('click', handleClick, { capture: true });
};
}, [handleClick]);
return {
ref, isInside, setIsInside, reset, toggle, close, open
};
};Варианты использования
Кнопка с выпадающим меню ButtonWithMenu
import React, { memo, useEffect } from 'react';
import { Button } from '~/components/GeneralLayoutComponent/components/DmsLayout/components/Button';
import { useOutsideClick } from '~/hooks';
import { RelativeWrapper } from './styles';
type TProps = {
label?: string;
labelRenderer?: ({ isOpened }: { isOpened: boolean }) => React.ReactNode;
collapsibleContentRenderer: (_ps: { closePopup: () => void }) => React.ReactNode;
shouldBeOpenedByDefault?: boolean;
}
export const ButtonWithMenu = memo(({
label, collapsibleContentRenderer, labelRenderer,
shouldBeOpenedByDefault
}: TProps) => {
const {
ref, isInside, toggle, open, close: closePopup
} = useOutsideClick<HTMLButtonElement>({
initialIsActive: shouldBeOpenedByDefault || false,
mode: 'external-control',
onOutsideClick: ({ close }) => close(),
});
useEffect(() => {
if (shouldBeOpenedByDefault) {
open();
}
}, [shouldBeOpenedByDefault, open]);
return (
<RelativeWrapper ref={ref}>
{
(!!label || labelRenderer) && (
<Button
color={!isInside ? 'special-white' : 'primary'}
variant="contained"
size="small"
onClick={toggle}
style={{
boxSizing: 'border-box',
maxWidth: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{
label || labelRenderer?.({ isOpened: isInside })
}
</Button>
)
}
{
isInside && (
<>
{collapsibleContentRenderer({ closePopup })}
</>
)
}
</RelativeWrapper>
);
});