// @flow import * as React from 'react'; import classNames from 'classnames'; import uniqueId from 'lodash/uniqueId'; import { List } from 'immutable'; import Tooltip from '../tooltip'; import { KEYS } from '../../constants'; import RoundPill from './RoundPill'; import Pill from './Pill'; import SuggestedPillsRow from './SuggestedPillsRow'; import type { RoundOption, Option, OptionValue, SuggestedPillsFilter } from './flowTypes'; import type { Position } from '../tooltip'; function stopDefaultEvent(event) { event.preventDefault(); event.stopPropagation(); } type Props = { allowInvalidPills: boolean, className?: string, disabled?: boolean, error?: React.Node, /** Position of error message tooltip */ errorTooltipPosition?: Position, /** Called on pill render to get a specific class name to use for a particular option. Note: Only has effect when showRoundedPills is true. */ getPillClassName?: (option: Option) => string, /** Function to retrieve the image URL associated with a pill */ getPillImageUrl?: (data: { id: string | number, [key: string]: any }) => string, innerRef?: React.Ref, inputProps: Object, /** Allows disabling the textarea element without disabling the whole PillSelector */ isInputDisabled?: boolean, /** Whether to show textarea in next line when focused */ isInputFocusedNextLine?: boolean, onInput: Function, onRemove: Function, onSuggestedPillAdd?: Function, placeholder: string, selectedOptions: List, /** Whether to show avatars in pills (if rounded style is enabled) */ showAvatars?: boolean, /** Whether to use rounded style for pills */ showRoundedPills?: boolean, suggestedPillsData?: Array, suggestedPillsFilter?: SuggestedPillsFilter, suggestedPillsTitle?: string, validator: (option: Option | OptionValue) => boolean, }; type State = { isFocused: boolean, selectedIndex: number, }; type DefaultProps = { allowInvalidPills: boolean, disabled: boolean, error: string, errorTooltipPosition: Position, inputProps: Object, placeholder: string, selectedOptions: List, validator: () => boolean, }; type Config = React.Config; class PillSelectorBase extends React.Component { static defaultProps: DefaultProps = { allowInvalidPills: false, disabled: false, error: '', errorTooltipPosition: 'bottom-left', inputProps: {}, placeholder: '', selectedOptions: [], validator: () => true, }; state = { isFocused: false, selectedIndex: -1, }; getNumSelected = (): number => { const { selectedOptions } = this.props; return typeof selectedOptions.size === 'number' ? selectedOptions.size : selectedOptions.length; }; getPillsByKey = (key: string): Array => { const { selectedOptions } = this.props; return selectedOptions.map(option => option[key]); }; inputEl: HTMLInputElement; handleClick = () => { this.inputEl.focus(); }; handleFocus = () => { this.setState({ isFocused: true }); }; handleBlur = () => { this.setState({ isFocused: false }); }; hiddenEl: HTMLSpanElement; handleKeyDown = (event: SyntheticKeyboardEvent<>) => { const inputValue = this.inputEl.value; const numPills = this.getNumSelected(); const { selectedIndex } = this.state; switch (event.key) { case KEYS.backspace: { let index = -1; if (selectedIndex >= 0) { // remove selected pill index = selectedIndex; this.resetSelectedIndex(); this.inputEl.focus(); } else if (inputValue === '') { // remove last pill index = numPills - 1; } if (index >= 0) { const { onRemove, selectedOptions } = this.props; const selectedOption = // $FlowFixMe typeof selectedOptions.get === 'function' ? selectedOptions.get(index) : selectedOptions[index]; onRemove(selectedOption, index); stopDefaultEvent(event); } break; } case KEYS.arrowLeft: if (selectedIndex >= 0) { // select previous pill this.setState({ selectedIndex: Math.max(selectedIndex - 1, 0), }); stopDefaultEvent(event); } else if (inputValue === '' && numPills > 0) { // select last pill this.hiddenEl.focus(); this.setState({ selectedIndex: numPills - 1 }); stopDefaultEvent(event); } break; case KEYS.arrowRight: { if (selectedIndex >= 0) { const index = selectedIndex + 1; if (index >= numPills) { // deselect last pill this.resetSelectedIndex(); this.inputEl.focus(); } else { // select next pill this.setState({ selectedIndex: index }); } stopDefaultEvent(event); } break; } // no default } }; errorMessageID = uniqueId('errorMessage'); hiddenRef = (hiddenEl: ?HTMLSpanElement) => { if (hiddenEl) { this.hiddenEl = hiddenEl; } }; resetSelectedIndex = () => { if (this.state.selectedIndex !== -1) { this.setState({ selectedIndex: -1 }); } }; render() { const { isFocused, selectedIndex } = this.state; const { allowInvalidPills, className, disabled, error, errorTooltipPosition, getPillClassName, getPillImageUrl, inputProps, isInputDisabled, isInputFocusedNextLine, onInput, onRemove, onSuggestedPillAdd, placeholder, innerRef, selectedOptions, showAvatars, showRoundedPills, suggestedPillsData, suggestedPillsFilter, suggestedPillsTitle, validator, ...rest } = this.props; const suggestedPillsEnabled = suggestedPillsData && suggestedPillsData.length > 0; const hasError = !!error; const classes = classNames('bdl-PillSelector', 'pill-selector-input-wrapper', { 'is-disabled': disabled, 'bdl-is-disabled': disabled, 'is-focused': isFocused, 'show-error': hasError, 'pill-selector-suggestions-enabled': suggestedPillsEnabled, 'bdl-PillSelector--suggestionsEnabled': suggestedPillsEnabled, }); const ariaAttrs = { 'aria-invalid': hasError, 'aria-errormessage': this.errorMessageID, 'aria-describedby': this.errorMessageID, }; return ( {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} {showRoundedPills ? selectedOptions.map((option: RoundOption, index: number) => { return ( ); }) : selectedOptions.map((option: Option, index: number) => { // TODO: This and associated types will be removed once all views are updates with round pills. return ( ); })} {/* hidden element for focus/key events during pill selection */}