Skip to content

Commit 623da5e

Browse files
authored
feat(V5 Select/Dropdown/MenuContainer): arrow key handling to focus items (#11249)
* feat(Select): default arrow key handling to focus items * feat(Dropdown): default arrow key handling to focus items * feat(MenuContainer): default arrow key handling to focus items * fix: invoke callback only when toggle is focused and menu opened * fix: query selector, refactor common functionality * feat: use general onToggleKeydown instead of onToggleArrowKeydown * refactor(onToggleArrowKeydownDefault): don't use :has() selector * fix: remove isTypeahead prop in favor of variant * feat(MenuContainer): update to include V6 changes
1 parent af540d5 commit 623da5e

File tree

11 files changed

+105
-21
lines changed

11 files changed

+105
-21
lines changed

packages/react-core/src/components/Dropdown/Dropdown.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import { css } from '@patternfly/react-styles';
33
import { Menu, MenuContent, MenuProps } from '../Menu';
44
import { Popper } from '../../helpers/Popper/Popper';
5-
import { useOUIAProps, OUIAProps } from '../../helpers';
5+
import { useOUIAProps, OUIAProps, onToggleArrowKeydownDefault } from '../../helpers';
66

77
export interface DropdownPopperProps {
88
/** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */
@@ -51,6 +51,8 @@ export interface DropdownProps extends MenuProps, OUIAProps {
5151
onOpenChange?: (isOpen: boolean) => void;
5252
/** @beta Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */
5353
onOpenChangeKeys?: string[];
54+
/** Callback to override the toggle keydown behavior. By default, when the toggle has focus and the menu is open, pressing the up/down arrow keys will focus a valid non-disabled menu item - the first item for the down arrow key and last item for the up arrow key. */
55+
onToggleKeydown?: (event: KeyboardEvent) => void;
5456
/** Indicates if the menu should be without the outer box-shadow. */
5557
isPlain?: boolean;
5658
/** Indicates if the menu should be scrollable. */
@@ -85,6 +87,7 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
8587
toggle,
8688
shouldFocusToggleOnSelect = false,
8789
onOpenChange,
90+
onToggleKeydown,
8891
isPlain,
8992
isScrollable,
9093
innerRef,
@@ -123,6 +126,14 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
123126
toggleRef.current?.focus();
124127
}
125128
}
129+
130+
if (toggleRef.current?.contains(event.target as Node)) {
131+
if (onToggleKeydown) {
132+
onToggleKeydown(event);
133+
} else if (isOpen) {
134+
onToggleArrowKeydownDefault(event, menuRef);
135+
}
136+
}
126137
};
127138

128139
const handleClick = (event: MouseEvent) => {
@@ -157,6 +168,7 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
157168
toggleRef,
158169
onOpenChange,
159170
onOpenChangeKeys,
171+
onToggleKeydown,
160172
shouldPreventScrollOnItemFocus,
161173
shouldFocusFirstItemOnOpen,
162174
focusTimeoutDelay

packages/react-core/src/components/Menu/MenuContainer.tsx

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { Popper } from '../../helpers/Popper/Popper';
2+
import { onToggleArrowKeydownDefault, Popper } from '../../helpers';
33

44
export interface MenuPopperProps {
55
/** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */
@@ -17,6 +17,7 @@ export interface MenuPopperProps {
1717
/** Flag to prevent the popper from overflowing its container and becoming partially obscured. */
1818
preventOverflow?: boolean;
1919
}
20+
2021
export interface MenuContainerProps {
2122
/** Menu to be rendered */
2223
menu: React.ReactElement<any, string | React.JSXElementConstructor<any>>;
@@ -33,10 +34,14 @@ export interface MenuContainerProps {
3334
onOpenChange?: (isOpen: boolean) => void;
3435
/** Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */
3536
onOpenChangeKeys?: string[];
37+
/** Callback to override the toggle keydown behavior. By default, when the toggle has focus and the menu is open, pressing the up/down arrow keys will focus a valid non-disabled menu item - the first item for the down arrow key and last item for the up arrow key. */
38+
onToggleKeydown?: (event: KeyboardEvent) => void;
3639
/** z-index of the dropdown menu */
3740
zIndex?: number;
3841
/** Additional properties to pass to the Popper */
3942
popperProps?: MenuPopperProps;
43+
/** @beta Flag indicating the first menu item should be focused after opening the dropdown. */
44+
shouldFocusFirstItemOnOpen?: boolean;
4045
/** Flag indicating if scroll on focus of the first menu item should occur. */
4146
shouldPreventScrollOnItemFocus?: boolean;
4247
/** Time in ms to wait before firing the toggles' focus event. Defaults to 0 */
@@ -54,12 +59,30 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
5459
toggle,
5560
toggleRef,
5661
onOpenChange,
62+
onToggleKeydown,
5763
zIndex = 9999,
5864
popperProps,
5965
onOpenChangeKeys = ['Escape', 'Tab'],
66+
shouldFocusFirstItemOnOpen = false,
6067
shouldPreventScrollOnItemFocus = true,
6168
focusTimeoutDelay = 0
6269
}: MenuContainerProps) => {
70+
const prevIsOpen = React.useRef<boolean>(isOpen);
71+
React.useEffect(() => {
72+
// menu was opened, focus on first menu item
73+
if (prevIsOpen.current === false && isOpen === true && shouldFocusFirstItemOnOpen) {
74+
setTimeout(() => {
75+
const firstElement = menuRef?.current?.querySelector(
76+
'li button:not(:disabled),li input:not(:disabled),li a:not([aria-disabled="true"])'
77+
);
78+
firstElement && (firstElement as HTMLElement).focus({ preventScroll: shouldPreventScrollOnItemFocus });
79+
}, focusTimeoutDelay);
80+
}
81+
82+
prevIsOpen.current = isOpen;
83+
// eslint-disable-next-line react-hooks/exhaustive-deps
84+
}, [isOpen]);
85+
6386
React.useEffect(() => {
6487
const handleMenuKeys = (event: KeyboardEvent) => {
6588
// Close the menu on tab or escape if onOpenChange is provided
@@ -72,19 +95,17 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
7295
toggleRef.current?.focus();
7396
}
7497
}
75-
};
7698

77-
const handleClick = (event: MouseEvent) => {
78-
// toggle was opened, focus on first menu item
79-
if (isOpen && toggleRef.current?.contains(event.target as Node)) {
80-
setTimeout(() => {
81-
const firstElement = menuRef?.current?.querySelector(
82-
'li button:not(:disabled),li input:not(:disabled),li a:not([aria-disabled="true"])'
83-
);
84-
firstElement && (firstElement as HTMLElement).focus({ preventScroll: shouldPreventScrollOnItemFocus });
85-
}, focusTimeoutDelay);
99+
if (toggleRef.current?.contains(event.target as Node)) {
100+
if (onToggleKeydown) {
101+
onToggleKeydown(event);
102+
} else if (isOpen) {
103+
onToggleArrowKeydownDefault(event, menuRef);
104+
}
86105
}
106+
};
87107

108+
const handleClick = (event: MouseEvent) => {
88109
// If the event is not on the toggle and onOpenChange callback is provided, close the menu
89110
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
90111
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
@@ -100,7 +121,16 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
100121
window.removeEventListener('keydown', handleMenuKeys);
101122
window.removeEventListener('click', handleClick);
102123
};
103-
}, [focusTimeoutDelay, isOpen, menuRef, onOpenChange, onOpenChangeKeys, shouldPreventScrollOnItemFocus, toggleRef]);
124+
}, [
125+
focusTimeoutDelay,
126+
isOpen,
127+
menuRef,
128+
onOpenChange,
129+
onOpenChangeKeys,
130+
onToggleKeydown,
131+
shouldPreventScrollOnItemFocus,
132+
toggleRef
133+
]);
104134

105135
return (
106136
<Popper

packages/react-core/src/components/Select/Select.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import { css } from '@patternfly/react-styles';
33
import { Menu, MenuContent, MenuProps } from '../Menu';
44
import { Popper } from '../../helpers/Popper/Popper';
5-
import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../helpers';
5+
import { getOUIAProps, OUIAProps, getDefaultOUIAId, onToggleArrowKeydownDefault } from '../../helpers';
66

77
export interface SelectPopperProps {
88
/** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */
@@ -62,6 +62,10 @@ export interface SelectProps extends MenuProps, OUIAProps {
6262
onOpenChange?: (isOpen: boolean) => void;
6363
/** @beta Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */
6464
onOpenChangeKeys?: string[];
65+
/** Callback to override the toggle keydown behavior. By default, when the toggle has focus and the menu is open, pressing the up/down arrow keys will focus a valid non-disabled menu item - the first item for the down arrow key and last item for the up arrow key. */
66+
onToggleKeydown?: (event: KeyboardEvent) => void;
67+
/** Select variant. For typeahead variant focus won't shift to menu items when pressing up/down arrows. */
68+
variant?: 'default' | 'typeahead';
6569
/** Indicates if the select should be without the outer box-shadow */
6670
isPlain?: boolean;
6771
/** @hide Forwarded ref */
@@ -95,6 +99,8 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
9599
shouldFocusFirstItemOnOpen = false,
96100
onOpenChange,
97101
onOpenChangeKeys = ['Escape', 'Tab'],
102+
onToggleKeydown,
103+
variant,
98104
isPlain,
99105
innerRef,
100106
zIndex = 9999,
@@ -130,6 +136,14 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
130136
toggleRef.current?.focus();
131137
}
132138
}
139+
140+
if (toggleRef.current?.contains(event.target as Node)) {
141+
if (onToggleKeydown) {
142+
onToggleKeydown(event);
143+
} else if (isOpen && variant !== 'typeahead') {
144+
onToggleArrowKeydownDefault(event, menuRef);
145+
}
146+
}
133147
};
134148

135149
const handleClick = (event: MouseEvent) => {
@@ -162,6 +176,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
162176
toggleRef,
163177
onOpenChange,
164178
onOpenChangeKeys,
179+
onToggleKeydown,
165180
shouldPreventScrollOnItemFocus,
166181
shouldFocusFirstItemOnOpen,
167182
focusTimeoutDelay

packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
245245
!isOpen && closeMenu();
246246
}}
247247
toggle={toggle}
248-
shouldFocusFirstItemOnOpen={false}
248+
variant="typeahead"
249249
>
250250
<SelectList isAriaMultiselectable id="select-multi-typeahead-listbox">
251251
{selectOptions.map((option, index) => (

packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
238238
!isOpen && closeMenu();
239239
}}
240240
toggle={toggle}
241-
shouldFocusFirstItemOnOpen={false}
241+
variant="typeahead"
242242
>
243243
<SelectList isAriaMultiselectable id="select-multi-typeahead-checkbox-listbox">
244244
{selectOptions.map((option, index) => (

packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
256256
!isOpen && closeMenu();
257257
}}
258258
toggle={toggle}
259-
shouldFocusFirstItemOnOpen={false}
259+
variant="typeahead"
260260
>
261261
<SelectList isAriaMultiselectable id="select-multi-create-typeahead-listbox">
262262
{selectOptions.map((option, index) => (

packages/react-core/src/components/Select/examples/SelectTypeahead.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export const SelectTypeahead: React.FunctionComponent = () => {
241241
!isOpen && closeMenu();
242242
}}
243243
toggle={toggle}
244-
shouldFocusFirstItemOnOpen={false}
244+
variant="typeahead"
245245
>
246246
<SelectList id="select-typeahead-listbox">
247247
{selectOptions.map((option, index) => (

packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
248248
!isOpen && closeMenu();
249249
}}
250250
toggle={toggle}
251-
shouldFocusFirstItemOnOpen={false}
251+
variant="typeahead"
252252
>
253253
<SelectList id="select-create-typeahead-listbox">
254254
{selectOptions.map((option, index) => (

packages/react-core/src/helpers/KeyboardHandler.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ export const setTabIndex = (options: HTMLElement[]) => {
170170
}
171171
};
172172

173+
/**
174+
* This function is used in Dropdown, Select and MenuContainer as a default toggle keydown behavior. When the toggle has focus and the menu is open, pressing the up/down arrow keys will focus a valid non-disabled menu item - the first item for the down arrow key and last item for the up arrow key.
175+
*
176+
* @param event Event triggered by the keyboard
177+
* @param menuRef Menu reference
178+
*/
179+
export const onToggleArrowKeydownDefault = (event: KeyboardEvent, menuRef: React.RefObject<HTMLDivElement>) => {
180+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
181+
return;
182+
}
183+
184+
event.preventDefault();
185+
186+
const listItems = Array.from(menuRef.current?.querySelectorAll('li'));
187+
const focusableElements = listItems
188+
.map((li) => li.querySelector('button:not(:disabled),input:not(:disabled),a:not([aria-disabled="true"])'))
189+
.filter((el) => el !== null);
190+
191+
let focusableElement: Element;
192+
if (event.key === 'ArrowDown') {
193+
focusableElement = focusableElements[0];
194+
} else {
195+
focusableElement = focusableElements[focusableElements.length - 1];
196+
}
197+
focusableElement && (focusableElement as HTMLElement).focus();
198+
};
199+
173200
class KeyboardHandler extends React.Component<KeyboardHandlerProps> {
174201
static displayName = 'KeyboardHandler';
175202
static defaultProps: KeyboardHandlerProps = {

packages/react-templates/src/components/Select/MultiTypeaheadSelect.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export const MultiTypeaheadSelectBase: React.FunctionComponent<MultiTypeaheadSel
320320
!isOpen && closeMenu();
321321
}}
322322
toggle={toggle}
323-
shouldFocusFirstItemOnOpen={false}
323+
variant="typeahead"
324324
ref={innerRef}
325325
{...props}
326326
>

0 commit comments

Comments
 (0)