Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/menu/src/MenuContainer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('MenuContainer', () => {
return (
<MenuContainer
{...props}
items={items}
items={[...items]} // create a new reference on each render to simulate cases similar to Garden Menu
onChange={handleChange}
triggerRef={triggerRef}
menuRef={menuRef}
Expand Down Expand Up @@ -911,6 +911,29 @@ describe('MenuContainer', () => {

expect(getByText('Previous')).toHaveFocus();
});

it('closes menu and returns focus to trigger when clicking on document.body after navigating to nested menu', async () => {
const { getByText, getByTestId } = render(<TestMenuNested />);
const trigger = getByTestId('trigger');
const menu = getByTestId('menu');

trigger.focus();

await waitFor(async () => {
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
});

expect(getByText('Previous')).toHaveFocus();
expect(menu).toBeVisible();

await waitFor(async () => {
await user.click(document.body);
});

expect(trigger).toHaveFocus();
expect(menu).not.toBeVisible();
});
});
});

Expand Down
29 changes: 18 additions & 11 deletions packages/menu/src/useMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,8 +409,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
(event: React.FocusEvent) => {
const win = environment || window;

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

closeMenu(StateChangeTypes.MenuBlur);
}

return () => {
clearTimeout(timeoutId);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leaks are unlikely here since setTimeout(..., 0) runs almost immediately. However, it's still good to follow best practices.

};
});
},
[closeMenu, controlledIsExpanded, environment, menuRef, returnFocusToTrigger, triggerRef]
Expand Down Expand Up @@ -598,16 +602,16 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
}
},
[
environment,
getNextFocusedValue,
getSelectedItems,
isExpandedControlled,
isFocusedValueControlled,
isSelectedItemsControlled,
onChange,
returnFocusToTrigger,
environment,
rtl,
getNextFocusedValue,
isFocusedValueControlled,
state.nestedPathIds,
onChange
state.nestedPathIds
]
);

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

ref && ref.focus();
if (ref) {
ref.focus();

dispatch({ type: StateChangeTypes.FnInternalUpdate, payload: { focusOnOpen: false } });
}
}
}, [
values,
menuVisible,
itemRefs,
controlledFocusedValue,
state.focusOnOpen,
controlledIsExpanded,
triggerRef
controlledIsExpanded
]);

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

if (valuesChanged && !state.isTransitionNext && !state.isTransitionPrevious) {
dispatch({
type: StateChangeTypes.FnSetStateRefs,
type: StateChangeTypes.FnInternalUpdate,
payload: { valuesRef: values }
});
}
Expand Down
20 changes: 7 additions & 13 deletions packages/menu/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const triggerLink = (element: HTMLAnchorElement, view: Window) => {
};

export const StateChangeTypes: Record<string, string> = {
FnSetStateRefs: 'fn:setStateRefs',
FnInternalUpdate: 'fn:internalUpdate',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although the new naming might be preferable, are we concerned this introduces a corner case breaking change? Would it make sense to leave 'fn:setStateRefs' as the change type name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only stateChangeType that wasn’t previously exposed to consumers through the onChange callback, so it should be safe to rename without causing any issues.

FnMenuTransitionFinish: 'fn:menuTransitionFinish',
TriggerClick: 'trigger:click',
TriggerKeyDownEnter: `trigger:keyDown:${KEYS.ENTER}`,
Expand Down Expand Up @@ -98,12 +98,6 @@ type ReducerAction = {
export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action) => {
let changes: ReducerState | null = null;

// Reset `focusOnOpen` if it was previously set to true; this prevents
// forced focus on the initial item in future state updates.
if (state.focusOnOpen) {
changes = { ...state, focusOnOpen: false };
}

Comment on lines -101 to -106
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect hook resets focusOnOpen on update, eliminating the need for an explicit dispatch call. This method has proven to be more reliable, especially with nested submenus.

switch (action.type) {
case StateChangeTypes.MenuBlur:
case StateChangeTypes.MenuKeyDownEscape:
Expand All @@ -118,7 +112,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action

if (stateChanges) {
changes = {
...(changes || state),
...state,
...stateChanges
};
}
Expand Down Expand Up @@ -150,7 +144,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action

if (stateChanges) {
changes = {
...(changes || state),
...state,
...stateChanges
};
}
Expand Down Expand Up @@ -183,7 +177,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action

if (stateChanges) {
changes = {
...(changes || state),
...state,
...stateChanges
};
}
Expand All @@ -197,7 +191,7 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action

if (stateChanges) {
changes = {
...(changes || state),
...state,
...stateChanges,
transitionType: null,
isTransitionNext: false,
Expand All @@ -208,10 +202,10 @@ export const stateReducer: Reducer<ReducerState, ReducerAction> = (state, action
break;
}

case StateChangeTypes.FnSetStateRefs: {
case StateChangeTypes.FnInternalUpdate: {
const { ...props } = action.payload;

changes = { ...(changes || state), ...props };
changes = { ...state, ...props };

break;
}
Expand Down