From e09b12122b60b69f10d18d034ff180b5cf2db098 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Mon, 16 Feb 2026 11:42:24 +0100 Subject: [PATCH 1/4] feat: add onFocusIn and onFocusOut --- pages/calendar/simple.page.tsx | 2 +- .../month-calendar-permutations.page.tsx | 2 +- .../year-calendar-permutations.page.tsx | 2 +- pages/dropdown/common.tsx | 2 +- pages/dropdown/fixed-container.page.tsx | 2 +- pages/dropdown/focus-trap.page.tsx | 2 +- pages/dropdown/interior-fitting.page.tsx | 16 +- .../dropdown/interior-stretch-height.page.tsx | 6 +- .../dropdown/interior-with-wrapping.page.tsx | 8 +- pages/dropdown/list-with-sticky-item.page.tsx | 2 +- pages/dropdown/min-width.page.tsx | 2 +- pages/dropdown/positioning.page.tsx | 10 +- pages/dropdown/prefer-center.page.tsx | 6 +- pages/dropdown/simple.page.tsx | 8 +- pages/options-list/simple.page.tsx | 2 +- src/button-dropdown/internal.tsx | 2 +- src/date-picker/index.tsx | 2 +- src/date-range-picker/dropdown.tsx | 6 +- src/date-range-picker/index.tsx | 4 +- .../dropdown/__tests__/dropdown.test.tsx | 198 ++++++++++++++++-- src/internal/components/dropdown/index.tsx | 29 ++- .../components/dropdown/interfaces.ts | 17 +- 22 files changed, 264 insertions(+), 66 deletions(-) diff --git a/pages/calendar/simple.page.tsx b/pages/calendar/simple.page.tsx index 6d031bc545..debd2016c3 100644 --- a/pages/calendar/simple.page.tsx +++ b/pages/calendar/simple.page.tsx @@ -16,7 +16,7 @@ export default function CalendarPage() { maxWidth="trigger" stretchHeight={true} open={true} - onDropdownClose={() => {}} + onOutsideClick={() => {}} onMouseDown={() => {}} trigger={null} content={ diff --git a/pages/date-range-picker/month-calendar-permutations.page.tsx b/pages/date-range-picker/month-calendar-permutations.page.tsx index 1ee8a85208..1a458e8976 100644 --- a/pages/date-range-picker/month-calendar-permutations.page.tsx +++ b/pages/date-range-picker/month-calendar-permutations.page.tsx @@ -93,7 +93,7 @@ export default function DateRangePickerCalendarPage() { {}} + onOutsideClick={() => {}} onMouseDown={() => {}} trigger={null} content={ diff --git a/pages/date-range-picker/year-calendar-permutations.page.tsx b/pages/date-range-picker/year-calendar-permutations.page.tsx index 673dc47cbe..7b0a3888c9 100644 --- a/pages/date-range-picker/year-calendar-permutations.page.tsx +++ b/pages/date-range-picker/year-calendar-permutations.page.tsx @@ -73,7 +73,7 @@ export default function DateRangePickerCalendarPage() { {}} + onOutsideClick={() => {}} onMouseDown={() => {}} trigger={null} content={ diff --git a/pages/dropdown/common.tsx b/pages/dropdown/common.tsx index 1c61f6490b..bcef0fbc3a 100644 --- a/pages/dropdown/common.tsx +++ b/pages/dropdown/common.tsx @@ -16,7 +16,7 @@ export function SampleDropdown({ id, children }: { id: string; children: React.R } open={isOpened} - onDropdownClose={() => setOpened(false)} + onOutsideClick={() => setOpened(false)} content={children} /> ); diff --git a/pages/dropdown/fixed-container.page.tsx b/pages/dropdown/fixed-container.page.tsx index a8ec130287..02ac5e7b5c 100644 --- a/pages/dropdown/fixed-container.page.tsx +++ b/pages/dropdown/fixed-container.page.tsx @@ -32,7 +32,7 @@ export default function () { } open={open} - onDropdownClose={() => setOpen(false)} + onOutsideClick={() => setOpen(false)} content={} minWidth={'trigger'} /> diff --git a/pages/dropdown/focus-trap.page.tsx b/pages/dropdown/focus-trap.page.tsx index b43eeb667b..d72ad1712f 100644 --- a/pages/dropdown/focus-trap.page.tsx +++ b/pages/dropdown/focus-trap.page.tsx @@ -91,7 +91,7 @@ export default function DropdownScenario() { } open={isOpen} - onDropdownClose={() => setIsOpen(false)} + onOutsideClick={() => setIsOpen(false)} header={
diff --git a/pages/dropdown/interior-fitting.page.tsx b/pages/dropdown/interior-fitting.page.tsx index 87cc078a62..483ba32297 100644 --- a/pages/dropdown/interior-fitting.page.tsx +++ b/pages/dropdown/interior-fitting.page.tsx @@ -40,7 +40,7 @@ export default function DropdownScenario() { } open={openParent1} - onDropdownClose={() => setOpenParent1(false)} + onOutsideClick={() => setOpenParent1(false)} content={
  • @@ -53,7 +53,7 @@ export default function DropdownScenario() {
} open={openChild1} - onDropdownClose={() => setOpenChild1(false)} + onOutsideClick={() => setOpenChild1(false)} content={} /> @@ -72,7 +72,7 @@ export default function DropdownScenario() { } open={openParent2} - onDropdownClose={() => setOpenParent2(false)} + onOutsideClick={() => setOpenParent2(false)} content={
  • @@ -85,7 +85,7 @@ export default function DropdownScenario() { } open={openChild2} - onDropdownClose={() => setOpenChild2(false)} + onOutsideClick={() => setOpenChild2(false)} content={} />
  • @@ -104,7 +104,7 @@ export default function DropdownScenario() { } open={openParent3} - onDropdownClose={() => setOpenParent3(false)} + onOutsideClick={() => setOpenParent3(false)} content={
    • @@ -117,7 +117,7 @@ export default function DropdownScenario() { } open={openChild3} - onDropdownClose={() => setOpenChild3(false)} + onOutsideClick={() => setOpenChild3(false)} content={} />
    • @@ -140,7 +140,7 @@ export default function DropdownScenario() { } open={openParent4} - onDropdownClose={() => setOpenParent4(false)} + onOutsideClick={() => setOpenParent4(false)} content={
      • @@ -153,7 +153,7 @@ export default function DropdownScenario() { } open={openChild4} - onDropdownClose={() => setOpenChild4(false)} + onOutsideClick={() => setOpenChild4(false)} content={} />
      • diff --git a/pages/dropdown/interior-stretch-height.page.tsx b/pages/dropdown/interior-stretch-height.page.tsx index ed5ef7aea9..e9e6ee94df 100644 --- a/pages/dropdown/interior-stretch-height.page.tsx +++ b/pages/dropdown/interior-stretch-height.page.tsx @@ -41,7 +41,7 @@ export default function DropdownScenario() { } open={openParent1} - onDropdownClose={() => setOpenParent1(false)} + onOutsideClick={() => setOpenParent1(false)} content={} /> @@ -60,7 +60,7 @@ export default function DropdownScenario() { } open={openParent2} - onDropdownClose={() => setOpenParent2(false)} + onOutsideClick={() => setOpenParent2(false)} content={} /> @@ -80,7 +80,7 @@ export default function DropdownScenario() { } open={openParent3} - onDropdownClose={() => setOpenParent3(false)} + onOutsideClick={() => setOpenParent3(false)} content={
        diff --git a/pages/dropdown/interior-with-wrapping.page.tsx b/pages/dropdown/interior-with-wrapping.page.tsx index 6cb887209d..870728cee4 100644 --- a/pages/dropdown/interior-with-wrapping.page.tsx +++ b/pages/dropdown/interior-with-wrapping.page.tsx @@ -37,7 +37,7 @@ export default function DropdownScenario() { } open={openParent5} - onDropdownClose={() => setOpenParent5(false)} + onOutsideClick={() => setOpenParent5(false)} content={
        • @@ -50,7 +50,7 @@ export default function DropdownScenario() {
        } open={openChild5} - onDropdownClose={() => setOpenChild5(false)} + onOutsideClick={() => setOpenChild5(false)} content={} /> @@ -73,7 +73,7 @@ export default function DropdownScenario() { } open={openParent6} - onDropdownClose={() => setOpenParent6(false)} + onOutsideClick={() => setOpenParent6(false)} content={
        • @@ -86,7 +86,7 @@ export default function DropdownScenario() { } open={openChild6} - onDropdownClose={() => setOpenChild6(false)} + onOutsideClick={() => setOpenChild6(false)} content={} />
        • diff --git a/pages/dropdown/list-with-sticky-item.page.tsx b/pages/dropdown/list-with-sticky-item.page.tsx index 44cf32b7ce..a2a396a65f 100644 --- a/pages/dropdown/list-with-sticky-item.page.tsx +++ b/pages/dropdown/list-with-sticky-item.page.tsx @@ -78,7 +78,7 @@ export default function MultiselectPage() { setOpen(!open)}>Open dropdown} open={open} - onDropdownClose={() => setOpen(false)} + onOutsideClick={() => setOpen(false)} expandToViewport={urlParams.expandToViewport} header={ urlParams.withHeader ? ( diff --git a/pages/dropdown/min-width.page.tsx b/pages/dropdown/min-width.page.tsx index ded52c01b4..8e2c0cd91a 100644 --- a/pages/dropdown/min-width.page.tsx +++ b/pages/dropdown/min-width.page.tsx @@ -35,7 +35,7 @@ export default function DropdownScenario() { } open={open} - onDropdownClose={() => setOpen(false)} + onOutsideClick={() => setOpen(false)} minWidth={800} content={} /> diff --git a/pages/dropdown/positioning.page.tsx b/pages/dropdown/positioning.page.tsx index f4b28544b7..9c05f82976 100644 --- a/pages/dropdown/positioning.page.tsx +++ b/pages/dropdown/positioning.page.tsx @@ -43,7 +43,7 @@ export default function DropdownScenario() { } open={open1} - onDropdownClose={() => setOpen1(false)} + onOutsideClick={() => setOpen1(false)} content={} /> @@ -58,7 +58,7 @@ export default function DropdownScenario() { } open={open2} - onDropdownClose={() => setOpen2(false)} + onOutsideClick={() => setOpen2(false)} content={} /> @@ -73,7 +73,7 @@ export default function DropdownScenario() { } open={open3} - onDropdownClose={() => setOpen3(false)} + onOutsideClick={() => setOpen3(false)} content={} /> @@ -88,7 +88,7 @@ export default function DropdownScenario() { } open={open4} - onDropdownClose={() => setOpen4(false)} + onOutsideClick={() => setOpen4(false)} content={} /> @@ -107,7 +107,7 @@ export default function DropdownScenario() { } open={open5} - onDropdownClose={() => setOpen5(false)} + onOutsideClick={() => setOpen5(false)} content={} /> diff --git a/pages/dropdown/prefer-center.page.tsx b/pages/dropdown/prefer-center.page.tsx index 589c18f40a..2e9890922e 100644 --- a/pages/dropdown/prefer-center.page.tsx +++ b/pages/dropdown/prefer-center.page.tsx @@ -35,7 +35,7 @@ export default function DropdownScenario() { } open={open1} - onDropdownClose={() => setOpen1(false)} + onOutsideClick={() => setOpen1(false)} content={} /> @@ -51,7 +51,7 @@ export default function DropdownScenario() { } open={open2} - onDropdownClose={() => setOpen2(false)} + onOutsideClick={() => setOpen2(false)} content={} /> @@ -67,7 +67,7 @@ export default function DropdownScenario() { } open={open3} - onDropdownClose={() => setOpen3(false)} + onOutsideClick={() => setOpen3(false)} content={} /> diff --git a/pages/dropdown/simple.page.tsx b/pages/dropdown/simple.page.tsx index ce341ab0cd..37493678b6 100644 --- a/pages/dropdown/simple.page.tsx +++ b/pages/dropdown/simple.page.tsx @@ -21,7 +21,7 @@ export default function DropdownScenario() { Dropdown opens up or down depending on the size of the content and available space above and below the trigger
        • - Dropdown fires an onDropdownClose event a click outside of the fly-out content + Dropdown fires an onOutsideClick event a click outside of the fly-out content
        @@ -32,7 +32,7 @@ export default function DropdownScenario() { } open={open1} - onDropdownClose={() => setOpen1(false)} + onOutsideClick={() => setOpen1(false)} content={} />
        @@ -44,7 +44,7 @@ export default function DropdownScenario() { } open={open2} - onDropdownClose={() => setOpen2(false)} + onOutsideClick={() => setOpen2(false)} header={
        } footer={
        } content={} @@ -60,7 +60,7 @@ export default function DropdownScenario() { } open={open3} - onDropdownClose={() => setOpen3(false)} + onOutsideClick={() => setOpen3(false)} content={} />
        diff --git a/pages/options-list/simple.page.tsx b/pages/options-list/simple.page.tsx index b57f8f6240..a6cb34f092 100644 --- a/pages/options-list/simple.page.tsx +++ b/pages/options-list/simple.page.tsx @@ -35,7 +35,7 @@ export default function OptionsListScenario() { Dropdown trigger } - onDropdownClose={toggleDropdown} + onOutsideClick={toggleDropdown} content={ {[...Array(50)].map((_, index) => ( diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index cf3345105f..cd01ce6aae 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -364,7 +364,7 @@ const InternalButtonDropdown = React.forwardRef( hideBlockBorder={false} expandToViewport={expandToViewport} preferCenter={preferCenter} - onDropdownClose={() => toggleDropdown()} + onOutsideClick={() => toggleDropdown()} trigger={trigger} dropdownId={dropdownId} content={ diff --git a/src/date-picker/index.tsx b/src/date-picker/index.tsx index 216d0aed0f..7cfcfa213e 100644 --- a/src/date-picker/index.tsx +++ b/src/date-picker/index.tsx @@ -190,7 +190,7 @@ const DatePicker = React.forwardRef( maxWidth="trigger" stretchHeight={true} open={isDropDownOpen} - onDropdownClose={onDropdownCloseHandler} + onOutsideClick={onDropdownCloseHandler} trigger={trigger} expandToViewport={expandToViewport} scrollable={false} diff --git a/src/date-range-picker/dropdown.tsx b/src/date-range-picker/dropdown.tsx index 4ac388c1d4..521dcac98e 100644 --- a/src/date-range-picker/dropdown.tsx +++ b/src/date-range-picker/dropdown.tsx @@ -58,7 +58,7 @@ interface DateRangePickerDropdownProps Pick { onClear: () => void; onApply: (value: null | DateRangePickerProps.Value) => DateRangePickerProps.ValidationResult; - onDropdownClose: () => void; + onOutsideClick: () => void; isSingleGrid: boolean; customAbsoluteRangeControl: DateRangePickerProps.AbsoluteRangeControl | undefined; renderRelativeRangeContent: DateRangePickerProps.RelativeRangeControl | undefined; @@ -75,7 +75,7 @@ export function DateRangePickerDropdown({ onApply: applyValue, getTimeOffset, timeOffset, - onDropdownClose, + onOutsideClick, relativeOptions, showClearButton, isSingleGrid, @@ -120,7 +120,7 @@ export function DateRangePickerDropdown({ const closeDropdown = () => { setApplyClicked(false); - onDropdownClose(); + onOutsideClick(); }; const onClear = () => { diff --git a/src/date-range-picker/index.tsx b/src/date-range-picker/index.tsx index d6453290f0..eb369a8538 100644 --- a/src/date-range-picker/index.tsx +++ b/src/date-range-picker/index.tsx @@ -314,7 +314,7 @@ const DateRangePicker = React.forwardRef( closeDropdown()} + onOutsideClick={() => closeDropdown()} trigger={trigger} expandToViewport={expandToViewport} dropdownId={dropdownId} @@ -326,7 +326,7 @@ const DateRangePicker = React.forwardRef( startOfWeek={startOfWeek} locale={normalizedLocale} isSingleGrid={isSingleGrid} - onDropdownClose={() => closeDropdown(true)} + onOutsideClick={() => closeDropdown(true)} value={value} showClearButton={showClearButton} isDateEnabled={isDateEnabled} diff --git a/src/internal/components/dropdown/__tests__/dropdown.test.tsx b/src/internal/components/dropdown/__tests__/dropdown.test.tsx index 321782f16e..4a3105a961 100644 --- a/src/internal/components/dropdown/__tests__/dropdown.test.tsx +++ b/src/internal/components/dropdown/__tests__/dropdown.test.tsx @@ -48,24 +48,24 @@ describe('Dropdown Component', () => { expect(wrapper.find(`#${id}`)).toBeTruthy(); }); }); - describe('"DropdownClose" Event', () => { - test('fires close event on outside click', async () => { - const handleCloseDropdown = jest.fn(); + describe('"OutsideClick" Event', () => { + test('fires event on outside click', async () => { + const handleOutsideClick = jest.fn(); const [, outsideElement] = renderDropdown( - } onDropdownClose={handleCloseDropdown} open={true} /> + } onOutsideClick={handleOutsideClick} open={true} /> ); await runPendingEvents(); act(() => outsideElement.click()); - expect(handleCloseDropdown).toHaveBeenCalled(); + expect(handleOutsideClick).toHaveBeenCalled(); }); - test('does not fire close event when a portaled element inside dropdown is clicked', async () => { - const handleCloseDropdown = jest.fn(); + test('does not fire event when a portaled element inside dropdown is clicked', async () => { + const handleOutsideClick = jest.fn(); renderDropdown( } - onDropdownClose={handleCloseDropdown} + onOutsideClick={handleOutsideClick} open={true} content={ { await runPendingEvents(); act(() => screen.getByTestId('inside').click()); - expect(handleCloseDropdown).not.toHaveBeenCalled(); + expect(handleOutsideClick).not.toHaveBeenCalled(); }); - test('does not fire close event when a self-destructible element inside dropdown was clicked', async () => { + test('does not fire event when a self-destructible element inside dropdown was clicked', async () => { function SelfDestructible() { const [visible, setVisible] = useState(true); return visible ? ( @@ -94,21 +94,16 @@ describe('Dropdown Component', () => { Gone! ); } - const handleCloseDropdown = jest.fn(); + const handleOutsideClick = jest.fn(); const [wrapper] = renderDropdown( - } - onDropdownClose={handleCloseDropdown} - open={true} - content={} - /> + } onOutsideClick={handleOutsideClick} open={true} content={} /> ); await runPendingEvents(); // NB: this should NOT be wrapped into act or React re-render will happen too late to reproduce the issue wrapper.find('[data-testid="dismiss"]')!.click(); - expect(handleCloseDropdown).not.toHaveBeenCalled(); + expect(handleOutsideClick).not.toHaveBeenCalled(); expect(screen.getByTestId('after-dismiss')).toBeTruthy(); }); }); @@ -159,6 +154,173 @@ describe('Dropdown Component', () => { }); }); + describe('dropdown content focus events (onFocusIn/onFocusOut)', () => { + test('fires onFocusIn when any element inside dropdown content gains focus', async () => { + const handleFocusIn = jest.fn(); + const [, outsideElement] = renderDropdown( + } + onFocusIn={handleFocusIn} + open={true} + content={ + <> + + + + } + /> + ); + await runPendingEvents(); + + // Focus on first button in dropdown content + screen.getByTestId('button1').focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(1); + + // Focus on second button in dropdown content - onFocusIn fires again + screen.getByTestId('button2').focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(2); + + // Focus outside - onFocusIn should not fire + outsideElement.focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(2); + }); + + test('fires onFocusOut when focus leaves dropdown content entirely', async () => { + const handleFocusOut = jest.fn(); + const [, outsideElement] = renderDropdown( + } + onFocusOut={handleFocusOut} + open={true} + content={ + <> + + + + } + /> + ); + await runPendingEvents(); + + // Focus on first button in dropdown content + screen.getByTestId('button1').focus(); + expect(handleFocusOut).not.toHaveBeenCalled(); + + // Move focus between elements in dropdown - onFocusOut should not fire + screen.getByTestId('button2').focus(); + expect(handleFocusOut).not.toHaveBeenCalled(); + + // Focus outside dropdown content - onFocusOut should fire + outsideElement.focus(); + expect(handleFocusOut).toHaveBeenCalledTimes(1); + }); + + test('fires onFocusOut when focus moves from dropdown content to trigger', async () => { + const handleFocusOut = jest.fn(); + renderDropdown( + } + onFocusOut={handleFocusOut} + open={true} + content={} + /> + ); + await runPendingEvents(); + + // Focus on button in dropdown content + screen.getByTestId('button1').focus(); + expect(handleFocusOut).not.toHaveBeenCalled(); + + // Move focus to trigger - onFocusOut SHOULD fire because trigger is outside dropdown content + screen.getByTestId('trigger').focus(); + expect(handleFocusOut).toHaveBeenCalledTimes(1); + }); + + test('onFocusIn fires for each focus event on nested interactive elements', async () => { + const handleFocusIn = jest.fn(); + const handleFocusOut = jest.fn(); + const [, outsideElement] = renderDropdown( + } + onFocusIn={handleFocusIn} + onFocusOut={handleFocusOut} + open={true} + content={ + + } + /> + ); + await runPendingEvents(); + + // Focus on input - onFocusIn fires + screen.getByTestId('input').focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(1); + expect(handleFocusOut).not.toHaveBeenCalled(); + + // Move to link - onFocusIn fires again, onFocusOut does not + screen.getByTestId('link').focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(2); + expect(handleFocusOut).not.toHaveBeenCalled(); + + // Move outside - onFocusOut fires + outsideElement.focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(2); + expect(handleFocusOut).toHaveBeenCalledTimes(1); + }); + + test('works correctly with expandToViewport (portaled content)', async () => { + const handleFocusIn = jest.fn(); + const handleFocusOut = jest.fn(); + const [, outsideElement] = renderDropdown( + } + onFocusIn={handleFocusIn} + onFocusOut={handleFocusOut} + open={true} + expandToViewport={true} + content={} + /> + ); + await runPendingEvents(); + + // Focus on portaled content - onFocusIn fires + screen.getByTestId('inside').focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(1); + expect(handleFocusOut).not.toHaveBeenCalled(); + + // Move focus outside - onFocusOut fires + outsideElement.focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(1); + expect(handleFocusOut).toHaveBeenCalledTimes(1); + }); + + test('onFocusIn does not fire when trigger gains focus', async () => { + const handleFocusIn = jest.fn(); + renderDropdown( + } + onFocusIn={handleFocusIn} + open={true} + content={} + /> + ); + await runPendingEvents(); + + // Focus on trigger - onFocusIn should not fire (trigger is not part of dropdown content) + screen.getByTestId('trigger').focus(); + expect(handleFocusIn).not.toHaveBeenCalled(); + + // Focus on dropdown content - onFocusIn should fire + screen.getByTestId('button1').focus(); + expect(handleFocusIn).toHaveBeenCalledTimes(1); + }); + }); + describe('dropdown recalculate position on scroll', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index d0b3806874..c9fc023c4b 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -79,6 +79,8 @@ interface TransitionContentProps { position?: DropdownContextProviderProps['position']; open?: boolean; onMouseDown?: React.MouseEventHandler; + onFocusIn?: React.FocusEventHandler; + onFocusOut?: React.FocusEventHandler; id?: string; role?: string; ariaLabelledby?: string; @@ -104,6 +106,8 @@ const TransitionContent = ({ position, open, onMouseDown, + onFocusIn, + onFocusOut, id, role, ariaLabelledby, @@ -138,6 +142,8 @@ const TransitionContent = ({ aria-hidden={!open} style={dropdownStyles} onMouseDown={onMouseDown} + onFocus={onFocusIn} + onBlur={onFocusOut} >
        + !dropdownRef.current || !nodeBelongs(dropdownRef.current, element); + + const focusInHandler = (event: React.FocusEvent) => { + fireNonCancelableEvent(onFocusIn, event); + }; + + const focusOutHandler = (event: React.FocusEvent) => { + if (!event.relatedTarget || isOutsideDropdownContent(event.relatedTarget)) { + fireNonCancelableEvent(onFocusOut, event); + } + }; + // Check if the dropdown has enough space to fit with its desired width constraints // If not, remove the class that allows flexible width sizing const fixStretching = () => { @@ -372,7 +393,7 @@ const Dropdown = ({ // shadow root if the component is rendered inside shadow DOM. const target = event.composedPath ? event.composedPath()[0] : event.target; if (!nodeBelongs(dropdownRef.current, target) && !nodeBelongs(triggerRef.current, target)) { - fireNonCancelableEvent(onDropdownClose); + fireNonCancelableEvent(onOutsideClick); } }; window.addEventListener('click', clickListener, true); @@ -380,7 +401,7 @@ const Dropdown = ({ return () => { window.removeEventListener('click', clickListener, true); }; - }, [open, onDropdownClose]); + }, [open, onOutsideClick]); // sync dropdown position on scroll and resize useLayoutEffect(() => { @@ -484,6 +505,8 @@ const Dropdown = ({ maxWidth={getMaxWidthCssValue()} footer={footer} onMouseDown={onMouseDown} + onFocusIn={focusInHandler} + onFocusOut={focusOutHandler} isRefresh={isRefresh} dropdownRef={dropdownRef} verticalContainerRef={verticalContainerRef} diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index d4603738e3..05025e27c3 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -82,9 +82,11 @@ export interface DropdownProps extends ExpandToViewport { open?: boolean; /** - * Called when a user clicks outside of the dropdown content, when it is open. + * Called when the user clicks outside the dropdown and trigger. + * The dropdown does not close automatically - the parent component + * must update the `open` prop to close the dropdown. */ - onDropdownClose?: NonCancelableEventHandler; + onOutsideClick?: NonCancelableEventHandler; /** * Called when a mouse button is pressed inside the dropdown content. @@ -158,6 +160,17 @@ export interface DropdownProps extends ExpandToViewport { */ onBlur?: NonCancelableEventHandler>; + /** + * Called when any element inside the dropdown content gains focus. + * This includes nested interactive elements like buttons, links, or inputs. + */ + onFocusIn?: NonCancelableEventHandler>; + + /** + * Called when focus leaves the dropdown content entirely. + */ + onFocusOut?: NonCancelableEventHandler>; + /** * ID for the dropdown content wrapper */ From bee3915218c6923e6db757951fc453ba9806485c Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Wed, 18 Feb 2026 10:03:17 +0100 Subject: [PATCH 2/4] feat: add onEscape handler --- .../dropdown/__tests__/dropdown.test.tsx | 58 +++++++++++++++++++ src/internal/components/dropdown/index.tsx | 20 +++++++ .../components/dropdown/interfaces.ts | 7 +++ 3 files changed, 85 insertions(+) diff --git a/src/internal/components/dropdown/__tests__/dropdown.test.tsx b/src/internal/components/dropdown/__tests__/dropdown.test.tsx index 4a3105a961..b8c42f933b 100644 --- a/src/internal/components/dropdown/__tests__/dropdown.test.tsx +++ b/src/internal/components/dropdown/__tests__/dropdown.test.tsx @@ -321,6 +321,64 @@ describe('Dropdown Component', () => { }); }); + describe('Escape key event', () => { + test('fires onEscape when Escape key is pressed while dropdown is open', async () => { + const handleEscape = jest.fn(); + renderDropdown(} onEscape={handleEscape} open={true} />); + await runPendingEvents(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(handleEscape).toHaveBeenCalledTimes(1); + }); + + test('does not fire onEscape when dropdown is closed', async () => { + const handleEscape = jest.fn(); + renderDropdown(} onEscape={handleEscape} open={false} />); + await runPendingEvents(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(handleEscape).not.toHaveBeenCalled(); + }); + + test('stops propagation to prevent parent handlers from catching the event', async () => { + const handleEscape = jest.fn(); + const parentHandler = jest.fn(); + + render( +
        +
        + ); + await runPendingEvents(); + + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + const stopPropagationSpy = jest.spyOn(event, 'stopPropagation'); + + window.dispatchEvent(event); + + expect(handleEscape).toHaveBeenCalledTimes(1); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + + test('works with expandToViewport (portaled content)', async () => { + const handleEscape = jest.fn(); + renderDropdown( + } + onEscape={handleEscape} + open={true} + expandToViewport={true} + content={} + /> + ); + await runPendingEvents(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(handleEscape).toHaveBeenCalledTimes(1); + }); + }); + describe('dropdown recalculate position on scroll', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index c9fc023c4b..a7836877bd 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -187,6 +187,7 @@ const Dropdown = ({ onBlur, onFocusIn, onFocusOut, + onEscape, contentKey, dropdownContentId, dropdownContentRole, @@ -403,6 +404,25 @@ const Dropdown = ({ }; }, [open, onOutsideClick]); + // subscribe to Escape key press + useEffect(() => { + if (!open) { + return; + } + const keydownListener = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + // Prevent any surrounding modals or dialogs from acting on this Escape key + event.stopPropagation(); + fireNonCancelableEvent(onEscape); + } + }; + window.addEventListener('keydown', keydownListener, true); + + return () => { + window.removeEventListener('keydown', keydownListener, true); + }; + }, [open, onEscape]); + // sync dropdown position on scroll and resize useLayoutEffect(() => { if (!expandToViewport || !open) { diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index 05025e27c3..7814561b5e 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -171,6 +171,13 @@ export interface DropdownProps extends ExpandToViewport { */ onFocusOut?: NonCancelableEventHandler>; + /** + * Called when the user presses the Escape key while the dropdown is open. + * The dropdown does not close automatically - the parent component + * must update the `open` prop to close the dropdown. + */ + onEscape?: NonCancelableEventHandler; + /** * ID for the dropdown content wrapper */ From 413dba5e5d121b56cfba2bd792f33e8b70cda6c9 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Wed, 18 Feb 2026 10:54:59 +0100 Subject: [PATCH 3/4] fix: add onEscape if callback provided --- src/internal/components/dropdown/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index a7836877bd..c57052964e 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -406,7 +406,8 @@ const Dropdown = ({ // subscribe to Escape key press useEffect(() => { - if (!open) { + // Only add the listener if onEscape callback is provided + if (!open || !onEscape) { return; } const keydownListener = (event: KeyboardEvent) => { From c9c975296041e2e01660ee81396fb4bb06e280e3 Mon Sep 17 00:00:00 2001 From: Maximilian Schoell Date: Wed, 18 Feb 2026 12:23:21 +0100 Subject: [PATCH 4/4] refactor: rename focus callbacks --- .../dropdown/__tests__/dropdown.test.tsx | 114 +++++++++--------- src/internal/components/dropdown/index.tsx | 30 ++--- .../components/dropdown/interfaces.ts | 8 +- 3 files changed, 79 insertions(+), 73 deletions(-) diff --git a/src/internal/components/dropdown/__tests__/dropdown.test.tsx b/src/internal/components/dropdown/__tests__/dropdown.test.tsx index b8c42f933b..31dc37ede1 100644 --- a/src/internal/components/dropdown/__tests__/dropdown.test.tsx +++ b/src/internal/components/dropdown/__tests__/dropdown.test.tsx @@ -154,13 +154,13 @@ describe('Dropdown Component', () => { }); }); - describe('dropdown content focus events (onFocusIn/onFocusOut)', () => { - test('fires onFocusIn when any element inside dropdown content gains focus', async () => { - const handleFocusIn = jest.fn(); + describe('dropdown content focus events (onFocusEnter/onFocusLeave)', () => { + test('fires onFocusEnter only when focus enters dropdown content from outside', async () => { + const handleFocusEnter = jest.fn(); const [, outsideElement] = renderDropdown( } - onFocusIn={handleFocusIn} + onFocusEnter={handleFocusEnter} open={true} content={ <> @@ -172,25 +172,29 @@ describe('Dropdown Component', () => { ); await runPendingEvents(); - // Focus on first button in dropdown content + // Focus on first button in dropdown content - onFocusEnter fires screen.getByTestId('button1').focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(1); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); - // Focus on second button in dropdown content - onFocusIn fires again + // Focus on second button in dropdown content - onFocusEnter should NOT fire (focus already inside) screen.getByTestId('button2').focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(2); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); - // Focus outside - onFocusIn should not fire + // Focus outside outsideElement.focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(2); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + + // Focus back into dropdown - onFocusEnter fires again + screen.getByTestId('button1').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(2); }); - test('fires onFocusOut when focus leaves dropdown content entirely', async () => { - const handleFocusOut = jest.fn(); + test('fires onFocusLeave when focus leaves dropdown content entirely', async () => { + const handleFocusLeave = jest.fn(); const [, outsideElement] = renderDropdown( } - onFocusOut={handleFocusOut} + onFocusLeave={handleFocusLeave} open={true} content={ <> @@ -204,23 +208,23 @@ describe('Dropdown Component', () => { // Focus on first button in dropdown content screen.getByTestId('button1').focus(); - expect(handleFocusOut).not.toHaveBeenCalled(); + expect(handleFocusLeave).not.toHaveBeenCalled(); - // Move focus between elements in dropdown - onFocusOut should not fire + // Move focus between elements in dropdown - onFocusLeave should not fire screen.getByTestId('button2').focus(); - expect(handleFocusOut).not.toHaveBeenCalled(); + expect(handleFocusLeave).not.toHaveBeenCalled(); - // Focus outside dropdown content - onFocusOut should fire + // Focus outside dropdown content - onFocusLeave should fire outsideElement.focus(); - expect(handleFocusOut).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); }); - test('fires onFocusOut when focus moves from dropdown content to trigger', async () => { - const handleFocusOut = jest.fn(); + test('fires onFocusLeave when focus moves from dropdown content to trigger', async () => { + const handleFocusLeave = jest.fn(); renderDropdown( } - onFocusOut={handleFocusOut} + onFocusLeave={handleFocusLeave} open={true} content={} /> @@ -229,21 +233,21 @@ describe('Dropdown Component', () => { // Focus on button in dropdown content screen.getByTestId('button1').focus(); - expect(handleFocusOut).not.toHaveBeenCalled(); + expect(handleFocusLeave).not.toHaveBeenCalled(); - // Move focus to trigger - onFocusOut SHOULD fire because trigger is outside dropdown content + // Move focus to trigger - onFocusLeave SHOULD fire because trigger is outside dropdown content screen.getByTestId('trigger').focus(); - expect(handleFocusOut).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); }); - test('onFocusIn fires for each focus event on nested interactive elements', async () => { - const handleFocusIn = jest.fn(); - const handleFocusOut = jest.fn(); + test('onFocusEnter fires only once when entering dropdown, not for internal focus changes', async () => { + const handleFocusEnter = jest.fn(); + const handleFocusLeave = jest.fn(); const [, outsideElement] = renderDropdown( } - onFocusIn={handleFocusIn} - onFocusOut={handleFocusOut} + onFocusEnter={handleFocusEnter} + onFocusLeave={handleFocusLeave} open={true} content={
        @@ -257,30 +261,30 @@ describe('Dropdown Component', () => { ); await runPendingEvents(); - // Focus on input - onFocusIn fires + // Focus on input - onFocusEnter fires screen.getByTestId('input').focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(1); - expect(handleFocusOut).not.toHaveBeenCalled(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).not.toHaveBeenCalled(); - // Move to link - onFocusIn fires again, onFocusOut does not + // Move to link - onFocusEnter should NOT fire again (already inside), onFocusLeave does not fire screen.getByTestId('link').focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(2); - expect(handleFocusOut).not.toHaveBeenCalled(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).not.toHaveBeenCalled(); - // Move outside - onFocusOut fires + // Move outside - onFocusLeave fires outsideElement.focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(2); - expect(handleFocusOut).toHaveBeenCalledTimes(1); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); }); test('works correctly with expandToViewport (portaled content)', async () => { - const handleFocusIn = jest.fn(); - const handleFocusOut = jest.fn(); + const handleFocusEnter = jest.fn(); + const handleFocusLeave = jest.fn(); const [, outsideElement] = renderDropdown( } - onFocusIn={handleFocusIn} - onFocusOut={handleFocusOut} + onFocusEnter={handleFocusEnter} + onFocusLeave={handleFocusLeave} open={true} expandToViewport={true} content={} @@ -288,36 +292,36 @@ describe('Dropdown Component', () => { ); await runPendingEvents(); - // Focus on portaled content - onFocusIn fires + // Focus on portaled content - onFocusEnter fires screen.getByTestId('inside').focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(1); - expect(handleFocusOut).not.toHaveBeenCalled(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).not.toHaveBeenCalled(); - // Move focus outside - onFocusOut fires + // Move focus outside - onFocusLeave fires outsideElement.focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(1); - expect(handleFocusOut).toHaveBeenCalledTimes(1); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); }); - test('onFocusIn does not fire when trigger gains focus', async () => { - const handleFocusIn = jest.fn(); + test('onFocusEnter does not fire when trigger gains focus', async () => { + const handleFocusEnter = jest.fn(); renderDropdown( } - onFocusIn={handleFocusIn} + onFocusEnter={handleFocusEnter} open={true} content={} /> ); await runPendingEvents(); - // Focus on trigger - onFocusIn should not fire (trigger is not part of dropdown content) + // Focus on trigger - onFocusEnter should not fire (trigger is not part of dropdown content) screen.getByTestId('trigger').focus(); - expect(handleFocusIn).not.toHaveBeenCalled(); + expect(handleFocusEnter).not.toHaveBeenCalled(); - // Focus on dropdown content - onFocusIn should fire + // Focus on dropdown content - onFocusEnter should fire screen.getByTestId('button1').focus(); - expect(handleFocusIn).toHaveBeenCalledTimes(1); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); }); }); diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index c57052964e..fadee1d310 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -79,8 +79,8 @@ interface TransitionContentProps { position?: DropdownContextProviderProps['position']; open?: boolean; onMouseDown?: React.MouseEventHandler; - onFocusIn?: React.FocusEventHandler; - onFocusOut?: React.FocusEventHandler; + onFocusEnter?: React.FocusEventHandler; + onFocusLeave?: React.FocusEventHandler; id?: string; role?: string; ariaLabelledby?: string; @@ -106,8 +106,8 @@ const TransitionContent = ({ position, open, onMouseDown, - onFocusIn, - onFocusOut, + onFocusEnter, + onFocusLeave, id, role, ariaLabelledby, @@ -142,8 +142,8 @@ const TransitionContent = ({ aria-hidden={!open} style={dropdownStyles} onMouseDown={onMouseDown} - onFocus={onFocusIn} - onBlur={onFocusOut} + onFocus={onFocusEnter} + onBlur={onFocusLeave} >
        !dropdownRef.current || !nodeBelongs(dropdownRef.current, element); - const focusInHandler = (event: React.FocusEvent) => { - fireNonCancelableEvent(onFocusIn, event); + const focusEnterHandler = (event: React.FocusEvent) => { + if (!event.relatedTarget || isOutsideDropdownContent(event.relatedTarget)) { + fireNonCancelableEvent(onFocusEnter, event); + } }; - const focusOutHandler = (event: React.FocusEvent) => { + const focusLeaveHandler = (event: React.FocusEvent) => { if (!event.relatedTarget || isOutsideDropdownContent(event.relatedTarget)) { - fireNonCancelableEvent(onFocusOut, event); + fireNonCancelableEvent(onFocusLeave, event); } }; @@ -526,8 +528,8 @@ const Dropdown = ({ maxWidth={getMaxWidthCssValue()} footer={footer} onMouseDown={onMouseDown} - onFocusIn={focusInHandler} - onFocusOut={focusOutHandler} + onFocusEnter={focusEnterHandler} + onFocusLeave={focusLeaveHandler} isRefresh={isRefresh} dropdownRef={dropdownRef} verticalContainerRef={verticalContainerRef} diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index 7814561b5e..3a6b8d6ef5 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -161,15 +161,15 @@ export interface DropdownProps extends ExpandToViewport { onBlur?: NonCancelableEventHandler>; /** - * Called when any element inside the dropdown content gains focus. - * This includes nested interactive elements like buttons, links, or inputs. + * Called when focus enters the dropdown content from outside. + * This fires only once when focus moves into the dropdown, not when moving between elements within it. */ - onFocusIn?: NonCancelableEventHandler>; + onFocusEnter?: NonCancelableEventHandler>; /** * Called when focus leaves the dropdown content entirely. */ - onFocusOut?: NonCancelableEventHandler>; + onFocusLeave?: NonCancelableEventHandler>; /** * Called when the user presses the Escape key while the dropdown is open.