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;