Skip to content
Closed
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
46 changes: 45 additions & 1 deletion src/components/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,17 @@ const dropdownProps: object = {
portal: false,
};

const DropdownComponent = (): JSX.Element => {
const DropdownComponent = ({
trapFocus,
}: {
trapFocus?: boolean;
}): JSX.Element => {
const [visible, setVisibility] = useState(false);

return (
<Dropdown
{...dropdownProps}
trapFocus={trapFocus}
onVisibleChange={(isVisible) => setVisibility(isVisible)}
>
<Button
Expand Down Expand Up @@ -573,4 +578,43 @@ describe('Dropdown', () => {
expect(referenceElement.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByText('User profile 1')).toBeVisible();
});

test('Allows focus cycling when trapFocus prop is enabled', async () => {
const { getByTestId } = render(<DropdownComponent trapFocus />);
const referenceElement = getByTestId('dropdown-reference');

// Click to open dropdown
act(() => {
userEvent.click(referenceElement);
});

// Wait for menu to be visible
await waitFor(() => screen.getByText('User profile 1'));

await waitFor(() =>
expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true)
);

// Tab to third (last) menu item
act(() => {
userEvent.tab();
userEvent.tab();
});

// Verify last menu item is focused
await waitFor(() =>
expect(screen.getByTestId('User profile 3').matches(':focus')).toBe(true)
);

// Cycle focus back to first menu item
act(() => {
userEvent.tab();
});

// Verify dropdown remains open and first menu item is focused
expect(referenceElement.getAttribute('aria-expanded')).toBe('true');
await waitFor(() =>
expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true)
);
});
});
23 changes: 10 additions & 13 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const Dropdown: FC<DropdownProps> = React.memo(
showDropdown,
style,
tabIndex = 0,
trapFocus = false,
trigger = 'click',
visible,
width,
Expand Down Expand Up @@ -296,19 +297,14 @@ export const Dropdown: FC<DropdownProps> = React.memo(
if (event?.key === eventKeys.ESCAPE) {
toggle(false)(event);
}
if (event?.key === eventKeys.TAB) {
// Let FloatingFocusManager handle Tab/Shift+Tab when trapping focus.
// Only perform custom Tab-handling when trapFocus is false.
if (!trapFocus && event?.key === eventKeys.TAB) {
timeout && clearTimeout(timeout);
timeout = setTimeout(() => {
if (!refs.floating.current.matches(':focus-within')) {
toggle(false)(event);
}
}, NO_ANIMATION_DURATION);
}
if (event?.key === eventKeys.TAB && event.shiftKey) {
timeout && clearTimeout(timeout);
timeout = setTimeout(() => {
if (!refs.floating.current.matches(':focus-within')) {
toggle(toggleDropdownOnShiftTab)(event);
const value = event.shiftKey ? toggleDropdownOnShiftTab : false;
toggle(value)(event);
}
}, NO_ANIMATION_DURATION);
}
Expand Down Expand Up @@ -399,10 +395,11 @@ export const Dropdown: FC<DropdownProps> = React.memo(
<FloatingFocusManager
context={context}
key={dropdownId}
modal={false}
order={['reference', 'content']}
modal={trapFocus}
visuallyHiddenDismiss={trapFocus}
order={trapFocus ? undefined : ['reference', 'content']}
initialFocus={-1}
returnFocus={false}
returnFocus={trapFocus}
>
<div
ref={refs.setFloating}
Expand Down
5 changes: 5 additions & 0 deletions src/components/Dropdown/Dropdown.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export interface DropdownProps {
* The optional tab index of the reference element.
*/
tabIndex?: number;
/**
* If focus should be trapped within the dropdown when visible
* @default false
*/
trapFocus?: boolean;
/**
* The trigger mode that opens the dropdown
* @default 'click'
Expand Down