Skip to content

Commit 93b6278

Browse files
authored
fix(container-menu)!: improves logic for returning focus to trigger (#659)
BREAKING CHANGE: A new `restoreFocus` prop has been added. By default `restoreFocus` is `true` and will return the focus to the trigger on menu item selection, ESC / TAB key press, and clicking outside the menu dropdown. User who need to keep the dropdown open on selection must set `restoreFocus={false}` and manually handle the focus return.
1 parent bc24076 commit 93b6278

File tree

4 files changed

+72
-39
lines changed

4 files changed

+72
-39
lines changed

packages/menu/src/MenuContainer.spec.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,20 @@ describe('MenuContainer', () => {
371371

372372
expect(trigger).toHaveFocus();
373373
});
374+
375+
it('does not return focus to trigger when Escape key pressed in expanded menu if `restoreFocus` is false', async () => {
376+
const { getByTestId } = render(<TestMenu items={ITEMS} restoreFocus={false} />);
377+
const trigger = getByTestId('trigger');
378+
379+
trigger.focus();
380+
381+
await waitFor(async () => {
382+
await user.keyboard('{ArrowDown}');
383+
await user.keyboard('{Escape}');
384+
});
385+
386+
expect(trigger).not.toHaveFocus();
387+
});
374388
});
375389

376390
describe('menu items', () => {

packages/menu/src/MenuContainer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,10 @@ MenuContainer.propTypes = {
2929
defaultExpanded: PropTypes.bool,
3030
selectedItems: PropTypes.arrayOf(PropTypes.any),
3131
focusedValue: PropTypes.oneOfType([PropTypes.string]),
32-
defaultFocusedValue: PropTypes.oneOfType([PropTypes.string])
32+
defaultFocusedValue: PropTypes.oneOfType([PropTypes.string]),
33+
restoreFocus: PropTypes.bool
34+
};
35+
36+
MenuContainer.defaultProps = {
37+
restoreFocus: true
3338
};

packages/menu/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface IUseMenuProps<T = HTMLButtonElement, M = HTMLElement> {
6666
focusedValue?: string | null;
6767
/** Determines focused value on menu initialization */
6868
defaultFocusedValue?: string;
69+
/** Returns keyboard focus to the element that triggered the menu */
70+
restoreFocus?: boolean;
6971
/** Sets the selected values in a controlled menu */
7072
selectedItems?: ISelectedItem[];
7173
/**

packages/menu/src/useMenu.ts

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import React, {
1212
useEffect,
1313
useMemo,
1414
useReducer,
15-
useRef,
1615
useState
1716
} from 'react';
1817
import { useSelection } from '@zendeskgarden/container-selection';
@@ -49,6 +48,7 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
4948
onChange = () => undefined,
5049
isExpanded,
5150
defaultExpanded = false,
51+
restoreFocus = true,
5252
selectedItems,
5353
focusedValue,
5454
defaultFocusedValue
@@ -94,8 +94,6 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
9494
*/
9595

9696
const [menuVisible, setMenuVisible] = useState<boolean>(false);
97-
const focusTriggerRef = useRef<boolean>(false);
98-
9997
const [state, dispatch] = useReducer(stateReducer, {
10098
focusedValue,
10199
isExpanded: isExpanded || defaultExpanded,
@@ -135,6 +133,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
135133

136134
// Internal
137135

136+
const returnFocusToTrigger = useCallback(
137+
(skip?: boolean) => {
138+
if (!skip && restoreFocus && triggerRef.current) {
139+
triggerRef.current.focus();
140+
}
141+
},
142+
[triggerRef, restoreFocus]
143+
);
144+
138145
const closeMenu = useCallback(
139146
(changeType: string) => {
140147
dispatch({
@@ -281,13 +288,22 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
281288
}
282289
});
283290

291+
// Skip focus return when isExpanded === true
292+
returnFocusToTrigger(!controlledIsExpanded);
293+
284294
onChange({
285295
type: changeType,
286296
focusedValue: null,
287297
isExpanded: !controlledIsExpanded
288298
});
289299
},
290-
[controlledIsExpanded, isFocusedValueControlled, isExpandedControlled, onChange]
300+
[
301+
isFocusedValueControlled,
302+
isExpandedControlled,
303+
controlledIsExpanded,
304+
returnFocusToTrigger,
305+
onChange
306+
]
291307
);
292308

293309
const handleTriggerKeyDown = useCallback(
@@ -321,14 +337,23 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
321337
}
322338
});
323339

340+
returnFocusToTrigger();
341+
324342
onChange({
325343
type: changeType,
326344
focusedValue: defaultFocusedValue || nextFocusedValue,
327345
isExpanded: true
328346
});
329347
}
330348
},
331-
[isExpandedControlled, isFocusedValueControlled, defaultFocusedValue, onChange, values]
349+
[
350+
values,
351+
isFocusedValueControlled,
352+
defaultFocusedValue,
353+
isExpandedControlled,
354+
returnFocusToTrigger,
355+
onChange
356+
]
332357
);
333358

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

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

344-
if (KEYS.ESCAPE === key) {
345-
focusTriggerRef.current = true;
346-
}
369+
// TODO: Investigate why focus goes to body instead of next interactive element on TAB keydown. Meanwhile, returning focus to trigger.
370+
returnFocusToTrigger();
347371

348372
closeMenu(type);
349373
}
350374
},
351-
[closeMenu, focusTriggerRef]
375+
[closeMenu, returnFocusToTrigger]
352376
);
353377

354378
const handleMenuBlur = useCallback(
355379
(event: MouseEvent) => {
356380
const path = event.composedPath();
357381

358382
if (!path.includes(menuRef.current!) && !path.includes(triggerRef.current!)) {
383+
returnFocusToTrigger();
359384
closeMenu(StateChangeTypes.MenuBlur);
360385
}
361386
},
362-
[closeMenu, menuRef, triggerRef]
387+
[closeMenu, menuRef, returnFocusToTrigger, triggerRef]
363388
);
364389

365390
const handleMenuMouseLeave = useCallback(() => {
@@ -395,6 +420,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
395420
}
396421
});
397422

423+
returnFocusToTrigger(isTransitionItem);
424+
398425
onChange({
399426
type: changeType,
400427
value: item.value,
@@ -403,10 +430,11 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
403430
});
404431
},
405432
[
433+
getSelectedItems,
406434
state.nestedPathIds,
407435
isExpandedControlled,
408436
isSelectedItemsControlled,
409-
getSelectedItems,
437+
returnFocusToTrigger,
410438
onChange
411439
]
412440
);
@@ -448,19 +476,13 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
448476
...(nextSelection && { selectedItems: nextSelection })
449477
};
450478

451-
if (item.href) {
452-
if (key === KEYS.SPACE) {
453-
event.preventDefault();
479+
event.preventDefault();
454480

455-
triggerLink(event.target as HTMLAnchorElement, environment || window);
456-
}
457-
} else {
458-
event.preventDefault();
481+
if (item.href) {
482+
triggerLink(event.target as HTMLAnchorElement, environment || window);
459483
}
460484

461-
if (!isTransitionItem) {
462-
focusTriggerRef.current = true;
463-
}
485+
returnFocusToTrigger(isTransitionItem);
464486
} else if (key === KEYS.RIGHT) {
465487
if (rtl && isPrevious) {
466488
changeType = StateChangeTypes.MenuItemKeyDownPrevious;
@@ -529,15 +551,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
529551
}
530552
},
531553
[
532-
rtl,
533-
state.nestedPathIds,
554+
getSelectedItems,
534555
isExpandedControlled,
535-
isFocusedValueControlled,
536556
isSelectedItemsControlled,
537-
focusTriggerRef,
557+
returnFocusToTrigger,
538558
environment,
559+
rtl,
539560
getNextFocusedValue,
540-
getSelectedItems,
561+
isFocusedValueControlled,
562+
state.nestedPathIds,
541563
onChange
542564
]
543565
);
@@ -594,12 +616,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
594616
}, [controlledIsExpanded, handleMenuBlur, handleMenuKeyDown, environment]);
595617

596618
/**
597-
* Handles focus depending on menu state:
598-
* - When opened, focus the menu via `focusedValue`
599-
* - When closed, focus the trigger via `triggerRef`
600-
*
601-
* This effect is intended to prevent focusing the trigger or menu
602-
* unless the menu is in the right expansion state.
619+
* When the menu is opened, this effect sets focus on the current menu item using `focusedValue`
620+
* or on the first menu item.
603621
*/
604622
useEffect(() => {
605623
if (state.focusOnOpen && menuVisible && controlledFocusedValue && controlledIsExpanded) {
@@ -614,13 +632,7 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
614632

615633
ref && ref.focus();
616634
}
617-
618-
if (!menuVisible && !controlledIsExpanded && focusTriggerRef.current) {
619-
triggerRef?.current?.focus();
620-
focusTriggerRef.current = false;
621-
}
622635
}, [
623-
focusTriggerRef,
624636
values,
625637
menuVisible,
626638
itemRefs,

0 commit comments

Comments
 (0)