Skip to content
Merged
Changes from 2 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
96 changes: 57 additions & 39 deletions packages/menu/src/useMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import React, {
useEffect,
useMemo,
useReducer,
useRef,
useState
} from 'react';
import { useSelection } from '@zendeskgarden/container-selection';
Expand Down Expand Up @@ -94,8 +93,6 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
*/

const [menuVisible, setMenuVisible] = useState<boolean>(false);
const focusTriggerRef = useRef<boolean>(false);

const [state, dispatch] = useReducer(stateReducer, {
focusedValue: focusedValue || defaultFocusedValue,
isExpanded: isExpanded || defaultExpanded,
Expand Down Expand Up @@ -135,6 +132,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme

// Internal

const returnFocusToTrigger = useCallback(
(skip?: boolean) => {
if (!skip && triggerRef.current) {
triggerRef.current.focus();
}
},
[triggerRef]
);

const closeMenu = useCallback(
(changeType: string) => {
dispatch({
Expand Down Expand Up @@ -272,22 +278,32 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
event.stopPropagation();

const changeType = StateChangeTypes.TriggerClick;
const nextIsExpanded = !controlledIsExpanded;
Copy link
Contributor

Choose a reason for hiding this comment

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

I actually think the code below reads better if we use !controlledIsExpanded. I confused nextisExpanded as referring to the next / nested menu state.


dispatch({
type: changeType,
payload: {
...(!isFocusedValueControlled && { focusedValue: null }),
...(!isExpandedControlled && { isExpanded: !controlledIsExpanded })
...(!isExpandedControlled && { isExpanded: nextIsExpanded })
}
});

// Skip focus return when isExpanded === true
returnFocusToTrigger(nextIsExpanded && isExpandedControlled);

onChange({
type: changeType,
focusedValue: null,
isExpanded: !controlledIsExpanded
});
},
[controlledIsExpanded, isFocusedValueControlled, isExpandedControlled, onChange]
[
isFocusedValueControlled,
isExpandedControlled,
controlledIsExpanded,
returnFocusToTrigger,
onChange
]
);

const handleTriggerKeyDown = useCallback(
Expand Down Expand Up @@ -321,14 +337,23 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
}
});

returnFocusToTrigger(isExpandedControlled);

onChange({
type: changeType,
focusedValue: defaultFocusedValue || nextFocusedValue,
isExpanded: true
});
}
},
[isExpandedControlled, isFocusedValueControlled, defaultFocusedValue, onChange, values]
[
values,
isFocusedValueControlled,
defaultFocusedValue,
isExpandedControlled,
returnFocusToTrigger,
onChange
]
);

const handleMenuKeyDown = useCallback(
Expand All @@ -341,25 +366,25 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme

const type = StateChangeTypes[key === KEYS.ESCAPE ? 'MenuKeyDownEscape' : 'MenuKeyDownTab'];

if (KEYS.ESCAPE === key) {
focusTriggerRef.current = true;
}
// TODO: Investigate why focus goes to body instead of next interactive element on TAB keydown. Meanwhile, returning focus to trigger.
Copy link
Member

Choose a reason for hiding this comment

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

What are the current results of this investigation?

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 mystery prevails. 🤔

I verified that returnFocusToTrigger is only called once on TAB keypress. ✅

The browser first sets the activeElement to body. Pressing TAB once more sets the focus to the next interactive element. That implies the placement in the tab sequence isn't reset. I created a JIRA task to revisit.

returnFocusToTrigger();

closeMenu(type);
}
},
[closeMenu, focusTriggerRef]
[closeMenu, returnFocusToTrigger]
);

const handleMenuBlur = useCallback(
(event: MouseEvent) => {
const path = event.composedPath();

if (!path.includes(menuRef.current!) && !path.includes(triggerRef.current!)) {
returnFocusToTrigger();
closeMenu(StateChangeTypes.MenuBlur);
}
},
[closeMenu, menuRef, triggerRef]
[closeMenu, menuRef, returnFocusToTrigger, triggerRef]
);

const handleMenuMouseLeave = useCallback(() => {
Expand Down Expand Up @@ -395,6 +420,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
}
});

returnFocusToTrigger(isTransitionItem && isExpandedControlled);

onChange({
type: changeType,
value: item.value,
Expand All @@ -403,10 +430,11 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
});
},
[
getSelectedItems,
state.nestedPathIds,
isExpandedControlled,
isSelectedItemsControlled,
getSelectedItems,
returnFocusToTrigger,
onChange
]
);
Expand Down Expand Up @@ -448,19 +476,13 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
...(nextSelection && { selectedItems: nextSelection })
};

if (item.href) {
if (key === KEYS.SPACE) {
event.preventDefault();
event.preventDefault();

triggerLink(event.target as HTMLAnchorElement, environment || window);
}
} else {
event.preventDefault();
if (item.href) {
triggerLink(event.target as HTMLAnchorElement, environment || window);
}

if (!isTransitionItem) {
focusTriggerRef.current = true;
}
returnFocusToTrigger(isTransitionItem && isExpandedControlled);
} else if (key === KEYS.RIGHT) {
if (rtl && isPrevious) {
changeType = StateChangeTypes.MenuItemKeyDownPrevious;
Expand Down Expand Up @@ -529,15 +551,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
}
},
[
rtl,
state.nestedPathIds,
getSelectedItems,
isExpandedControlled,
isFocusedValueControlled,
isSelectedItemsControlled,
focusTriggerRef,
returnFocusToTrigger,
environment,
rtl,
getNextFocusedValue,
getSelectedItems,
isFocusedValueControlled,
state.nestedPathIds,
onChange
]
);
Expand Down Expand Up @@ -573,6 +595,12 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
setMenuVisible(controlledIsExpanded);
}, [controlledIsExpanded]);

useEffect(() => {
if (isExpandedControlled && isExpanded === false) {
returnFocusToTrigger();
}
}, [isExpandedControlled, isExpanded, returnFocusToTrigger]);

/**
* Respond to clicks outside the open menu
*/
Expand All @@ -594,12 +622,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
}, [controlledIsExpanded, handleMenuBlur, handleMenuKeyDown, environment]);

/**
* Handles focus depending on menu state:
* - When opened, focus the menu via `focusedValue`
* - When closed, focus the trigger via `triggerRef`
*
* This effect is intended to prevent focusing the trigger or menu
* unless the menu is in the right expansion state.
* When the menu is opened, this effect sets focus on the current menu item using `focusedValue`
* or on the first menu item.
*/
useEffect(() => {
if (state.focusOnOpen && menuVisible && controlledFocusedValue && controlledIsExpanded) {
Expand All @@ -614,13 +638,7 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme

ref && ref.focus();
}

if (!menuVisible && !controlledIsExpanded && focusTriggerRef.current) {
triggerRef?.current?.focus();
focusTriggerRef.current = false;
}
}, [
focusTriggerRef,
values,
menuVisible,
itemRefs,
Expand Down