diff --git a/webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap b/webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap index 644833cd6a..6730e35a63 100644 --- a/webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap +++ b/webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap @@ -3,7 +3,6 @@ exports[`components/BrowseChannels should match snapshot and state 1`] = ` } id="browseChannelsModal" - keyboardEscape={true} modalHeaderText={ } - modalLocation="center" onExited={[Function]} - show={true} - showCloseButton={true} - showHeader={true} > } - modalLocation="center" onExited={[Function]} onHide={[Function]} show={false} - showCloseButton={true} - showHeader={true} > } - modalLocation="center" onExited={[Function]} onHide={[Function]} show={false} - showCloseButton={true} - showHeader={true} > } - modalLocation="center" onExited={[Function]} onHide={[Function]} show={false} - showCloseButton={true} - showHeader={true} >
{body} diff --git a/webapp/channels/src/components/multiselect/multiselect_list.tsx b/webapp/channels/src/components/multiselect/multiselect_list.tsx index 1a401bdc47..e6535eba0c 100644 --- a/webapp/channels/src/components/multiselect/multiselect_list.tsx +++ b/webapp/channels/src/components/multiselect/multiselect_list.tsx @@ -225,7 +225,6 @@ export default class MultiSelectList extends React.PureComponen id='multiSelectList' className='more-modal__options' role='presentation' - aria-hidden={true} > {optionControls}
diff --git a/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap b/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap index b12dcc6a53..a9e5ba803e 100644 --- a/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap +++ b/webapp/channels/src/components/product_notices_modal/__snapshots__/product_notices.test.tsx.snap @@ -4,7 +4,6 @@ exports[`ProductNoticesModal Match snapshot for single notice 1`] = ` } - enforceFocus={true} handleConfirm={[Function]} handleEnterKeyPress={[Function]} - id="genericModal" - keyboardEscape={true} modalHeaderText={ title } - modalLocation="center" onExited={[Function]} - show={true} - showCloseButton={true} - showHeader={true} > @@ -67,22 +58,15 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = ` id="generic.done" /> } - enforceFocus={true} handleCancel={[Function]} handleConfirm={[Function]} handleEnterKeyPress={[Function]} - id="genericModal" - keyboardEscape={true} modalHeaderText={ title } - modalLocation="center" onExited={[Function]} - show={true} - showCloseButton={true} - showHeader={true} > } - enforceFocus={true} handleConfirm={[Function]} handleEnterKeyPress={[Function]} - id="genericModal" - keyboardEscape={true} modalHeaderText={ for sysadmin } - modalLocation="center" onExited={[Function]} - show={true} - showCloseButton={true} - showHeader={true} >
} - modalLocation="center" modalSubheaderText={
{ @@ -149,7 +149,7 @@ describe('components/QuickSwitchModal', () => { }); describe('accessibility', () => { - it('should restore focus to button', () => { + it('should restore focus to button', async () => { const channelNavigatorProps = { showUnreadsCategory: false, isQuickSwitcherOpen: false, @@ -168,8 +168,10 @@ describe('components/QuickSwitchModal', () => { , ); - userEvent.click(screen.getByTestId('SidebarChannelNavigatorButton')); - userEvent.keyboard('{escape}'); + await act(async () => { + userEvent.click(await screen.getByTestId('SidebarChannelNavigatorButton')); + userEvent.keyboard('{escape}'); + }); expect(screen.getByTestId('SidebarChannelNavigatorButton')).toHaveFocus(); }); }); diff --git a/webapp/channels/src/components/team_groups_manage_modal/team_groups_manage_modal.test.tsx b/webapp/channels/src/components/team_groups_manage_modal/team_groups_manage_modal.test.tsx index 1b417d88d0..163bd0385f 100644 --- a/webapp/channels/src/components/team_groups_manage_modal/team_groups_manage_modal.test.tsx +++ b/webapp/channels/src/components/team_groups_manage_modal/team_groups_manage_modal.test.tsx @@ -33,8 +33,10 @@ describe('components/TeamGroupsManageModal', () => { const wrapper = renderWithContext(); expect(await wrapper.findByTestId('group-name')).toBeInTheDocument(); userEvent.click(wrapper.getByTestId('menu-button')); + userEvent.click(wrapper.getByTestId('remove-group-button')); - expect(wrapper.getByTestId('confirm-modal')).toBeInTheDocument(); + + expect(await wrapper.findByTestId('confirm-modal')).toBeInTheDocument(); }); test('should call loadItems on render', async () => { @@ -44,10 +46,12 @@ describe('components/TeamGroupsManageModal', () => { test('should hide confirm modal when cancel button is clicked', async () => { const wrapper = renderWithContext(); - await wrapper.findByTestId('group-name'); + expect(await wrapper.findByTestId('group-name')).toBeInTheDocument(); userEvent.click(wrapper.getByTestId('menu-button')); + userEvent.click(wrapper.getByTestId('remove-group-button')); - expect(wrapper.getByTestId('confirm-modal')).toBeInTheDocument(); + expect(await wrapper.findByTestId('confirm-modal')).toBeInTheDocument(); + userEvent.click(wrapper.getByTestId('cancel-button')); await waitForElementToBeRemoved(() => wrapper.queryByTestId('confirm-modal')); expect(wrapper.queryByTestId('confirm-modal')).toBeNull(); diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx index 1149c7d796..f874335faa 100644 --- a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx @@ -368,6 +368,7 @@ class UserSettingsModal extends React.PureComponent { bodyPadding={false} modalHeaderText={headerTitle} modalLocation='top' + delayFocusTrap={true} >
{ - static defaultProps: Partial = { - show: true, - id: 'genericModal', - autoCloseOnCancelButton: true, - autoCloseOnConfirmButton: true, - enforceFocus: true, - keyboardEscape: true, - bodyPadding: true, - showCloseButton: true, - showHeader: true, - modalLocation: 'center', - }; +export const GenericModal: React.FC = ({ + show = true, + id = 'genericModal', + autoCloseOnCancelButton = true, + autoCloseOnConfirmButton = true, + enforceFocus = true, + keyboardEscape = true, + bodyPadding = true, + showCloseButton = true, + showHeader = true, + modalLocation = 'center', + className, + onExited, + onEntered, + onHide, + modalHeaderText, + modalSubheaderText, + handleCancel, + handleConfirm, + handleEnterKeyPress, + handleKeydown, + confirmButtonText, + confirmButtonClassName, + cancelButtonText, + cancelButtonClassName, + isConfirmDisabled, + isDeleteModal, + container, + ariaLabel, + ariaLabelledby, + errorText, + compassDesign, + backdrop, + backdropClassName, + tabIndex, + children, + autoFocusConfirmButton, + headerInput, + bodyDivider, + bodyOverflowVisible, + footerContent, + footerDivider, + appendedContent, + headerButton, + dataTestId, + delayFocusTrap, +}) => { + // Create a ref for the modal container + const containerRef = useRef(null); - constructor(props: Props) { - super(props); + const [showState, setShowState] = useState(show); - this.state = { - show: props.show!, - isFocalTrapActive: false, - }; - } + // Use focus trap to keep focus within the modal when it's open + useFocusTrap(showState, containerRef, { + delayMs: delayFocusTrap ? 500 : undefined, + }); - componentDidUpdate(prevProps: Props) { - if (prevProps.show !== this.props.show) { - this.setState({show: Boolean(this.props.show)}); - } - } + useEffect(() => { + setShowState(show); + }, [show]); - onHide = () => { - this.setState({show: false}); - this.props.onHide?.(); - }; + const onHideCallback = useCallback(() => { + setShowState(false); + onHide?.(); + }, [onHide]); - handleCancel = (event: React.MouseEvent) => { + const handleCancelCallback = useCallback((event: React.MouseEvent) => { event.preventDefault(); - if (this.props.autoCloseOnCancelButton) { - this.onHide(); + if (autoCloseOnCancelButton) { + onHideCallback(); } - if (this.props.handleCancel) { - this.props.handleCancel(); - } - }; + handleCancel?.(); + }, [autoCloseOnCancelButton, onHideCallback, handleCancel]); - handleConfirm = (event: React.MouseEvent) => { + const handleConfirmCallback = useCallback((event: React.MouseEvent) => { event.preventDefault(); - if (this.props.autoCloseOnConfirmButton) { - this.onHide(); + if (autoCloseOnConfirmButton) { + onHideCallback(); } - if (this.props.handleConfirm) { - this.props.handleConfirm(); - } - }; + handleConfirm?.(); + }, [autoCloseOnConfirmButton, onHideCallback, handleConfirm]); - private onEnterKeyDown = (event: React.KeyboardEvent) => { + const onEnterKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter') { if (event.nativeEvent.isComposing) { return; } - if (this.props.handleConfirm && this.props.autoCloseOnConfirmButton) { - this.onHide(); - } - if (this.props.handleEnterKeyPress) { - this.props.handleEnterKeyPress(); + if (handleConfirm && autoCloseOnConfirmButton) { + onHideCallback(); } + handleEnterKeyPress?.(); } - this.props.handleKeydown?.(event); - }; + handleKeydown?.(event); + }, [handleConfirm, autoCloseOnConfirmButton, onHideCallback, handleEnterKeyPress, handleKeydown]); - render() { - let confirmButton; - if (this.props.handleConfirm) { - const isConfirmOrDeleteClassName = this.props.isDeleteModal ? 'delete' : 'confirm'; - let confirmButtonText: React.ReactNode = ( - - ); - if (this.props.confirmButtonText) { - confirmButtonText = this.props.confirmButtonText; - } - - confirmButton = ( - - ); - } - - let cancelButton; - if (this.props.handleCancel) { - let cancelButtonText: React.ReactNode = ( - - ); - if (this.props.cancelButtonText) { - cancelButtonText = this.props.cancelButtonText; - } - - cancelButton = ( - - ); - } - - const headerText = this.props.modalHeaderText && ( -
-

- {this.props.modalHeaderText} -

- {this.props.headerButton} -
+ // Build confirm button if provided. + let confirmButtonElement; + if (handleConfirm) { + const buttonTypeClass = isDeleteModal ? 'delete' : 'confirm'; + let confirmButtonTextContent: React.ReactNode = ( + ); - - const locationClassMapping: Record['modalLocation'], string> = { - top: 'GenericModal__location--top', - center: 'GenericModal__location--center', - bottom: 'GenericModal__location--bottom', - }; - - const modalLocationClass = locationClassMapping[this.props.modalLocation ?? 'center']; - - // Accessibility labeling strategy: - // 1. We always set aria-labelledby to ensure the modal has a proper label - // - First try to use the provided ariaLabeledBy prop - // - Fall back to 'genericModalLabel' which references the modal title - // 2. We also support aria-label as a secondary option - // - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist - // - This provides a fallback for accessibility in case the referenced element is missing - // Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence - const ariaLabelledby = this.props.ariaLabelledby || 'genericModalLabel'; - - return ( - -
- {this.props.showHeader && -
- {this.props.compassDesign && ( - <> - {headerText} - {this.props.headerInput} - - )} - { - this.props.modalSubheaderText && -
-
- {this.props.modalSubheaderText} -
-
- } -
-
} - - {this.props.compassDesign ? ( - this.props.errorText && ( -
- - {this.props.errorText} -
- ) - ) : ( - headerText - )} -
- {this.props.children} -
-
- {(cancelButton || confirmButton || this.props.footerContent) && ( - - {(cancelButton || confirmButton) ? ( - <> - {cancelButton} - {confirmButton} - - ) : ( - this.props.footerContent - )} - - )} - {Boolean(this.props.appendedContent) && this.props.appendedContent} -
-
+ {confirmButtonTextContent} + ); } -} + + // Build cancel button if provided. + let cancelButtonElement; + if (handleCancel) { + let cancelButtonTextContent: React.ReactNode = ( + + ); + if (cancelButtonText) { + cancelButtonTextContent = cancelButtonText; + } + cancelButtonElement = ( + + ); + } + + // Build header text if provided. + const headerText = modalHeaderText && ( +
+

+ {modalHeaderText} +

+ {headerButton} +
+ ); + + // Map modalLocation to a CSS class. + const locationClassMapping: Record = { + top: 'GenericModal__location--top', + center: 'GenericModal__location--center', + bottom: 'GenericModal__location--bottom', + }; + const modalLocationClass = locationClassMapping[modalLocation]; + + // Accessibility labeling strategy: + // 1. We always set aria-labelledby to ensure the modal has a proper label + // - First try to use the provided ariaLabeledBy prop + // - Fall back to 'genericModalLabel' which references the modal title + // 2. We also support aria-label as a secondary option + // - This will only be used by screen readers if the element referenced by aria-labelledby doesn't exist + // - This provides a fallback for accessibility in case the referenced element is missing + // Note: When both aria-labelledby and aria-label are present, aria-labelledby takes precedence + const ariaLabelledbyValue = ariaLabelledby || 'genericModalLabel'; + + return ( + +
+ {showHeader && ( + +
+ {compassDesign && ( + <> + {headerText} + {headerInput} + + )} + {modalSubheaderText && ( +
+
+ {modalSubheaderText} +
+
+ )} +
+
+ )} + + {compassDesign ? ( + errorText && ( +
+ + {errorText} +
+ ) + ) : ( + headerText + )} +
+ {children} +
+
+ {(cancelButtonElement || confirmButtonElement || footerContent) && ( + + {(cancelButtonElement || confirmButtonElement) ? ( + <> + {cancelButtonElement} + {confirmButtonElement} + + ) : ( + footerContent + )} + + )} + {Boolean(appendedContent) && appendedContent} +
+
+ ); +}; + +export default GenericModal; diff --git a/webapp/platform/components/src/hooks/useFocusTrap.test.tsx b/webapp/platform/components/src/hooks/useFocusTrap.test.tsx new file mode 100644 index 0000000000..0699a7109a --- /dev/null +++ b/webapp/platform/components/src/hooks/useFocusTrap.test.tsx @@ -0,0 +1,259 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {render, screen} from '@testing-library/react'; +import React, {useRef} from 'react'; + +import {useFocusTrap} from './useFocusTrap'; + +// Test component that uses the hook +function FocusTrapTestComponent({ + isActive = true, + initialFocus = false, + restoreFocus = false, + delayMs = 0, +}: { + isActive?: boolean; + initialFocus?: boolean; + restoreFocus?: boolean; + delayMs?: number; +}) { + const containerRef = useRef(null); + + useFocusTrap(isActive, containerRef, { + initialFocus, + restoreFocus, + delayMs, + }); + + return ( +
+ + + +
+ ); +} + +// Test component with nested focus traps +function NestedFocusTrapsComponent() { + const outerRef = useRef(null); + const innerRef = useRef(null); + + useFocusTrap(true, outerRef); + useFocusTrap(true, innerRef); + + return ( +
+ +
+ + +
+ +
+ ); +} + +describe('useFocusTrap', () => { + beforeEach(() => { + // Create a div to hold our rendered components + const container = document.createElement('div'); + container.id = 'root'; + document.body.appendChild(container); + + // Create an element outside the focus trap for testing restoreFocus + const outsideButton = document.createElement('button'); + outsideButton.setAttribute('data-testid', 'outside-button'); + outsideButton.textContent = 'Outside Button'; + document.body.appendChild(outsideButton); + }); + + afterEach(() => { + // Clean up + document.body.innerHTML = ''; + jest.useRealTimers(); + }); + + // Helper function to simulate Tab key press + const simulateTabKey = (shiftKey = false) => { + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + code: 'Tab', + shiftKey, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(tabEvent); + }; + + // Helper function to simulate tab navigation with focus trap + const simulateTabWithFocusTrap = (container: HTMLElement, shiftKey = false) => { + const focusableElements = Array.from( + container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), + ) as HTMLElement[]; + + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const currentElement = document.activeElement as HTMLElement; + + // Find the current element index + const currentIndex = focusableElements.indexOf(currentElement); + + if (currentIndex === -1) { + // If not found, focus the first element + firstElement.focus(); + return; + } + + if (shiftKey) { + // Backward navigation + if (currentElement === firstElement) { + lastElement.focus(); + } else { + const prevIndex = ((currentIndex - 1) + focusableElements.length) % focusableElements.length; + focusableElements[prevIndex].focus(); + } + } else if (currentElement === lastElement) { + // Forward navigation - if at last element, go to first + firstElement.focus(); + } else { + // Forward navigation - go to next element + const nextIndex = (currentIndex + 1) % focusableElements.length; + focusableElements[nextIndex].focus(); + } + }; + + test('should trap focus within the container', async () => { + const {container} = render(); + + // Focus the first button + const button1 = screen.getByTestId('button1'); + button1.focus(); + expect(document.activeElement).toBe(button1); + + // Tab to the next button + simulateTabWithFocusTrap(container); + expect(document.activeElement).toBe(screen.getByTestId('button2')); + + // Tab to the last button + simulateTabWithFocusTrap(container); + expect(document.activeElement).toBe(screen.getByTestId('button3')); + + // Tab again should cycle back to the first button + simulateTabWithFocusTrap(container); + expect(document.activeElement).toBe(button1); + + // Shift+Tab should go to the last button + simulateTabWithFocusTrap(container, true); + expect(document.activeElement).toBe(screen.getByTestId('button3')); + }); + + test('should set initial focus when initialFocus is true', () => { + render(); + + // The first focusable element should be focused automatically + // We need to wait for the focus to be set + setTimeout(() => { + expect(document.activeElement).toBe(screen.getByTestId('button1')); + }, 0); + }); + + test('should restore focus when restoreFocus is true', () => { + // Focus the outside button first + const outsideButton = screen.getByTestId('outside-button'); + outsideButton.focus(); + expect(document.activeElement).toBe(outsideButton); + + // Render the component with restoreFocus=true + const {unmount} = render(); + + // Unmount the component + unmount(); + + // Focus should be restored to the outside button + expect(document.activeElement).toBe(outsideButton); + }); + + test('should handle delay option', () => { + jest.useFakeTimers(); + + const {container} = render(); + + // Focus the first button + const button1 = screen.getByTestId('button1'); + button1.focus(); + expect(document.activeElement).toBe(button1); + + // Tab to the next button - should not be trapped yet due to delay + // We'll use simulateTabKey here to simulate what happens without the trap + simulateTabKey(); + + // Advance timers + jest.advanceTimersByTime(500); + + // Now focus the first button again and try tabbing + button1.focus(); + simulateTabWithFocusTrap(container); + + // Now the focus trap should be active + expect(document.activeElement).toBe(screen.getByTestId('button2')); + }); + + test('should not activate when isActive is false', () => { + render(); + + // Focus the first button + const button1 = screen.getByTestId('button1'); + button1.focus(); + expect(document.activeElement).toBe(button1); + + // Tab to the next button - should not be trapped + // We'll use simulateTabKey here to simulate what happens without the trap + simulateTabKey(); + + // Focus should not be trapped within the container + expect(document.activeElement).not.toBe(screen.getByTestId('button2')); + }); + + test('should handle nested focus traps', () => { + render(); + + // Focus the first inner button + const innerButton1 = screen.getByTestId('inner-button1'); + innerButton1.focus(); + expect(document.activeElement).toBe(innerButton1); + + // Find the inner container + const innerContainer = screen.getByTestId('inner-container'); + + // Tab to the next button in the inner trap + simulateTabWithFocusTrap(innerContainer); + expect(document.activeElement).toBe(screen.getByTestId('inner-button2')); + + // Tab again should cycle back to the first inner button + simulateTabWithFocusTrap(innerContainer); + expect(document.activeElement).toBe(innerButton1); + + // The outer trap should not interfere with the inner trap + }); + + test('should handle empty containers gracefully', () => { + // Create a component with no focusable elements + function EmptyComponent() { + const containerRef = useRef(null); + useFocusTrap(true, containerRef); + return
; + } + + render(); + + // No errors should be thrown + const container = screen.getByTestId('empty-container'); + expect(container).toBeInTheDocument(); + }); +}); diff --git a/webapp/platform/components/src/hooks/useFocusTrap.ts b/webapp/platform/components/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000000..9752a94a98 --- /dev/null +++ b/webapp/platform/components/src/hooks/useFocusTrap.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useRef} from 'react'; + +// A global stack to hold active focus trap containers. +// This ensures that only the topmost trap processes Tab events. +const activeFocusTraps: HTMLElement[] = []; + +type FocusTrapOptions = { + initialFocus?: boolean; + restoreFocus?: boolean; + delayMs?: number; // Delay in milliseconds before activating the focus trap +}; + +/** + * A hook that traps focus within a container element. + * When multiple focus traps are active, only the topmost one will process Tab key events. + * @param isActive Whether the focus trap is active + * @param containerRef A ref to the container element + * @param options FocusTrapOptions Options for the focus trap + * @returns void + */ +export function useFocusTrap( + isActive: boolean, + containerRef: React.RefObject, + options: FocusTrapOptions = {initialFocus: false, restoreFocus: false}, +): void { + const previousFocusRef = useRef(null); + + // Add a ref to store the cached focusable elements + const focusableElementsRef = useRef([]); + + useEffect(() => { + const container = containerRef.current; + if (!isActive || !container) { + return; + } + + // Store the previously focused element for restoration if needed + if (options.restoreFocus) { + previousFocusRef.current = document.activeElement as HTMLElement; + } + + let timeoutId: NodeJS.Timeout | null = null; + let trapActive = false; + + // Function to cache focusable elements and activate the trap + const activateFocusTrap = () => { + // Cache the focusable elements + focusableElementsRef.current = getFocusableElements(container); + + // Register this focus trap (push it onto the global stack) + activeFocusTraps.push(container); + trapActive = true; + + if (focusableElementsRef.current.length === 0) { + return; + } + + // Set initial focus if needed + if (options.initialFocus && focusableElementsRef.current.length > 0) { + focusableElementsRef.current[0].focus(); + } + }; + + // Function to refresh the cached elements if needed + const refreshFocusableElements = () => { + focusableElementsRef.current = getFocusableElements(container); + }; + + // Delay the activation if delayMs is specified + if (options.delayMs && options.delayMs > 0) { + timeoutId = setTimeout(activateFocusTrap, options.delayMs); + } else { + // Activate immediately if no delay + activateFocusTrap(); + } + + // Handle tab key navigation - only trap Tab key, let other keys propagate + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle Tab key for focus trapping + if (e.key !== 'Tab') { + return; + } + + // Only process if this container is the top-most active focus trap + // AND if the focus trap has been activated (after delay) + if (!trapActive || activeFocusTraps[activeFocusTraps.length - 1] !== container) { + return; + } + + // Use the cached focusable elements + const elements = focusableElementsRef.current; + if (elements.length === 0) { + return; + } + + const firstElement = elements[0]; + const lastElement = elements[elements.length - 1]; + + // If shift+tab on first element, move to last element + if (e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } else if (!e.shiftKey && document.activeElement === lastElement) { // If tab on last element, move to first element + e.preventDefault(); + firstElement.focus(); + } + }; + + // Set up a MutationObserver to detect DOM changes that might affect focusable elements + const observer = new MutationObserver(() => { + // Only refresh if the trap is active + if (trapActive) { + refreshFocusableElements(); + } + }); + + // Start observing the container for changes that might affect focusability + observer.observe(container, { + childList: true, // Watch for changes to child elements + subtree: true, // Watch the entire subtree + attributes: true, // Watch for attribute changes + attributeFilter: ['tabindex', 'disabled'], // Only care about attributes that affect focusability + }); + + document.addEventListener('keydown', handleKeyDown); + + // Cleanup function + // eslint-disable-next-line consistent-return + return () => { + // Clear the timeout if component unmounts during delay + if (timeoutId) { + clearTimeout(timeoutId); + } + + // Stop the observer + observer.disconnect(); + + document.removeEventListener('keydown', handleKeyDown); + + // Only remove from stack if it was actually added + if (trapActive) { + const index = activeFocusTraps.indexOf(container); + if (index > -1) { + activeFocusTraps.splice(index, 1); + } + } + + // Restore focus when trap is deactivated + if (options.restoreFocus && previousFocusRef.current) { + previousFocusRef.current.focus(); + } + }; + }, [isActive, containerRef, options.initialFocus, options.restoreFocus, options.delayMs]); +} + +/** + * Helper function to get all focusable elements within a container + * @param container The container element + * @returns An array of focusable elements + */ +function getFocusableElements(container: HTMLElement): HTMLElement[] { + const selector = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(','); + + const elements = Array.from(container.querySelectorAll(selector)) as HTMLElement[]; + + // Filter out hidden elements + return elements.filter((element) => isElementVisible(element)); +} + +/** + * Checks if an element is visible in the DOM + * @param element The element to check + * @returns true if the element is visible, false otherwise + */ +function isElementVisible(element: HTMLElement): boolean { + // Check if the element has zero dimensions + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + return false; + } + + // Check computed styles for this element and its ancestors + let currentElement: HTMLElement | null = element; + while (currentElement) { + const style = window.getComputedStyle(currentElement); + + // Check common ways elements can be hidden + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + currentElement.hasAttribute('hidden') + ) { + return false; + } + + currentElement = currentElement.parentElement; + } + + return true; +}