Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): add focusTrap options to AnchoredOverlay #1775

Merged
merged 3 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/pages/components/AnchoredOverlay.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
<>
<button>Outside Before</button>
<AnchoredOverlay target={targetRef} open focusTrap={false}>
<button>Inside</button>
</AnchoredOverlay>
<button>Outside After</button>
</>
);

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(
<>
<button>Outside Before</button>
<AnchoredOverlay target={targetRef} open focusTrap data-testid="overlay">
<button>First</button>
<button>Second</button>
<button>Third</button>
</AnchoredOverlay>
<button>Outside After</button>
</>
);

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(
<AnchoredOverlay target={targetRef} open focusTrap data-testid="overlay">
<button>Inside Button</button>
</AnchoredOverlay>
);

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<HTMLDivElement>();
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/components/AnchoredOverlay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof useFocusTrap>[1];
children?: React.ReactNode;
} & PolymorphicProps<React.HTMLAttributes<Overlay>>;

Expand Down Expand Up @@ -56,6 +61,8 @@ const AnchoredOverlay = forwardRef(
style,
open = false,
offset,
focusTrap,
focusTrapOptions,
onOpenChange,
onPlacementChange,
...props
Expand Down Expand Up @@ -99,6 +106,8 @@ const AnchoredOverlay = forwardRef(
}
});

useFocusTrap(ref, !focusTrap ? { disabled: true } : focusTrapOptions);

useEffect(() => {
if (typeof onPlacementChange === 'function') {
onPlacementChange(placement);
Expand Down
Loading