Skip to content

Commit

Permalink
feat(V5 Select/Dropdown/MenuContainer): arrow key handling to focus i…
Browse files Browse the repository at this point in the history
…tems (#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
  • Loading branch information
adamviktora authored Dec 3, 2024
1 parent af540d5 commit 623da5e
Show file tree
Hide file tree
Showing 11 changed files with 105 additions and 21 deletions.
14 changes: 13 additions & 1 deletion packages/react-core/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { css } from '@patternfly/react-styles';
import { Menu, MenuContent, MenuProps } from '../Menu';
import { Popper } from '../../helpers/Popper/Popper';
import { useOUIAProps, OUIAProps } from '../../helpers';
import { useOUIAProps, OUIAProps, onToggleArrowKeydownDefault } from '../../helpers';

export interface DropdownPopperProps {
/** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */
Expand Down Expand Up @@ -51,6 +51,8 @@ export interface DropdownProps extends MenuProps, OUIAProps {
onOpenChange?: (isOpen: boolean) => void;
/** @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. */
onOpenChangeKeys?: string[];
/** 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. */
onToggleKeydown?: (event: KeyboardEvent) => void;
/** Indicates if the menu should be without the outer box-shadow. */
isPlain?: boolean;
/** Indicates if the menu should be scrollable. */
Expand Down Expand Up @@ -85,6 +87,7 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
toggle,
shouldFocusToggleOnSelect = false,
onOpenChange,
onToggleKeydown,
isPlain,
isScrollable,
innerRef,
Expand Down Expand Up @@ -123,6 +126,14 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
toggleRef.current?.focus();
}
}

if (toggleRef.current?.contains(event.target as Node)) {
if (onToggleKeydown) {
onToggleKeydown(event);
} else if (isOpen) {
onToggleArrowKeydownDefault(event, menuRef);
}
}
};

const handleClick = (event: MouseEvent) => {
Expand Down Expand Up @@ -157,6 +168,7 @@ const DropdownBase: React.FunctionComponent<DropdownProps> = ({
toggleRef,
onOpenChange,
onOpenChangeKeys,
onToggleKeydown,
shouldPreventScrollOnItemFocus,
shouldFocusFirstItemOnOpen,
focusTimeoutDelay
Expand Down
54 changes: 42 additions & 12 deletions packages/react-core/src/components/Menu/MenuContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Popper } from '../../helpers/Popper/Popper';
import { onToggleArrowKeydownDefault, Popper } from '../../helpers';

export interface MenuPopperProps {
/** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */
Expand All @@ -17,6 +17,7 @@ export interface MenuPopperProps {
/** Flag to prevent the popper from overflowing its container and becoming partially obscured. */
preventOverflow?: boolean;
}

export interface MenuContainerProps {
/** Menu to be rendered */
menu: React.ReactElement<any, string | React.JSXElementConstructor<any>>;
Expand All @@ -33,10 +34,14 @@ export interface MenuContainerProps {
onOpenChange?: (isOpen: boolean) => void;
/** 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. */
onOpenChangeKeys?: string[];
/** 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. */
onToggleKeydown?: (event: KeyboardEvent) => void;
/** z-index of the dropdown menu */
zIndex?: number;
/** Additional properties to pass to the Popper */
popperProps?: MenuPopperProps;
/** @beta Flag indicating the first menu item should be focused after opening the dropdown. */
shouldFocusFirstItemOnOpen?: boolean;
/** Flag indicating if scroll on focus of the first menu item should occur. */
shouldPreventScrollOnItemFocus?: boolean;
/** Time in ms to wait before firing the toggles' focus event. Defaults to 0 */
Expand All @@ -54,12 +59,30 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
toggle,
toggleRef,
onOpenChange,
onToggleKeydown,
zIndex = 9999,
popperProps,
onOpenChangeKeys = ['Escape', 'Tab'],
shouldFocusFirstItemOnOpen = false,
shouldPreventScrollOnItemFocus = true,
focusTimeoutDelay = 0
}: MenuContainerProps) => {
const prevIsOpen = React.useRef<boolean>(isOpen);
React.useEffect(() => {
// menu was opened, focus on first menu item
if (prevIsOpen.current === false && isOpen === true && shouldFocusFirstItemOnOpen) {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector(
'li button:not(:disabled),li input:not(:disabled),li a:not([aria-disabled="true"])'
);
firstElement && (firstElement as HTMLElement).focus({ preventScroll: shouldPreventScrollOnItemFocus });
}, focusTimeoutDelay);
}

prevIsOpen.current = isOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);

React.useEffect(() => {
const handleMenuKeys = (event: KeyboardEvent) => {
// Close the menu on tab or escape if onOpenChange is provided
Expand All @@ -72,19 +95,17 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
toggleRef.current?.focus();
}
}
};

const handleClick = (event: MouseEvent) => {
// toggle was opened, focus on first menu item
if (isOpen && toggleRef.current?.contains(event.target as Node)) {
setTimeout(() => {
const firstElement = menuRef?.current?.querySelector(
'li button:not(:disabled),li input:not(:disabled),li a:not([aria-disabled="true"])'
);
firstElement && (firstElement as HTMLElement).focus({ preventScroll: shouldPreventScrollOnItemFocus });
}, focusTimeoutDelay);
if (toggleRef.current?.contains(event.target as Node)) {
if (onToggleKeydown) {
onToggleKeydown(event);
} else if (isOpen) {
onToggleArrowKeydownDefault(event, menuRef);
}
}
};

const handleClick = (event: MouseEvent) => {
// If the event is not on the toggle and onOpenChange callback is provided, close the menu
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
Expand All @@ -100,7 +121,16 @@ export const MenuContainer: React.FunctionComponent<MenuContainerProps> = ({
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClick);
};
}, [focusTimeoutDelay, isOpen, menuRef, onOpenChange, onOpenChangeKeys, shouldPreventScrollOnItemFocus, toggleRef]);
}, [
focusTimeoutDelay,
isOpen,
menuRef,
onOpenChange,
onOpenChangeKeys,
onToggleKeydown,
shouldPreventScrollOnItemFocus,
toggleRef
]);

return (
<Popper
Expand Down
17 changes: 16 additions & 1 deletion packages/react-core/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { css } from '@patternfly/react-styles';
import { Menu, MenuContent, MenuProps } from '../Menu';
import { Popper } from '../../helpers/Popper/Popper';
import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../helpers';
import { getOUIAProps, OUIAProps, getDefaultOUIAId, onToggleArrowKeydownDefault } from '../../helpers';

export interface SelectPopperProps {
/** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */
Expand Down Expand Up @@ -62,6 +62,10 @@ export interface SelectProps extends MenuProps, OUIAProps {
onOpenChange?: (isOpen: boolean) => void;
/** @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. */
onOpenChangeKeys?: string[];
/** 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. */
onToggleKeydown?: (event: KeyboardEvent) => void;
/** Select variant. For typeahead variant focus won't shift to menu items when pressing up/down arrows. */
variant?: 'default' | 'typeahead';
/** Indicates if the select should be without the outer box-shadow */
isPlain?: boolean;
/** @hide Forwarded ref */
Expand Down Expand Up @@ -95,6 +99,8 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
shouldFocusFirstItemOnOpen = false,
onOpenChange,
onOpenChangeKeys = ['Escape', 'Tab'],
onToggleKeydown,
variant,
isPlain,
innerRef,
zIndex = 9999,
Expand Down Expand Up @@ -130,6 +136,14 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
toggleRef.current?.focus();
}
}

if (toggleRef.current?.contains(event.target as Node)) {
if (onToggleKeydown) {
onToggleKeydown(event);
} else if (isOpen && variant !== 'typeahead') {
onToggleArrowKeydownDefault(event, menuRef);
}
}
};

const handleClick = (event: MouseEvent) => {
Expand Down Expand Up @@ -162,6 +176,7 @@ const SelectBase: React.FunctionComponent<SelectProps & OUIAProps> = ({
toggleRef,
onOpenChange,
onOpenChangeKeys,
onToggleKeydown,
shouldPreventScrollOnItemFocus,
shouldFocusFirstItemOnOpen,
focusTimeoutDelay
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
>
<SelectList isAriaMultiselectable id="select-multi-typeahead-listbox">
{selectOptions.map((option, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
>
<SelectList isAriaMultiselectable id="select-multi-typeahead-checkbox-listbox">
{selectOptions.map((option, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
>
<SelectList isAriaMultiselectable id="select-multi-create-typeahead-listbox">
{selectOptions.map((option, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export const SelectTypeahead: React.FunctionComponent = () => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
>
<SelectList id="select-typeahead-listbox">
{selectOptions.map((option, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => {
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
>
<SelectList id="select-create-typeahead-listbox">
{selectOptions.map((option, index) => (
Expand Down
27 changes: 27 additions & 0 deletions packages/react-core/src/helpers/KeyboardHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,33 @@ export const setTabIndex = (options: HTMLElement[]) => {
}
};

/**
* 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.
*
* @param event Event triggered by the keyboard
* @param menuRef Menu reference
*/
export const onToggleArrowKeydownDefault = (event: KeyboardEvent, menuRef: React.RefObject<HTMLDivElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}

event.preventDefault();

const listItems = Array.from(menuRef.current?.querySelectorAll('li'));
const focusableElements = listItems
.map((li) => li.querySelector('button:not(:disabled),input:not(:disabled),a:not([aria-disabled="true"])'))
.filter((el) => el !== null);

let focusableElement: Element;
if (event.key === 'ArrowDown') {
focusableElement = focusableElements[0];
} else {
focusableElement = focusableElements[focusableElements.length - 1];
}
focusableElement && (focusableElement as HTMLElement).focus();
};

class KeyboardHandler extends React.Component<KeyboardHandlerProps> {
static displayName = 'KeyboardHandler';
static defaultProps: KeyboardHandlerProps = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export const MultiTypeaheadSelectBase: React.FunctionComponent<MultiTypeaheadSel
!isOpen && closeMenu();
}}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
ref={innerRef}
{...props}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
onSelect={_onSelect}
onOpenChange={(isOpen) => !isOpen && closeMenu()}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
variant="typeahead"
ref={innerRef}
{...props}
>
Expand Down

0 comments on commit 623da5e

Please sign in to comment.