autoComplete.tsx 14 KB

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