diff --git a/src/components/Dropdown/Dropdown.stories.tsx b/src/components/Dropdown/Dropdown.stories.tsx index de64dc42d..12ca249fd 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 () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + await waitFor(() => + expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true) + ); + const option1 = screen.getByTestId('User profile 1'); + act(() => { + fireEvent.keyDown(option1, { key: 'Tab', code: 'Tab', shiftKey: true }); + // JSDOM does not move focus on Tab/Shift+Tab. Simulate the browser moving focus + // 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) => { + setTimeout(resolve, 20); + }) + ); + await waitFor(() => + expect(referenceElement.getAttribute('aria-expanded')).toBe('false') + ); + expect(container.querySelector('.dropdown-wrapper')).toBeFalsy(); + expect(referenceElement).toHaveFocus(); + }); + + test('With shouldCloseOnTab, Shift+Tab from overlay closes dropdown and returns focus to trigger', async () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + await waitFor(() => + expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true) + ); + const option1 = screen.getByTestId('User profile 1'); + act(() => { + 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) => { + setTimeout(resolve, ANIMATION_DURATION + 50); + }) + ); + await waitFor(() => + expect(referenceElement.getAttribute('aria-expanded')).toBe('false') + ); + expect(container.querySelector('.dropdown-wrapper')).toBeFalsy(); + expect(referenceElement).toHaveFocus(); + }); + + test('With toggleDropdownOnShiftTab, dropdown stays open when focus leaves overlay via Shift+Tab', async () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + await waitFor(() => + expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true) + ); + const option1 = screen.getByTestId('User profile 1'); + act(() => { + 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) => { + setTimeout(resolve, 20); + }) + ); + expect(referenceElement.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('.dropdown-wrapper')).toBeTruthy(); + expect(screen.getByText('User profile 1')).toBeInTheDocument(); + }); + + test('With shouldCloseOnTab, Tab from reference while dropdown is open closes and does not refocus reference', async () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + referenceElement.focus(); + act(() => { + fireEvent.keyDown(referenceElement, { key: 'Tab', code: 'Tab' }); + }); + await waitFor(() => + expect(referenceElement.getAttribute('aria-expanded')).toBe('false') + ); + expect(container.querySelector('.dropdown-wrapper')).toBeFalsy(); + expect(referenceElement).not.toHaveFocus(); + }); + + test('With initialFocus false, opening dropdown does not move focus to overlay', async () => { + const { getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(false); + expect(referenceElement).toHaveFocus(); + }); + + test('Closes dropdown when Shift+Tab is pressed on reference while dropdown is open', async () => { + const { container, getByTestId } = render( + + ); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + expect(referenceElement.getAttribute('aria-expanded')).toBe('true'); + act(() => { + fireEvent.keyDown(referenceElement, { + key: 'Tab', + code: 'Tab', + shiftKey: true, + }); + }); + // Allow close animation and effect to run + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 20); + }) + ); + await waitFor(() => + expect(referenceElement.getAttribute('aria-expanded')).toBe('false') + ); + expect(container.querySelector('.dropdown-wrapper')).toBeFalsy(); + }); + + test('Focuses the next focusable element on the page when dropdown is closed by Tab', async () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + const nextFocusable = getByTestId('next-focusable'); + act(() => { + userEvent.click(referenceElement); + }); + await waitFor(() => screen.getByText('User profile 1')); + await waitFor(() => + expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true) + ); + act(() => { + // Tab through the three overlay items to reach the next focusable on the page + userEvent.tab(); + userEvent.tab(); + userEvent.tab(); + }); + await waitFor(() => + expect(referenceElement.getAttribute('aria-expanded')).toBe('false') + ); + expect(container.querySelector('.dropdown-wrapper')).toBeFalsy(); + expect(nextFocusable).toHaveFocus(); + expect(referenceElement).not.toHaveFocus(); + }); + + test('Focuses the first focusable element when dropdown is opened with Arrow Down from reference', async () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + act(() => { + userEvent.type(referenceElement, MOCK_EVENT_KEYS.ARROWDOWN); + }); + await waitFor(() => screen.getByText('User profile 1')); + await waitFor(() => + expect(screen.getByTestId('User profile 1').matches(':focus')).toBe(true) + ); + expect(screen.getByTestId('User profile 1')).toHaveFocus(); + expect(referenceElement.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('.dropdown-wrapper')).toBeTruthy(); + }); + + test('Focuses the last focusable element when dropdown is opened with Arrow Up from reference', async () => { + const { container, getByTestId } = render(); + const referenceElement = getByTestId('dropdown-reference'); + const lastOverlayItem = () => screen.getByTestId('User profile 3'); + act(() => { + referenceElement.focus(); + }); + act(() => { + fireEvent.keyDown(referenceElement, { key: 'ArrowUp', code: 'ArrowUp' }); + }); + await waitFor(() => screen.getByText('User profile 3')); + expect(referenceElement.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('.dropdown-wrapper')).toBeTruthy(); + await waitFor( + () => expect(lastOverlayItem().matches(':focus')).toBe(true), + { timeout: 1000, interval: 50 } + ); + expect(lastOverlayItem()).toHaveFocus(); + }); + + test('Allows tabbing between overlay items after click', async () => { const { getByTestId } = render(); const referenceElement = getByTestId('dropdown-reference'); @@ -584,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 54c916972..38e92e8f3 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -81,7 +81,7 @@ export const Dropdown: FC = React.memo( // Overlay should not be focusable by default to prevent issues with expected tab order overlayTabIndex = -1, overlayProps, - toggleDropdownOnShiftTab = true, + toggleDropdownOnShiftTab = false, }, ref: React.ForwardedRef ) => { @@ -110,22 +110,42 @@ export const Dropdown: FC = React.memo( const intervalRef: React.MutableRefObject = useRef(null); + const closedByTabRef: React.MutableRefObject = + useRef(false); + const focusLastOnOpenRef: React.MutableRefObject = + useRef(false); + const focusTargetAfterCloseRef: React.MutableRefObject< + (() => HTMLElement | null) | null + > = useRef<(() => HTMLElement | null) | null>(null); + + const hasFocusWithin = (el: HTMLElement | null): boolean => { + if (!el) return false; + try { + return el.matches(':focus-within'); + } catch { + // JSDOM does not support :focus-within (throws SyntaxError); fallback for tests. + return canUseDocElement() && el.contains(document.activeElement); + } + }; - const firstFocusableElement = (): HTMLElement => { - const getFocusableElements = (): HTMLElement[] => { - return [ - ...(refs.floating?.current.querySelectorAll( - SELECTORS - ) as unknown as HTMLElement[]), - ].filter((el: HTMLElement) => focusable(el)); - }; - const focusableElements: HTMLElement[] = refs.floating?.current - ? getFocusableElements?.() - : null; - return focusableElements?.[0]; + const getDocumentFocusableElements = (): HTMLElement[] => { + if (!canUseDocElement()) return []; + return Array.from( + document.querySelectorAll(SELECTORS) + ).filter((el) => focusable(el)); + }; + + const getNextFocusableAfterReference = (): HTMLElement | null => { + const reference = document.getElementById(dropdownReferenceId); + if (!reference) return null; + const allFocusable = getDocumentFocusableElements(); + const refIndex = allFocusable.indexOf(reference as HTMLElement); + if (refIndex === -1 || refIndex === allFocusable.length - 1) + return null; + return allFocusable[refIndex + 1] ?? null; }; - const getFocusableItems = (): HTMLElement[] => { + const getOverlayFocusableItems = (): HTMLElement[] => { if (!refs.floating.current) return []; return Array.from( @@ -133,17 +153,28 @@ export const Dropdown: FC = React.memo( ).filter((el) => focusable(el)); }; - const focusFirstElement = (): void => { - const elementToFocus: HTMLElement = firstFocusableElement?.(); + const focusUntilActive = (elementToFocus: HTMLElement): void => { clearInterval(intervalRef?.current); intervalRef.current = setInterval((): void => { - elementToFocus?.focus(); + elementToFocus.focus(); if (document.activeElement === elementToFocus) { clearInterval(intervalRef?.current); } }, ANIMATION_DURATION); }; + const focusFirstElement = (): void => { + const focusableItems: HTMLElement[] = getOverlayFocusableItems(); + if (focusableItems.length === 0) return; + focusUntilActive(focusableItems[0]); + }; + + const focusLastElement = (): void => { + const focusableItems: HTMLElement[] = getOverlayFocusableItems(); + if (focusableItems.length === 0) return; + focusUntilActive(focusableItems[focusableItems.length - 1]); + }; + const focusOnElement = (elementToFocus: HTMLElement): void => { elementToFocus?.focus(); }; @@ -184,6 +215,7 @@ export const Dropdown: FC = React.memo( referenceElement = document.getElementById(dropdownReferenceId); } if (closeOnOutsideClick && closeOnReferenceClick && !mergedVisible) { + focusTargetAfterCloseRef.current = () => referenceElement; toggle(false)(e); } if ( @@ -191,6 +223,7 @@ export const Dropdown: FC = React.memo( !referenceElement.contains(e.target as Node) && !mergedVisible ) { + focusTargetAfterCloseRef.current = () => referenceElement; toggle(false)(e); } onClickOutside?.(e); @@ -242,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) { @@ -264,10 +304,14 @@ export const Dropdown: FC = React.memo( return; } referenceOnKeydown?.(event); + + const key = event.key; + const isReferenceFocused = document.activeElement === event.target; + if ( - (event?.key === eventKeys.ENTER || event?.key === eventKeys.SPACE) && - canUseDocElement() && - document.activeElement === event.target + (key === eventKeys.ENTER || key === eventKeys.SPACE) && + isReferenceFocused && + canUseDocElement() ) { timeout && clearTimeout(timeout); timeout = setTimeout(() => { @@ -279,32 +323,39 @@ export const Dropdown: FC = React.memo( }, ANIMATION_DURATION); } if ( - event?.key === eventKeys.ARROWDOWN && - document.activeElement === event.target && + key === eventKeys.ARROWDOWN && + isReferenceFocused && !mergedVisible ) { - event?.preventDefault(); + event.preventDefault(); toggle(true)(event); } - if ( - event?.key === eventKeys.ARROWUP && - document.activeElement === event.target && - mergedVisible - ) { - event?.preventDefault(); - toggle(false)(event); - } - if (event?.key === eventKeys.ESCAPE) { - toggle(false)(event); + if (key === eventKeys.ARROWUP && isReferenceFocused) { + event.preventDefault(); + if (mergedVisible) { + toggle(false)(event); + } else { + focusLastOnOpenRef.current = true; + toggle(true)(event); + } } - if (event?.key === eventKeys.TAB && mergedVisible && shouldCloseOnTab) { + if (key === eventKeys.ESCAPE) { + focusTargetAfterCloseRef.current = () => + document.getElementById(dropdownReferenceId) as HTMLElement | null; toggle(false)(event); } if ( - event?.key === eventKeys.TAB && - event.shiftKey && - !(event.target as HTMLElement).matches(':focus-within') + key === eventKeys.TAB && + !event.shiftKey && + mergedVisible && + shouldCloseOnTab ) { + closedByTabRef.current = true; + toggle(false)(event); + } + // Shift+Tab on reference while open: user is navigating away, so close the dropdown. + if (key === eventKeys.TAB && event.shiftKey && mergedVisible) { + closedByTabRef.current = true; toggle(false)(event); } }; @@ -314,7 +365,7 @@ export const Dropdown: FC = React.memo( !event.defaultPrevented && (event.key === eventKeys.ARROWDOWN || event.key === eventKeys.ARROWUP) ) { - const items = getFocusableItems(); + const items = getOverlayFocusableItems(); const currentIndex = items.indexOf( document.activeElement as HTMLElement ); @@ -334,13 +385,29 @@ 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; toggle(false)(event); return; } if (event?.key === eventKeys.TAB && mergedVisible && !event.shiftKey) { if (shouldCloseOnTab) { + event.preventDefault(); + closedByTabRef.current = true; + focusTargetAfterCloseRef.current = getNextFocusableAfterReference; toggle(false)(event); } else { timeout && clearTimeout(timeout); @@ -349,18 +416,38 @@ export const Dropdown: FC = React.memo( refs.floating.current && !refs.floating.current.contains(document.activeElement) ) { + closedByTabRef.current = true; toggle(false)(event); } }, NO_ANIMATION_DURATION); } } + // Shift+Tab: with shouldCloseOnTab, close and focus the trigger; otherwise + // after a short delay, if focus left the overlay, close or keep open per toggleDropdownOnShiftTab. if (event?.key === eventKeys.TAB && event.shiftKey && mergedVisible) { - timeout && clearTimeout(timeout); - timeout = setTimeout(() => { - if (!refs.floating.current.matches(':focus-within')) { - toggle(toggleDropdownOnShiftTab)(event); + if (shouldCloseOnTab) { + event.preventDefault(); + closedByTabRef.current = true; + focusTargetAfterCloseRef.current = () => + document.getElementById( + dropdownReferenceId + ) as HTMLElement | null; + toggle(false)(event); + } else { + // Wait for focus to leave overlay, then close or keep open per toggleDropdownOnShiftTab + if (timeout) { + clearTimeout(timeout); } - }, NO_ANIMATION_DURATION); + timeout = setTimeout(() => { + const focusLeftOverlay = !hasFocusWithin(refs.floating.current); + if (!focusLeftOverlay) return; + const shouldRemainOpen = toggleDropdownOnShiftTab; + if (!shouldRemainOpen) { + closedByTabRef.current = true; + } + toggle(shouldRemainOpen)(event); + }, NO_ANIMATION_DURATION); + } } }; @@ -428,25 +515,35 @@ export const Dropdown: FC = React.memo( }); }; + // Clear focus interval on unmount + useEffect(() => { + return () => { + intervalRef.current && clearInterval(intervalRef.current); + intervalRef.current = null; + }; + }, []); + useEffect(() => { + // If the dropdown is opened, focus first or last focusable element if (initialFocus && mergedVisible) { - focusFirstElement(); + if (focusLastOnOpenRef.current) { + focusLastOnOpenRef.current = false; + focusLastElement(); + } else { + focusFirstElement(); + } } - if (!mergedVisible && previouslyClosing) { - const referenceElement: HTMLElement = - document.getElementById(dropdownReferenceId); - referenceElement?.focus(); + if (mergedVisible) return; + clearInterval(intervalRef?.current); + + if (closedByTabRef.current) { + closedByTabRef.current = false; } - if (!mergedVisible) { - clearInterval(intervalRef?.current); + if (focusTargetAfterCloseRef.current) { + focusTargetAfterCloseRef.current?.()?.focus(); + focusTargetAfterCloseRef.current = null; } - }, [ - dropdownReferenceId, - initialFocus, - intervalRef, - mergedVisible, - previouslyClosing, - ]); + }, [dropdownReferenceId, initialFocus, intervalRef, mergedVisible]); const getDropdown = (): JSX.Element => mergedVisible && ( @@ -463,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} diff --git a/src/components/Dropdown/Dropdown.types.ts b/src/components/Dropdown/Dropdown.types.ts index 7c549cc7a..7dd99a929 100644 --- a/src/components/Dropdown/Dropdown.types.ts +++ b/src/components/Dropdown/Dropdown.types.ts @@ -96,8 +96,10 @@ export interface DropdownProps { */ onVisibleChange?: (visible: boolean) => void; /** - * If the dropdown should be shown when the user presses the shift + tab key - * @default true + * When false (default), the dropdown closes when focus leaves the overlay via Shift+Tab (recommended for a11y). + * When true, the dropdown remains open. + * @default false + * @deprecated Passing true is deprecated; prefer the default. Support for true may be removed in the future. */ toggleDropdownOnShiftTab?: boolean; /**