autoComplete.tsx 12 KB

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