hooks_use-focus-trap.js

import { React } from '@gravityforms/libraries';
import { setupFocusManager } from './helpers/focus-manager';
import { focusSelector, focusable, tabbable } from './helpers/tabbable';
import scopeTab from './helpers/scope-tab';
import { createAriaHider } from './helpers/aria-hider';

const { useCallback, useEffect, useMemo, useRef } = React;

/**
 * @module useFocusTrap
 * @description A hook to trap focus inside a specific element so the user can't tab outside it.
 * Supports granular control over focus behaviors via options.
 *
 * @author https://github.com/charlie-tango
 *
 * @since 1.1.3
 *
 * @param {boolean}       active                    Whether the element that will trap the focus is active or not.
 * @param {object}        options                   Options for the focus trap hook.
 * @param {boolean}       options.ariaModal         Whether to apply role and aria-modal attributes. Default false.
 * @param {string}        options.ariaRoleType      Role type when ariaModal is true: 'dialog' or 'alertdialog'. Default 'dialog'.
 * @param {boolean}       options.disableAriaHider  Whether to disable aria hiding. Default false.
 * @param {string|number} options.focusKey          Optional key that when changed, resets focus state and re-applies focus.
 * @param {string}        options.focusSelector     Selector or element to focus initially.
 * @param {boolean}       options.tabScopingEnabled Whether to trap Tab key within container. Default true.
 *
 * @return {Function} A callback ref to attach to the element that will trap the focus.
 *
 * @example
 * import React from 'react'
 * import { useFocusTrap } from '@gravityforms/react-utils';
 *
 * const Component = () => {
 *  const ref = useFocusTrap()
 *  return (
 *   <div ref={ref}>
 *     <button>Trapped to the button</button>
 *   </div>
 *  )
 * }
 *
 * export default Component
 *
 * @example
 * // With granular control (e.g., for a pinnable flyout)
 * const trapRef = useFocusTrap(isOpen, {
 *   tabScopingEnabled: !isPinned,
 *   ariaModal: !isPinned,
 * });
 *
 */
function useFocusTrap( active = true, options = {} ) {
	const {
		ariaModal = false,
		ariaRoleType = 'dialog',
		disableAriaHider = false,
		focusKey = '',
		focusSelector: focusSelectorOption,
		tabScopingEnabled = true,
	} = options;

	const ref = useRef( null );
	const restoreAria = useRef( null );

	const {
		markForFocusLater,
		returnFocus,
		setupScopedFocus,
		teardownScopedFocus,
	} = useMemo( () => setupFocusManager(), [] );

	/**
	 * @function setAria
	 * @description Sets the aria attributes.
	 *
	 * @since 5.1.2
	 *
	 * @return {void}
	 */
	const setAria = useCallback( () => {
		if ( ariaModal ) {
			ref.current.setAttribute( 'role', ariaRoleType );
			ref.current.setAttribute( 'aria-modal', 'true' );
		} else if ( ! disableAriaHider ) {
			restoreAria.current = createAriaHider( ref.current );
		}
	}, [ ariaModal, disableAriaHider, ariaRoleType ] );

	/**
	 * @function unsetAria
	 * @description Unsets the aria attributes.
	 *
	 * @since 5.1.2
	 *
	 * @return {void}
	 */
	const unsetAria = useCallback( () => {
		if ( restoreAria.current ) {
			restoreAria.current();
			restoreAria.current = null;
		}
		if ( ref.current ) {
			ref.current.removeAttribute( 'role' );
			ref.current.removeAttribute( 'aria-modal' );
		}
	}, [] );

	/**
	 * @function applyInitialFocus
	 * @description Applies initial focus to the first focusable element.
	 *
	 * @since 5.1.1
	 *
	 * @param {HTMLElement} node The container node.
	 *
	 * @return {void}
	 */
	const applyInitialFocus = useCallback( ( node ) => {
		let focusElement = null;

		if ( focusSelectorOption ) {
			focusElement = typeof focusSelectorOption === 'string'
				? node.querySelector( focusSelectorOption )
				: focusSelectorOption;
		}

		if ( ! focusElement ) {
			const children = Array.from( node.querySelectorAll( focusSelector ) );
			focusElement =
				children.find( tabbable ) ||
				children.find( focusable ) ||
				null;

			if ( ! focusElement && focusable( node ) ) {
				focusElement = node;
			}
		}

		if ( focusElement ) {
			focusElement.focus();
		}
	}, [ focusSelectorOption ] );

	/**
	 * @description Callback ref that handles activation/deactivation.
	 *
	 * @since 1.1.3
	 *
	 * @param {HTMLElement|null} node The DOM node.
	 *
	 * @return {void}
	 */
	const setRef = useCallback( ( node ) => {
		ref.current = active && node ? node : null;
	}, [ active ] );

	// Setup focus trap when the active state changes. Save return focus for later.
	useEffect( () => {
		if ( ! ref.current || ! active ) {
			return;
		}

		setupScopedFocus( ref.current );
		markForFocusLater();
		setAria();

		setTimeout( () => {
			if ( ref?.current?.ownerDocument ) {
				applyInitialFocus( ref.current );
			} else if ( process.env.RUN_MODE === 'development' ) {
				console.warn( '[useFocusTrap]: The focus trap is not part of the DOM yet, so it is unable to correctly set focus. Make sure to render the ref node.' );
			}
		}, 10 );

		return () => {
			returnFocus();
			teardownScopedFocus();
			unsetAria();
		};
	}, [ active ] );

	// Setup focus trap when the tabScopingEnabled state changes.
	useEffect( () => {
		if ( ! ref.current || ! tabScopingEnabled ) {
			return;
		}

		setupScopedFocus( ref.current );
		setAria();

		return () => {
			teardownScopedFocus();
			unsetAria();
		};
	}, [ tabScopingEnabled ] );

	// Apply initial focus when focus key changes.
	useEffect( () => {
		if ( ! ref.current || ! focusKey ) {
			return;
		}

		applyInitialFocus( ref.current );
	}, [ focusKey ] );

	// WHY: Tab scoping - responds to both active and tabScopingEnabled changes
	useEffect( () => {
		if ( ! active || ! tabScopingEnabled ) {
			return;
		}

		/**
		 * @description Handles Tab key to scope focus within container.
		 *
		 * @since 1.1.3
		 *
		 * @param {KeyboardEvent} event Keyboard event.
		 *
		 * @return {void}
		 */
		const handleKeyDown = ( event ) => {
			if ( event.key === 'Tab' && ref.current ) {
				scopeTab( ref.current, event );
			}
		};

		document.addEventListener( 'keydown', handleKeyDown );
		return () => {
			document.removeEventListener( 'keydown', handleKeyDown );
		};
	}, [ active, tabScopingEnabled ] );

	return setRef;
}

export default useFocusTrap;