import { Cross as CrossIcon } from '@transferwise/icons'; import { clsx } from 'clsx'; import { DebouncedFunc } from 'lodash'; import clamp from 'lodash.clamp'; import debounce from 'lodash.debounce'; import React, { Component, ReactNode } from 'react'; import { injectIntl, WrappedComponentProps } from 'react-intl'; import Chip from '../chips/Chip'; import { Size, Sentiment, SizeMedium, SizeLarge, addClickClassToDocumentOnIos, removeClickClassFromDocumentOnIos, stopPropagation, } from '../common'; import { InlinePrompt, InlinePromptProps } from '../prompt'; import { withInputAttributes, WithInputAttributesProps } from '../inputs/contexts'; import TypeaheadInput from './typeaheadInput/TypeaheadInput'; import TypeaheadOption from './typeaheadOption/TypeaheadOption'; import messages from './Typeahead.messages'; const DEFAULT_MIN_QUERY_LENGTH = 3; const SEARCH_DELAY = 200; export type TypeaheadOption = { label: string; note?: string; secondary?: string; value?: T; clearQueryOnSelect?: boolean; keepFocusOnSelect?: boolean; }; export interface TypeaheadProps extends Partial { id: string; name: string; addon?: ReactNode; /** * @deprecated Use [`Field`](?path=/docs/forms-field--docs) component and its `message` and `sentiment` props instead. * @deprecated `error`, `info` and `success` are deprecated as alert types and will be soon removed. */ alert?: { message: InlinePromptProps['children']; type?: | InlinePromptProps['sentiment'] | `${Sentiment.ERROR}` | `${Sentiment.INFO}` | `${Sentiment.SUCCESS}`; }; /** @default false */ allowNew?: boolean; /** @default true */ autoFillOnBlur?: boolean; /** @default false */ autoFocus?: boolean; /** @default [] */ chipSeparators?: readonly string[]; /** @default true */ clearable?: boolean; footer?: ReactNode; /** @default [] */ initialValue?: readonly TypeaheadOption[]; /** @default 'new-password' */ inputAutoComplete?: string; maxHeight?: number; /** @default 3 */ minQueryLength?: number; placeholder?: string; /** @default false */ multiple?: boolean; options: readonly TypeaheadOption[]; /** @default 200 */ searchDelay?: number; /** @default true */ showSuggestions?: boolean; /** @default true */ showNewEntry?: boolean; /** @default 'md' */ size?: SizeMedium | SizeLarge; onBlur?: () => void; onChange: (options: TypeaheadOption[]) => void; onFocus?: () => void; onInputChange?: (query: string) => void; onSearch?: (query: string) => void; validateChip?: (chip: TypeaheadOption) => boolean; } type TypeaheadPropsWithInputAttributes = TypeaheadProps & Partial & WrappedComponentProps; type TypeaheadState = { selected: readonly TypeaheadOption[]; keyboardFocusedOptionIndex: number | null; errorState: boolean; query: string; optionsShown: boolean; isFocused: boolean; }; class Typeahead extends Component, TypeaheadState> { declare props: TypeaheadPropsWithInputAttributes & Required, keyof typeof Typeahead.defaultProps>>; static defaultProps = { allowNew: false, autoFillOnBlur: true, autoFocus: false, chipSeparators: [], clearable: true, initialValue: [], inputAutoComplete: 'new-password', minQueryLength: DEFAULT_MIN_QUERY_LENGTH, multiple: false, searchDelay: SEARCH_DELAY, showSuggestions: true, showNewEntry: true, size: Size.MEDIUM, validateChip: () => true, } satisfies Partial>; optionRefs: (React.RefObject | null)[]; constructor(props: TypeaheadPropsWithInputAttributes) { super(props); const { searchDelay, initialValue, multiple } = this.props; this.handleSearchDebounced = debounce(this.handleSearch, searchDelay); const initialQuery = !multiple && initialValue.length > 0 ? initialValue[0].label : ''; this.state = { selected: initialValue, errorState: false, query: initialQuery, keyboardFocusedOptionIndex: null, optionsShown: false, isFocused: false, }; this.optionRefs = [] as (React.RefObject | null)[]; } handleSearchDebounced: DebouncedFunc['handleSearch']>; UNSAFE_componentWillReceiveProps(nextProps: TypeaheadPropsWithInputAttributes) { if (nextProps.multiple !== this.props.multiple) { this.setState((previousState) => { const { selected } = previousState; if (!nextProps.multiple && selected.length > 0) { return { query: selected[0].label, selected: [selected[0]], }; } return { selected: previousState.selected, query: '', }; }); } } componentWillUnmount() { this.handleSearchDebounced.cancel(); } handleOnFocus = () => { this.showMenu(); this.props.onFocus?.(); }; onOptionSelected = (event: React.MouseEvent, item: TypeaheadOption) => { event.preventDefault(); this.selectItem(item); }; handleOnChange: React.ChangeEventHandler = (event) => { const { optionsShown, selected } = this.state; const { multiple, onInputChange } = this.props; if (!optionsShown) { this.showMenu(); } const query = event.target.value; if (!multiple && selected.length > 0) { this.updateSelectedValue([]); } this.setState({ query }, () => { this.handleSearchDebounced(query); if (onInputChange) { onInputChange(query); } }); }; handleOnPaste: React.ClipboardEventHandler = (event) => { const { allowNew, multiple, chipSeparators } = this.props; const { selected } = this.state; if (allowNew && multiple && chipSeparators.length > 0) { event.preventDefault(); const value = event.clipboardData.getData('text'); if (value) { const regex = new RegExp(chipSeparators.join('|')); const pastedChips = value .split(regex) .map((label) => ({ label: label.trim() })) .filter((chip) => chip.label); this.updateSelectedValue([...selected, ...pastedChips]); } } }; handleOnKeyDown: React.KeyboardEventHandler = (event) => { const { showSuggestions, allowNew, multiple, chipSeparators, options } = this.props; const { keyboardFocusedOptionIndex, query, selected } = this.state; const chipsMode = !showSuggestions && allowNew && multiple; if (chipsMode && ['Enter', 'Tab', ...chipSeparators].includes(event.key) && query.trim()) { event.preventDefault(); this.selectItem({ label: query }); } else { switch (event.key) { case 'ArrowDown': event.preventDefault(); this.moveFocusedOption(1); break; case 'ArrowUp': event.preventDefault(); this.moveFocusedOption(-1); break; case 'Enter': event.preventDefault(); if (keyboardFocusedOptionIndex != null && options[keyboardFocusedOptionIndex]) { this.selectItem(options[keyboardFocusedOptionIndex]); } else if (allowNew && query.trim()) { this.selectItem({ label: query }); } break; case 'Backspace': if (multiple && !query && selected.length > 0) { this.updateSelectedValue(selected.slice(0, -1)); } break; default: break; } } }; moveFocusedOption(offset: number) { this.setState((previousState) => { const { keyboardFocusedOptionIndex } = previousState; const { options } = this.props; let index = 0; if (keyboardFocusedOptionIndex !== null) { index = clamp(keyboardFocusedOptionIndex + offset, 0, options.length - 1); } const optionRef = this.optionRefs[index]; if (optionRef?.current) { optionRef.current.focus(); // Set focus on the option element } return { keyboardFocusedOptionIndex: index, }; }); } selectItem = (item: TypeaheadOption) => { const { multiple } = this.props; let selected = [...this.state.selected]; let query; if (multiple) { selected.push(item); query = ''; } else { selected = [item]; query = item.label; } this.updateSelectedValue(selected); if (!item.keepFocusOnSelect) { this.hideMenu(); } if (item.clearQueryOnSelect) { query = ''; } this.setState({ query, }); }; handleSearch = (query: string) => { const { onSearch } = this.props; if (onSearch) { onSearch(query); } this.setState((previousState) => ({ keyboardFocusedOptionIndex: previousState.keyboardFocusedOptionIndex === null ? null : 0, })); }; handleDocumentClick = () => { if (this.state.optionsShown) { this.hideMenu(); const { allowNew, onBlur, autoFillOnBlur } = this.props; const { query } = this.state; this.setState({ isFocused: false, }); if (allowNew && autoFillOnBlur && query.trim()) { this.selectItem({ label: query }); } if (onBlur) { onBlur(); } } }; showMenu = () => { this.setState( { isFocused: true, optionsShown: true, }, () => { addClickClassToDocumentOnIos(); document.addEventListener('click', this.handleDocumentClick, false); }, ); }; hideMenu = () => { this.setState( { optionsShown: false, keyboardFocusedOptionIndex: null, }, () => { removeClickClassFromDocumentOnIos(); document.removeEventListener('click', this.handleDocumentClick, false); }, ); }; updateSelectedValue = (selected: readonly TypeaheadOption[]) => { const { onChange, validateChip } = this.props; const errorState = selected.some((chip) => !validateChip(chip)); this.setState({ selected, errorState }, () => { onChange([...selected]); }); }; clear = (event: React.MouseEvent) => { event.preventDefault(); if (this.state.selected.length > 0) { this.updateSelectedValue([]); } this.setState({ query: '', }); }; removeChip = (option: TypeaheadOption) => { const { selected } = this.state; if (selected.length > 0) { this.updateSelectedValue([...selected.filter((selectedOption) => selectedOption !== option)]); } }; renderChip = (option: TypeaheadOption, idx: number): ReactNode => { const valid = this.props.validateChip?.(option); return ( this.removeChip(option)} /> ); }; renderMenu = ({ footer, options, id, keyboardFocusedOptionIndex, query, allowNew, showNewEntry, dropdownOpen, }: Pick< TypeaheadPropsWithInputAttributes, 'footer' | 'options' | 'id' | 'allowNew' | 'showNewEntry' > & Pick, 'keyboardFocusedOptionIndex' | 'query'> & { dropdownOpen: boolean; }) => { const optionsToRender = [...options]; if ( allowNew && query.trim() && options.every((option) => option.label.trim().toUpperCase() !== query.trim().toUpperCase()) && showNewEntry ) { optionsToRender.push({ label: query, }); } return (
{(!!optionsToRender.length || footer) && (
    {optionsToRender.map((option, idx) => { const ref = React.createRef(); this.optionRefs[idx] = ref; return ( { this.onOptionSelected(event, option); }} /> ); })} {footer}
)}
); }; render() { const { inputAttributes, id: idProp, placeholder, multiple, size, addon, name, clearable, allowNew, footer, showSuggestions, showNewEntry, options, minQueryLength, autoFocus, maxHeight, alert, inputAutoComplete, } = this.props; const id = idProp ?? inputAttributes?.id; const { errorState, query, selected, optionsShown, keyboardFocusedOptionIndex } = this.state; const clearButton = clearable && (query || selected.length > 0); const dropdownOpen = optionsShown && showSuggestions && query.length >= minQueryLength; const menu = this.renderMenu({ footer, options, id, keyboardFocusedOptionIndex, query, allowNew, showNewEntry, dropdownOpen, }); const alertType = (() => { if (!alert?.type || alert.type === Sentiment.INFO) { return Sentiment.NEUTRAL; } if (alert.type === Sentiment.ERROR) { return Sentiment.NEGATIVE; } if (alert.type === Sentiment.SUCCESS) { return Sentiment.POSITIVE; } return alert.type; })(); const hasError = errorState || (alert && alertType === Sentiment.NEGATIVE); const displayAlert = (!errorState && alert) || (alert && alertType === Sentiment.NEGATIVE); const hasWarning = displayAlert && alertType === Sentiment.WARNING; const hasInfo = displayAlert && alertType === Sentiment.NEUTRAL; return ( /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */
0, 'typeahead--empty': selected.length === 0, 'typeahead--multiple': multiple, open: dropdownOpen, })} onClick={stopPropagation} >
{addon && {addon}} {clearButton && (
)}
{displayAlert ? ( {alert.message} ) : ( menu )}
); } } export default injectIntl(withInputAttributes(Typeahead, { nonLabelable: true })) as ( props: TypeaheadProps, ) => React.ReactElement;