import React, { Component, type JSX } from 'react'; import { AutoCompleteAssistiveHint } from '@digigov/react-core/AutoCompleteAssistiveHint'; import { AutoCompleteContainer } from '@digigov/react-core/AutoCompleteContainer'; import { AutoCompleteInputBase } from '@digigov/react-core/AutoCompleteInputBase'; import { AutoCompleteMultipleInput } from '@digigov/react-core/AutoCompleteMultipleInput'; import { AutoCompleteMultipleInputContainer } from '@digigov/react-core/AutoCompleteMultipleInputContainer'; import { AutoCompleteResultList } from '@digigov/react-core/AutoCompleteResultList'; import { AutoCompleteResultListItem } from '@digigov/react-core/AutoCompleteResultListItem'; import { Base } from '@digigov/react-core/Base'; import { CaretIcon } from '@digigov/react-icons/CaretIcon'; import { Chip, ChipContainer } from '@digigov/ui/content/Chip'; import Status from '@digigov/ui/form/AutoComplete/Status'; import { isIosDevice, keyCodes, isPrintableKeyCode, } from '@digigov/ui/form/AutoComplete/utils'; import Button from '@digigov/ui/form/Button'; import Checkbox, { CheckboxItem } from '@digigov/ui/form/Checkbox'; import { NormalText } from '@digigov/ui/typography/NormalText'; export interface AutoCompleteProps { source: (query: string, syncResults: (options: string[]) => void) => void; id: string; multiple?: boolean; tStatusResults?: (x: number, y: string) => string; tStatusNoResults?: () => string; tStatusQueryTooShort?: (x: number) => string; tStatusSelectedOption?: (x: string, y: number, z: number) => string; tNoResults?: () => string; tAssistiveHint?: () => string; templates?: { suggestion?: (value: any, query: string | string[]) => any; inputValue?: (value: any) => string; }; width?: '25%' | '33.3%' | '50%' | '66.6%' | '75%' | '100%' | 'full'; autoselect?: boolean; hint?: boolean; defaultValue?: string | string[]; minLength?: number; name?: string; placeholder?: string; onConfirm?: (x: any) => void; confirmOnBlur?: boolean; required?: boolean; numberOfSelected?: 1 | 2 | 3 | 'all'; } export interface State { focused: any; hovered: any; menuOpen: boolean; options: any[]; query: string; selectedValues: any[]; validChoiceMade: boolean; selected: any; ariaHint: boolean; } export interface NewQueryProps { menuOpen?: boolean; query?: string; } export default class AutoComplete extends Component { static defaultProps = { width: 'full', autoselect: false, minLength: 0, name: 'ds-input-autocomplete', placeholder: '', hint: false, onConfirm: (): void => { return; }, confirmOnBlur: false, required: false, tNoResults: (): string => 'No results found', tAssistiveHint: (): string => 'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.', }; elementReferences = {}; constructor(props: AutoCompleteProps) { super(props); const customState: any = {}; if (Array.isArray(props.defaultValue)) { customState.selectedValues = props.defaultValue as string[]; } else if (props.defaultValue) { customState.selectedValues = [props.defaultValue as string]; } this.state = { focused: null, hovered: null, menuOpen: false, options: [], query: this.props.multiple ? '' : (props.defaultValue as string) || '', selectedValues: [], validChoiceMade: false, selected: null, ariaHint: true, ...customState, }; this.handleComponentBlur = this.handleComponentBlur.bind(this); this.handleAutoCompleteBlur = this.handleAutoCompleteBlur.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUpArrow = this.handleUpArrow.bind(this); this.handleDownArrow = this.handleDownArrow.bind(this); this.handleEnter = this.handleEnter.bind(this); this.handlePrintableKey = this.handlePrintableKey.bind(this); this.handleListMouseLeave = this.handleListMouseLeave.bind(this); if (this.props.multiple) { this.handleOptionClick = this.handleOptionClickMultiple.bind(this); } else { this.handleOptionClick = this.handleOptionClick.bind(this); } this.handleOptionFocus = this.handleOptionFocus.bind(this); this.handleOptionMouseDown = this.handleOptionMouseDown.bind(this); this.handleOptionMouseEnter = this.handleOptionMouseEnter.bind(this); this.handleInputChange = this.handleInputChange.bind(this); this.handleInputFocus = this.handleInputFocus.bind(this); this.getDirectInputChanges = this.getDirectInputChanges.bind(this); } isQueryAnOption(query: string, options: string[]): boolean { return ( options .map((entry) => this.templateInputValue(entry).toLowerCase()) .indexOf(query.toLowerCase()) !== -1 ); } componentDidMount(): void { this.getDirectInputChanges(); } getDirectInputChanges(): void { const inputReference = this.elementReferences[-1]; const queryHasChanged = inputReference && inputReference.value !== (this.state.query ?? ''); if (queryHasChanged) { this.handleInputChange({ target: { value: inputReference.value } }); } } componentDidUpdate(_, prevState: Readonly): void { const { focused } = this.state; const componentLostFocus = focused === null; const focusedChanged = prevState.focused !== focused; const focusDifferentElement = focusedChanged && !componentLostFocus; if (focusDifferentElement) { this.elementReferences[focused].focus(); } const focusedInput = focused === -1; const componentGainedFocus = focusedChanged && prevState.focused === null; const selectAllText = focusedInput && componentGainedFocus; if (selectAllText) { const inputElement = this.elementReferences[focused]; inputElement.setSelectionRange(0, inputElement.value.length); } } hasAutoselect(): boolean { return (isIosDevice() as boolean) ? false : (this.props.autoselect as boolean); } // This template is used when converting from a state.options object into a state.query. templateInputValue(value: string): string { const inputValueTemplate = this.props.templates && this.props.templates.inputValue; return inputValueTemplate ? inputValueTemplate(value) : value; } // This template is used when displaying results / suggestions. templateSuggestion(value: string): string { const suggestionTemplate = this.props.templates && this.props.templates.suggestion; return suggestionTemplate ? suggestionTemplate(value, this.state.query) : value; } handleComponentBlur(newState?: NewQueryProps): void { const { options, query, selected } = this.state; const { confirmOnBlur, autoselect, onConfirm } = this.props; let newQuery = query; if (confirmOnBlur || autoselect) { newQuery = newState?.query ?? query; } if (confirmOnBlur) { onConfirm?.(options[selected as number]); } this.setState({ focused: null, menuOpen: newState?.menuOpen || false, query: newQuery, selected: null, validChoiceMade: this.isQueryAnOption(newQuery, options), }); } handleListMouseLeave(): void { this.setState({ hovered: null, }); } handleAutoCompleteBlur(event, index?: number): void { const { autoselect } = this.props; const { focused, options, query, selected } = this.state; let focusingOutsideComponent = false; if (event.relatedTarget === null) { focusingOutsideComponent = true; } else { // This affects if we have multiple autocompletes in same page and we click the first button-arrow and then another button-arrow focusingOutsideComponent = this.elementReferences['button-arrow'] !== event.relatedTarget; } const focusingInput = event.relatedTarget === this.elementReferences[-1]; const focusingButtonArrow = this.elementReferences['button-arrow'] === event.relatedTarget || false; let focusingAnotherOption = focused !== null && focused !== -1; if (index !== undefined) { focusingAnotherOption = focused !== index && focused !== -1; } // Check if the user clicks outside and not either on option, either on input or on button arrow const blurComponent = focusingOutsideComponent && !(focusingAnotherOption || focusingInput || focusingButtonArrow); if (blurComponent && !autoselect) { // In handleInputBlur: const newQuery = isIosDevice() ? query : this.templateInputValue(options[selected as number]); this.handleComponentBlur({ menuOpen: false, query: newQuery, }); } if (blurComponent && autoselect) { // In handleInputBlur: const selectedOption = this.templateInputValue( options[selected as number] ); const newQuery = isIosDevice() ? query : selectedOption?.toLowerCase().includes(query.toLowerCase()) ? selectedOption : query; this.handleComponentBlur({ menuOpen: false, query: newQuery, }); } } handleInputChange(event: { target: any }): void { const { source, minLength } = this.props; const query = event.target.value; const queryEmpty = query.length === 0; this.setState({ query, ariaHint: queryEmpty, }); const minLengthQuery = minLength != undefined && minLength > query.length ? '' : query; source(minLengthQuery, (options) => { const optionsAvailable = options.length > 0; this.setState({ menuOpen: optionsAvailable, options, selected: -1, validChoiceMade: false, }); }); } handleInputClick( event: React.MouseEvent ): void { this.handleInputChange(event); } handleInputFocus(): void { const { source } = this.props; const { query, validChoiceMade, options } = this.state; const { minLength } = this.props; const shouldReopenMenu = !validChoiceMade && query.length >= (minLength as number) && options.length > 0; if (shouldReopenMenu) { this.setState(({ menuOpen }) => ({ focused: -1, menuOpen: shouldReopenMenu || menuOpen, selected: -1, })); } else { source(query, (options) => { this.setState({ focused: -1, options, }); }); } } handleOptionFocus(index: number): void { this.setState({ focused: index, hovered: null, selected: index, }); } handleOptionMouseEnter(index: number): void { // iOS Safari prevents click event if mouseenter adds hover background colour // See: https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW4 if (!isIosDevice()) { this.setState({ hovered: index, }); } this.handleOptionFocus(index); } handleOptionClickMultiple(index: number): void { const selectedOption = this.state.options[index]; let selectedValues = this.state.selectedValues; if (this.state.selectedValues.indexOf(selectedOption) === -1) { selectedValues = selectedValues.concat([selectedOption]); } else { selectedValues.splice( this.state.selectedValues.indexOf(selectedOption), 1 ); } this.props.onConfirm?.(selectedValues); this.setState({ focused: index, hovered: null, menuOpen: true, query: '', selectedValues, selected: index, validChoiceMade: true, }); this.forceUpdate(); } handleOptionClick(index: number): void { const selectedOption = this.state.options[index]; const newQuery = this.templateInputValue(selectedOption); this.props.onConfirm?.(selectedOption); const selectedValues = this.props.multiple ? this.state.selectedValues.concat(selectedOption) : [selectedOption]; this.setState({ focused: -1, hovered: null, menuOpen: false, query: newQuery, selectedValues, selected: -1, validChoiceMade: true, }); this.forceUpdate(); } handleOptionMouseDown(event: { preventDefault: () => void }): void { // Safari triggers focusOut before click, but if you // preventDefault on mouseDown, you can stop that from happening. // If this is removed, clicking on an option in Safari will trigger // `handleOptionBlur`, which closes the menu, and the click will // trigger on the element underneath instead. // See: http://stackoverflow.com/questions/7621711/how-to-prevent-blur-running-when-clicking-a-link-in-jquery event.preventDefault(); } handleUpArrow(event: { preventDefault: () => void }): void { event.preventDefault(); const { menuOpen, selected } = this.state; const isNotAtTop = selected !== -1; const allowMoveUp = isNotAtTop && menuOpen; if (allowMoveUp) { this.handleOptionFocus((selected as number) - 1); } } handleDownArrow(event: { preventDefault: () => void }): void { event.preventDefault(); // if not open, open if (this.state.menuOpen === false) { event.preventDefault(); this.props.source('', (options) => { this.setState({ menuOpen: true, options, selected: 0, focused: 0, hovered: null, }); }); } else if (this.state.menuOpen === true) { const { menuOpen, options, selected } = this.state; const isNotAtBottom = selected !== options.length - 1; const allowMoveDown = isNotAtBottom && menuOpen; if (allowMoveDown) { this.handleOptionFocus((selected as number) + 1); } } } handleSpace(event: { preventDefault: () => void }): void { // if not open, open if (this.state.menuOpen === false && this.state.query === '') { event.preventDefault(); this.props.source('', (options) => { this.setState({ menuOpen: true, options, }); }); } const focusIsOnOption = this.state.focused !== -1; if (focusIsOnOption) { event.preventDefault(); this.handleOptionClick(this.state.focused); } } handleEnter(event: { preventDefault: () => void }): void { if (this.state.menuOpen) { event.preventDefault(); const hasSelectedOption = this.state.selected >= 0; if (hasSelectedOption) { this.handleOptionClick(this.state.selected); if (this.props.multiple) { this.setState({ menuOpen: false, focused: -1, }); } } } } handlePrintableKey(event: { target: any }): void { const inputElement = this.elementReferences[-1]; const eventIsOnInput = event.target === inputElement; if (!eventIsOnInput) { // FIXME: This would be better if it was in componentDidUpdate, // but using setState to trigger that seems to not work correctly // in preact@8.1.0. inputElement.focus(); } } handleKeyDown(event: { preventDefault: () => void; keyCode: number; target: any; }): void { switch (keyCodes[event.keyCode]) { case 'up': this.handleUpArrow(event); break; case 'down': this.handleDownArrow(event); break; case 'space': this.handleSpace(event); break; case 'enter': this.handleEnter(event); break; case 'escape': { this.handleComponentBlur({ query: this.state.query, menuOpen: false, }); break; } case 'backspace': if (this.props.multiple) { if ( this.state.query.length === 0 && this.state.selectedValues.length > 0 ) { event.preventDefault(); const updatedSelectedValues = this.state.selectedValues.slice( 0, -1 ); this.setState( { selectedValues: updatedSelectedValues, }, () => { this.props.onConfirm?.(updatedSelectedValues); } ); } else { if (isPrintableKeyCode(event.keyCode)) { this.handlePrintableKey(event); } } } else { this.props.onConfirm?.(''); } break; default: if (isPrintableKeyCode(event.keyCode)) { this.handlePrintableKey(event); } break; } } render(): JSX.Element { const { id, width, minLength, name, placeholder, required, hint, tNoResults, tStatusQueryTooShort, tStatusNoResults, tStatusSelectedOption, tStatusResults, tAssistiveHint, multiple, source, } = this.props; const { focused, hovered, menuOpen, options, selected, ariaHint, validChoiceMade, } = this.state; const autoselect = this.hasAutoselect(); const query = this.state.query ?? ''; const inputFocused = focused === -1; const noOptionsAvailable = options.length === 0; const queryNotEmpty = query.length !== 0; const queryLongEnough = query.length >= (minLength as number); const showNoOptionsFound = inputFocused && noOptionsAvailable && queryNotEmpty && queryLongEnough; const componentIsFocused = focused !== null; const optionFocused = focused !== -1 && focused !== null; const menuIsVisible = menuOpen || showNoOptionsFound; const selectedOptionText = this.templateInputValue(options[selected]); const optionBeginsWithQuery = selectedOptionText && selectedOptionText.toLowerCase().indexOf(query.toLowerCase()) === 0; const hintValue = optionBeginsWithQuery && (autoselect || hint) ? query + selectedOptionText.substr(query.length) : ''; const assistiveHintID = id + '__assistiveHint'; const ariaDescribedProp = ariaHint ? { 'aria-describedby': assistiveHintID, } : null; const showAutoCompleteResultListItems = ( options: string[], multiple: boolean ) => { { return options.map((option, index) => { const showFocused = focused === -1 ? selected === index : focused === index; const iosPosinsetHtml = isIosDevice() ? ( {index + 1} από {options.length} ) : ( '' ); return ( this.handleAutoCompleteBlur(event, index)} onClick={() => this.handleOptionClick(index)} onMouseDown={this.handleOptionMouseDown} onMouseEnter={() => this.handleOptionMouseEnter(index)} ref={(optionEl) => { this.elementReferences[index] = optionEl; }} role="option" tabIndex={-1} // @ts-ignore value={typeof option === 'object' ? option?.value : option} aria-posinset={index + 1} aria-setsize={options.length} > {multiple ? ( {}} > {this.templateSuggestion(option)} ) : ( this.templateSuggestion(option) )} {iosPosinsetHtml} ); }); } }; return ( {multiple && !autoselect ? ( {this.state.selectedValues.map((value, index) => { return ( { this.setState( (prevState) => ({ selectedValues: prevState.selectedValues.filter( (v) => { if (typeof v === 'string') { return v !== value; } else { return v.value !== value.value; } } ), }), () => { this.props.onConfirm?.(this.state.selectedValues); } ); }} > {typeof value === 'string' ? value : value.label.primary} ); })} {!componentIsFocused && typeof this.props.numberOfSelected === 'number' && this.state.selectedValues.length > this.props.numberOfSelected && ( + {this.state.selectedValues.length - this.props.numberOfSelected} )} this.handleInputClick(event)} onBlur={(event) => this.handleAutoCompleteBlur(event)} onChange={this.handleInputChange} onFocus={this.handleInputFocus} name={name} placeholder={hintValue || placeholder} ref={(inputElement) => { this.elementReferences[-1] = inputElement; }} role="combobox" required={required} value={query} /> this.handleListMouseLeave()} id={`${id}__listbox`} role="listbox" > {showAutoCompleteResultListItems(options, multiple)} {showNoOptionsFound && ( {tNoResults?.()} )} {tAssistiveHint?.()} ) : ( <> this.handleInputClick(event)} onBlur={this.handleAutoCompleteBlur} onChange={this.handleInputChange} onFocus={this.handleInputFocus} name={name} placeholder={hintValue || placeholder} ref={(inputElement) => { this.elementReferences[-1] = inputElement; }} role="combobox" required={required} value={query} /> this.handleListMouseLeave()} id={`${id}__listbox`} role="listbox" > {showAutoCompleteResultListItems(options, false)} {showNoOptionsFound && ( {tNoResults?.()} )} {tAssistiveHint?.()} )} ); } } export { AutoComplete, AutoCompleteInputBase, AutoCompleteResultList, AutoCompleteResultListItem, AutoCompleteContainer, AutoCompleteAssistiveHint, AutoCompleteMultipleInputContainer, AutoCompleteMultipleInput, };