/** * Inspired by [Downshift](https://github.com/paypal/downshift) * * Implemented with a stripped-down, compatible API for our use case. * May be worthwhile to switch if we find we need more features * * Basic idea is that we call `children` with props necessary to render with any sort of component structure. * This component handles logic like when the dropdown menu should be displayed, as well as handling keyboard input, how * it is rendered should be left to the child. */ import {Component} from 'react'; import DeprecatedDropdownMenu, { GetActorArgs, GetMenuArgs, } from 'sentry/components/deprecatedDropdownMenu'; import {uniqueId} from 'sentry/utils/guid'; const defaultProps = { itemToString: () => '', /** * If input should be considered an "actor". If there is another parent actor, then this should be `false`. * e.g. You have a button that opens this in a dropdown. */ inputIsActor: true, disabled: false, closeOnSelect: true, /** * Can select autocomplete item with "Enter" key */ shouldSelectWithEnter: true, /** * Can select autocomplete item with "Tab" key */ shouldSelectWithTab: false, }; type Item = { 'data-test-id'?: string; disabled?: boolean; }; type GetInputArgs = { onBlur?: (event: React.FocusEvent) => void; onChange?: (event: React.ChangeEvent) => void; onFocus?: (event: React.FocusEvent) => void; onKeyDown?: (event: React.KeyboardEvent) => void; placeholder?: string; style?: React.CSSProperties; type?: string; }; type GetInputOutput = GetInputArgs & GetActorArgs & { value?: string; }; type GetItemArgs = { index: number; item: T; onClick?: (item: T) => (e: React.MouseEvent) => void; }; type ChildrenProps = Parameters[0] & { /** * Returns props for the input element that handles searching the items */ getInputProps: ( args: GetInputArgs ) => GetInputOutput; /** * Returns props for an individual item */ getItemProps: (args: GetItemArgs) => { onClick: (e: React.MouseEvent) => void; }; /** * The actively highlighted item index */ highlightedIndex: number; /** * The current value of the input box */ inputValue: string; /** * Registers the total number of items in the dropdown menu. * * This must be called for keyboard navigation to work. */ registerItemCount: (count?: number) => void; /** * Registers an item as being visible in the autocomplete menu. Returns an * cleanup function that unregisters the item as visible. * * This is needed for managing keyboard navigation when using react virtualized. * * NOTE: Even when NOT using a virtualized list, this must still be called for * keyboard navigation to work! */ registerVisibleItem: (index: number, item: T) => () => void; /** * The current selected item */ selectedItem?: T; }; type State = { highlightedIndex: number; inputValue: string; isOpen: boolean; selectedItem?: T; }; type Props = typeof defaultProps & { /** * Must be a function that returns a component */ children: (props: ChildrenProps) => React.ReactElement | null; disabled: boolean; defaultHighlightedIndex?: number; defaultInputValue?: string; inputValue?: string; isOpen?: boolean; itemToString?: (item?: T) => string; onClose?: (...args: Array) => void; onInputValueChange?: (value: string) => void; onMenuOpen?: () => void; onOpen?: (...args: Array) => void; onSelect?: ( item: T, state?: State, e?: React.MouseEvent | React.KeyboardEvent ) => void; /** * Resets autocomplete input when menu closes */ resetInputOnClose?: boolean; }; class AutoComplete extends Component, State> { static defaultProps = defaultProps; state: State = this.getInitialState(); getInitialState() { const {defaultHighlightedIndex, isOpen, inputValue, defaultInputValue} = this.props; return { isOpen: !!isOpen, highlightedIndex: defaultHighlightedIndex || 0, inputValue: inputValue ?? defaultInputValue ?? '', selectedItem: undefined, }; } componentDidMount() { this._mounted = true; } componentDidUpdate(_prevProps: Props, prevState: State) { // If we do NOT want to close on select, then we should not reset highlight state // when we select an item (when we select an item, `this.state.selectedItem` changes) if (this.props.closeOnSelect && this.state.selectedItem !== prevState.selectedItem) { this.resetHighlightState(); } } componentWillUnmount() { this._mounted = false; window.clearTimeout(this.blurTimeout); window.clearTimeout(this.cancelCloseTimeout); } private _mounted: boolean = false; private _id = `autocomplete-${uniqueId()}`; /** * Used to track keyboard navigation of items. */ items = new Map(); /** * When using a virtualized list the length of the items mapping will not match * the actual item count. This stores the _real_ item count. */ itemCount?: number; blurTimeout: number | undefined = undefined; cancelCloseTimeout: number | undefined = undefined; get inputValueIsControlled() { return typeof this.props.inputValue !== 'undefined'; } get isOpenIsControlled() { return typeof this.props.isOpen !== 'undefined'; } get inputValue() { return this.props.inputValue ?? this.state.inputValue; } get isOpen() { return this.isOpenIsControlled ? this.props.isOpen : this.state.isOpen; } makeItemId = (index: number) => { return `${this._id}-item-${index}`; }; getItemElement = (index: number) => { const id = this.makeItemId(index); const element = document.getElementById(id); return element; }; /** * Resets `this.items` and `this.state.highlightedIndex`. * Should be called whenever `inputValue` changes. */ resetHighlightState() { // reset items and expect `getInputProps` in child to give us a list of new items this.setState({highlightedIndex: this.props.defaultHighlightedIndex ?? 0}); } makeHandleInputChange( onChange: GetInputArgs['onChange'] ) { // Some inputs (e.g. input) pass in only the event to the onChange listener and // others (e.g. TextField) pass in both the value and the event to the onChange listener. // This returned function is to accomodate both kinds of input components. return ( valueOrEvent: string | React.ChangeEvent, event?: React.ChangeEvent ) => { const value: string = event === undefined ? (valueOrEvent as React.ChangeEvent).target.value : (valueOrEvent as string); const changeEvent: React.ChangeEvent = event === undefined ? (valueOrEvent as React.ChangeEvent) : event; // We force `isOpen: true` here because: // 1) it's possible to have menu closed but input with focus (i.e. hitting "Esc") // 2) you select an item, input still has focus, and then change input this.openMenu(); if (!this.inputValueIsControlled) { this.setState({ inputValue: value, }); } this.props.onInputValueChange?.(value); onChange?.(changeEvent); }; } makeHandleInputFocus(onFocus: GetInputArgs['onFocus']) { return (e: React.FocusEvent) => { this.openMenu(); onFocus?.(e); }; } /** * We need this delay because we want to close the menu when input * is blurred (i.e. clicking or via keyboard). However we have to handle the * case when we want to click on the dropdown and causes focus. * * Clicks outside should close the dropdown immediately via , * however blur via keyboard will have a 200ms delay */ makehandleInputBlur(onBlur: GetInputArgs['onBlur']) { return (e: React.FocusEvent) => { window.clearTimeout(this.blurTimeout); this.blurTimeout = window.setTimeout(() => { this.closeMenu(); onBlur?.(e); }, 200); }; } // Dropdown detected click outside, we should close handleClickOutside = async () => { // Otherwise, it's possible that this gets fired multiple times // e.g. click outside triggers closeMenu and at the same time input gets blurred, so // a timer is set to close the menu window.clearTimeout(this.blurTimeout); // Wait until the current macrotask completes, in the case that the click // happened on a hovercard or some other element rendered outside of the // autocomplete, but controlled by the existence of the autocomplete, we // need to ensure any click handlers are run. await new Promise(resolve => window.setTimeout(resolve)); this.closeMenu(); }; makeHandleInputKeydown( onKeyDown: GetInputArgs['onKeyDown'] ) { return (e: React.KeyboardEvent) => { const item = this.items.get(this.state.highlightedIndex); const isEnter = this.props.shouldSelectWithEnter && e.key === 'Enter'; const isTab = this.props.shouldSelectWithTab && e.key === 'Tab'; if (item !== undefined && (isEnter || isTab)) { if (!item.disabled) { this.handleSelect(item, e); } e.preventDefault(); } if (e.key === 'ArrowUp') { this.moveHighlightedIndex(-1); e.preventDefault(); } if (e.key === 'ArrowDown') { this.moveHighlightedIndex(1); e.preventDefault(); } if (e.key === 'Escape') { this.closeMenu(); } onKeyDown?.(e); }; } makeHandleItemClick({item, index}: GetItemArgs) { return (e: React.MouseEvent) => { if (item.disabled) { return; } window.clearTimeout(this.blurTimeout); this.setState({highlightedIndex: index}); this.handleSelect(item, e); }; } makeHandleMouseEnter({item, index}: GetItemArgs) { return (_e: React.MouseEvent) => { if (item.disabled) { return; } this.setState({highlightedIndex: index}); }; } handleMenuMouseDown = () => { window.clearTimeout(this.cancelCloseTimeout); // Cancel close menu from input blur (mouseDown event can occur before input blur :() this.cancelCloseTimeout = window.setTimeout(() => { window.clearTimeout(this.blurTimeout); }); }; /** * When an item is selected via clicking or using the keyboard (e.g. pressing "Enter") */ handleSelect(item: T, e: React.MouseEvent | React.KeyboardEvent) { const {onSelect, itemToString, closeOnSelect} = this.props; onSelect?.(item, this.state, e); if (closeOnSelect) { this.closeMenu(); this.setState({ inputValue: itemToString(item), selectedItem: item, }); return; } this.setState({selectedItem: item}); } moveHighlightedIndex(step: number) { let newIndex = this.state.highlightedIndex + step; // when this component is in virtualized mode, only a subset of items will // be passed down, making the map size inaccurate. instead we manually pass // the length as itemCount const listSize = this.itemCount ?? this.items.size; // Make sure new index is within bounds newIndex = Math.max(0, Math.min(newIndex, listSize - 1)); this.setState({highlightedIndex: newIndex}, () => { // Scroll the newly highlighted element into view const highlightedElement = this.getItemElement(newIndex); if (highlightedElement && typeof highlightedElement.scrollIntoView === 'function') { highlightedElement.scrollIntoView({block: 'nearest'}); } }); } /** * Open dropdown menu * * This is exposed to render function */ openMenu = (...args: Array) => { const {onOpen, disabled} = this.props; onOpen?.(...args); if (disabled || this.isOpenIsControlled) { return; } this.resetHighlightState(); this.setState({ isOpen: true, }); }; /** * Close dropdown menu * * This is exposed to render function */ closeMenu = (...args: Array) => { const {onClose, resetInputOnClose} = this.props; onClose?.(...args); if (!this._mounted) { return; } this.setState(state => ({ isOpen: !this.isOpenIsControlled ? false : state.isOpen, inputValue: resetInputOnClose ? '' : state.inputValue, })); }; getInputProps( inputProps?: GetInputArgs ): GetInputOutput { const {onChange, onKeyDown, onFocus, onBlur, ...rest} = inputProps ?? {}; return { ...rest, value: this.inputValue, onChange: this.makeHandleInputChange(onChange), onKeyDown: this.makeHandleInputKeydown(onKeyDown), onFocus: this.makeHandleInputFocus(onFocus), onBlur: this.makehandleInputBlur(onBlur), }; } getItemProps = (itemProps: GetItemArgs) => { const {item, index, ...props} = itemProps ?? {}; return { ...props, id: this.makeItemId(index), role: 'option', 'data-test-id': item['data-test-id'], onClick: this.makeHandleItemClick(itemProps), onMouseEnter: this.makeHandleMouseEnter(itemProps), }; }; registerVisibleItem = (index: number, item: T) => { this.items.set(index, item); return () => this.items.delete(index); }; registerItemCount = (count?: number) => { this.itemCount = count; }; render() { const {children, onMenuOpen, inputIsActor} = this.props; const {selectedItem, highlightedIndex} = this.state; const isOpen = this.isOpen; return ( {dropdownMenuProps => children({ ...dropdownMenuProps, getMenuProps: (props?: GetMenuArgs) => dropdownMenuProps.getMenuProps({ ...props, onMouseDown: this.handleMenuMouseDown, }), getInputProps: ( props?: GetInputArgs ): GetInputOutput => { const inputProps = this.getInputProps(props); return inputIsActor ? dropdownMenuProps.getActorProps(inputProps as GetActorArgs) : inputProps; }, getItemProps: this.getItemProps, registerVisibleItem: this.registerVisibleItem, registerItemCount: this.registerItemCount, inputValue: this.inputValue, selectedItem, highlightedIndex, actions: { open: this.openMenu, close: this.closeMenu, }, }) } ); } } export default AutoComplete;