AutocompleteDropdown.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. /*
  2. Copyright (C) 2017 Cloudbase Solutions SRL
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>.
  13. */
  14. // @flow
  15. import * as React from 'react'
  16. import { observer } from 'mobx-react'
  17. import styled, { css } from 'styled-components'
  18. import ReactDOM from 'react-dom'
  19. import autobind from 'autobind-decorator'
  20. import AutocompleteInput from '../../atoms/AutocompleteInput'
  21. import { Tip, updateTipStyle, scrollItemIntoView, handleKeyNavigation } from '../Dropdown'
  22. import tipImage from '../Dropdown/images/tip'
  23. import Palette from '../../styleUtils/Palette'
  24. import DomUtils from '../../../utils/DomUtils'
  25. import StyleProps from '../../styleUtils/StyleProps'
  26. import requiredImage from './images/required.svg'
  27. const getWidth = props => {
  28. if (props.width) {
  29. return props.width - 2
  30. }
  31. if (props.large) {
  32. return StyleProps.inputSizes.large.width - 2
  33. }
  34. return StyleProps.inputSizes.regular.width - 2
  35. }
  36. const Wrapper = styled.div`
  37. position: relative;
  38. ${props => props.embedded ? css`width: 100%;` : props.width ? css`width: ${props.width}px;` : ''}
  39. `
  40. const Required = styled.div`
  41. position: absolute;
  42. width: 8px;
  43. height: 8px;
  44. right: ${props => props.right}px;
  45. top: 12px;
  46. background: url('${requiredImage}') center no-repeat;
  47. `
  48. const List = styled.div`
  49. position: absolute;
  50. background: white;
  51. cursor: pointer;
  52. width: ${props => getWidth(props)}px;
  53. border: 1px solid ${Palette.grayscale[3]};
  54. border-radius: ${StyleProps.borderRadius};
  55. z-index: 1000;
  56. `
  57. const Separator = styled.div`
  58. width: calc(100% - 32px);
  59. height: 1px;
  60. margin: 8px 16px;
  61. background: ${Palette.grayscale[3]};
  62. `
  63. const ListItems = styled.div`
  64. max-height: 400px;
  65. overflow: auto;
  66. `
  67. const SearchNotFound = styled.div`
  68. padding: 8px;
  69. cursor: default;
  70. `
  71. const ListItem = styled.div`
  72. position: relative;
  73. color: ${props => props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
  74. ${props => props.arrowSelected ? css`background: ${Palette.primary}44;` : ''}
  75. ${props => props.selected ? css`background: ${Palette.primary};` : ''}
  76. ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
  77. padding: 8px 16px;
  78. transition: all ${StyleProps.animations.swift};
  79. word-break: break-word;
  80. &:first-child {
  81. border-top-left-radius: ${StyleProps.borderRadius};
  82. border-top-right-radius: ${StyleProps.borderRadius};
  83. }
  84. &:last-child {
  85. border-bottom-left-radius: ${StyleProps.borderRadius};
  86. border-bottom-right-radius: ${StyleProps.borderRadius};
  87. }
  88. &:hover {
  89. background: ${Palette.primary};
  90. color: white;
  91. }
  92. `
  93. const DuplicatedLabel = styled.div`
  94. display: flex;
  95. font-size: 11px;
  96. span {
  97. text-overflow: ellipsis;
  98. white-space: nowrap;
  99. overflow: hidden;
  100. }
  101. `
  102. type Props = {
  103. selectedItem?: any,
  104. items?: any[],
  105. labelField?: string,
  106. valueField?: string,
  107. className?: string,
  108. onChange?: (item: any) => void,
  109. onInputChange?: (value: string, filteredItems: any[]) => void,
  110. noItemsMessage?: string,
  111. disabled?: boolean,
  112. disabledLoading?: boolean,
  113. width?: number,
  114. dimNullValue?: boolean,
  115. highlight?: boolean,
  116. 'data-test-id'?: string,
  117. required?: boolean,
  118. embedded?: boolean,
  119. }
  120. type State = {
  121. showDropdownList: boolean,
  122. firstItemHover: boolean,
  123. searchValue: string,
  124. filteredItems: any[],
  125. arrowSelection: ?number,
  126. }
  127. @observer
  128. class AutocompleteDropdown extends React.Component<Props, State> {
  129. static defaultProps: $Shape<Props> = {
  130. noItemsMessage: 'No results found',
  131. }
  132. state = {
  133. showDropdownList: false,
  134. firstItemHover: false,
  135. searchValue: '',
  136. filteredItems: [],
  137. arrowSelection: null,
  138. }
  139. buttonRef: HTMLElement
  140. listRef: HTMLElement
  141. listItemsRef: HTMLElement
  142. tipRef: HTMLElement
  143. firstItemRef: HTMLElement
  144. scrollableParent: HTMLElement
  145. buttonRect: ClientRect
  146. itemMouseDown: boolean
  147. componentWillMount() {
  148. this.setState({
  149. filteredItems: this.props.items,
  150. searchValue: this.props.selectedItem ? this.getLabel(this.props.selectedItem) : '',
  151. })
  152. }
  153. componentDidMount() {
  154. if (this.buttonRef) {
  155. this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
  156. this.scrollableParent.addEventListener('scroll', this.handleScroll)
  157. window.addEventListener('resize', this.handleScroll)
  158. this.buttonRect = this.buttonRef.getBoundingClientRect()
  159. }
  160. }
  161. componentWillReceiveProps(newProps: Props) {
  162. this.setState({ filteredItems: this.getFilteredItems(newProps) })
  163. }
  164. componentWillUpdate() {
  165. if (this.buttonRef) this.buttonRect = this.buttonRef.getBoundingClientRect()
  166. }
  167. componentDidUpdate() {
  168. this.updateListPosition()
  169. }
  170. componentWillUnmount() {
  171. window.removeEventListener('resize', this.handleScroll, false)
  172. this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
  173. }
  174. getLabel(item: any) {
  175. let labelField = this.props.labelField || 'label'
  176. if (item == null) {
  177. return ''
  178. }
  179. return (item[labelField] != null && item[labelField].toString()) || item.toString()
  180. }
  181. getValue(item: any) {
  182. let valueField = this.props.valueField || 'value'
  183. if (item == null) {
  184. return null
  185. }
  186. if (typeof item === 'string') {
  187. return item
  188. }
  189. return (item[valueField] != null && item[valueField].toString()) || null
  190. }
  191. getFilteredItems(props?: ?Props, searchValue?: string): any[] {
  192. let useProps = props || this.props
  193. let useSearch = searchValue === undefined ? this.state.searchValue : searchValue
  194. if (!useProps.items) {
  195. return []
  196. }
  197. return useProps.items.filter(i => {
  198. const label = this.getLabel(i).toLowerCase()
  199. const value = this.getValue(i) || ''
  200. return label.indexOf(useSearch.toLowerCase()) > -1 || value.indexOf(useSearch.toLowerCase()) > -1
  201. })
  202. }
  203. @autobind
  204. handleScroll() {
  205. if (this.buttonRef) {
  206. if (DomUtils.isElementInViewport(this.buttonRef, this.scrollableParent)) {
  207. this.buttonRect = this.buttonRef.getBoundingClientRect()
  208. this.updateListPosition()
  209. } else if (this.state.showDropdownList) {
  210. this.setState({ showDropdownList: false })
  211. }
  212. }
  213. }
  214. closeDropdownList() {
  215. if (!this.itemMouseDown) {
  216. this.setState({ showDropdownList: false })
  217. }
  218. }
  219. handleItemClick(item: any) {
  220. this.setState({
  221. showDropdownList: false,
  222. firstItemHover: false,
  223. searchValue: this.getLabel(item),
  224. filteredItems: this.getFilteredItems(null, this.getLabel(item)),
  225. })
  226. if (this.props.onChange) {
  227. this.props.onChange(item)
  228. }
  229. }
  230. handleItemMouseEnter(index: number) {
  231. if (index === 0) {
  232. this.setState({ firstItemHover: true })
  233. }
  234. }
  235. handleItemMouseLeave(index: number) {
  236. if (index === 0) {
  237. this.setState({ firstItemHover: false })
  238. }
  239. }
  240. handleInputKeyDown(e: SyntheticKeyboardEvent<HTMLInputElement>) {
  241. if (!this.state.showDropdownList) {
  242. return
  243. }
  244. handleKeyNavigation({
  245. submitKeys: ['Enter'],
  246. keyboardEvent: e,
  247. arrowSelection: this.state.arrowSelection,
  248. items: this.state.filteredItems,
  249. selectedItem: this.props.selectedItem,
  250. onSubmit: item => { this.handleItemClick(item) },
  251. onGetValue: item => this.getValue(item),
  252. onSelection: arrowSelection => {
  253. this.setState({ arrowSelection }, () => {
  254. this.scrollIntoView(arrowSelection)
  255. })
  256. },
  257. })
  258. }
  259. handleSearchInputChange(searchValue: string, isFocus?: boolean) {
  260. let filteredItems = isFocus ? this.props.items || [] : this.getFilteredItems(null, searchValue)
  261. this.setState({
  262. searchValue,
  263. filteredItems,
  264. showDropdownList: true,
  265. arrowSelection: null,
  266. }, () => {
  267. if (isFocus) {
  268. this.scrollIntoView()
  269. }
  270. })
  271. if (this.props.onInputChange) {
  272. this.props.onInputChange(searchValue, filteredItems)
  273. }
  274. }
  275. scrollIntoView(itemIndex?: number) {
  276. let selectedItemIndex = this.state.filteredItems
  277. .findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
  278. let actualItemIndex = itemIndex != null ? itemIndex : selectedItemIndex
  279. scrollItemIntoView(this.listRef, this.listItemsRef, actualItemIndex)
  280. }
  281. updateListPosition() {
  282. if (!this.state.showDropdownList || !this.listRef || !this.buttonRef || !document.body) {
  283. return
  284. }
  285. let buttonHeight = this.buttonRef.offsetHeight
  286. let tipHeight = 8
  287. let listTop = this.buttonRect.top + buttonHeight + tipHeight
  288. let listHeight = this.listRef.offsetHeight
  289. if (listTop + listHeight + 16 > window.innerHeight) {
  290. listHeight = window.innerHeight - listTop - 16
  291. } else {
  292. listHeight = 400
  293. }
  294. // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
  295. let scrollOffset = 0
  296. if (parseInt(document.body.style.top, 10) < 0) {
  297. scrollOffset = -parseInt(document.body && document.body.style.top, 10)
  298. }
  299. let widthDiff = this.listRef.offsetWidth - this.buttonRef.offsetWidth
  300. this.listRef.style.top = `${listTop + (window.pageYOffset || scrollOffset)}px`
  301. this.listRef.style.left = `${(this.buttonRect.left + window.pageXOffset) - widthDiff}px`
  302. if (this.listItemsRef) {
  303. this.listItemsRef.style.maxHeight = `${listHeight}px`
  304. updateTipStyle(this.listItemsRef, this.tipRef, this.firstItemRef)
  305. }
  306. }
  307. renderItems() {
  308. if (this.state.filteredItems.length === 0) {
  309. return null
  310. }
  311. let selectedValue = this.getValue(this.props.selectedItem)
  312. let duplicatedLabels = []
  313. this.state.filteredItems.forEach((item, i) => {
  314. let label = this.getLabel(item)
  315. for (let j = i + 1; j < this.state.filteredItems.length; j += 1) {
  316. if (label === this.getLabel(this.state.filteredItems[j]) && !duplicatedLabels.find(item2 => this.getLabel(item2) === label)) {
  317. duplicatedLabels.push(label)
  318. }
  319. }
  320. })
  321. return (
  322. <ListItems innerRef={ref => { this.listItemsRef = ref }}>
  323. {this.state.filteredItems.map((item, i) => {
  324. if (item.separator === true) {
  325. return <Separator key={`sep-${i}`} />
  326. }
  327. let label = this.getLabel(item)
  328. let value = this.getValue(item)
  329. let duplicatedLabel = duplicatedLabels.find(l => l === label)
  330. let listItem = (
  331. <ListItem
  332. data-test-id="ad-listItem"
  333. key={value}
  334. innerRef={ref => { if (i === 0) { this.firstItemRef = ref } }}
  335. onMouseDown={() => { this.itemMouseDown = true }}
  336. onMouseUp={() => { this.itemMouseDown = false }}
  337. onMouseEnter={() => { this.handleItemMouseEnter(i) }}
  338. onMouseLeave={() => { this.handleItemMouseLeave(i) }}
  339. onClick={() => { this.handleItemClick(item) }}
  340. selected={value !== null && value === selectedValue}
  341. dim={this.props.dimNullValue && value == null}
  342. arrowSelected={i === this.state.arrowSelection}
  343. >
  344. {label}
  345. {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
  346. </ListItem>
  347. )
  348. return listItem
  349. })}
  350. </ListItems>
  351. )
  352. }
  353. renderSearchNotFound() {
  354. if (this.state.searchValue === '' || !this.props.items || this.props.items.length === 0 || this.state.filteredItems.length > 0) {
  355. return null
  356. }
  357. return (
  358. <ListItems>
  359. <SearchNotFound onClick={() => { this.setState({ showDropdownList: false }) }}>
  360. {this.props.noItemsMessage}
  361. </SearchNotFound>
  362. </ListItems>
  363. )
  364. }
  365. renderList() {
  366. if (!this.state.showDropdownList) {
  367. return null
  368. }
  369. const body: any = document.body
  370. const selectedItemValue = this.getValue(this.props.selectedItem)
  371. const firstItemValue = this.state.filteredItems.length > 0 ? this.getValue(this.state.filteredItems[0]) : null
  372. const isFirstItemSelected = selectedItemValue !== null && selectedItemValue === firstItemValue
  373. let list = ReactDOM.createPortal((
  374. <List {...this.props} innerRef={ref => { this.listRef = ref }}>
  375. <Tip
  376. innerRef={ref => { this.tipRef = ref }}
  377. primary={this.state.firstItemHover || isFirstItemSelected}
  378. dangerouslySetInnerHTML={{ __html: tipImage }}
  379. />
  380. {this.renderItems()}
  381. {this.renderSearchNotFound()}
  382. </List>
  383. ), body)
  384. return list
  385. }
  386. render() {
  387. let nullLabel = this.props.items && this.getValue(this.props.items[0]) === null ? this.getLabel(this.props.items[0]) : ''
  388. let inputValue = this.getValue(this.props.selectedItem) === null && this.state.searchValue === nullLabel ? '' : this.state.searchValue
  389. return (
  390. <Wrapper
  391. data-test-id={this.props['data-test-id'] || 'acDropdown-wrapper'}
  392. className={this.props.className}
  393. width={this.props.width}
  394. embedded={this.props.embedded}
  395. >
  396. <AutocompleteInput
  397. width={this.props.width}
  398. innerRef={ref => { this.buttonRef = ref }}
  399. onBlur={() => { this.closeDropdownList() }}
  400. value={inputValue}
  401. onChange={searchValue => { this.handleSearchInputChange(searchValue) }}
  402. onFocus={() => { this.handleSearchInputChange(this.state.searchValue, true) }}
  403. highlight={this.props.highlight}
  404. disabled={this.props.disabled}
  405. disabledLoading={this.props.disabledLoading}
  406. embedded={this.props.embedded}
  407. onInputKeyDown={e => { this.handleInputKeyDown(e) }}
  408. />
  409. {this.props.required ? <Required right={this.props.embedded ? -24 : -16} /> : null}
  410. {this.renderList()}
  411. </Wrapper>
  412. )
  413. }
  414. }
  415. export default AutocompleteDropdown