UNPKG

box-ui-elements-mlh

Version:
597 lines (515 loc) 21.6 kB
// @flow import * as React from 'react'; import classNames from 'classnames'; import uniqueId from 'lodash/uniqueId'; import findIndex from 'lodash/findIndex'; import { FormattedMessage, injectIntl } from 'react-intl'; import { scrollIntoView } from '../../utils/dom'; import IconCheck from '../../icons/general/IconCheck'; import SelectButton from '../select-button'; import DatalistItem from '../datalist-item'; import PopperComponent from '../popper'; import SelectFieldDropdown from './SelectFieldDropdown'; import type { SelectOptionValueProp, SelectOptionProp } from './props'; import { PLACEMENT_BOTTOM_END, PLACEMENT_BOTTOM_START } from '../popper/constants'; import SearchForm from '../search-form/SearchForm'; import CLEAR from './constants'; import { ARROW_DOWN, ARROW_UP, ENTER, ESCAPE, SPACE, TAB } from '../../common/keyboard-events'; import messages from './messages'; import './SelectField.scss'; function stopDefaultEvent(event) { event.preventDefault(); event.stopPropagation(); } function toggleOption(options, value) { const index = options.indexOf(value); if (index === -1) { options.push(value); } else { options.splice(index, 1); } } type Props = { /** List of classnames of the relatedTarget that should prevent handleBlur from firing */ blurExceptionClassNames?: Array<string>, /** Props to add to the button element */ buttonProps?: Object, /** CSS class for the select container */ className?: string, /** The fallback option value when other options are all unselected. Default option cannot be selected at the same time as other options. `selectedValues` must not be empty when this option is used. */ defaultValue?: SelectOptionValueProp, /** An optional error to show within a tooltip. */ error?: React.Node, /* Intl object */ intl: Object, /** The select button is disabled if true */ isDisabled?: boolean, /** Whether to allow the dropdown to overflow its boundaries and remain attached to its reference */ isEscapedWithReference?: boolean, /** Whether to align the dropdown to the right */ isRightAligned: boolean, /** The select field overlay (dropdown) will have a scrollbar and max-height if true * */ isScrollable?: boolean, multiple: boolean, /** Function will be called with an array of all selected options after user selects a new option */ onChange: Function, /** Function will be called with the user selected option (even on deselect or when the option was previously selected) */ onOptionSelect?: Function, /** Function that allows custom rendering of select field options. When not provided the component will only render the option's displayText by default */ optionRenderer: (option: SelectOptionProp) => React.Node, /** List of options (displayText, value) */ options: Array<SelectOptionProp>, /** The select button text shown when no options are selected. */ placeholder?: string | React.Element<any>, /** The currently selected option values (can be empty) */ selectedValues: Array<SelectOptionValueProp>, /** Array of ordered indices indicating where to insert separators (ex. index 2 means insert a separator after option 2) */ separatorIndices: Array<number>, /** Boolean to determine whether or not to show the clear option */ shouldShowClearOption?: boolean, /** Boolean to determine whether or not to show the search field */ shouldShowSearchInput?: boolean, /** The select button text (by default, component will use comma separated list of all selected option displayText) */ title?: string | React.Element<any>, }; type State = { activeItemID: ?string, activeItemIndex: number, isOpen: boolean, searchText: string, shouldScrollIntoView: boolean, }; function defaultOptionRenderer({ displayText }: SelectOptionProp) { return ( <span className="bdl-SelectField-optionText" title={displayText}> {displayText} </span> ); } class BaseSelectField extends React.Component<Props, State> { static defaultProps = { buttonProps: {}, isDisabled: false, isRightAligned: false, isScrollable: false, multiple: false, optionRenderer: defaultOptionRenderer, options: [], selectedValues: [], separatorIndices: [], shouldShowClearOption: false, shouldShowSearchInput: false, }; constructor(props: Props) { super(props); this.selectFieldID = uniqueId('selectfield'); this.selectFieldContainerRef = React.createRef(); this.state = { activeItemID: null, activeItemIndex: -1, isOpen: false, searchText: '', shouldScrollIntoView: false, }; } componentWillUnmount() { if (this.state.isOpen) { // Clean-up global click handlers document.removeEventListener('click', this.handleDocumentClick); } } updateSearchText = (text: string) => { const { options } = this.props; const optionIndex = findIndex(options, element => element.displayText.toLowerCase().includes(text.toLowerCase()), ); if (optionIndex >= 0) { this.setActiveItem(optionIndex); } this.setState({ searchText: text, }); }; handleDocumentClick = (event: MouseEvent) => { const container = this.selectFieldContainerRef.current; const isInside = (container && event.target instanceof Node && container.contains(event.target)) || container === event.target; if (!isInside) { this.closeDropdown(); } }; setActiveItem = (index: number, shouldScrollIntoView?: boolean = true) => { this.setState({ activeItemIndex: index, shouldScrollIntoView }); if (index === -1) { this.setActiveItemID(null); } }; setActiveItemID = (id: ?string) => { const { shouldScrollIntoView } = this.state; const itemEl = id ? document.getElementById(id) : null; this.setState({ activeItemID: id, shouldScrollIntoView: false }, () => { if (shouldScrollIntoView) { scrollIntoView(itemEl, { block: 'nearest' }); } }); }; selectFieldID: string; selectFieldContainerRef: { current: null | HTMLDivElement }; searchInputRef: HTMLInputElement; handleChange = (selectedItems: Array<SelectOptionProp>) => { const { onChange } = this.props; if (onChange) { onChange(selectedItems); } }; handleOptionSelect = (selectedItem: SelectOptionProp) => { const { onOptionSelect } = this.props; if (onOptionSelect) { onOptionSelect(selectedItem); } }; handleButtonClick = () => { if (this.state.isOpen) { this.closeDropdown(); } else { this.openDropdown(); } }; handleClearClick = () => { this.handleChange([]); }; handleButtonKeyDown = (event: SyntheticKeyboardEvent<>) => { const { activeItemIndex } = this.state; // If user is interacting with the select dropdown, don't close on space/enter (i.e. prevent click event) if ((event.key === SPACE || event.key === ENTER) && activeItemIndex !== -1) { event.preventDefault(); } }; handleBlur = (event?: SyntheticFocusEvent<>) => { const { isOpen } = this.state; const { blurExceptionClassNames = [] } = this.props; const exceptionClasses = ['search-input', 'select-button', ...blurExceptionClassNames]; if ( isOpen && event && event.relatedTarget && exceptionClasses.every( className => event && !(event.relatedTarget: window.HTMLInputElement).classList.contains(className), ) ) { this.closeDropdown(); } }; handleKeyDown = (event: SyntheticKeyboardEvent<HTMLDivElement>) => { const { key } = event; const { options, shouldShowClearOption, shouldShowSearchInput } = this.props; const { activeItemIndex, isOpen } = this.state; const itemCount = options.length; switch (key) { case ARROW_DOWN: stopDefaultEvent(event); if (isOpen) { const nextIndex = activeItemIndex === itemCount - 1 ? -1 : activeItemIndex + 1; this.setActiveItem(nextIndex); } else { this.openDropdown(); } break; case ARROW_UP: stopDefaultEvent(event); if (isOpen) { const prevIndex = activeItemIndex === -1 ? itemCount - 1 : activeItemIndex - 1; this.setActiveItem(prevIndex); } else { this.openDropdown(); } break; case ENTER: case SPACE: if (shouldShowSearchInput) { // Allow space key presses in the search string when search field is active if (key === SPACE) { break; } // Enter presses should be ignored when no item is active if (key === ENTER && activeItemIndex === -1) { stopDefaultEvent(event); break; } } if (activeItemIndex !== -1 && isOpen) { stopDefaultEvent(event); const isClearOption = shouldShowClearOption && activeItemIndex === 0; if (isClearOption) { this.handleClearClick(); } else { this.selectOption(activeItemIndex); } // Enter always closes dropdown (even for multiselect) if (key === ENTER) { this.closeDropdown(); } } break; case ESCAPE: if (isOpen) { stopDefaultEvent(event); this.closeDropdown(); } break; case TAB: if (isOpen) { this.closeDropdown(); } break; default: { if (!shouldShowSearchInput) { stopDefaultEvent(event); const lowerCaseKey = key.toLowerCase(); const optionIndex = findIndex( options, option => option.displayText.toLowerCase().indexOf(lowerCaseKey) === 0, ); if (optionIndex >= 0) { this.setActiveItem(optionIndex); } } } } }; openDropdown = () => { const { shouldShowSearchInput } = this.props; if (!this.state.isOpen) { this.setState( { isOpen: true }, () => shouldShowSearchInput && this.searchInputRef && this.searchInputRef.focus(), ); document.addEventListener('click', this.handleDocumentClick); } }; closeDropdown = () => { if (this.state.isOpen) { this.setState({ activeItemID: null, activeItemIndex: -1, isOpen: false, searchText: '', }); document.removeEventListener('click', this.handleDocumentClick); } }; selectOption = (index: number) => { const { multiple } = this.props; if (multiple) { this.selectMultiOption(index); } else { this.selectSingleOption(index); this.closeDropdown(); // Close dropdown for single select fields } }; getFilteredOptions = (): Array<SelectOptionProp> => { const { options } = this.props; const { searchText } = this.state; return options.filter(option => { const isSubstring = option.displayText.toLowerCase().includes(searchText.toLowerCase()); const isClearOption = option.value === CLEAR; return searchText ? isSubstring && !isClearOption : true; }); }; selectSingleOption(index: number) { const { selectedValues } = this.props; const item = this.getFilteredOptions()[index]; // If item not previously selected, fire change handler if (!selectedValues.includes(item.value)) { this.handleChange([item]); } this.handleOptionSelect(item); } selectMultiOption = (index: number) => { const { defaultValue, options, selectedValues } = this.props; const hasDefaultValue = defaultValue != null; // Checks if not undefined or null const item = this.getFilteredOptions()[index]; // If we are already using the default option, just return without firing onChange if (hasDefaultValue && defaultValue === item.value) { this.selectSingleOption(index); return; } // Copy the array so we can freely modify it const newSelectedValues = selectedValues.slice(0); toggleOption(newSelectedValues, item.value); // Apply constraints if a defaultValue is specified if (hasDefaultValue) { const defaultOptionIndex = findIndex(options, option => option.value === defaultValue); if (defaultOptionIndex !== -1) { if (newSelectedValues.length === 0) { // If nothing is selected, we should select the default option this.selectSingleOption(defaultOptionIndex); return; } if (newSelectedValues.length > 1 && newSelectedValues.includes(defaultValue)) { // Remove the default option from the selected values when more than one thing is selected newSelectedValues.splice(defaultOptionIndex, 1); } } } // Fire onchange event with selected items this.handleChange(options.filter(option => newSelectedValues.includes(option.value))); this.handleOptionSelect(item); }; renderButtonText = () => { const { options, placeholder, selectedValues, title } = this.props; const selectedItemCount = selectedValues.length; // When there are no options selected, render placeholder if (selectedItemCount === 0 && placeholder) { return placeholder; } // User-specified title when options are selected if (title) { return title; } // Auto-generate button title based on selected options const selectedOptions = options.filter(option => selectedValues.includes(option.value)); return selectedOptions.map(option => option.displayText).join(', '); }; renderSearchInput = () => { const { intl } = this.props; const { searchText } = this.state; const getSearchInput = element => { this.searchInputRef = element; }; return ( <SearchForm className="select-field-search-container" getSearchInput={getSearchInput} onChange={this.updateSearchText} placeholder={intl.formatMessage(messages.searchPlaceholder)} value={searchText} /> ); }; renderSelectButton = () => { const { activeItemID, isOpen } = this.state; const { buttonProps: buttonElProps, isDisabled, className, error } = this.props; const buttonText = this.renderButtonText(); const buttonProps = { ...buttonElProps, 'aria-activedescendant': activeItemID, 'aria-autocomplete': 'list', 'aria-expanded': isOpen, 'aria-owns': this.selectFieldID, className, isDisabled, onClick: this.handleButtonClick, onKeyDown: this.handleButtonKeyDown, // @NOTE: Technically, only text inputs should be combo-boxes but ARIA specs do not cover custom select dropdowns role: 'combobox', title: buttonText, }; return ( // Need to store the select button reference so we can calculate the button width // in order to set it as the min width of the dropdown list <SelectButton {...buttonProps} error={error}> {buttonText} </SelectButton> ); }; renderSelectOptions = () => { const { optionRenderer, selectedValues, separatorIndices, shouldShowClearOption } = this.props; const { activeItemIndex } = this.state; const filteredOptions = this.getFilteredOptions(); if (filteredOptions.length === 0) { return ( <DatalistItem className="select-option is-disabled"> <FormattedMessage {...messages.noResults} /> </DatalistItem> ); } const selectOptions = filteredOptions.map<React.Element<typeof DatalistItem | 'li'>>((item, index) => { const { value } = item; const isSelected = selectedValues.includes(value); const isClearOption = shouldShowClearOption && value === CLEAR; const itemProps: Object = { className: classNames('select-option', { 'is-clear-option': isClearOption }), key: index, /* preventDefault on click to prevent wrapping label from re-triggering the select button */ onClick: event => { event.preventDefault(); if (isClearOption) { this.handleClearClick(); } else { this.selectOption(index); } }, onMouseEnter: () => { this.setActiveItem(index, false); }, setActiveItemID: this.setActiveItemID, }; if (index === activeItemIndex) { itemProps.isActive = true; } itemProps.isSelected = isSelected; // The below actually does have a key, but eslint can't catch that /* eslint-disable react/jsx-key */ return ( <DatalistItem {...itemProps}> <div className="select-option-check-icon"> {isSelected ? <IconCheck height={16} width={16} /> : null} </div> {optionRenderer(item)} </DatalistItem> ); /* eslint-enable react/jsx-key */ }); separatorIndices.forEach((separatorIndex, index) => { selectOptions.splice(separatorIndex + index, 0, <li key={`separator${separatorIndex}`} role="separator" />); }); return selectOptions; }; render() { const { className, multiple, isEscapedWithReference, isRightAligned, isScrollable, selectedValues, shouldShowSearchInput, } = this.props; const { isOpen } = this.state; // @TODO: Need invariants on specific conditions. // 1) # of options should be non-zero // 2) selectedValues, if defined, should all exist in options // 3) defaultValue, if defined, should exist in options // 4) defaultValue, if defined, should mean selectedValues is never empty // 5) defaultValue, if defined, cannot be selected in addition to other options (must be exclusive) const dropdownPlacement = isRightAligned ? PLACEMENT_BOTTOM_END : PLACEMENT_BOTTOM_START; // popper.js modifier to allow dropdown to overflow its boundaries and remain attached to its reference const dropdownModifiers = isEscapedWithReference ? { preventOverflow: { escapeWithReference: true } } : {}; return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions <div className={classNames(className, 'bdl-SelectField', 'select-container')} onBlur={this.handleBlur} onKeyDown={this.handleKeyDown} ref={this.selectFieldContainerRef} > <PopperComponent placement={dropdownPlacement} isOpen={isOpen} modifiers={dropdownModifiers}> {this.renderSelectButton()} <SelectFieldDropdown isScrollable={isScrollable} multiple={multiple} selectedValues={selectedValues} selectFieldID={this.selectFieldID} > {shouldShowSearchInput && this.renderSearchInput()} {this.renderSelectOptions()} </SelectFieldDropdown> </PopperComponent> </div> ); } } export { BaseSelectField as BaseSelectFieldBase }; export default injectIntl(BaseSelectField);