diff --git a/packages/react-core/src/components/Dropdown/Dropdown.tsx b/packages/react-core/src/components/Dropdown/Dropdown.tsx index b1bac70c701..3bc93f54a84 100644 --- a/packages/react-core/src/components/Dropdown/Dropdown.tsx +++ b/packages/react-core/src/components/Dropdown/Dropdown.tsx @@ -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. */ @@ -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. */ @@ -85,6 +87,7 @@ const DropdownBase: React.FunctionComponent = ({ toggle, shouldFocusToggleOnSelect = false, onOpenChange, + onToggleKeydown, isPlain, isScrollable, innerRef, @@ -123,6 +126,14 @@ const DropdownBase: React.FunctionComponent = ({ 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) => { @@ -157,6 +168,7 @@ const DropdownBase: React.FunctionComponent = ({ toggleRef, onOpenChange, onOpenChangeKeys, + onToggleKeydown, shouldPreventScrollOnItemFocus, shouldFocusFirstItemOnOpen, focusTimeoutDelay diff --git a/packages/react-core/src/components/Menu/MenuContainer.tsx b/packages/react-core/src/components/Menu/MenuContainer.tsx index e6d2c8ad7e2..d59c36cb6fa 100644 --- a/packages/react-core/src/components/Menu/MenuContainer.tsx +++ b/packages/react-core/src/components/Menu/MenuContainer.tsx @@ -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. */ @@ -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>; @@ -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 */ @@ -54,12 +59,30 @@ export const MenuContainer: React.FunctionComponent = ({ toggle, toggleRef, onOpenChange, + onToggleKeydown, zIndex = 9999, popperProps, onOpenChangeKeys = ['Escape', 'Tab'], + shouldFocusFirstItemOnOpen = false, shouldPreventScrollOnItemFocus = true, focusTimeoutDelay = 0 }: MenuContainerProps) => { + const prevIsOpen = React.useRef(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 @@ -72,19 +95,17 @@ export const MenuContainer: React.FunctionComponent = ({ 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)) { @@ -100,7 +121,16 @@ export const MenuContainer: React.FunctionComponent = ({ window.removeEventListener('keydown', handleMenuKeys); window.removeEventListener('click', handleClick); }; - }, [focusTimeoutDelay, isOpen, menuRef, onOpenChange, onOpenChangeKeys, shouldPreventScrollOnItemFocus, toggleRef]); + }, [ + focusTimeoutDelay, + isOpen, + menuRef, + onOpenChange, + onOpenChangeKeys, + onToggleKeydown, + shouldPreventScrollOnItemFocus, + toggleRef + ]); return ( 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 */ @@ -95,6 +99,8 @@ const SelectBase: React.FunctionComponent = ({ shouldFocusFirstItemOnOpen = false, onOpenChange, onOpenChangeKeys = ['Escape', 'Tab'], + onToggleKeydown, + variant, isPlain, innerRef, zIndex = 9999, @@ -130,6 +136,14 @@ const SelectBase: React.FunctionComponent = ({ 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) => { @@ -162,6 +176,7 @@ const SelectBase: React.FunctionComponent = ({ toggleRef, onOpenChange, onOpenChangeKeys, + onToggleKeydown, shouldPreventScrollOnItemFocus, shouldFocusFirstItemOnOpen, focusTimeoutDelay diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx index 57f2b4d27f6..8db59ef0baf 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeahead.tsx @@ -245,7 +245,7 @@ export const SelectMultiTypeahead: React.FunctionComponent = () => { !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstItemOnOpen={false} + variant="typeahead" > {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx index da38e0930e1..493575dda67 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCheckbox.tsx @@ -238,7 +238,7 @@ export const SelectMultiTypeaheadCheckbox: React.FunctionComponent = () => { !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstItemOnOpen={false} + variant="typeahead" > {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx index 16f3afb2c63..df36f8cc5c6 100644 --- a/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx +++ b/packages/react-core/src/components/Select/examples/SelectMultiTypeaheadCreatable.tsx @@ -256,7 +256,7 @@ export const SelectMultiTypeaheadCreatable: React.FunctionComponent = () => { !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstItemOnOpen={false} + variant="typeahead" > {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx index 2c67674115e..d516f1851a3 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeahead.tsx @@ -241,7 +241,7 @@ export const SelectTypeahead: React.FunctionComponent = () => { !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstItemOnOpen={false} + variant="typeahead" > {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx b/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx index 7b0d9df194d..a9a5f2a87fe 100644 --- a/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx +++ b/packages/react-core/src/components/Select/examples/SelectTypeaheadCreatable.tsx @@ -248,7 +248,7 @@ export const SelectTypeaheadCreatable: React.FunctionComponent = () => { !isOpen && closeMenu(); }} toggle={toggle} - shouldFocusFirstItemOnOpen={false} + variant="typeahead" > {selectOptions.map((option, index) => ( diff --git a/packages/react-core/src/helpers/KeyboardHandler.tsx b/packages/react-core/src/helpers/KeyboardHandler.tsx index 1d499b992fa..c777e3f2e8d 100644 --- a/packages/react-core/src/helpers/KeyboardHandler.tsx +++ b/packages/react-core/src/helpers/KeyboardHandler.tsx @@ -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) => { + 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 { static displayName = 'KeyboardHandler'; static defaultProps: KeyboardHandlerProps = { diff --git a/packages/react-templates/src/components/Select/MultiTypeaheadSelect.tsx b/packages/react-templates/src/components/Select/MultiTypeaheadSelect.tsx index ff8a7973224..41d2f9afb34 100644 --- a/packages/react-templates/src/components/Select/MultiTypeaheadSelect.tsx +++ b/packages/react-templates/src/components/Select/MultiTypeaheadSelect.tsx @@ -320,7 +320,7 @@ export const MultiTypeaheadSelectBase: React.FunctionComponent diff --git a/packages/react-templates/src/components/Select/TypeaheadSelect.tsx b/packages/react-templates/src/components/Select/TypeaheadSelect.tsx index e344ebff1c5..5e129c22721 100644 --- a/packages/react-templates/src/components/Select/TypeaheadSelect.tsx +++ b/packages/react-templates/src/components/Select/TypeaheadSelect.tsx @@ -366,7 +366,7 @@ export const TypeaheadSelectBase: React.FunctionComponent onSelect={_onSelect} onOpenChange={(isOpen) => !isOpen && closeMenu()} toggle={toggle} - shouldFocusFirstItemOnOpen={false} + variant="typeahead" ref={innerRef} {...props} >