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..31dc37ede1 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,235 @@ describe('Dropdown Component', () => { }); }); + 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( + } + onFocusEnter={handleFocusEnter} + open={true} + content={ + <> + + + + } + /> + ); + await runPendingEvents(); + + // Focus on first button in dropdown content - onFocusEnter fires + screen.getByTestId('button1').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + + // Focus on second button in dropdown content - onFocusEnter should NOT fire (focus already inside) + screen.getByTestId('button2').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + + // Focus outside + outsideElement.focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + + // Focus back into dropdown - onFocusEnter fires again + screen.getByTestId('button1').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(2); + }); + + test('fires onFocusLeave when focus leaves dropdown content entirely', async () => { + const handleFocusLeave = jest.fn(); + const [, outsideElement] = renderDropdown( + } + onFocusLeave={handleFocusLeave} + open={true} + content={ + <> + + + + } + /> + ); + await runPendingEvents(); + + // Focus on first button in dropdown content + screen.getByTestId('button1').focus(); + expect(handleFocusLeave).not.toHaveBeenCalled(); + + // Move focus between elements in dropdown - onFocusLeave should not fire + screen.getByTestId('button2').focus(); + expect(handleFocusLeave).not.toHaveBeenCalled(); + + // Focus outside dropdown content - onFocusLeave should fire + outsideElement.focus(); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); + }); + + test('fires onFocusLeave when focus moves from dropdown content to trigger', async () => { + const handleFocusLeave = jest.fn(); + renderDropdown( + } + onFocusLeave={handleFocusLeave} + open={true} + content={} + /> + ); + await runPendingEvents(); + + // Focus on button in dropdown content + screen.getByTestId('button1').focus(); + expect(handleFocusLeave).not.toHaveBeenCalled(); + + // Move focus to trigger - onFocusLeave SHOULD fire because trigger is outside dropdown content + screen.getByTestId('trigger').focus(); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); + }); + + 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( + } + onFocusEnter={handleFocusEnter} + onFocusLeave={handleFocusLeave} + open={true} + content={ + + } + /> + ); + await runPendingEvents(); + + // Focus on input - onFocusEnter fires + screen.getByTestId('input').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).not.toHaveBeenCalled(); + + // Move to link - onFocusEnter should NOT fire again (already inside), onFocusLeave does not fire + screen.getByTestId('link').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).not.toHaveBeenCalled(); + + // Move outside - onFocusLeave fires + outsideElement.focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); + }); + + test('works correctly with expandToViewport (portaled content)', async () => { + const handleFocusEnter = jest.fn(); + const handleFocusLeave = jest.fn(); + const [, outsideElement] = renderDropdown( + } + onFocusEnter={handleFocusEnter} + onFocusLeave={handleFocusLeave} + open={true} + expandToViewport={true} + content={} + /> + ); + await runPendingEvents(); + + // Focus on portaled content - onFocusEnter fires + screen.getByTestId('inside').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).not.toHaveBeenCalled(); + + // Move focus outside - onFocusLeave fires + outsideElement.focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + expect(handleFocusLeave).toHaveBeenCalledTimes(1); + }); + + test('onFocusEnter does not fire when trigger gains focus', async () => { + const handleFocusEnter = jest.fn(); + renderDropdown( + } + onFocusEnter={handleFocusEnter} + open={true} + content={} + /> + ); + await runPendingEvents(); + + // Focus on trigger - onFocusEnter should not fire (trigger is not part of dropdown content) + screen.getByTestId('trigger').focus(); + expect(handleFocusEnter).not.toHaveBeenCalled(); + + // Focus on dropdown content - onFocusEnter should fire + screen.getByTestId('button1').focus(); + expect(handleFocusEnter).toHaveBeenCalledTimes(1); + }); + }); + + 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 d0b3806874..fadee1d310 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; + onFocusEnter?: React.FocusEventHandler; + onFocusLeave?: React.FocusEventHandler; id?: string; role?: string; ariaLabelledby?: string; @@ -104,6 +106,8 @@ const TransitionContent = ({ position, open, onMouseDown, + onFocusEnter, + onFocusLeave, id, role, ariaLabelledby, @@ -138,6 +142,8 @@ const TransitionContent = ({ aria-hidden={!open} style={dropdownStyles} onMouseDown={onMouseDown} + onFocus={onFocusEnter} + onBlur={onFocusLeave} >
        + !dropdownRef.current || !nodeBelongs(dropdownRef.current, element); + + const focusEnterHandler = (event: React.FocusEvent) => { + if (!event.relatedTarget || isOutsideDropdownContent(event.relatedTarget)) { + fireNonCancelableEvent(onFocusEnter, event); + } + }; + + const focusLeaveHandler = (event: React.FocusEvent) => { + if (!event.relatedTarget || isOutsideDropdownContent(event.relatedTarget)) { + fireNonCancelableEvent(onFocusLeave, 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 +396,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 +404,27 @@ const Dropdown = ({ return () => { window.removeEventListener('click', clickListener, true); }; - }, [open, onDropdownClose]); + }, [open, onOutsideClick]); + + // subscribe to Escape key press + useEffect(() => { + // Only add the listener if onEscape callback is provided + if (!open || !onEscape) { + 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(() => { @@ -484,6 +528,8 @@ const Dropdown = ({ maxWidth={getMaxWidthCssValue()} footer={footer} onMouseDown={onMouseDown} + 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 d4603738e3..3a6b8d6ef5 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,24 @@ export interface DropdownProps extends ExpandToViewport { */ onBlur?: NonCancelableEventHandler>; + /** + * 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. + */ + onFocusEnter?: NonCancelableEventHandler>; + + /** + * Called when focus leaves the dropdown content entirely. + */ + onFocusLeave?: 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 */