diff --git a/docs/pages/components/AnchoredOverlay.mdx b/docs/pages/components/AnchoredOverlay.mdx index 13bd6cfd4..17b6af5d1 100644 --- a/docs/pages/components/AnchoredOverlay.mdx +++ b/docs/pages/components/AnchoredOverlay.mdx @@ -173,6 +173,16 @@ function AnchoredOverlayWithOffsetExample() { type: 'number', description: 'An optional offset number to position the anchor element from its anchored target.' }, + { + name: 'focusTrap', + type: 'boolean', + description: 'When set, traps focus within the AnchoredOverlay.' + }, + { + name: 'focusTrapOptions', + type: 'object', + description: 'When `focusTrap` is true, optional arguments to configure the focus trap.' + }, { name: 'as', type: 'React.ElementType', diff --git a/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx index 4fdbd385b..53bb799b3 100644 --- a/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import AnchoredOverlay from './'; import axe from '../../axe'; @@ -123,6 +123,76 @@ test('should call onPlacementChange with initial placement', () => { expect(onPlacementChange).toHaveBeenCalledWith('top'); }); +test('should not trap focus when focusTrap is false', async () => { + const targetRef = { current: document.createElement('button') }; + const user = userEvent.setup(); + + render( + <> + + + + + + + ); + + const buttons = screen.getAllByRole('button'); + + buttons[0].focus(); + expect(buttons[0]).toHaveFocus(); + await user.tab(); + expect(buttons[1]).toHaveFocus(); + await user.tab(); + expect(buttons[2]).toHaveFocus(); +}); + +test('should trap focus when focusTrap is true', async () => { + const targetRef = { current: document.createElement('button') }; + const user = userEvent.setup(); + + render( + <> + + + + + + + + + ); + + const buttons = within(screen.getByTestId('overlay')).getAllByRole('button'); + + expect(buttons[0]).toHaveFocus(); + await user.tab(); + expect(buttons[1]).toHaveFocus(); + await user.tab(); + expect(buttons[2]).toHaveFocus(); + await user.tab(); + expect(buttons[0]).toHaveFocus(); +}); + +test('should restore focus when focusTrap is unmounted', async () => { + const targetRef = { current: document.createElement('button') }; + const outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + outsideButton.focus(); + + const { unmount } = render( + + + + ); + + expect(screen.getByText('Inside Button')).toHaveFocus(); + unmount(); + expect(outsideButton).toHaveFocus(); + + document.body.removeChild(outsideButton); +}); + test('should support ref prop', () => { const targetRef = { current: document.createElement('button') }; const ref = React.createRef(); diff --git a/packages/react/src/components/AnchoredOverlay/index.tsx b/packages/react/src/components/AnchoredOverlay/index.tsx index 74b40379d..4934bda75 100644 --- a/packages/react/src/components/AnchoredOverlay/index.tsx +++ b/packages/react/src/components/AnchoredOverlay/index.tsx @@ -10,6 +10,7 @@ import { type PolymorphicProps } from '../../utils/polymorphicComponent'; import resolveElement from '../../utils/resolveElement'; import useSharedRef from '../../utils/useSharedRef'; import useEscapeKey from '../../utils/useEscapeKey'; +import useFocusTrap from '../../utils/useFocusTrap'; type AnchoredOverlayProps< Overlay extends HTMLElement, @@ -27,6 +28,10 @@ type AnchoredOverlayProps< onPlacementChange?: (placement: Placement) => void; /** An optional offset number to position the anchor element from its anchored target. */ offset?: number; + /** When set, traps focus within the AnchoredOverlay. */ + focusTrap?: boolean; + /** When `focusTrap` is true, optional arguments to configure the focus trap. */ + focusTrapOptions?: Parameters[1]; children?: React.ReactNode; } & PolymorphicProps>; @@ -56,6 +61,8 @@ const AnchoredOverlay = forwardRef( style, open = false, offset, + focusTrap, + focusTrapOptions, onOpenChange, onPlacementChange, ...props @@ -99,6 +106,8 @@ const AnchoredOverlay = forwardRef( } }); + useFocusTrap(ref, !focusTrap ? { disabled: true } : focusTrapOptions); + useEffect(() => { if (typeof onPlacementChange === 'function') { onPlacementChange(placement);