autoComplete.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  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 React from 'react';
  12. import DropdownMenu, {GetActorArgs, GetMenuArgs} from 'app/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 GetInputArgs<E extends HTMLInputElement> = {
  32. type?: string;
  33. placeholder?: string;
  34. style?: React.CSSProperties;
  35. onChange?: (event: React.ChangeEvent<E>) => void;
  36. onKeyDown?: (event: React.KeyboardEvent<E>) => void;
  37. onFocus?: (event: React.FocusEvent<E>) => void;
  38. onBlur?: (event: React.FocusEvent<E>) => void;
  39. };
  40. type GetInputOutput<E extends HTMLInputElement> = GetInputArgs<E> &
  41. GetActorArgs<E> & {
  42. value?: string;
  43. };
  44. export type GetItemArgs<T> = {
  45. item: T;
  46. index: number;
  47. onClick?: (item: T) => (e: React.MouseEvent) => void;
  48. style?: React.CSSProperties;
  49. };
  50. type ChildrenProps<T> = Parameters<DropdownMenu['props']['children']>[0] & {
  51. highlightedIndex: number;
  52. getInputProps: <E extends HTMLInputElement = HTMLInputElement>(
  53. args: GetInputArgs<E>
  54. ) => GetInputOutput<E>;
  55. getItemProps: (
  56. args: GetItemArgs<T>
  57. ) => Pick<GetItemArgs<T>, 'style'> & {
  58. onClick: (e: React.MouseEvent) => void;
  59. };
  60. inputValue: string;
  61. selectedItem?: T;
  62. };
  63. type State<T> = {
  64. isOpen: boolean;
  65. highlightedIndex: number;
  66. inputValue: string;
  67. selectedItem?: T;
  68. };
  69. type Props<T> = typeof defaultProps & {
  70. /**
  71. * Must be a function that returns a component
  72. */
  73. children: (props: ChildrenProps<T>) => React.ReactElement;
  74. disabled: boolean;
  75. itemToString?: (item?: T) => string;
  76. /**
  77. * Resets autocomplete input when menu closes
  78. */
  79. resetInputOnClose?: boolean;
  80. /**
  81. * Currently, this does not act as a "controlled" prop, only for initial state of dropdown
  82. */
  83. isOpen?: boolean;
  84. defaultHighlightedIndex?: number;
  85. defaultInputValue?: string;
  86. onOpen?: (...args: Array<any>) => void;
  87. onClose?: (...args: Array<any>) => void;
  88. onSelect?: (
  89. item: T,
  90. state?: State<T>,
  91. e?: React.MouseEvent | React.KeyboardEvent
  92. ) => void;
  93. onMenuOpen?: () => void;
  94. };
  95. class AutoComplete<T> extends React.Component<Props<T>, State<T>> {
  96. static defaultProps = defaultProps;
  97. state: State<T> = this.getInitialState();
  98. getInitialState() {
  99. const {defaultHighlightedIndex, isOpen, defaultInputValue} = this.props;
  100. return {
  101. isOpen: !!isOpen,
  102. highlightedIndex: defaultHighlightedIndex || 0,
  103. inputValue: defaultInputValue || '',
  104. selectedItem: undefined,
  105. };
  106. }
  107. UNSAFE_componentWillReceiveProps(nextProps, nextState) {
  108. // If we do NOT want to close on select, then we should not reset highlight state
  109. // when we select an item (when we select an item, `this.state.selectedItem` changes)
  110. if (!nextProps.closeOnSelect && this.state.selectedItem !== nextState.selectedItem) {
  111. return;
  112. }
  113. this.resetHighlightState();
  114. }
  115. UNSAFE_componentWillUpdate() {
  116. this.items.clear();
  117. }
  118. items = new Map();
  119. blurTimer: any;
  120. itemCount?: number;
  121. isControlled = () => typeof this.props.isOpen !== 'undefined';
  122. getOpenState = () => {
  123. const {isOpen} = this.props;
  124. return this.isControlled() ? isOpen : this.state.isOpen;
  125. };
  126. /**
  127. * Resets `this.items` and `this.state.highlightedIndex`.
  128. * Should be called whenever `inputValue` changes.
  129. */
  130. resetHighlightState = () => {
  131. // reset items and expect `getInputProps` in child to give us a list of new items
  132. this.setState({
  133. highlightedIndex: this.props.defaultHighlightedIndex || 0,
  134. });
  135. };
  136. handleInputChange = <E extends HTMLInputElement>({
  137. onChange,
  138. }: Pick<GetInputArgs<E>, 'onChange'>) => (e: React.ChangeEvent<E>) => {
  139. const value = e.target.value;
  140. // We force `isOpen: true` here because:
  141. // 1) it's possible to have menu closed but input with focus (i.e. hitting "Esc")
  142. // 2) you select an item, input still has focus, and then change input
  143. this.openMenu();
  144. this.setState({
  145. inputValue: value,
  146. });
  147. onChange?.(e);
  148. };
  149. handleInputFocus = <E extends HTMLInputElement>({
  150. onFocus,
  151. }: Pick<GetInputArgs<E>, 'onFocus'>) => (e: React.FocusEvent<E>) => {
  152. this.openMenu();
  153. onFocus?.(e);
  154. };
  155. /**
  156. *
  157. * We need this delay because we want to close the menu when input
  158. * is blurred (i.e. clicking or via keyboard). However we have to handle the
  159. * case when we want to click on the dropdown and causes focus.
  160. *
  161. * Clicks outside should close the dropdown immediately via <DropdownMenu />,
  162. * however blur via keyboard will have a 200ms delay
  163. */
  164. handleInputBlur = <E extends HTMLInputElement>({
  165. onBlur,
  166. }: Pick<GetInputArgs<E>, 'onBlur'>) => (e: React.FocusEvent<E>) => {
  167. this.blurTimer = setTimeout(() => {
  168. this.closeMenu();
  169. onBlur?.(e);
  170. }, 200);
  171. };
  172. // Dropdown detected click outside, we should close
  173. handleClickOutside = async () => {
  174. // Otherwise, it's possible that this gets fired multiple times
  175. // e.g. click outside triggers closeMenu and at the same time input gets blurred, so
  176. // a timer is set to close the menu
  177. if (this.blurTimer) {
  178. clearTimeout(this.blurTimer);
  179. }
  180. // Wait until the current macrotask completes, in the case that the click
  181. // happened on a hovercard or some other element rendered outside of the
  182. // autocomplete, but controlled by the existence of the autocomplete, we
  183. // need to ensure any click handlers are run.
  184. await new Promise(resolve => setTimeout(resolve));
  185. this.closeMenu();
  186. };
  187. handleInputKeyDown = <E extends HTMLInputElement>({
  188. onKeyDown,
  189. }: Pick<GetInputArgs<E>, 'onKeyDown'>) => (e: React.KeyboardEvent<E>) => {
  190. const hasHighlightedItem =
  191. this.items.size && this.items.has(this.state.highlightedIndex);
  192. const canSelectWithEnter = this.props.shouldSelectWithEnter && e.key === 'Enter';
  193. const canSelectWithTab = this.props.shouldSelectWithTab && e.key === 'Tab';
  194. if (hasHighlightedItem && (canSelectWithEnter || canSelectWithTab)) {
  195. this.handleSelect(this.items.get(this.state.highlightedIndex), e);
  196. e.preventDefault();
  197. }
  198. if (e.key === 'ArrowUp') {
  199. this.moveHighlightedIndex(-1);
  200. e.preventDefault();
  201. }
  202. if (e.key === 'ArrowDown') {
  203. this.moveHighlightedIndex(1);
  204. e.preventDefault();
  205. }
  206. if (e.key === 'Escape') {
  207. this.closeMenu();
  208. }
  209. onKeyDown?.(e);
  210. };
  211. handleItemClick = ({onClick, item, index}: GetItemArgs<T>) => (e: React.MouseEvent) => {
  212. if (this.blurTimer) {
  213. clearTimeout(this.blurTimer);
  214. }
  215. this.setState({highlightedIndex: index});
  216. this.handleSelect(item, e);
  217. onClick?.(item)(e);
  218. };
  219. handleMenuMouseDown = () => {
  220. // Cancel close menu from input blur (mouseDown event can occur before input blur :()
  221. setTimeout(() => {
  222. if (this.blurTimer) {
  223. clearTimeout(this.blurTimer);
  224. }
  225. });
  226. };
  227. /**
  228. * When an item is selected via clicking or using the keyboard (e.g. pressing "Enter")
  229. */
  230. handleSelect = (item: T, e: React.MouseEvent | React.KeyboardEvent) => {
  231. const {onSelect, itemToString, closeOnSelect} = this.props;
  232. onSelect?.(item, this.state, e);
  233. if (closeOnSelect) {
  234. this.closeMenu();
  235. this.setState({
  236. inputValue: itemToString(item),
  237. selectedItem: item,
  238. });
  239. return;
  240. }
  241. this.setState({selectedItem: item});
  242. };
  243. moveHighlightedIndex(step: number) {
  244. let newIndex = this.state.highlightedIndex + step;
  245. // when this component is in virtualized mode, only a subset of items will be passed
  246. // down, making the array length inaccurate. instead we manually pass the length as itemCount
  247. const listSize = this.itemCount || this.items.size;
  248. // Make sure new index is within bounds
  249. newIndex = Math.max(0, Math.min(newIndex, listSize - 1));
  250. this.setState({highlightedIndex: newIndex});
  251. }
  252. /**
  253. * Open dropdown menu
  254. *
  255. * This is exposed to render function
  256. */
  257. openMenu = (...args: Array<any>) => {
  258. const {onOpen, disabled} = this.props;
  259. onOpen?.(...args);
  260. if (disabled || this.isControlled()) {
  261. return;
  262. }
  263. this.resetHighlightState();
  264. this.setState({
  265. isOpen: true,
  266. });
  267. };
  268. /**
  269. * Close dropdown menu
  270. *
  271. * This is exposed to render function
  272. */
  273. closeMenu = (...args: Array<any>) => {
  274. const {onClose, resetInputOnClose} = this.props;
  275. onClose?.(...args);
  276. if (this.isControlled()) {
  277. return;
  278. }
  279. this.setState(state => ({
  280. isOpen: false,
  281. inputValue: resetInputOnClose ? '' : state.inputValue,
  282. }));
  283. };
  284. getInputProps = <E extends HTMLInputElement>(
  285. inputProps?: GetInputArgs<E>
  286. ): GetInputOutput<E> => {
  287. const {onChange, onKeyDown, onFocus, onBlur, ...rest} = inputProps ?? {};
  288. return {
  289. ...rest,
  290. value: this.state.inputValue,
  291. onChange: this.handleInputChange<E>({onChange}),
  292. onKeyDown: this.handleInputKeyDown<E>({onKeyDown}),
  293. onFocus: this.handleInputFocus<E>({onFocus}),
  294. onBlur: this.handleInputBlur<E>({onBlur}),
  295. };
  296. };
  297. getItemProps = (itemProps: GetItemArgs<T>) => {
  298. const {item, index, ...props} = itemProps ?? {};
  299. if (!item) {
  300. // eslint-disable-next-line no-console
  301. console.warn('getItemProps requires an object with an `item` key');
  302. }
  303. const newIndex = index ?? this.items.size;
  304. this.items.set(newIndex, item);
  305. return {
  306. ...props,
  307. onClick: this.handleItemClick({item, index: newIndex, ...props}),
  308. };
  309. };
  310. getMenuProps = <E extends Element>(props?: GetMenuArgs<E>): GetMenuArgs<E> => {
  311. this.itemCount = props?.itemCount;
  312. return {
  313. ...(props ?? {}),
  314. onMouseDown: this.handleMenuMouseDown,
  315. };
  316. };
  317. render() {
  318. const {children, onMenuOpen, inputIsActor} = this.props;
  319. const {selectedItem, inputValue, highlightedIndex} = this.state;
  320. const isOpen = this.getOpenState();
  321. return (
  322. <DropdownMenu
  323. isOpen={isOpen}
  324. onClickOutside={this.handleClickOutside}
  325. onOpen={onMenuOpen}
  326. >
  327. {dropdownMenuProps =>
  328. children({
  329. ...dropdownMenuProps,
  330. getMenuProps: <E extends Element = Element>(props?: GetMenuArgs<E>) =>
  331. dropdownMenuProps.getMenuProps(this.getMenuProps(props)),
  332. getInputProps: <E extends HTMLInputElement = HTMLInputElement>(
  333. props?: GetInputArgs<E>
  334. ): GetInputOutput<E> => {
  335. const inputProps = this.getInputProps<E>(props);
  336. if (!inputIsActor) {
  337. return inputProps;
  338. }
  339. return dropdownMenuProps.getActorProps<E>(inputProps as GetActorArgs<E>);
  340. },
  341. getItemProps: this.getItemProps,
  342. inputValue,
  343. selectedItem,
  344. highlightedIndex,
  345. actions: {
  346. open: this.openMenu,
  347. close: this.closeMenu,
  348. },
  349. })
  350. }
  351. </DropdownMenu>
  352. );
  353. }
  354. }
  355. export default AutoComplete;