Skip to content

Commit ffb3841

Browse files
authored
feat(Select/Dropdown/MenuContainer): arrow key handling to focus items (#11132)
* 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
1 parent dee6247 commit ffb3841

File tree

11 files changed

+87
-12
lines changed

11 files changed

+87
-12
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
/** 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,
@@ -139,6 +142,14 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
139142
toggleRef.current?.focus();
140143
}
141144
}
145+
146+
if (toggleRef.current?.contains(event.target as Node)) {
147+
if (onToggleKeydown) {
148+
onToggleKeydown(event);
149+
} else if (isOpen) {
150+
onToggleArrowKeydownDefault(event, menuRef);
151+
}
152+
}
142153
};
143154

144155
const handleClick = (event: MouseEvent) => {
@@ -163,6 +174,7 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
163174
toggleRef,
164175
onOpenChange,
165176
onOpenChangeKeys,
177+
onToggleKeydown,
166178
shouldPreventScrollOnItemFocus,
167179
shouldFocusFirstItemOnOpen,
168180
focusTimeoutDelay

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

Lines changed: 24 additions & 3 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,6 +34,8 @@ 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 */
@@ -55,10 +58,11 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
5558
toggle,
5659
toggleRef,
5760
onOpenChange,
61+
onToggleKeydown,
5862
zIndex = 9999,
5963
popperProps,
6064
onOpenChangeKeys = ['Escape', 'Tab'],
61-
shouldFocusFirstItemOnOpen = true,
65+
shouldFocusFirstItemOnOpen = false,
6266
shouldPreventScrollOnItemFocus = true,
6367
focusTimeoutDelay = 0
6468
}: MenuContainerProps) => {
@@ -90,6 +94,14 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
9094
toggleRef.current?.focus();
9195
}
9296
}
97+
98+
if (toggleRef.current?.contains(event.target as Node)) {
99+
if (onToggleKeydown) {
100+
onToggleKeydown(event);
101+
} else if (isOpen) {
102+
onToggleArrowKeydownDefault(event, menuRef);
103+
}
104+
}
93105
};
94106

95107
const handleClick = (event: MouseEvent) => {
@@ -108,7 +120,16 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
108120
window.removeEventListener('keydown', handleMenuKeys);
109121
window.removeEventListener('click', handleClick);
110122
};
111-
}, [focusTimeoutDelay, isOpen, menuRef, onOpenChange, onOpenChangeKeys, shouldPreventScrollOnItemFocus, toggleRef]);
123+
}, [
124+
focusTimeoutDelay,
125+
isOpen,
126+
menuRef,
127+
onOpenChange,
128+
onOpenChangeKeys,
129+
onToggleKeydown,
130+
shouldPreventScrollOnItemFocus,
131+
toggleRef
132+
]);
112133

113134
return (
114135
<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
/** 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,
@@ -144,6 +150,14 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
144150
toggleRef.current?.focus();
145151
}
146152
}
153+
154+
if (toggleRef.current?.contains(event.target as Node)) {
155+
if (onToggleKeydown) {
156+
onToggleKeydown(event);
157+
} else if (isOpen && variant !== 'typeahead') {
158+
onToggleArrowKeydownDefault(event, menuRef);
159+
}
160+
}
147161
};
148162

149163
const handleClick = (event: MouseEvent) => {
@@ -168,6 +182,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
168182
toggleRef,
169183
onOpenChange,
170184
onOpenChangeKeys,
185+
onToggleKeydown,
171186
shouldPreventScrollOnItemFocus,
172187
shouldFocusFirstItemOnOpen,
173188
focusTimeoutDelay

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
247247
!isOpen && closeMenu();
248248
}}
249249
toggle={toggle}
250-
shouldFocusFirstItemOnOpen={false}
250+
variant="typeahead"
251251
>
252252
<SelectList isAriaMultiselectable id="select-multi-typeahead-listbox">
253253
{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
@@ -241,7 +241,7 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
241241
!isOpen && closeMenu();
242242
}}
243243
toggle={toggle}
244-
shouldFocusFirstItemOnOpen={false}
244+
variant="typeahead"
245245
>
246246
<SelectList isAriaMultiselectable id="select-multi-typeahead-checkbox-listbox">
247247
{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
@@ -260,7 +260,7 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
260260
!isOpen && closeMenu();
261261
}}
262262
toggle={toggle}
263-
shouldFocusFirstItemOnOpen={false}
263+
variant="typeahead"
264264
>
265265
<SelectList isAriaMultiselectable id="select-multi-create-typeahead-listbox">
266266
{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
@@ -244,7 +244,7 @@ export const SelectTypeahead: React.FunctionComponent = () => {
244244
!isOpen && closeMenu();
245245
}}
246246
toggle={toggle}
247-
shouldFocusFirstItemOnOpen={false}
247+
variant="typeahead"
248248
>
249249
<SelectList id="select-typeahead-listbox">
250250
{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
@@ -251,7 +251,7 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
251251
!isOpen && closeMenu();
252252
}}
253253
toggle={toggle}
254-
shouldFocusFirstItemOnOpen={false}
254+
variant="typeahead"
255255
>
256256
<SelectList id="select-create-typeahead-listbox">
257257
{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
@@ -176,6 +176,33 @@ export const setTabIndex = (options: HTMLElement[]) => {
176176
}
177177
};
178178

179+
/**
180+
* 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.
181+
*
182+
* @param event Event triggered by the keyboard
183+
* @param menuRef Menu reference
184+
*/
185+
export const onToggleArrowKeydownDefault = (event: KeyboardEvent, menuRef: React.RefObject<HTMLDivElement>) => {
186+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
187+
return;
188+
}
189+
190+
event.preventDefault();
191+
192+
const listItems = Array.from(menuRef.current?.querySelectorAll('li'));
193+
const focusableElements = listItems
194+
.map((li) => li.querySelector('button:not(:disabled),input:not(:disabled),a:not([aria-disabled="true"])'))
195+
.filter((el) => el !== null);
196+
197+
let focusableElement: Element;
198+
if (event.key === 'ArrowDown') {
199+
focusableElement = focusableElements[0];
200+
} else {
201+
focusableElement = focusableElements[focusableElements.length - 1];
202+
}
203+
focusableElement && (focusableElement as HTMLElement).focus();
204+
};
205+
179206
class KeyboardHandler extends React.Component<KeyboardHandlerProps> {
180207
static displayName = 'KeyboardHandler';
181208
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)