autoComplete.tsx 14 KB

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