autoComplete.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. /**
  2. * Inspired by [Downshift](https://github.com/paypal/downshift)
  3. *
  4. * Implemented with a stripped-down, compatible API for our use case.
  5. * May be worthwhile to switch if we find we need more features
  6. *
  7. * Basic idea is that we call `children` with props necessary to render with any sort of component structure.
  8. * This component handles logic like when the dropdown menu should be displayed, as well as handling keyboard input, how
  9. * it is rendered should be left to the child.
  10. */
  11. import {Component} from 'react';
  12. import DeprecatedDropdownMenu, {
  13. GetActorArgs,
  14. GetMenuArgs,
  15. } from 'sentry/components/deprecatedDropdownMenu';
  16. import {uniqueId} from 'sentry/utils/guid';
  17. const defaultProps = {
  18. itemToString: () => '',
  19. /**
  20. * If input should be considered an "actor". If there is another parent actor, then this should be `false`.
  21. * e.g. You have a button that opens this <AutoComplete> in a dropdown.
  22. */
  23. inputIsActor: true,
  24. disabled: false,
  25. closeOnSelect: true,
  26. /**
  27. * Can select autocomplete item with "Enter" key
  28. */
  29. shouldSelectWithEnter: true,
  30. /**
  31. * Can select autocomplete item with "Tab" key
  32. */
  33. shouldSelectWithTab: false,
  34. };
  35. type Item = {
  36. 'data-test-id'?: string;
  37. disabled?: boolean;
  38. };
  39. type GetInputArgs<E extends HTMLInputElement> = {
  40. onBlur?: (event: React.FocusEvent<E>) => void;
  41. onChange?: (event: React.ChangeEvent<E>) => void;
  42. onFocus?: (event: React.FocusEvent<E>) => void;
  43. onKeyDown?: (event: React.KeyboardEvent<E>) => void;
  44. placeholder?: string;
  45. style?: React.CSSProperties;
  46. type?: string;
  47. };
  48. type GetInputOutput<E extends HTMLInputElement> = GetInputArgs<E> &
  49. GetActorArgs<E> & {
  50. value?: string;
  51. };
  52. type GetItemArgs<T> = {
  53. index: number;
  54. item: T;
  55. onClick?: (item: T) => (e: React.MouseEvent) => void;
  56. };
  57. type ChildrenProps<T> = Parameters<DeprecatedDropdownMenu['props']['children']>[0] & {
  58. /**
  59. * Returns props for the input element that handles searching the items
  60. */
  61. getInputProps: <E extends HTMLInputElement = HTMLInputElement>(
  62. args: GetInputArgs<E>
  63. ) => GetInputOutput<E>;
  64. /**
  65. * Returns props for an individual item
  66. */
  67. getItemProps: (args: GetItemArgs<T>) => {
  68. onClick: (e: React.MouseEvent) => void;
  69. };
  70. /**
  71. * The actively highlighted item index
  72. */
  73. highlightedIndex: number;
  74. /**
  75. * The current value of the input box
  76. */
  77. inputValue: string;
  78. /**
  79. * Registers the total number of items in the dropdown menu.
  80. *
  81. * This must be called for keyboard navigation to work.
  82. */
  83. registerItemCount: (count?: number) => void;
  84. /**
  85. * Registers an item as being visible in the autocomplete menu. Returns an
  86. * cleanup function that unregisters the item as visible.
  87. *
  88. * This is needed for managing keyboard navigation when using react virtualized.
  89. *
  90. * NOTE: Even when NOT using a virtualized list, this must still be called for
  91. * keyboard navigation to work!
  92. */
  93. registerVisibleItem: (index: number, item: T) => () => void;
  94. /**
  95. * The current selected item
  96. */
  97. selectedItem?: T;
  98. };
  99. type State<T> = {
  100. highlightedIndex: number;
  101. inputValue: string;
  102. isOpen: boolean;
  103. selectedItem?: T;
  104. };
  105. type Props<T> = typeof defaultProps & {
  106. /**
  107. * Must be a function that returns a component
  108. */
  109. children: (props: ChildrenProps<T>) => React.ReactElement | null;
  110. disabled: boolean;
  111. defaultHighlightedIndex?: number;
  112. defaultInputValue?: string;
  113. inputValue?: string;
  114. isOpen?: boolean;
  115. itemToString?: (item?: T) => string;
  116. onClose?: (...args: Array<any>) => void;
  117. onInputValueChange?: (value: string) => void;
  118. onMenuOpen?: () => void;
  119. onOpen?: (...args: Array<any>) => void;
  120. onSelect?: (
  121. item: T,
  122. state?: State<T>,
  123. e?: React.MouseEvent | React.KeyboardEvent
  124. ) => void;
  125. /**
  126. * Resets autocomplete input when menu closes
  127. */
  128. resetInputOnClose?: boolean;
  129. };
  130. class AutoComplete<T extends Item> extends Component<Props<T>, State<T>> {
  131. static defaultProps = defaultProps;
  132. state: State<T> = this.getInitialState();
  133. getInitialState() {
  134. const {defaultHighlightedIndex, isOpen, inputValue, defaultInputValue} = this.props;
  135. return {
  136. isOpen: !!isOpen,
  137. highlightedIndex: defaultHighlightedIndex || 0,
  138. inputValue: inputValue ?? defaultInputValue ?? '',
  139. selectedItem: undefined,
  140. };
  141. }
  142. componentDidMount() {
  143. this._mounted = true;
  144. }
  145. componentDidUpdate(_prevProps: Props<T>, prevState: State<T>) {
  146. // If we do NOT want to close on select, then we should not reset highlight state
  147. // when we select an item (when we select an item, `this.state.selectedItem` changes)
  148. if (this.props.closeOnSelect && this.state.selectedItem !== prevState.selectedItem) {
  149. this.resetHighlightState();
  150. }
  151. }
  152. componentWillUnmount() {
  153. this._mounted = false;
  154. window.clearTimeout(this.blurTimeout);
  155. window.clearTimeout(this.cancelCloseTimeout);
  156. }
  157. private _mounted: boolean = false;
  158. private _id = `autocomplete-${uniqueId()}`;
  159. /**
  160. * Used to track keyboard navigation of items.
  161. */
  162. items = new Map<number, T>();
  163. /**
  164. * When using a virtualized list the length of the items mapping will not match
  165. * the actual item count. This stores the _real_ item count.
  166. */
  167. itemCount?: number;
  168. blurTimeout: number | undefined = undefined;
  169. cancelCloseTimeout: number | undefined = undefined;
  170. get inputValueIsControlled() {
  171. return typeof this.props.inputValue !== 'undefined';
  172. }
  173. get isOpenIsControlled() {
  174. return typeof this.props.isOpen !== 'undefined';
  175. }
  176. get inputValue() {
  177. return this.props.inputValue ?? this.state.inputValue;
  178. }
  179. get isOpen() {
  180. return this.isOpenIsControlled ? this.props.isOpen : this.state.isOpen;
  181. }
  182. makeItemId = (index: number) => {
  183. return `${this._id}-item-${index}`;
  184. };
  185. getItemElement = (index: number) => {
  186. const id = this.makeItemId(index);
  187. const element = document.getElementById(id);
  188. return element;
  189. };
  190. /**
  191. * Resets `this.items` and `this.state.highlightedIndex`.
  192. * Should be called whenever `inputValue` changes.
  193. */
  194. resetHighlightState() {
  195. // reset items and expect `getInputProps` in child to give us a list of new items
  196. this.setState({highlightedIndex: this.props.defaultHighlightedIndex ?? 0});
  197. }
  198. makeHandleInputChange<E extends HTMLInputElement>(
  199. onChange: GetInputArgs<E>['onChange']
  200. ) {
  201. // Some inputs (e.g. input) pass in only the event to the onChange listener and
  202. // others (e.g. TextField) pass in both the value and the event to the onChange listener.
  203. // This returned function is to accomodate both kinds of input components.
  204. return (
  205. valueOrEvent: string | React.ChangeEvent<E>,
  206. event?: React.ChangeEvent<E>
  207. ) => {
  208. const value: string =
  209. event === undefined
  210. ? (valueOrEvent as React.ChangeEvent<E>).target.value
  211. : (valueOrEvent as string);
  212. const changeEvent: React.ChangeEvent<E> =
  213. event === undefined ? (valueOrEvent as React.ChangeEvent<E>) : event;
  214. // We force `isOpen: true` here because:
  215. // 1) it's possible to have menu closed but input with focus (i.e. hitting "Esc")
  216. // 2) you select an item, input still has focus, and then change input
  217. this.openMenu();
  218. if (!this.inputValueIsControlled) {
  219. this.setState({
  220. inputValue: value,
  221. });
  222. }
  223. this.props.onInputValueChange?.(value);
  224. onChange?.(changeEvent);
  225. };
  226. }
  227. makeHandleInputFocus<E extends HTMLInputElement>(onFocus: GetInputArgs<E>['onFocus']) {
  228. return (e: React.FocusEvent<E>) => {
  229. this.openMenu();
  230. onFocus?.(e);
  231. };
  232. }
  233. /**
  234. * We need this delay because we want to close the menu when input
  235. * is blurred (i.e. clicking or via keyboard). However we have to handle the
  236. * case when we want to click on the dropdown and causes focus.
  237. *
  238. * Clicks outside should close the dropdown immediately via <DeprecatedDropdownMenu />,
  239. * however blur via keyboard will have a 200ms delay
  240. */
  241. makehandleInputBlur<E extends HTMLInputElement>(onBlur: GetInputArgs<E>['onBlur']) {
  242. return (e: React.FocusEvent<E>) => {
  243. window.clearTimeout(this.blurTimeout);
  244. this.blurTimeout = window.setTimeout(() => {
  245. this.closeMenu();
  246. onBlur?.(e);
  247. }, 200);
  248. };
  249. }
  250. // Dropdown detected click outside, we should close
  251. handleClickOutside = async () => {
  252. // Otherwise, it's possible that this gets fired multiple times
  253. // e.g. click outside triggers closeMenu and at the same time input gets blurred, so
  254. // a timer is set to close the menu
  255. window.clearTimeout(this.blurTimeout);
  256. // Wait until the current macrotask completes, in the case that the click
  257. // happened on a hovercard or some other element rendered outside of the
  258. // autocomplete, but controlled by the existence of the autocomplete, we
  259. // need to ensure any click handlers are run.
  260. await new Promise(resolve => window.setTimeout(resolve));
  261. this.closeMenu();
  262. };
  263. makeHandleInputKeydown<E extends HTMLInputElement>(
  264. onKeyDown: GetInputArgs<E>['onKeyDown']
  265. ) {
  266. return (e: React.KeyboardEvent<E>) => {
  267. const item = this.items.get(this.state.highlightedIndex);
  268. const isEnter = this.props.shouldSelectWithEnter && e.key === 'Enter';
  269. const isTab = this.props.shouldSelectWithTab && e.key === 'Tab';
  270. if (item !== undefined && (isEnter || isTab)) {
  271. if (!item.disabled) {
  272. this.handleSelect(item, e);
  273. }
  274. e.preventDefault();
  275. }
  276. if (e.key === 'ArrowUp') {
  277. this.moveHighlightedIndex(-1);
  278. e.preventDefault();
  279. }
  280. if (e.key === 'ArrowDown') {
  281. this.moveHighlightedIndex(1);
  282. e.preventDefault();
  283. }
  284. if (e.key === 'Escape') {
  285. this.closeMenu();
  286. }
  287. onKeyDown?.(e);
  288. };
  289. }
  290. makeHandleItemClick({item, index}: GetItemArgs<T>) {
  291. return (e: React.MouseEvent) => {
  292. if (item.disabled) {
  293. return;
  294. }
  295. window.clearTimeout(this.blurTimeout);
  296. this.setState({highlightedIndex: index});
  297. this.handleSelect(item, e);
  298. };
  299. }
  300. makeHandleMouseEnter({item, index}: GetItemArgs<T>) {
  301. return (_e: React.MouseEvent) => {
  302. if (item.disabled) {
  303. return;
  304. }
  305. this.setState({highlightedIndex: index});
  306. };
  307. }
  308. handleMenuMouseDown = () => {
  309. window.clearTimeout(this.cancelCloseTimeout);
  310. // Cancel close menu from input blur (mouseDown event can occur before input blur :()
  311. this.cancelCloseTimeout = window.setTimeout(() => {
  312. window.clearTimeout(this.blurTimeout);
  313. });
  314. };
  315. /**
  316. * When an item is selected via clicking or using the keyboard (e.g. pressing "Enter")
  317. */
  318. handleSelect(item: T, e: React.MouseEvent | React.KeyboardEvent) {
  319. const {onSelect, itemToString, closeOnSelect} = this.props;
  320. onSelect?.(item, this.state, e);
  321. if (closeOnSelect) {
  322. this.closeMenu();
  323. this.setState({
  324. inputValue: itemToString(item),
  325. selectedItem: item,
  326. });
  327. return;
  328. }
  329. this.setState({selectedItem: item});
  330. }
  331. moveHighlightedIndex(step: number) {
  332. let newIndex = this.state.highlightedIndex + step;
  333. // when this component is in virtualized mode, only a subset of items will
  334. // be passed down, making the map size inaccurate. instead we manually pass
  335. // the length as itemCount
  336. const listSize = this.itemCount ?? this.items.size;
  337. // Make sure new index is within bounds
  338. newIndex = Math.max(0, Math.min(newIndex, listSize - 1));
  339. this.setState({highlightedIndex: newIndex}, () => {
  340. // Scroll the newly highlighted element into view
  341. const highlightedElement = this.getItemElement(newIndex);
  342. if (highlightedElement && typeof highlightedElement.scrollIntoView === 'function') {
  343. highlightedElement.scrollIntoView({block: 'nearest'});
  344. }
  345. });
  346. }
  347. /**
  348. * Open dropdown menu
  349. *
  350. * This is exposed to render function
  351. */
  352. openMenu = (...args: Array<any>) => {
  353. const {onOpen, disabled} = this.props;
  354. onOpen?.(...args);
  355. if (disabled || this.isOpenIsControlled) {
  356. return;
  357. }
  358. this.resetHighlightState();
  359. this.setState({
  360. isOpen: true,
  361. });
  362. };
  363. /**
  364. * Close dropdown menu
  365. *
  366. * This is exposed to render function
  367. */
  368. closeMenu = (...args: Array<any>) => {
  369. const {onClose, resetInputOnClose} = this.props;
  370. onClose?.(...args);
  371. if (!this._mounted) {
  372. return;
  373. }
  374. this.setState(state => ({
  375. isOpen: !this.isOpenIsControlled ? false : state.isOpen,
  376. inputValue: resetInputOnClose ? '' : state.inputValue,
  377. }));
  378. };
  379. getInputProps<E extends HTMLInputElement>(
  380. inputProps?: GetInputArgs<E>
  381. ): GetInputOutput<E> {
  382. const {onChange, onKeyDown, onFocus, onBlur, ...rest} = inputProps ?? {};
  383. return {
  384. ...rest,
  385. value: this.inputValue,
  386. onChange: this.makeHandleInputChange<E>(onChange),
  387. onKeyDown: this.makeHandleInputKeydown<E>(onKeyDown),
  388. onFocus: this.makeHandleInputFocus<E>(onFocus),
  389. onBlur: this.makehandleInputBlur<E>(onBlur),
  390. };
  391. }
  392. getItemProps = (itemProps: GetItemArgs<T>) => {
  393. const {item, index, ...props} = itemProps ?? {};
  394. return {
  395. ...props,
  396. id: this.makeItemId(index),
  397. role: 'option',
  398. 'data-test-id': item['data-test-id'],
  399. onClick: this.makeHandleItemClick(itemProps),
  400. onMouseEnter: this.makeHandleMouseEnter(itemProps),
  401. };
  402. };
  403. registerVisibleItem = (index: number, item: T) => {
  404. this.items.set(index, item);
  405. return () => this.items.delete(index);
  406. };
  407. registerItemCount = (count?: number) => {
  408. this.itemCount = count;
  409. };
  410. render() {
  411. const {children, onMenuOpen, inputIsActor} = this.props;
  412. const {selectedItem, highlightedIndex} = this.state;
  413. const isOpen = this.isOpen;
  414. return (
  415. <DeprecatedDropdownMenu
  416. isOpen={isOpen}
  417. onClickOutside={this.handleClickOutside}
  418. onOpen={onMenuOpen}
  419. >
  420. {dropdownMenuProps =>
  421. children({
  422. ...dropdownMenuProps,
  423. getMenuProps: <E extends Element = Element>(props?: GetMenuArgs<E>) =>
  424. dropdownMenuProps.getMenuProps({
  425. ...props,
  426. onMouseDown: this.handleMenuMouseDown,
  427. }),
  428. getInputProps: <E extends HTMLInputElement = HTMLInputElement>(
  429. props?: GetInputArgs<E>
  430. ): GetInputOutput<E> => {
  431. const inputProps = this.getInputProps<E>(props);
  432. return inputIsActor
  433. ? dropdownMenuProps.getActorProps<E>(inputProps as GetActorArgs<E>)
  434. : inputProps;
  435. },
  436. getItemProps: this.getItemProps,
  437. registerVisibleItem: this.registerVisibleItem,
  438. registerItemCount: this.registerItemCount,
  439. inputValue: this.inputValue,
  440. selectedItem,
  441. highlightedIndex,
  442. actions: {
  443. open: this.openMenu,
  444. close: this.closeMenu,
  445. },
  446. })
  447. }
  448. </DeprecatedDropdownMenu>
  449. );
  450. }
  451. }
  452. export default AutoComplete;