Skip to content

Commit 2253f70

Browse files
authored
fix(container-menu): ensure outside clicks always close dropdown menu (#699)
1 parent cbb5a34 commit 2253f70

File tree

3 files changed

+49
-25
lines changed

3 files changed

+49
-25
lines changed

packages/menu/src/MenuContainer.spec.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('MenuContainer', () => {
149149
return (
150150
<MenuContainer
151151
{...props}
152-
items={items}
152+
items={[...items]} // create a new reference on each render to simulate cases similar to Garden Menu
153153
onChange={handleChange}
154154
triggerRef={triggerRef}
155155
menuRef={menuRef}
@@ -911,6 +911,29 @@ describe('MenuContainer', () => {
911911

912912
expect(getByText('Previous')).toHaveFocus();
913913
});
914+
915+
it('closes menu and returns focus to trigger when clicking on document.body after navigating to nested menu', async () => {
916+
const { getByText, getByTestId } = render(<TestMenuNested />);
917+
const trigger = getByTestId('trigger');
918+
const menu = getByTestId('menu');
919+
920+
trigger.focus();
921+
922+
await waitFor(async () => {
923+
await user.keyboard('{ArrowDown}');
924+
await user.keyboard('{Enter}');
925+
});
926+
927+
expect(getByText('Previous')).toHaveFocus();
928+
expect(menu).toBeVisible();
929+
930+
await waitFor(async () => {
931+
await user.click(document.body);
932+
});
933+
934+
expect(trigger).toHaveFocus();
935+
expect(menu).not.toBeVisible();
936+
});
914937
});
915938
});
916939

packages/menu/src/useMenu.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -409,8 +409,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
409409
(event: React.FocusEvent) => {
410410
const win = environment || window;
411411

412-
setTimeout(() => {
413-
// Timeout is required to ensure blur is handled after focus
412+
// Timeout is required to ensure blur is handled after focus
413+
const timeoutId = setTimeout(() => {
414414
const activeElement = win.document.activeElement;
415415
const isMenuOrTriggerFocused =
416416
menuRef.current?.contains(activeElement) || triggerRef.current?.contains(activeElement);
@@ -427,6 +427,10 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
427427

428428
closeMenu(StateChangeTypes.MenuBlur);
429429
}
430+
431+
return () => {
432+
clearTimeout(timeoutId);
433+
};
430434
});
431435
},
432436
[closeMenu, controlledIsExpanded, environment, menuRef, returnFocusToTrigger, triggerRef]
@@ -598,16 +602,16 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
598602
}
599603
},
600604
[
605+
environment,
606+
getNextFocusedValue,
601607
getSelectedItems,
602608
isExpandedControlled,
609+
isFocusedValueControlled,
603610
isSelectedItemsControlled,
611+
onChange,
604612
returnFocusToTrigger,
605-
environment,
606613
rtl,
607-
getNextFocusedValue,
608-
isFocusedValueControlled,
609-
state.nestedPathIds,
610-
onChange
614+
state.nestedPathIds
611615
]
612616
);
613617

@@ -674,16 +678,19 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
674678
ref = itemRefs[values[0]].current;
675679
}
676680

677-
ref && ref.focus();
681+
if (ref) {
682+
ref.focus();
683+
684+
dispatch({ type: StateChangeTypes.FnInternalUpdate, payload: { focusOnOpen: false } });
685+
}
678686
}
679687
}, [
680688
values,
681689
menuVisible,
682690
itemRefs,
683691
controlledFocusedValue,
684692
state.focusOnOpen,
685-
controlledIsExpanded,
686-
triggerRef
693+
controlledIsExpanded
687694
]);
688695

689696
/**
@@ -694,7 +701,7 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
694701

695702
if (valuesChanged && !state.isTransitionNext && !state.isTransitionPrevious) {
696703
dispatch({
697-
type: StateChangeTypes.FnSetStateRefs,
704+
type: StateChangeTypes.FnInternalUpdate,
698705
payload: { valuesRef: values }
699706
});
700707
}

packages/menu/src/utils.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const triggerLink = (element: HTMLAnchorElement, view: Window) => {
2626
};
2727

2828
export const StateChangeTypes: Record<string, string> = {
29-
FnSetStateRefs: 'fn:setStateRefs',
29+
FnInternalUpdate: 'fn:internalUpdate',
3030
FnMenuTransitionFinish: 'fn:menuTransitionFinish',
3131
TriggerClick: 'trigger:click',
3232
TriggerKeyDownEnter: `trigger:keyDown:${KEYS.ENTER}`,
@@ -98,12 +98,6 @@ type ReducerAction = {
9898
export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action) => {
9999
let changes: ReducerState | null = null;
100100

101-
// Reset `focusOnOpen` if it was previously set to true; this prevents
102-
// forced focus on the initial item in future state updates.
103-
if (state.focusOnOpen) {
104-
changes = { ...state, focusOnOpen: false };
105-
}
106-
107101
switch (action.type) {
108102
case StateChangeTypes.MenuBlur:
109103
case StateChangeTypes.MenuKeyDownEscape:
@@ -118,7 +112,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action
118112

119113
if (stateChanges) {
120114
changes = {
121-
...(changes || state),
115+
...state,
122116
...stateChanges
123117
};
124118
}
@@ -150,7 +144,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action
150144

151145
if (stateChanges) {
152146
changes = {
153-
...(changes || state),
147+
...state,
154148
...stateChanges
155149
};
156150
}
@@ -183,7 +177,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action
183177

184178
if (stateChanges) {
185179
changes = {
186-
...(changes || state),
180+
...state,
187181
...stateChanges
188182
};
189183
}
@@ -197,7 +191,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action
197191

198192
if (stateChanges) {
199193
changes = {
200-
...(changes || state),
194+
...state,
201195
...stateChanges,
202196
transitionType: null,
203197
isTransitionNext: false,
@@ -208,10 +202,10 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action
208202
break;
209203
}
210204

211-
case StateChangeTypes.FnSetStateRefs: {
205+
case StateChangeTypes.FnInternalUpdate: {
212206
const { ...props } = action.payload;
213207

214-
changes = { ...(changes || state), ...props };
208+
changes = { ...state, ...props };
215209

216210
break;
217211
}

0 commit comments

Comments
 (0)