Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ exports[`AccessConsoles switching SerialConsole and VncConsole 1`] = `
"current": null,
}
}
moveFocusToLastMenuItem={[Function]}
onClickTypeaheadToggleButton={[Function]}
onClose={[Function]}
onEnter={[Function]}
Expand Down
181 changes: 158 additions & 23 deletions packages/react-core/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import { SelectMenu } from './SelectMenu';
import { SelectOption, SelectOptionObject } from './SelectOption';
import { SelectGroup, SelectGroupProps } from './SelectGroup';
import { SelectToggle } from './SelectToggle';
import { SelectContext, SelectVariant, SelectPosition, SelectDirection, KeyTypes } from './selectConstants';
import {
SelectContext,
SelectVariant,
SelectPosition,
SelectDirection,
KeyTypes,
SelectFooterTabbableItems
} from './selectConstants';
import { Chip, ChipGroup, ChipGroupProps } from '../ChipGroup';
import { Spinner } from '../Spinner';
import {
Expand All @@ -28,6 +35,7 @@ import { Divider } from '../Divider';
import { ToggleMenuBaseProps, Popper } from '../../helpers/Popper/Popper';
import { createRenderableFavorites, extendItemsWithFavorite } from '../../helpers/favorites';
import { ValidatedOptions } from '../../helpers/constants';
import { findTabbableElements } from '../../helpers/util';

// seed for the aria-labelledby ID
let currentId = 0;
Expand Down Expand Up @@ -613,9 +621,42 @@ export class Select extends React.Component<SelectProps & OUIAProps, SelectState
}));
};

handleTypeaheadKeys = (position: string) => {
switchFocusToFavoriteMenu = () => {
const { typeaheadCurrIndex, typeaheadStoredIndex } = this.state;
let indexForFocus = 0;

if (typeaheadCurrIndex !== -1) {
indexForFocus = typeaheadCurrIndex;
} else if (typeaheadStoredIndex !== -1) {
indexForFocus = typeaheadStoredIndex;
}

if (this.refCollection[indexForFocus] !== null && this.refCollection[indexForFocus][0] !== null) {
this.refCollection[indexForFocus][0].focus();
} else {
this.clearRef.current.focus();
}

this.setState({
tabbedIntoFavoritesMenu: true,
typeaheadCurrIndex: -1
});
};

moveFocusToLastMenuItem = () => {
const refCollectionLen = this.refCollection.length;
if (
refCollectionLen > 0 &&
this.refCollection[refCollectionLen - 1] !== null &&
this.refCollection[refCollectionLen - 1][0] !== null
) {
this.refCollection[refCollectionLen - 1][0].focus();
}
};

handleTypeaheadKeys = (position: string, shiftKey: boolean = false) => {
const { isOpen, onFavorite } = this.props;
const { typeaheadCurrIndex, tabbedIntoFavoritesMenu, typeaheadStoredIndex } = this.state;
const { typeaheadCurrIndex, tabbedIntoFavoritesMenu } = this.state;
const typeaheadActiveChild = this.getTypeaheadActiveChild(typeaheadCurrIndex);

if (isOpen) {
Expand All @@ -633,30 +674,110 @@ export class Select extends React.Component<SelectProps & OUIAProps, SelectState
}
} else if (position === 'tab') {
if (onFavorite) {
// if the input has focus, tab to the first item or the last item that was previously focused.
if (this.inputRef.current === document.activeElement) {
let indexForFocus = 0;
if (typeaheadCurrIndex !== -1) {
indexForFocus = typeaheadCurrIndex;
} else if (typeaheadStoredIndex !== -1) {
indexForFocus = typeaheadStoredIndex;
}

if (this.refCollection[indexForFocus] !== null && this.refCollection[indexForFocus][0] !== null) {
this.refCollection[indexForFocus][0].focus();
// If shift is also clicked and there is a footer, tab to the last item in tabbable footer
if (this.props.footer && shiftKey) {
const tabbableItems = findTabbableElements(this.footerRef, SelectFooterTabbableItems);
if (tabbableItems.length > 0) {
if (tabbableItems[tabbableItems.length - 1]) {
tabbableItems[tabbableItems.length - 1].focus();
}
}
} else {
this.clearRef.current.focus();
this.switchFocusToFavoriteMenu();
}

this.setState({
tabbedIntoFavoritesMenu: true,
typeaheadCurrIndex: -1
});
} else {
this.inputRef.current.focus();
this.setState({ tabbedIntoFavoritesMenu: false });
// focus is on menu or footer
if (this.props.footer) {
let tabbedIntoMenu = false;
const tabbableItems = findTabbableElements(this.footerRef, SelectFooterTabbableItems);
if (tabbableItems.length > 0) {
// if current element is not in footer, tab to first tabbable element in footer,
// if shift was clicked, tab to input since focus is on menu
const currentElementIndex = tabbableItems.findIndex((item: any) => item === document.activeElement);
if (currentElementIndex === -1) {
if (shiftKey) {
// currently in menu, shift back to input
this.inputRef.current.focus();
} else {
// currently in menu, tab to first tabbable item in footer
tabbableItems[0].focus();
}
} else {
// already in footer
if (shiftKey) {
// shift to previous item
if (currentElementIndex === 0) {
// on first footer item, shift back to menu
this.switchFocusToFavoriteMenu();
tabbedIntoMenu = true;
} else {
// shift to previous footer item
tabbableItems[currentElementIndex - 1].focus();
}
} else {
// tab to next tabbable item in footer or to input.
if (tabbableItems[currentElementIndex + 1]) {
tabbableItems[currentElementIndex + 1].focus();
} else {
this.inputRef.current.focus();
}
}
}
} else {
// no tabbable items in footer, tab to input
this.inputRef.current.focus();
tabbedIntoMenu = false;
}
this.setState({ tabbedIntoFavoritesMenu: tabbedIntoMenu });
} else {
this.inputRef.current.focus();
this.setState({ tabbedIntoFavoritesMenu: false });
}
}
} else {
this.onToggle(false);
// Close if there is no footer
if (!this.props.footer) {
this.onToggle(false);
} else {
// has footer
const tabbableItems = findTabbableElements(this.footerRef, SelectFooterTabbableItems);
const currentElementIndex = tabbableItems.findIndex((item: any) => item === document.activeElement);
if (this.inputRef.current === document.activeElement) {
if (shiftKey) {
// close toggle if shift key and tab on input
this.onToggle(false);
} else {
// tab to first tabbable item in footer
if (tabbableItems[0]) {
tabbableItems[0].focus();
} else {
this.onToggle(false);
}
}
} else {
// focus is in footer
if (shiftKey) {
if (currentElementIndex === 0) {
// shift tab back to input
this.inputRef.current.focus();
} else {
// shift to previous footer item
tabbableItems[currentElementIndex - 1].focus();
}
} else {
// tab to next footer item or close tab if last item
if (tabbableItems[currentElementIndex + 1]) {
tabbableItems[currentElementIndex + 1].focus();
} else {
// no next item, close toggle
this.onToggle(false);
this.inputRef.current.focus();
}
}
}
}
}
} else if (!tabbedIntoFavoritesMenu) {
if (this.refCollection[0][0] === null) {
Expand Down Expand Up @@ -856,7 +977,7 @@ export class Select extends React.Component<SelectProps & OUIAProps, SelectState
if (renderableItems.find(item => (item as any)?.key === 'loading') === undefined) {
if (loadingVariant === 'spinner') {
renderableItems.push(
<SelectOption isLoading key="loading" value="loading" isGrouped>
<SelectOption isLoading key="loading" value="loading">
<Spinner size="lg" />
</SelectOption>
);
Expand All @@ -866,7 +987,6 @@ export class Select extends React.Component<SelectProps & OUIAProps, SelectState
isLoad
key="loading"
value={loadingVariant.text}
isGrouped
setViewMoreNextIndex={this.setVieMoreNextIndex}
onClick={loadingVariant?.onClick}
/>
Expand Down Expand Up @@ -940,6 +1060,20 @@ export class Select extends React.Component<SelectProps & OUIAProps, SelectState
} else if (event.key === KeyTypes.ArrowRight) {
this.handleMenuKeys(0, 0, 'right');
event.preventDefault();
} else if (event.key === KeyTypes.Tab && variant !== SelectVariant.checkbox && this.props.footer) {
// tab to footer or close menu if shift key
if (event.shiftKey) {
this.onToggle(false);
} else {
const tabbableItems = findTabbableElements(this.footerRef, SelectFooterTabbableItems);
if (tabbableItems.length > 0) {
tabbableItems[0].focus();
event.stopPropagation();
event.preventDefault();
} else {
this.onToggle(false);
}
}
} else if (event.key === KeyTypes.Tab && variant === SelectVariant.checkbox) {
// More modal-like experience for checkboxes
// Let SelectOption handle this
Expand Down Expand Up @@ -1091,6 +1225,7 @@ export class Select extends React.Component<SelectProps & OUIAProps, SelectState
aria-labelledby={`${ariaLabelledBy || ''} ${selectToggleId}`}
aria-label={toggleAriaLabel}
handleTypeaheadKeys={this.handleTypeaheadKeys}
moveFocusToLastMenuItem={this.moveFocusToLastMenuItem}
isDisabled={isDisabled}
hasClearButton={hasOnClear}
hasFooter={footer !== undefined}
Expand Down
3 changes: 2 additions & 1 deletion packages/react-core/src/components/Select/SelectMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ class SelectMenuWithRef extends React.Component<SelectMenuProps> {
isChecked: this.checkForValue(option.props.value, checked),
sendRef,
keyHandler,
index: index++
index: index++,
isLastOptionBeforeFooter
})
)}
</fieldset>
Expand Down
6 changes: 5 additions & 1 deletion packages/react-core/src/components/Select/SelectOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ export class SelectOption extends React.Component<SelectOptionProps> {
}
event.stopPropagation();
} else {
keyHandler(index, innerIndex, 'tab');
if (event.shiftKey) {
keyHandler(index, innerIndex, 'up');
} else {
keyHandler(index, innerIndex, 'tab');
}
}
}
event.preventDefault();
Expand Down
55 changes: 38 additions & 17 deletions packages/react-core/src/components/Select/SelectToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import styles from '@patternfly/react-styles/css/components/Select/select';
import buttonStyles from '@patternfly/react-styles/css/components/Button/button';
import { css } from '@patternfly/react-styles';
import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon';
import { KeyTypes, SelectVariant } from './selectConstants';
import { KeyTypes, SelectVariant, SelectFooterTabbableItems } from './selectConstants';
import { PickOptional } from '../../helpers/typeUtils';
import { findTabbableElements } from '../../helpers/util';

export interface SelectToggleProps extends React.HTMLProps<HTMLElement> {
/** HTML ID of dropdown toggle */
Expand All @@ -22,7 +23,9 @@ export interface SelectToggleProps extends React.HTMLProps<HTMLElement> {
/** Callback for toggle close */
onClose?: () => void;
/** @hide Internal callback for toggle keyboard navigation */
handleTypeaheadKeys?: (position: string) => void;
handleTypeaheadKeys?: (position: string, shiftKey?: boolean) => void;
/** @hide Internal callback to move focus to last menu item */
moveFocusToLastMenuItem?: () => void;
/** Element which wraps toggle */
parentRef: React.RefObject<HTMLDivElement>;
/** The menu element */
Expand Down Expand Up @@ -106,16 +109,18 @@ export class SelectToggle extends React.Component<SelectToggleProps> {
}
};

findTabbableFooterElements = () => {
const tabbable = this.props.footerRef.current.querySelectorAll('input, button, select, textarea, a[href]');
const list = Array.prototype.filter.call(tabbable, function(item) {
return item.tabIndex >= '0';
});
return list;
};

handleGlobalKeys = (event: KeyboardEvent) => {
const { parentRef, menuRef, hasFooter, isOpen, variant, onToggle, onClose } = this.props;
const {
parentRef,
menuRef,
hasFooter,
footerRef,
isOpen,
variant,
onToggle,
onClose,
moveFocusToLastMenuItem
} = this.props;
const escFromToggle = parentRef && parentRef.current && parentRef.current.contains(event.target as Node);
const escFromWithinMenu =
menuRef && menuRef.current && menuRef.current.contains && menuRef.current.contains(event.target as Node);
Expand All @@ -124,13 +129,13 @@ export class SelectToggle extends React.Component<SelectToggleProps> {
event.key === KeyTypes.Tab &&
(variant === SelectVariant.typeahead || variant === SelectVariant.typeaheadMulti)
) {
this.props.handleTypeaheadKeys('tab');
this.props.handleTypeaheadKeys('tab', event.shiftKey);
event.preventDefault();
return;
}

if (isOpen && event.key === KeyTypes.Tab && hasFooter) {
const tabbableItems = this.findTabbableFooterElements();
const tabbableItems = findTabbableElements(footerRef, SelectFooterTabbableItems);

// If no tabbable item in footer close select
if (tabbableItems.length <= 0) {
Expand All @@ -139,14 +144,29 @@ export class SelectToggle extends React.Component<SelectToggleProps> {
this.toggle.current.focus();
return;
} else {
// if current element is not in footer, tab to first tabbable element in footer
const currentElementIndex = tabbableItems.findIndex(item => item === document.activeElement);
// if current element is not in footer, tab to first tabbable element in footer, or close if shift clicked
const currentElementIndex = tabbableItems.findIndex((item: any) => item === document.activeElement);
if (currentElementIndex === -1) {
tabbableItems[0].focus();
return;
if (event.shiftKey) {
if (variant !== 'checkbox') {
// only close non checkbox variation on shift clicked
onToggle(false);
onClose();
this.toggle.current.focus();
}
} else {
// tab to footer
tabbableItems[0].focus();
return;
}
}
// Current element is in footer.
if (event.shiftKey) {
// Move focus back to menuif current tab index is 0
if (currentElementIndex === 0) {
moveFocusToLastMenuItem();
event.preventDefault();
}
return;
}
// Tab to next element in footer or close if there are none
Expand Down Expand Up @@ -225,6 +245,7 @@ export class SelectToggle extends React.Component<SelectToggleProps> {
onClose,
onClickTypeaheadToggleButton,
handleTypeaheadKeys,
moveFocusToLastMenuItem,
parentRef,
menuRef,
id,
Expand Down
Loading