import * as React from 'react'; import styles from '@patternfly/react-styles/css/components/Select/select'; import badgeStyles from '@patternfly/react-styles/css/components/Badge/badge'; import formStyles from '@patternfly/react-styles/css/components/FormControl/form-control'; import buttonStyles from '@patternfly/react-styles/css/components/Button/button'; import { css } from '@patternfly/react-styles'; import TimesCircleIcon from '@patternfly/react-icons/dist/esm/icons/times-circle-icon'; import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon'; import ExclamationTriangleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon'; import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; import { SelectMenu } from './SelectMenu'; import { SelectOption, SelectOptionObject } from './SelectOption'; import { SelectGroup, SelectGroupProps } from './SelectGroup'; import { SelectToggle } from './SelectToggle'; import { SelectContext, SelectVariant, SelectPosition, SelectDirection, SelectFooterTabbableItems } from './selectConstants'; import { ChipGroup, ChipGroupProps } from '../../../components/Chip'; import { Chip } from '../../../components/Chip'; import { Spinner } from '../../../components/Spinner'; import { keyHandler, getNextIndex, getOUIAProps, OUIAProps, getDefaultOUIAId, PickOptional, GenerateId } from '../../../helpers'; import { KeyTypes } from '../../../helpers/constants'; import { Divider } from '../../../components/Divider'; import { Popper } from '../../../helpers/Popper/Popper'; import { createRenderableFavorites, extendItemsWithFavorite } from '../../../helpers/favorites'; import { ValidatedOptions } from '../../../helpers/constants'; import { findTabbableElements } from '../../../helpers/util'; import { TextInput } from '../../../components/TextInput'; // seed for the aria-labelledby ID let currentId = 0; export interface SelectViewMoreObject { /** View more text */ text: string; /** Callback for when the view more button is clicked */ onClick: (event: React.MouseEvent | React.ChangeEvent) => void; } export interface SelectProps extends Omit, 'onSelect' | 'ref' | 'checked' | 'selected'>, OUIAProps { /** Content rendered inside the Select. Must be React.ReactElement[] */ children?: React.ReactElement[]; /** Classes applied to the root of the Select */ className?: string; /** Indicates where menu will be aligned horizontally */ position?: SelectPosition | 'right' | 'left'; /** Flag specifying which direction the Select menu expands */ direction?: 'up' | 'down'; /** Flag to indicate if select is open */ isOpen?: boolean; /** Flag to indicate if select options are grouped */ isGrouped?: boolean; /** Display the toggle with no border or background */ isPlain?: boolean; /** Flag to indicate if select is disabled */ isDisabled?: boolean; /** Flag to indicate if the typeahead select allows new items */ isCreatable?: boolean; /** Flag to indicate if create option should be at top of typeahead */ isCreateOptionOnTop?: boolean; /** Flag indicating if placeholder styles should be applied */ hasPlaceholderStyle?: boolean; /** Flag indicating if the creatable option should set its value as a SelectOptionObject */ isCreateSelectOptionObject?: boolean; /** Value to indicate if the select is modified to show that validation state. * If set to success, select will be modified to indicate valid state. * If set to error, select will be modified to indicate error state. * If set to warning, select will be modified to indicate warning state. */ validated?: 'success' | 'warning' | 'error' | 'default'; /** Loading variant to display either the spinner or the view more text button */ loadingVariant?: 'spinner' | SelectViewMoreObject; /** Text displayed in typeahead select to prompt the user to create an item */ createText?: string; /** Title text of Select */ placeholderText?: string | React.ReactNode; /** Text to display in typeahead select when no results are found */ noResultsFoundText?: string; /** Array of selected items for multi select variants. */ selections?: string | SelectOptionObject | (string | SelectOptionObject)[]; /** Flag indicating if selection badge should be hidden for checkbox variant,default false */ isCheckboxSelectionBadgeHidden?: boolean; /** Id for select toggle element */ toggleId?: string; /** Ref for the select toggle element */ toggleRef?: React.Ref | React.Ref; /** Adds accessible text to Select */ 'aria-label'?: string; /** Id of label for the Select aria-labelledby */ 'aria-labelledby'?: string; /** Id of div for the select aria-labelledby */ 'aria-describedby'?: string; /** Flag indicating if the select is an invalid state */ 'aria-invalid'?: boolean; /** Label for input field of type ahead select variants */ typeAheadAriaLabel?: string; /** Id of div for input field of type ahead select variants */ typeAheadAriaDescribedby?: string; /** Label for clear selection button of type ahead select variants */ clearSelectionsAriaLabel?: string; /** Label for toggle of type ahead select variants */ toggleAriaLabel?: string; /** Label for remove chip button of multiple type ahead select variant */ removeSelectionAriaLabel?: string; /** ID list of favorited select items */ favorites?: string[]; /** Label for the favorites group */ favoritesLabel?: string; /** Enables favorites. Callback called when a select options's favorite button is clicked */ onFavorite?: (itemId: string, isFavorite: boolean) => void; /** Callback for selection behavior */ onSelect?: ( event: React.MouseEvent | React.ChangeEvent, value: string | SelectOptionObject, isPlaceholder?: boolean ) => void; /** Callback for toggle button behavior */ onToggle: (event: React.MouseEvent | React.ChangeEvent | React.KeyboardEvent | Event, isExpanded: boolean) => void; /** Callback for toggle blur */ onBlur?: (event?: any) => void; /** Callback for typeahead clear button */ onClear?: (event: React.MouseEvent) => void; /** Optional callback for custom filtering */ onFilter?: (e: React.ChangeEvent | null, value: string) => React.ReactElement[] | undefined; /** Optional callback for newly created options */ onCreateOption?: (newOptionValue: string) => void; /** Optional event handler called each time the value in the typeahead input changes. */ onTypeaheadInputChanged?: (value: string) => void; /** Variant of rendered Select */ variant?: 'single' | 'checkbox' | 'typeahead' | 'typeaheadmulti'; /** Width of the select container as a number of px or string percentage */ width?: string | number; /** Max height of the select container as a number of px or string percentage */ maxHeight?: string | number; /** Icon element to render inside the select toggle */ toggleIcon?: React.ReactElement; /** Custom icon for the dropdown replacing the CaretDownIcon */ toggleIndicator?: React.ReactElement; /** Custom content to render in the select menu. If this prop is defined, the variant prop will be ignored and the select will render with a single select toggle */ customContent?: React.ReactNode; /** Flag indicating if select should have an inline text input for filtering */ hasInlineFilter?: boolean; /** Placeholder text for inline filter */ inlineFilterPlaceholderText?: string; /** Custom text for select badge */ customBadgeText?: string | number; /** Prefix for the id of the input in the checkbox select variant*/ inputIdPrefix?: string; /** Value for the typeahead and inline filtering input autocomplete attribute. When targeting Chrome this property should be a random string. */ inputAutoComplete?: string; /** Optional props to pass to the chip group in the typeaheadmulti variant */ chipGroupProps?: Omit; /** Optional props to render custom chip group in the typeaheadmulti variant */ chipGroupComponent?: React.ReactNode; /** Flag for retaining keyboard-entered value in typeahead text field when focus leaves input away */ isInputValuePersisted?: boolean; /** Flag for retaining filter results on blur from keyboard-entered typeahead text */ isInputFilterPersisted?: boolean; /** Flag indicating the typeahead input value should reset upon selection */ shouldResetOnSelect?: boolean; /** Content rendered in the footer of the select menu */ footer?: React.ReactNode; /** The container to append the menu to. Defaults to 'inline'. * If your menu is being cut off you can append it to an element higher up the DOM tree. * Some examples: * menuAppendTo="parent" * menuAppendTo={() => document.body} * menuAppendTo={document.getElementById('target')} */ menuAppendTo?: HTMLElement | (() => HTMLElement) | 'inline' | 'parent'; /** Flag for indicating that the select menu should automatically flip vertically when * it reaches the boundary. This prop can only be used when the select component is not * appended inline, e.g. `menuAppendTo="parent"` */ isFlipEnabled?: boolean; /** z-index of the select menu when menuAppendTo is not inline. */ zIndex?: number; /** Value to overwrite the randomly generated data-ouia-component-id.*/ ouiaId?: number | string; /** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */ ouiaSafe?: boolean; } export interface SelectState { focusFirstOption: boolean; typeaheadInputValue: string | null; typeaheadFilteredChildren: React.ReactNode[]; favoritesGroup: React.ReactNode[]; typeaheadCurrIndex: number; creatableValue: string; tabbedIntoFavoritesMenu: boolean; typeaheadStoredIndex: number; ouiaStateId: string; viewMoreNextIndex: number; } class Select extends React.Component { static displayName = 'Select'; private parentRef = React.createRef(); private menuComponentRef = React.createRef(); private filterRef = React.createRef(); private clearRef = React.createRef(); private inputRef = React.createRef(); private refCollection: HTMLElement[][] = [[]]; private optionContainerRefCollection: HTMLElement[] = []; private footerRef = React.createRef(); static defaultProps: PickOptional = { children: [] as React.ReactElement[], className: '', position: SelectPosition.left, direction: SelectDirection.down, toggleId: null as string, isOpen: false, isGrouped: false, isPlain: false, isDisabled: false, hasPlaceholderStyle: false, isCreatable: false, isCreateOptionOnTop: false, validated: 'default', 'aria-label': '', 'aria-labelledby': '', 'aria-describedby': '', 'aria-invalid': false, typeAheadAriaLabel: '', typeAheadAriaDescribedby: '', clearSelectionsAriaLabel: 'Clear all', toggleAriaLabel: 'Options menu', removeSelectionAriaLabel: 'Remove', selections: [], createText: 'Create', placeholderText: '', noResultsFoundText: 'No results found', variant: SelectVariant.single, width: '', onClear: () => undefined as void, onCreateOption: () => undefined as void, toggleIcon: null as React.ReactElement, toggleIndicator: null as React.ReactElement, onFilter: null, onTypeaheadInputChanged: null, customContent: null, hasInlineFilter: false, inlineFilterPlaceholderText: null, customBadgeText: null, inputIdPrefix: '', inputAutoComplete: 'off', menuAppendTo: 'inline', favorites: [] as string[], favoritesLabel: 'Favorites', ouiaSafe: true, chipGroupComponent: null, isInputValuePersisted: false, isInputFilterPersisted: false, isCreateSelectOptionObject: false, shouldResetOnSelect: true, isFlipEnabled: true, zIndex: 9999 }; state: SelectState = { focusFirstOption: false, typeaheadInputValue: null, typeaheadFilteredChildren: React.Children.toArray(this.props.children), favoritesGroup: [] as React.ReactNode[], typeaheadCurrIndex: -1, typeaheadStoredIndex: -1, creatableValue: '', tabbedIntoFavoritesMenu: false, ouiaStateId: getDefaultOUIAId(Select.displayName, this.props.variant), viewMoreNextIndex: -1 }; getTypeaheadActiveChild = (typeaheadCurrIndex: number) => this.refCollection[typeaheadCurrIndex] ? this.refCollection[typeaheadCurrIndex][0] : null; componentDidUpdate = (prevProps: SelectProps, prevState: SelectState) => { if (this.props.hasInlineFilter) { this.refCollection[0][0] = this.filterRef.current; } // Move focus to top of the menu if state.focusFirstOption was updated to true and the menu does not have custom content if (!prevState.focusFirstOption && this.state.focusFirstOption && !this.props.customContent) { const firstRef = this.refCollection.find( (ref) => // If a select option is disabled then ref[0] will be undefined, so we want to return // the first ref that both a) is not null and b) is not disabled. ref !== null && ref[0] ); if (firstRef && firstRef[0]) { firstRef[0].focus(); } } else if ( // if viewMoreNextIndex is not -1, view more was clicked, set focus on first newly loaded item this.state.viewMoreNextIndex !== -1 && this.refCollection.length > this.state.viewMoreNextIndex && this.props.loadingVariant !== 'spinner' && this.refCollection[this.state.viewMoreNextIndex][0] && this.props.variant !== 'typeahead' && // do not hard focus newly added items for typeahead variants this.props.variant !== 'typeaheadmulti' ) { this.refCollection[this.state.viewMoreNextIndex][0].focus(); this.setState({ viewMoreNextIndex: -1 }); } const checkUpdatedChildren = (prevChildren: React.ReactElement[], currChildren: React.ReactElement[]) => Array.from(prevChildren).some((prevChild: React.ReactElement, index: number) => { const prevChildProps = prevChild.props; const currChild = currChildren[index]; const { props: currChildProps } = currChild; if (prevChildProps && currChildProps) { return ( prevChildProps.value !== currChildProps.value || prevChildProps.label !== currChildProps.label || prevChildProps.isDisabled !== currChildProps.isDisabled || prevChildProps.isPlaceholder !== currChildProps.isPlaceholder ); } else { return prevChild !== currChild; } }); const hasUpdatedChildren = prevProps.children.length !== this.props.children.length || checkUpdatedChildren(prevProps.children, this.props.children) || (this.props.isGrouped && Array.from(prevProps.children).some( (prevChild: React.ReactElement, index: number) => prevChild.type === SelectGroup && prevChild.props.children && this.props.children[index].props.children && (prevChild.props.children.length !== this.props.children[index].props.children.length || checkUpdatedChildren(prevChild.props.children, this.props.children[index].props.children)) )); if (hasUpdatedChildren) { this.updateTypeAheadFilteredChildren(prevState.typeaheadInputValue || '', null); } // for menus with favorites, // if the number of favorites or typeahead filtered children has changed, the generated // list of favorites needs to be updated if ( this.props.onFavorite && (this.props.favorites.length !== prevProps.favorites.length || this.state.typeaheadFilteredChildren !== prevState.typeaheadFilteredChildren) ) { const tempRenderableChildren = this.props.variant === 'typeahead' || this.props.variant === 'typeaheadmulti' ? this.state.typeaheadFilteredChildren : this.props.children; const renderableFavorites = createRenderableFavorites( tempRenderableChildren, this.props.isGrouped, this.props.favorites ); const favoritesGroup = renderableFavorites.length ? [ {renderableFavorites} , ] : []; this.setState({ favoritesGroup }); } }; onEnter = () => { this.setState({ focusFirstOption: true }); }; onToggle = (e: React.MouseEvent | React.ChangeEvent | React.KeyboardEvent | Event, isExpanded: boolean) => { const { isInputValuePersisted, onSelect, onToggle, hasInlineFilter } = this.props; if (!isExpanded && isInputValuePersisted && onSelect) { onSelect(undefined, this.inputRef.current ? this.inputRef.current.value : ''); } if (isExpanded && hasInlineFilter) { this.setState({ focusFirstOption: true }); } onToggle(e, isExpanded); }; onClose = () => { const { isInputFilterPersisted } = this.props; this.setState({ focusFirstOption: false, typeaheadInputValue: null, ...(!isInputFilterPersisted && { typeaheadFilteredChildren: React.Children.toArray(this.props.children) }), typeaheadCurrIndex: -1, tabbedIntoFavoritesMenu: false, viewMoreNextIndex: -1 }); }; onChange = (e: React.ChangeEvent) => { if (e.target.value.toString() !== '' && !this.props.isOpen) { this.onToggle(e, true); } if (this.props.onTypeaheadInputChanged) { this.props.onTypeaheadInputChanged(e.target.value.toString()); } this.setState({ typeaheadCurrIndex: -1, typeaheadInputValue: e.target.value, creatableValue: e.target.value }); this.updateTypeAheadFilteredChildren(e.target.value.toString(), e); this.refCollection = [[]]; }; updateTypeAheadFilteredChildren = (typeaheadInputValue: string, e: React.ChangeEvent | null) => { let typeaheadFilteredChildren: any; const { onFilter, isCreatable, isCreateOptionOnTop, onCreateOption, createText, noResultsFoundText, children, isGrouped, isCreateSelectOptionObject, loadingVariant } = this.props; if (onFilter) { /* The updateTypeAheadFilteredChildren callback is not only called on input changes but also when the children change. * In this case the e is null but we can get the typeaheadInputValue from the state. */ typeaheadFilteredChildren = onFilter(e, e ? e.target.value : typeaheadInputValue) || children; } else { let input: RegExp; try { input = new RegExp(typeaheadInputValue.toString(), 'i'); } catch (err) { input = new RegExp(typeaheadInputValue.toString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); } const childrenArray = React.Children.toArray(children) as React.ReactElement[]; if (isGrouped) { const childFilter = (child: React.ReactElement) => child.props.value && child.props.value.toString && this.getDisplay(child.props.value.toString(), 'text').search(input) === 0; typeaheadFilteredChildren = typeaheadInputValue.toString() !== '' ? React.Children.map(children, (group) => { if ( React.isValidElement>(group) && group.type === SelectGroup ) { const filteredGroupChildren = ( React.Children.toArray(group.props.children) as React.ReactElement[] ).filter(childFilter); if (filteredGroupChildren.length > 0) { return React.cloneElement(group, { titleId: group.props.label && group.props.label.replace(/\W/g, '-'), children: filteredGroupChildren as any }); } } else { return (React.Children.toArray(group) as React.ReactElement[]).filter(childFilter); } }) : childrenArray; } else { typeaheadFilteredChildren = typeaheadInputValue.toString() !== '' ? childrenArray.filter((child) => { const valueToCheck = child.props.value; // Dividers don't have value and should not be filtered if (!valueToCheck) { return true; } const isSelectOptionObject = typeof valueToCheck !== 'string' && (valueToCheck as SelectOptionObject).toString && (valueToCheck as SelectOptionObject).compareTo; // View more option should be returned as not a match if (loadingVariant !== 'spinner' && loadingVariant?.text === valueToCheck) { return true; } // spinner should be returned as not a match if (loadingVariant === 'spinner' && valueToCheck === 'loading') { return true; } if (isSelectOptionObject) { return (valueToCheck as SelectOptionObject).compareTo(typeaheadInputValue); } else { return this.getDisplay(child.props.value.toString(), 'text').search(input) === 0; } }) : childrenArray; } } if (!typeaheadFilteredChildren) { typeaheadFilteredChildren = []; } if (typeaheadFilteredChildren.length === 0) { !isCreatable && typeaheadFilteredChildren.push( ); } if (isCreatable && typeaheadInputValue !== '') { const newValue = typeaheadInputValue; if ( !typeaheadFilteredChildren.find( (i: React.ReactElement) => i.props.value && i.props.value.toString().toLowerCase() === newValue.toString().toLowerCase() ) ) { const newOptionValue = isCreateSelectOptionObject ? ({ toString: () => newValue, compareTo: (value) => this.toString().toLowerCase().includes(value.toString().toLowerCase()) } as SelectOptionObject) : newValue; const createSelectOption = ( onCreateOption && onCreateOption(newValue)} > {createText} "{newValue}" ); if (isCreateOptionOnTop) { typeaheadFilteredChildren.unshift(createSelectOption); } else { typeaheadFilteredChildren.push(createSelectOption); } } } this.setState({ typeaheadFilteredChildren }); }; onClick = (e: React.MouseEvent) => { if (!this.props.isOpen) { this.onToggle(e, true); } }; clearSelection = (_e: React.MouseEvent) => { this.setState({ typeaheadInputValue: null, typeaheadFilteredChildren: React.Children.toArray(this.props.children), typeaheadCurrIndex: -1 }); }; extendTypeaheadChildren(typeaheadCurrIndex: number, favoritesGroup?: React.ReactNode[]) { const { isGrouped, onFavorite, createText } = this.props; const typeaheadChildren = favoritesGroup ? favoritesGroup.concat(this.state.typeaheadFilteredChildren) : this.state.typeaheadFilteredChildren; const activeElement = this.optionContainerRefCollection[typeaheadCurrIndex]; let typeaheadActiveChild = this.getTypeaheadActiveChild(typeaheadCurrIndex); if (typeaheadActiveChild && typeaheadActiveChild.classList.contains('pf-m-description')) { typeaheadActiveChild = typeaheadActiveChild.firstElementChild as HTMLElement; } this.refCollection = [[]]; this.optionContainerRefCollection = []; if (isGrouped) { return React.Children.map(typeaheadChildren as React.ReactElement[], (group: React.ReactElement) => { if (group.type === Divider) { return group; } else if (group.type === SelectGroup && onFavorite) { return React.cloneElement(group, { titleId: group.props.label && group.props.label.replace(/\W/g, '-'), children: React.Children.map(group.props.children, (child: React.ReactElement) => child.type === Divider ? child : React.cloneElement(child as React.ReactElement, { isFocused: activeElement && (activeElement.id === (child as React.ReactElement).props.id || (this.props.isCreatable && typeaheadActiveChild.textContent === `${createText} "${(group as React.ReactElement).props.value}"`)) }) ) }); } else if (group.type === SelectGroup) { return React.cloneElement(group, { titleId: group.props.label && group.props.label.replace(/\W/g, '-'), children: React.Children.map(group.props.children, (child: React.ReactElement) => child.type === Divider ? child : React.cloneElement(child as React.ReactElement, { isFocused: typeaheadActiveChild && (typeaheadActiveChild.textContent === (child as React.ReactElement).props.value.toString() || (this.props.isCreatable && typeaheadActiveChild.textContent === `${createText} "${(child as React.ReactElement).props.value}"`)) }) ) }); } else { // group has been filtered down to SelectOption return React.cloneElement(group as React.ReactElement, { isFocused: typeaheadActiveChild && (typeaheadActiveChild.textContent === group.props.value.toString() || (this.props.isCreatable && typeaheadActiveChild.textContent === `${createText} "${group.props.value}"`)) }); } }); } return typeaheadChildren.map((child: React.ReactNode, index) => { const childElement = child as any; return childElement.type.displayName === 'Divider' ? child : React.cloneElement(child as React.ReactElement, { isFocused: typeaheadActiveChild ? typeaheadActiveChild.textContent === (child as React.ReactElement).props.value.toString() || (this.props.isCreatable && typeaheadActiveChild.textContent === `${createText} "${(child as React.ReactElement).props.value}"`) : index === typeaheadCurrIndex // fallback for view more + typeahead use cases, when the new expanded list is loaded and refCollection hasn't be updated yet }); }); } sendRef = ( optionRef: React.ReactNode, favoriteRef: React.ReactNode, optionContainerRef: React.ReactNode, index: number ) => { this.refCollection[index] = [optionRef as unknown as HTMLElement, favoriteRef as unknown as HTMLElement]; this.optionContainerRefCollection[index] = optionContainerRef as unknown as HTMLElement; }; handleMenuKeys = (index: number, innerIndex: number, position: string) => { keyHandler(index, innerIndex, position, this.refCollection, this.refCollection); if (this.props.variant === SelectVariant.typeahead || this.props.variant === SelectVariant.typeaheadMulti) { if (position !== 'tab') { this.handleTypeaheadKeys(position); } } }; moveFocus = (nextIndex: number, updateCurrentIndex: boolean = true) => { const { isCreatable, createText } = this.props; const hasDescriptionElm = Boolean( this.refCollection[nextIndex][0] && this.refCollection[nextIndex][0].classList.contains('pf-m-description') ); const isLoad = Boolean( this.refCollection[nextIndex][0] && this.refCollection[nextIndex][0].classList.contains('pf-m-load') ); const optionTextElm = hasDescriptionElm ? (this.refCollection[nextIndex][0].firstElementChild as HTMLElement) : this.refCollection[nextIndex][0]; let typeaheadInputValue = ''; if (isCreatable && optionTextElm.textContent.includes(createText)) { typeaheadInputValue = this.state.creatableValue; } else if (optionTextElm && !isLoad) { // !isLoad prevents the view more button text from appearing the typeahead input typeaheadInputValue = optionTextElm.textContent; } this.setState((prevState) => ({ typeaheadCurrIndex: updateCurrentIndex ? nextIndex : prevState.typeaheadCurrIndex, typeaheadStoredIndex: nextIndex, typeaheadInputValue })); }; 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, isCreatable } = this.props; const { typeaheadCurrIndex, tabbedIntoFavoritesMenu } = this.state; const typeaheadActiveChild = this.getTypeaheadActiveChild(typeaheadCurrIndex); if (isOpen) { if (position === 'enter') { if ( (typeaheadCurrIndex !== -1 || (isCreatable && this.refCollection.length === 1)) && // do not allow selection without moving to an initial option unless it is a single create option (typeaheadActiveChild || (this.refCollection[0] && this.refCollection[0][0])) ) { if (typeaheadActiveChild) { if (!typeaheadActiveChild.classList.contains('pf-m-load')) { const hasDescriptionElm = typeaheadActiveChild.childElementCount > 1; const typeaheadActiveChildText = hasDescriptionElm ? (typeaheadActiveChild.firstChild as HTMLElement).textContent : typeaheadActiveChild.textContent; this.setState({ typeaheadInputValue: typeaheadActiveChildText }); } } else if (this.refCollection[0] && this.refCollection[0][0]) { this.setState({ typeaheadInputValue: this.refCollection[0][0].textContent }); } if (typeaheadActiveChild) { typeaheadActiveChild.click(); } else { this.refCollection[0][0].click(); } } } 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) { // 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.switchFocusToFavoriteMenu(); } } else { // 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 { // Close if there is no footer if (!this.props.footer) { this.onToggle(null, false); this.onClose(); } 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(null, false); this.onClose(); } else { // tab to first tabbable item in footer if (tabbableItems[0]) { tabbableItems[0].focus(); } else { this.onToggle(null, false); this.onClose(); } } } 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(null, false); this.inputRef.current.focus(); this.onClose(); } } } } } } else if (!tabbedIntoFavoritesMenu) { if (this.refCollection[0][0] === null) { return; } let nextIndex; if (typeaheadCurrIndex === -1 && position === 'down') { nextIndex = 0; } else if (typeaheadCurrIndex === -1 && position === 'up') { nextIndex = this.refCollection.length - 1; } else if (position !== 'left' && position !== 'right') { nextIndex = getNextIndex(typeaheadCurrIndex, position, this.refCollection); } else { nextIndex = typeaheadCurrIndex; } if (this.refCollection[nextIndex] === null) { return; } this.moveFocus(nextIndex); } else { const nextIndex = this.refCollection.findIndex( (ref) => ref !== undefined && (ref[0] === document.activeElement || ref[1] === document.activeElement) ); this.moveFocus(nextIndex); } } }; onClickTypeaheadToggleButton = () => { if (this.inputRef && this.inputRef.current) { this.inputRef.current.focus(); } }; getDisplay = (value: string | SelectOptionObject, type: 'node' | 'text' = 'node') => { if (!value) { return; } const item = this.props.isGrouped ? (React.Children.toArray(this.props.children) as React.ReactElement[]) .reduce((acc, curr) => [...acc, ...React.Children.toArray(curr.props.children)], []) .find((child) => child.props.value.toString() === value.toString()) : React.Children.toArray(this.props.children).find( (child) => (child as React.ReactElement).props.value && (child as React.ReactElement).props.value.toString() === value.toString() ); if (item) { if (item && item.props.children) { if (type === 'node') { return item.props.children; } return this.findText(item); } return item.props.value.toString(); } return value.toString(); }; findText = (item: React.ReactNode) => { if (typeof item === 'string') { return item; } else if (!React.isValidElement(item)) { return ''; } else { const multi: string[] = []; React.Children.toArray(item.props.children).forEach((child) => multi.push(this.findText(child))); return multi.join(''); } }; generateSelectedBadge = () => { const { customBadgeText, selections } = this.props; if (customBadgeText !== null) { return customBadgeText; } if (Array.isArray(selections) && selections.length > 0) { return selections.length; } return null; }; setVieMoreNextIndex = () => { this.setState({ viewMoreNextIndex: this.refCollection.length - 1 }); }; isLastOptionBeforeFooter = (index: any) => this.props.footer && index === this.refCollection.length - 1 ? true : false; render() { const { children, chipGroupProps, chipGroupComponent, className, customContent, variant, direction, onSelect, onClear, onBlur, toggleId, toggleRef, isOpen, isGrouped, isPlain, isDisabled, hasPlaceholderStyle, validated, selections: selectionsProp, typeAheadAriaLabel, typeAheadAriaDescribedby, clearSelectionsAriaLabel, toggleAriaLabel, removeSelectionAriaLabel, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedby, 'aria-invalid': ariaInvalid, placeholderText, width, maxHeight, toggleIcon, toggleIndicator, ouiaId, ouiaSafe, hasInlineFilter, isCheckboxSelectionBadgeHidden, inlineFilterPlaceholderText, /* eslint-disable @typescript-eslint/no-unused-vars */ onFilter, /* eslint-disable @typescript-eslint/no-unused-vars */ onTypeaheadInputChanged, onCreateOption, isCreatable, onToggle, createText, noResultsFoundText, customBadgeText, inputIdPrefix, inputAutoComplete, /* eslint-disable @typescript-eslint/no-unused-vars */ isInputValuePersisted, isInputFilterPersisted, /* eslint-enable @typescript-eslint/no-unused-vars */ menuAppendTo, favorites, onFavorite, /* eslint-disable @typescript-eslint/no-unused-vars */ favoritesLabel, footer, loadingVariant, isCreateSelectOptionObject, isCreateOptionOnTop, shouldResetOnSelect, isFlipEnabled, zIndex, ...props } = this.props; const { focusFirstOption: openedOnEnter, typeaheadCurrIndex, typeaheadInputValue, typeaheadFilteredChildren, favoritesGroup } = this.state; const selectToggleId = toggleId || `pf-select-toggle-id-${currentId++}`; const selections = Array.isArray(selectionsProp) ? selectionsProp : [selectionsProp]; // Find out if the selected option is a placeholder const selectedOption = React.Children.toArray(children).find( (option: any) => option.props.value === selections[0] ) as any; const isSelectedPlaceholder = selectedOption && selectedOption.props.isPlaceholder; const hasAnySelections = Boolean(selections[0] && selections[0] !== ''); const typeaheadActiveChild = this.getTypeaheadActiveChild(typeaheadCurrIndex); let childPlaceholderText = null as string; // If onFavorites is set, add isFavorite prop to children and add a Favorites group to the SelectMenu let renderableItems: React.ReactNode[] = []; if (onFavorite) { // if variant is type-ahead call the extendTypeaheadChildren before adding favorites let tempExtendedChildren: (React.ReactElement | React.ReactNode | {})[] = children; if (variant === 'typeahead' || variant === 'typeaheadmulti') { tempExtendedChildren = this.extendTypeaheadChildren(typeaheadCurrIndex, favoritesGroup); } else if (onFavorite) { tempExtendedChildren = favoritesGroup.concat(children); } // mark items that are favorited with isFavorite renderableItems = extendItemsWithFavorite(tempExtendedChildren, isGrouped, favorites); } else { renderableItems = children; } if (!customContent) { if (!hasAnySelections && !placeholderText) { const childPlaceholder = React.Children.toArray(children).filter( (child: React.ReactNode) => (child as React.ReactElement).props.isPlaceholder === true ); childPlaceholderText = (childPlaceholder[0] && this.getDisplay((childPlaceholder[0] as React.ReactElement).props.value, 'node')) || (children[0] && this.getDisplay(children[0].props.value, 'node')); } } if (isOpen) { if (renderableItems.find((item) => (item as any)?.key === 'loading') === undefined) { if (loadingVariant === 'spinner') { renderableItems.push( ); } else if (loadingVariant?.text) { renderableItems.push( ); } } } const hasOnClear = onClear !== Select.defaultProps.onClear; const clearBtn = ( ); let selectedChips = null as any; if (variant === SelectVariant.typeaheadMulti) { selectedChips = chipGroupComponent ? ( chipGroupComponent ) : ( {selections && (selections as string[]).map((item) => ( onSelect(e, item)} closeBtnAriaLabel={removeSelectionAriaLabel} > {this.getDisplay(item, 'node')} ))} ); } if (hasInlineFilter) { const filterBox = (
{ if (event.key === KeyTypes.ArrowUp) { this.handleMenuKeys(0, 0, 'up'); event.preventDefault(); } else if (event.key === KeyTypes.ArrowDown) { this.handleMenuKeys(0, 0, 'down'); event.preventDefault(); } else if (event.key === KeyTypes.ArrowLeft) { this.handleMenuKeys(0, 0, 'left'); event.preventDefault(); } 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(event, false); } else { const tabbableItems = findTabbableElements(this.footerRef, SelectFooterTabbableItems); if (tabbableItems.length > 0) { tabbableItems[0].focus(); event.stopPropagation(); event.preventDefault(); } else { this.onToggle(event, false); } } } else if (event.key === KeyTypes.Tab && variant === SelectVariant.checkbox) { // More modal-like experience for checkboxes // Let SelectOption handle this if (event.shiftKey) { this.handleMenuKeys(0, 0, 'up'); } else { this.handleMenuKeys(0, 0, 'down'); } event.stopPropagation(); event.preventDefault(); } }} ref={this.filterRef} autoComplete={inputAutoComplete} />
); renderableItems = [filterBox, ...(typeaheadFilteredChildren as React.ReactElement[])].map((option, index) => React.cloneElement(option, { key: index }) ); } let variantProps: any; let variantChildren: any; if (customContent) { variantProps = { selected: selections, openedOnEnter, isCustomContent: true }; variantChildren = customContent; } else { switch (variant) { case 'single': variantProps = { selected: selections[0], hasInlineFilter, openedOnEnter }; variantChildren = renderableItems; break; case 'checkbox': variantProps = { checked: selections, isGrouped, hasInlineFilter, openedOnEnter }; variantChildren = renderableItems; break; case 'typeahead': variantProps = { selected: selections[0], openedOnEnter }; variantChildren = onFavorite ? renderableItems : this.extendTypeaheadChildren(typeaheadCurrIndex); if (variantChildren.length === 0) { variantChildren.push(); } break; case 'typeaheadmulti': variantProps = { selected: selections, openedOnEnter }; variantChildren = onFavorite ? renderableItems : this.extendTypeaheadChildren(typeaheadCurrIndex); if (variantChildren.length === 0) { variantChildren.push(); } break; } } const isStatic = isFlipEnabled && menuAppendTo !== 'inline'; const innerMenu = ( {variantChildren} ); const menuContainer = footer ?
{innerMenu}
: innerMenu; const popperRef = React.createRef(); const popperContainer = (
{isOpen && menuContainer}
); const mainContainer = (
{customContent && (
{toggleIcon && {toggleIcon}} {placeholderText}
)} {variant === SelectVariant.single && !customContent && (
{toggleIcon && {toggleIcon}} {this.getDisplay(selections[0] as string, 'node') || placeholderText || childPlaceholderText}
{hasOnClear && hasAnySelections && clearBtn}
)} {variant === SelectVariant.checkbox && !customContent && (
{toggleIcon && {toggleIcon}} {placeholderText} {!isCheckboxSelectionBadgeHidden && hasAnySelections && (
{this.generateSelectedBadge()}
)}
{hasOnClear && hasAnySelections && clearBtn}
)} {variant === SelectVariant.typeahead && !customContent && (
{toggleIcon && {toggleIcon}} this.onChange(event as React.ChangeEvent)} onClick={this.onClick} autoComplete={inputAutoComplete} isDisabled={isDisabled} ref={this.inputRef} />
{hasOnClear && (selections[0] || typeaheadInputValue) && clearBtn}
)} {variant === SelectVariant.typeaheadMulti && !customContent && (
{toggleIcon && {toggleIcon}} {selections && Array.isArray(selections) && selections.length > 0 && selectedChips} this.onChange(event as React.ChangeEvent)} onClick={this.onClick} autoComplete={inputAutoComplete} isDisabled={isDisabled} ref={this.inputRef} />
{hasOnClear && ((selections && selections.length > 0) || typeaheadInputValue) && clearBtn}
)} {validated === ValidatedOptions.success && ( )} {validated === ValidatedOptions.error && ( )} {validated === ValidatedOptions.warning && ( )}
{isOpen && menuAppendTo === 'inline' && menuContainer}
); const getParentElement = () => { if (this.parentRef && this.parentRef.current) { return this.parentRef.current.parentElement; } return null; }; return ( {(randomId) => ( {menuAppendTo === 'inline' ? ( mainContainer ) : ( )} )} ); } } export { Select };