From f967cec2ae02cb698e0b7b5dfe759fe32dd5fee6 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Thu, 5 Mar 2026 10:16:10 -0800 Subject: [PATCH 1/5] fix(a11y): fix Tab navigation behavior when Dropdown is closed; add story and unit test --- src/components/Dropdown/Dropdown.stories.tsx | 56 ++++++++++++++++++++ src/components/Dropdown/Dropdown.test.tsx | 53 +++++++++++++++++- src/components/Dropdown/Dropdown.tsx | 44 ++++++++------- 3 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx index de64dc42d..3017a9c95 100644 --- a/src/components/Dropdown/Dropdown.stories.tsx +++ b/src/components/Dropdown/Dropdown.stories.tsx @@ -10,6 +10,7 @@ import { TwoStateButton, } from '../Button'; import { CheckBox } from '../CheckBox'; +import { ConfigProvider } from '../ConfigProvider'; import { Dropdown } from './'; import { Icon, IconName } from '../Icon'; import { List } from '../List'; @@ -200,6 +201,54 @@ const Dropdown_Button_Story: ComponentStory = (args) => { ); }; +const Dropdown_Button_KeyboardFocus_Story: ComponentStory = ( + args +) => { + const [visible1, setVisibility1] = useState(false); + const [visible2, setVisibility2] = useState(false); + return ( + + + setVisibility1(isVisible)} + > + + + ); + }; + + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + + const option1 = screen.getByText('User profile 1'); + act(() => { + userEvent.type(option1, '{escape}'); + }); + + await waitFor(() => + expect(referenceElement.getAttribute('aria-expanded')).toBe('false') + ); + expect(container.querySelector('.dropdown-wrapper')).toBeFalsy(); + await waitFor(() => expect(referenceElement).toHaveFocus()); }); test('Closes dropdown when Shift+Tab is pressed from first focusable element in overlay', async () => { @@ -651,6 +685,7 @@ describe('Dropdown', () => { // so the component's deferred hasFocusWithin(floating) check sees focus left the overlay. referenceElement.focus(); }); + // Allow close animation and focus-restoration effect to run await act( async () => new Promise((resolve) => { @@ -679,6 +714,7 @@ describe('Dropdown', () => { fireEvent.keyDown(option1, { key: 'Tab', code: 'Tab', shiftKey: true }); referenceElement.focus(); }); + // Allow close animation and focus-restoration effect to run await act( async () => new Promise((resolve) => { @@ -707,6 +743,7 @@ describe('Dropdown', () => { fireEvent.keyDown(option1, { key: 'Tab', code: 'Tab', shiftKey: true }); referenceElement.focus(); }); + // Allow component's deferred hasFocusWithin check to run await act( async () => new Promise((resolve) => { @@ -764,6 +801,7 @@ describe('Dropdown', () => { shiftKey: true, }); }); + // Allow close animation and effect to run await act( async () => new Promise((resolve) => { @@ -802,13 +840,10 @@ describe('Dropdown', () => { }); test('Focuses the first focusable element when dropdown is opened with Arrow Down from reference', async () => { - const mockEventKeys = { - ARROWDOWN: 'ArrowDown', - }; const { container, getByTestId } = render(); const referenceElement = getByTestId('dropdown-reference'); act(() => { - userEvent.type(referenceElement, mockEventKeys.ARROWDOWN); + userEvent.type(referenceElement, MOCK_EVENT_KEYS.ARROWDOWN); }); await waitFor(() => screen.getByText('User profile 1')); await waitFor(() => @@ -839,10 +874,7 @@ describe('Dropdown', () => { expect(lastOverlayItem()).toHaveFocus(); }); - test('Allows tabbing into submenu after click', async () => { - const mockEventKeys = { - TAB: 'Tab', - }; + test('Allows tabbing between overlay items after click', async () => { const { getByTestId } = render(); const referenceElement = getByTestId('dropdown-reference'); @@ -861,7 +893,7 @@ describe('Dropdown', () => { // Tab to second menu item act(() => { - userEvent.type(referenceElement, mockEventKeys.TAB); + userEvent.type(referenceElement, MOCK_EVENT_KEYS.TAB); }); // Verify dropdown remains open diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 56855ee5c..b6fd4ea64 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -215,6 +215,7 @@ export const Dropdown: FC = React.memo( referenceElement = document.getElementById(dropdownReferenceId); } if (closeOnOutsideClick && closeOnReferenceClick && !mergedVisible) { + focusTargetAfterCloseRef.current = () => referenceElement; toggle(false)(e); } if ( @@ -222,6 +223,7 @@ export const Dropdown: FC = React.memo( !referenceElement.contains(e.target as Node) && !mergedVisible ) { + focusTargetAfterCloseRef.current = () => referenceElement; toggle(false)(e); } onClickOutside?.(e); @@ -331,6 +333,8 @@ export const Dropdown: FC = React.memo( } } if (key === eventKeys.ESCAPE) { + focusTargetAfterCloseRef.current = () => + document.getElementById(dropdownReferenceId) as HTMLElement | null; toggle(false)(event); } if ( @@ -375,6 +379,8 @@ export const Dropdown: FC = React.memo( } if (event.key === eventKeys.ESCAPE) { + focusTargetAfterCloseRef.current = () => + document.getElementById(dropdownReferenceId) as HTMLElement | null; toggle(false)(event); return; } @@ -402,7 +408,6 @@ export const Dropdown: FC = React.memo( // after a short delay, if focus left the overlay, close or keep open per toggleDropdownOnShiftTab. if (event?.key === eventKeys.TAB && event.shiftKey && mergedVisible) { if (shouldCloseOnTab) { - // Close and return focus to trigger after overlay hides event.preventDefault(); closedByTabRef.current = true; focusTargetAfterCloseRef.current = () => @@ -511,28 +516,16 @@ export const Dropdown: FC = React.memo( } } if (mergedVisible) return; - // If the dropdown just closed and user tabbed out, clear the ref and leave focus where it is; - // otherwise return focus to the trigger (Escape, click outside). clearInterval(intervalRef?.current); - if (!previouslyClosing) return; if (closedByTabRef.current) { closedByTabRef.current = false; - // Overlay is unmounted here; focus target is safe to resolve. + } + if (focusTargetAfterCloseRef.current) { focusTargetAfterCloseRef.current?.()?.focus(); focusTargetAfterCloseRef.current = null; - } else { - const referenceElement: HTMLElement = - document.getElementById(dropdownReferenceId); - referenceElement?.focus(); } - }, [ - dropdownReferenceId, - initialFocus, - intervalRef, - mergedVisible, - previouslyClosing, - ]); + }, [dropdownReferenceId, initialFocus, intervalRef, mergedVisible]); const getDropdown = (): JSX.Element => mergedVisible && ( From ba322edc1a95cf972edf311cc08337bb55330c65 Mon Sep 17 00:00:00 2001 From: Kris Heinrich Date: Fri, 20 Mar 2026 11:56:10 -0700 Subject: [PATCH 5/5] fix: close overlay and refocus trigger button on Enter/Space selection --- src/components/Dropdown/Dropdown.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index b6fd4ea64..38e92e8f3 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -275,6 +275,13 @@ export const Dropdown: FC = React.memo( height: height ?? '', }; + const handleDropdownClick = (event: React.MouseEvent): void => { + if (!closeOnDropdownClick) return; + focusTargetAfterCloseRef.current = () => + document.getElementById(dropdownReferenceId) as HTMLElement | null; + toggle(false, showDropdown)(event); + }; + const handleReferenceClick = (event: React.MouseEvent): void => { event.stopPropagation(); if (disabled) { @@ -378,6 +385,17 @@ export const Dropdown: FC = React.memo( } } + if ( + closeOnDropdownClick && + !event.defaultPrevented && + (event.key === eventKeys.ENTER || event.key === eventKeys.SPACE) + ) { + focusTargetAfterCloseRef.current = () => + document.getElementById(dropdownReferenceId) as HTMLElement | null; + toggle(false, showDropdown)(event); + return; + } + if (event.key === eventKeys.ESCAPE) { focusTargetAfterCloseRef.current = () => document.getElementById(dropdownReferenceId) as HTMLElement | null; @@ -542,9 +560,7 @@ export const Dropdown: FC = React.memo( style={dropdownStyles as React.CSSProperties} className={dropdownClasses} tabIndex={overlayTabIndex} - onClick={ - closeOnDropdownClick ? toggle(false, showDropdown) : null - } + onClick={handleDropdownClick} onKeyDown={handleFloatingKeyDown} id={dropdownId} role={role}