import { type RefObject, useRef, useState } from 'react'; import { arraysEquals } from '../helpers/array'; import { FOCUSABLE_ELEMENTS_LIST, Keys, pressedKey } from '../lib/accessibility'; import { contains, getActiveElementByAnotherElement, getWindow, isHTMLElement, useDOM, } from '../lib/dom'; import { useIsomorphicLayoutEffect } from '../lib/useIsomorphicLayoutEffect'; import { useMutationObserver } from './useMutationObserver'; import { useStableCallback } from './useStableCallback'; function isFocusableElement(el: Element): boolean { // eslint-disable-next-line no-restricted-properties return FOCUSABLE_ELEMENTS_LIST.some((sel) => el.matches(sel)); } const useRestoreFocus = ({ restoreFocus, timeout, mount, ref, }: Pick & { ref: RefObject; }) => { const restoreFocusRef = useRef(restoreFocus); restoreFocusRef.current = restoreFocus; const [restoreFocusTo, setRestoreFocusTo] = useState(null); const restoreFocusImpl = useStableCallback(() => { const shouldRestoreFocus = typeof restoreFocusRef.current === 'function' ? restoreFocusRef.current() : restoreFocusRef.current; if (!shouldRestoreFocus) { return; } const activeElement = getActiveElementByAnotherElement(ref.current); if ( activeElement && !ref.current?.contains(activeElement) && isFocusableElement(activeElement) ) { return; } setTimeout(() => { const restoreFocusElement = (isHTMLElement(shouldRestoreFocus) && shouldRestoreFocus) || (isHTMLElement(restoreFocusTo) && restoreFocusTo) || null; if (restoreFocusElement) { restoreFocusElement.focus(); setRestoreFocusTo(null); } }, timeout); }); useIsomorphicLayoutEffect( function calculateRestoreFocusTo() { if (!ref.current || !restoreFocusRef.current || !mount) { setRestoreFocusTo(null); return; } setRestoreFocusTo(getActiveElementByAnotherElement(ref.current)); }, [ref, mount], ); useIsomorphicLayoutEffect( function tryToRestoreFocusOnUnmount() { return () => { restoreFocusImpl(); }; }, [restoreFocusImpl], ); useIsomorphicLayoutEffect( function tryToRestoreFocusWhenFakeUnmount() { if (!mount) { restoreFocusImpl(); } }, [mount, restoreFocusImpl], ); }; const FOCUSABLE_ELEMENTS: string = FOCUSABLE_ELEMENTS_LIST.join(); export type UseFocusTrapProps = { /** * @default true */ mount?: boolean; /** * Форсированное отключение захвата фокуса * * @default false */ disabled?: boolean; /** * @default true */ autoFocus?: boolean | 'root'; /** * @default true */ restoreFocus?: boolean | (() => boolean | HTMLElement); /** * @default 0 */ timeout?: number; /** * Вызывается при нажатии на кнопку `Escape`. */ onClose?: VoidFunction; /** * Следует ли обрабатываеть событие нажатия клавиши Escape при "погружении", то есть * до того как это событие будет обработано на EventTarget * Удобно установить в false, если требуется запретить "всплытие" события до FocusTrap * * @default true */ captureEscapeKeyboardEvent?: boolean; /** * Пользовательские опции для MutationObserver, который отслеживает изменения DOM внутри компонента и пересчитывает ноды для фокуса. */ mutationObserverOptions?: MutationObserverInit; }; /** * @private */ export const useFocusTrap = ( ref: RefObject, { mount = true, disabled = false, autoFocus = true, restoreFocus = true, timeout = 0, onClose, captureEscapeKeyboardEvent = true, mutationObserverOptions, }: UseFocusTrapProps, ) => { const { document } = useDOM(); const focusableNodesRef = useRef([]); const focusNodeByIndex = (nodeIndex: number) => { const element = focusableNodesRef.current[nodeIndex]; if (element) { element.focus({ preventScroll: true, }); } }; useRestoreFocus({ restoreFocus, mount, timeout, ref, }); const recalculateFocusableNodesRef = (parentNode: HTMLElement) => { // eslint-disable-next-line no-restricted-properties const newFocusableElements = parentNode.querySelectorAll(FOCUSABLE_ELEMENTS); const nodes: HTMLElement[] = []; newFocusableElements.forEach((focusableEl) => { const { display, visibility } = getComputedStyle(focusableEl); if (display !== 'none' && visibility !== 'hidden') { nodes.push(focusableEl); } }); if (nodes.length === 0) { // Чтобы фокус был хотя бы на родителе nodes.push(parentNode); } focusableNodesRef.current = nodes; }; const onMutateParentHandler = (parentNode: HTMLElement) => { const oldFocusableNodes = [...focusableNodesRef.current]; recalculateFocusableNodesRef(parentNode); if (disabled || !autoFocus || arraysEquals(oldFocusableNodes, focusableNodesRef.current)) { return; } if (document) { const activeElement = document.activeElement as HTMLElement; const currentElementIndex = Math.max( document.activeElement ? focusableNodesRef.current.indexOf(activeElement) : -1, 0, ); focusNodeByIndex(currentElementIndex); } }; useMutationObserver( ref, () => ref.current && onMutateParentHandler(ref.current), mutationObserverOptions, ); useIsomorphicLayoutEffect(() => { ref.current && recalculateFocusableNodesRef(ref.current); }, [ref]); useIsomorphicLayoutEffect( function tryToAutoFocusToFirstNode() { if (!ref.current || !autoFocus || disabled) { return; } const autoFocusToNode = () => { if (!ref.current || !focusableNodesRef.current.length) { return; } const activeElement = getActiveElementByAnotherElement(ref.current); if (!contains(ref.current, activeElement)) { if (autoFocus === 'root') { ref.current?.focus(); } else { focusableNodesRef.current[0].focus(); } } }; const timeoutId = setTimeout(autoFocusToNode, timeout); return () => { clearTimeout(timeoutId); }; }, [autoFocus, timeout, disabled], ); useIsomorphicLayoutEffect( function initializeFocusTrap() { if (!ref.current) { return; } const onDocumentKeydown = (event: KeyboardEvent) => { if (disabled) { return; } const pressedKeyResult = pressedKey(event); switch (pressedKeyResult) { case Keys.TAB: { if (!focusableNodesRef.current.length) { return false; } const lastIdx = focusableNodesRef.current.length - 1; const targetIdx = focusableNodesRef.current.findIndex((node) => node === event.target); const shouldFocusFirstNode = targetIdx === -1 || (targetIdx === lastIdx && !event.shiftKey); if (shouldFocusFirstNode || (targetIdx === 0 && event.shiftKey)) { event.preventDefault(); const node = focusableNodesRef.current[shouldFocusFirstNode ? 0 : lastIdx]; if (node !== getActiveElementByAnotherElement(node)) { node.focus(); } return false; } break; } } return true; }; const onEscapeKeydown = (event: KeyboardEvent) => { if (disabled) { return; } const pressedKeyResult = pressedKey(event); if (pressedKeyResult === Keys.ESCAPE) { if (onClose) { event.preventDefault(); onClose(); } } return true; }; const doc = getWindow(ref.current).document; doc.addEventListener('keydown', onDocumentKeydown, { capture: true, }); doc.addEventListener('keydown', onEscapeKeydown, { capture: captureEscapeKeyboardEvent, }); return () => { doc.removeEventListener('keydown', onDocumentKeydown, true); doc.removeEventListener('keydown', onEscapeKeydown, captureEscapeKeyboardEvent); }; }, [onClose, ref, disabled], ); };