autoComplete.tsx 15 KB

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