select-collaborators.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import React, { useMemo, useState, useRef, useCallback } from 'react'
  2. import PropTypes from 'prop-types'
  3. import { Trans } from 'react-i18next'
  4. import { matchSorter } from 'match-sorter'
  5. import { useCombobox } from 'downshift'
  6. import classnames from 'classnames'
  7. import Icon from '../../../shared/components/icon'
  8. export default function SelectCollaborators({
  9. loading,
  10. options,
  11. placeholder,
  12. multipleSelectionProps
  13. }) {
  14. const {
  15. getSelectedItemProps,
  16. getDropdownProps,
  17. addSelectedItem,
  18. removeSelectedItem,
  19. selectedItems
  20. } = multipleSelectionProps
  21. const [inputValue, setInputValue] = useState('')
  22. const selectedEmails = useMemo(() => selectedItems.map(item => item.email), [
  23. selectedItems
  24. ])
  25. const unselectedOptions = useMemo(
  26. () => options.filter(option => !selectedEmails.includes(option.email)),
  27. [options, selectedEmails]
  28. )
  29. const filteredOptions = useMemo(
  30. () =>
  31. matchSorter(unselectedOptions, inputValue, {
  32. keys: ['name', 'email'],
  33. threshold: matchSorter.rankings.CONTAINS
  34. }),
  35. [unselectedOptions, inputValue]
  36. )
  37. const inputRef = useRef(null)
  38. const focusInput = useCallback(() => {
  39. if (inputRef.current) {
  40. window.setTimeout(() => {
  41. inputRef.current.focus()
  42. }, 10)
  43. }
  44. }, [inputRef])
  45. const isValidInput = useMemo(() => {
  46. if (inputValue.includes('@')) {
  47. for (const selectedItem of selectedItems) {
  48. if (selectedItem.email === inputValue) {
  49. return false
  50. }
  51. }
  52. }
  53. return true
  54. }, [inputValue, selectedItems])
  55. const addNewItem = useCallback(
  56. (email, focus = true) => {
  57. if (
  58. isValidInput &&
  59. email.includes('@') &&
  60. !selectedEmails.includes(email)
  61. ) {
  62. addSelectedItem({
  63. email,
  64. display: email,
  65. type: 'user'
  66. })
  67. setInputValue('')
  68. if (focus) {
  69. focusInput()
  70. }
  71. return true
  72. }
  73. },
  74. [addSelectedItem, selectedEmails, isValidInput, focusInput]
  75. )
  76. const {
  77. isOpen,
  78. getLabelProps,
  79. getMenuProps,
  80. getInputProps,
  81. getComboboxProps,
  82. highlightedIndex,
  83. getItemProps
  84. } = useCombobox({
  85. inputValue,
  86. defaultHighlightedIndex: 0,
  87. items: filteredOptions,
  88. itemToString: item => item && item.name,
  89. onStateChange: ({ inputValue, type, selectedItem }) => {
  90. switch (type) {
  91. // set inputValue when the input changes
  92. case useCombobox.stateChangeTypes.InputChange:
  93. setInputValue(inputValue)
  94. break
  95. // add a selected item on Enter (keypress), click or blur
  96. case useCombobox.stateChangeTypes.InputKeyDownEnter:
  97. case useCombobox.stateChangeTypes.ItemClick:
  98. case useCombobox.stateChangeTypes.InputBlur:
  99. if (selectedItem) {
  100. setInputValue('')
  101. addSelectedItem(selectedItem)
  102. }
  103. break
  104. }
  105. }
  106. })
  107. const showDropdownItems =
  108. isOpen && inputValue.length > 0 && filteredOptions.length > 0
  109. return (
  110. <div className="tags-input">
  111. {/* eslint-disable-next-line jsx-a11y/label-has-for */}
  112. <label className="small" {...getLabelProps()}>
  113. <Trans i18nKey="share_with_your_collabs" />
  114. &nbsp;
  115. {loading && <Icon type="refresh" spin />}
  116. </label>
  117. <div className="host">
  118. {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
  119. <div {...getComboboxProps()} className="tags" onClick={focusInput}>
  120. {selectedItems.map((selectedItem, index) => (
  121. <SelectedItem
  122. key={`selected-item-${index}`}
  123. removeSelectedItem={removeSelectedItem}
  124. selectedItem={selectedItem}
  125. focusInput={focusInput}
  126. index={index}
  127. getSelectedItemProps={getSelectedItemProps}
  128. />
  129. ))}
  130. <input
  131. {...getInputProps(
  132. getDropdownProps({
  133. className: classnames({
  134. input: true,
  135. 'invalid-tag': !isValidInput
  136. }),
  137. type: 'email',
  138. placeholder,
  139. size: inputValue.length
  140. ? inputValue.length + 5
  141. : placeholder.length,
  142. ref: inputRef,
  143. // preventKeyAction: showDropdown,
  144. onBlur: () => {
  145. // blur: if the dropdown isn't visible, try to create a new item using inputValue
  146. if (!showDropdownItems) {
  147. addNewItem(inputValue, false)
  148. }
  149. },
  150. onKeyDown: event => {
  151. switch (event.key) {
  152. case 'Enter':
  153. // Enter: always prevent form submission
  154. event.preventDefault()
  155. event.stopPropagation()
  156. break
  157. case 'Tab':
  158. // Tab: if the dropdown isn't visible, try to create a new item using inputValue and prevent blur if successful
  159. if (!showDropdownItems && addNewItem(inputValue)) {
  160. event.preventDefault()
  161. event.stopPropagation()
  162. }
  163. break
  164. case ',':
  165. // comma: try to create a new item using inputValue
  166. event.preventDefault()
  167. addNewItem(inputValue)
  168. break
  169. }
  170. },
  171. onPaste: event => {
  172. const data =
  173. // modern browsers
  174. event.clipboardData?.getData('text/plain') ??
  175. // IE11
  176. window.clipboardData?.getData('text')
  177. if (data) {
  178. const emails = data
  179. .split(/\s*,\s*/)
  180. .filter(item => item.includes('@'))
  181. if (emails.length) {
  182. // pasted comma-separated email addresses
  183. event.preventDefault()
  184. for (const email of emails) {
  185. addNewItem(email)
  186. }
  187. }
  188. }
  189. }
  190. })
  191. )}
  192. />
  193. </div>
  194. <div className={classnames({ autocomplete: showDropdownItems })}>
  195. <ul {...getMenuProps()} className="suggestion-list">
  196. {showDropdownItems &&
  197. filteredOptions.map((item, index) => (
  198. <Option
  199. key={item.email}
  200. index={index}
  201. item={item}
  202. selected={index === highlightedIndex}
  203. getItemProps={getItemProps}
  204. />
  205. ))}
  206. </ul>
  207. </div>
  208. </div>
  209. </div>
  210. )
  211. }
  212. SelectCollaborators.propTypes = {
  213. loading: PropTypes.bool.isRequired,
  214. options: PropTypes.array.isRequired,
  215. placeholder: PropTypes.string,
  216. multipleSelectionProps: PropTypes.shape({
  217. getSelectedItemProps: PropTypes.func.isRequired,
  218. getDropdownProps: PropTypes.func.isRequired,
  219. addSelectedItem: PropTypes.func.isRequired,
  220. removeSelectedItem: PropTypes.func.isRequired,
  221. selectedItems: PropTypes.array.isRequired
  222. }).isRequired
  223. }
  224. function Option({ selected, item, getItemProps, index }) {
  225. return (
  226. <li
  227. className={classnames('suggestion-item', { selected })}
  228. {...getItemProps({ item, index })}
  229. >
  230. <Icon type="user" modifier="fw" />
  231. &nbsp;
  232. {item.display}
  233. </li>
  234. )
  235. }
  236. Option.propTypes = {
  237. selected: PropTypes.bool.isRequired,
  238. item: PropTypes.shape({
  239. display: PropTypes.string.isRequired
  240. }),
  241. index: PropTypes.number.isRequired,
  242. getItemProps: PropTypes.func.isRequired
  243. }
  244. function SelectedItem({
  245. removeSelectedItem,
  246. selectedItem,
  247. focusInput,
  248. getSelectedItemProps,
  249. index
  250. }) {
  251. const handleClick = useCallback(
  252. event => {
  253. event.preventDefault()
  254. event.stopPropagation()
  255. removeSelectedItem(selectedItem)
  256. focusInput()
  257. },
  258. [focusInput, removeSelectedItem, selectedItem]
  259. )
  260. return (
  261. <span
  262. className="tag-item"
  263. {...getSelectedItemProps({ selectedItem, index })}
  264. >
  265. <Icon type="user" modifier="fw" />
  266. <span>{selectedItem.display}</span>
  267. <button
  268. type="button"
  269. className="remove-button btn-inline-link"
  270. aria-label="Remove"
  271. onClick={handleClick}
  272. >
  273. <Icon type="close" modifier="fw" />
  274. </button>
  275. </span>
  276. )
  277. }
  278. SelectedItem.propTypes = {
  279. focusInput: PropTypes.func.isRequired,
  280. removeSelectedItem: PropTypes.func.isRequired,
  281. selectedItem: PropTypes.shape({
  282. display: PropTypes.string.isRequired
  283. }),
  284. getSelectedItemProps: PropTypes.func.isRequired,
  285. index: PropTypes.number.isRequired
  286. }