AutocompleteDropdown.jsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  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 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 } 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.selected ? css`background: ${Palette.primary};` : ''}
  75. ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
  76. padding: 8px 16px;
  77. transition: all ${StyleProps.animations.swift};
  78. word-break: break-word;
  79. &:first-child {
  80. border-top-left-radius: ${StyleProps.borderRadius};
  81. border-top-right-radius: ${StyleProps.borderRadius};
  82. }
  83. &:last-child {
  84. border-bottom-left-radius: ${StyleProps.borderRadius};
  85. border-bottom-right-radius: ${StyleProps.borderRadius};
  86. }
  87. &:hover {
  88. background: ${Palette.primary};
  89. color: white;
  90. }
  91. `
  92. const DuplicatedLabel = styled.div`
  93. display: flex;
  94. font-size: 11px;
  95. span {
  96. text-overflow: ellipsis;
  97. white-space: nowrap;
  98. overflow: hidden;
  99. }
  100. `
  101. type Props = {
  102. selectedItem?: any,
  103. items?: any[],
  104. labelField?: string,
  105. valueField?: string,
  106. className?: string,
  107. onChange?: (item: any) => void,
  108. onInputChange?: (value: string, filteredItems: any[]) => void,
  109. noItemsMessage?: string,
  110. disabled?: boolean,
  111. disabledLoading?: boolean,
  112. width?: number,
  113. dimNullValue?: boolean,
  114. highlight?: boolean,
  115. 'data-test-id'?: string,
  116. required?: boolean,
  117. embedded?: boolean,
  118. }
  119. type State = {
  120. showDropdownList: boolean,
  121. firstItemHover: boolean,
  122. searchValue: string,
  123. filteredItems: any[],
  124. }
  125. @observer
  126. class AutocompleteDropdown extends React.Component<Props, State> {
  127. static defaultProps: $Shape<Props> = {
  128. noItemsMessage: 'No results found',
  129. }
  130. state = {
  131. showDropdownList: false,
  132. firstItemHover: false,
  133. searchValue: '',
  134. filteredItems: [],
  135. }
  136. buttonRef: HTMLElement
  137. listRef: HTMLElement
  138. listItemsRef: HTMLElement
  139. tipRef: HTMLElement
  140. firstItemRef: HTMLElement
  141. scrollableParent: HTMLElement
  142. buttonRect: ClientRect
  143. itemMouseDown: boolean
  144. componentWillMount() {
  145. this.setState({
  146. filteredItems: this.props.items,
  147. searchValue: this.props.selectedItem ? this.getLabel(this.props.selectedItem) : '',
  148. })
  149. }
  150. componentDidMount() {
  151. window.addEventListener('mousedown', this.handlePageClick, false)
  152. if (this.buttonRef) {
  153. this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
  154. this.scrollableParent.addEventListener('scroll', this.handleScroll)
  155. window.addEventListener('resize', this.handleScroll)
  156. this.buttonRect = this.buttonRef.getBoundingClientRect()
  157. }
  158. }
  159. componentWillReceiveProps(newProps: Props) {
  160. this.setState({ filteredItems: this.getFilteredItems(newProps) })
  161. }
  162. componentWillUpdate() {
  163. if (this.buttonRef) this.buttonRect = this.buttonRef.getBoundingClientRect()
  164. }
  165. componentDidUpdate() {
  166. this.updateListPosition()
  167. }
  168. componentWillUnmount() {
  169. window.removeEventListener('mousedown', this.handlePageClick, false)
  170. window.removeEventListener('resize', this.handleScroll, false)
  171. this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
  172. }
  173. getLabel(item: any) {
  174. let labelField = this.props.labelField || 'label'
  175. if (item == null) {
  176. return ''
  177. }
  178. return (item[labelField] != null && item[labelField].toString()) || item.toString()
  179. }
  180. getValue(item: any) {
  181. let valueField = this.props.valueField || 'value'
  182. if (item == null) {
  183. return null
  184. }
  185. if (typeof item === 'string') {
  186. return item
  187. }
  188. return (item[valueField] != null && item[valueField].toString()) || null
  189. }
  190. getFilteredItems(props?: ?Props, searchValue?: string): any[] {
  191. let useProps = props || this.props
  192. let useSearch = searchValue === undefined ? this.state.searchValue : searchValue
  193. if (!useProps.items) {
  194. return []
  195. }
  196. return useProps.items.filter(i => {
  197. const label = this.getLabel(i).toLowerCase()
  198. const value = this.getValue(i) || ''
  199. return label.indexOf(useSearch.toLowerCase()) > -1 || value.indexOf(useSearch.toLowerCase()) > -1
  200. })
  201. }
  202. @autobind
  203. handleScroll() {
  204. if (this.buttonRef) {
  205. if (DomUtils.isElementInViewport(this.buttonRef, this.scrollableParent)) {
  206. this.buttonRect = this.buttonRef.getBoundingClientRect()
  207. this.updateListPosition()
  208. } else if (this.state.showDropdownList) {
  209. this.setState({ showDropdownList: false })
  210. }
  211. }
  212. }
  213. @autobind
  214. handlePageClick() {
  215. if (!this.itemMouseDown) {
  216. this.setState({ showDropdownList: false })
  217. }
  218. }
  219. handleButtonClick() {
  220. if (this.props.disabled) {
  221. return
  222. }
  223. this.setState({
  224. showDropdownList: !this.state.showDropdownList,
  225. filteredItems: this.props.items,
  226. }, () => {
  227. this.scrollIntoView()
  228. })
  229. }
  230. handleItemClick(item: any) {
  231. this.setState({
  232. showDropdownList: false,
  233. firstItemHover: false,
  234. searchValue: this.getLabel(item),
  235. filteredItems: this.getFilteredItems(null, this.getLabel(item)),
  236. })
  237. if (this.props.onChange) {
  238. this.props.onChange(item)
  239. }
  240. }
  241. handleItemMouseEnter(index: number) {
  242. if (index === 0) {
  243. this.setState({ firstItemHover: true })
  244. }
  245. }
  246. handleItemMouseLeave(index: number) {
  247. if (index === 0) {
  248. this.setState({ firstItemHover: false })
  249. }
  250. }
  251. handleSearchInputChange(searchValue: string, isFocus?: boolean) {
  252. let filteredItems = isFocus ? this.props.items || [] : this.getFilteredItems(null, searchValue)
  253. this.setState({
  254. searchValue,
  255. filteredItems,
  256. showDropdownList: true,
  257. }, () => {
  258. if (isFocus) {
  259. this.scrollIntoView()
  260. }
  261. })
  262. if (this.props.onInputChange) {
  263. this.props.onInputChange(searchValue, filteredItems)
  264. }
  265. }
  266. scrollIntoView() {
  267. let itemIndex = this.state.filteredItems.findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
  268. scrollItemIntoView(this.listRef, this.listItemsRef, itemIndex)
  269. }
  270. updateListPosition() {
  271. if (!this.state.showDropdownList || !this.listRef || !this.buttonRef || !document.body) {
  272. return
  273. }
  274. let buttonHeight = this.buttonRef.offsetHeight
  275. let tipHeight = 8
  276. let listTop = this.buttonRect.top + buttonHeight + tipHeight
  277. let listHeight = this.listRef.offsetHeight
  278. if (listTop + listHeight + 16 > window.innerHeight) {
  279. listHeight = window.innerHeight - listTop - 16
  280. } else {
  281. listHeight = 400
  282. }
  283. // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
  284. let scrollOffset = 0
  285. if (parseInt(document.body.style.top, 10) < 0) {
  286. scrollOffset = -parseInt(document.body && document.body.style.top, 10)
  287. }
  288. let widthDiff = this.listRef.offsetWidth - this.buttonRef.offsetWidth
  289. this.listRef.style.top = `${listTop + (window.pageYOffset || scrollOffset)}px`
  290. this.listRef.style.left = `${(this.buttonRect.left + window.pageXOffset) - widthDiff}px`
  291. if (this.listItemsRef) {
  292. this.listItemsRef.style.maxHeight = `${listHeight}px`
  293. updateTipStyle(this.listItemsRef, this.tipRef, this.firstItemRef)
  294. }
  295. }
  296. renderItems() {
  297. if (this.state.filteredItems.length === 0) {
  298. return null
  299. }
  300. let selectedValue = this.getValue(this.props.selectedItem)
  301. let duplicatedLabels = []
  302. this.state.filteredItems.forEach((item, i) => {
  303. let label = this.getLabel(item)
  304. for (let j = i + 1; j < this.state.filteredItems.length; j += 1) {
  305. if (label === this.getLabel(this.state.filteredItems[j]) && !duplicatedLabels.find(item2 => this.getLabel(item2) === label)) {
  306. duplicatedLabels.push(label)
  307. }
  308. }
  309. })
  310. return (
  311. <ListItems innerRef={ref => { this.listItemsRef = ref }}>
  312. {this.state.filteredItems.map((item, i) => {
  313. if (item.separator === true) {
  314. return <Separator key={`sep-${i}`} />
  315. }
  316. let label = this.getLabel(item)
  317. let value = this.getValue(item)
  318. let duplicatedLabel = duplicatedLabels.find(l => l === label)
  319. let listItem = (
  320. <ListItem
  321. data-test-id="ad-listItem"
  322. key={value}
  323. innerRef={ref => { if (i === 0) { this.firstItemRef = ref } }}
  324. onMouseDown={() => { this.itemMouseDown = true }}
  325. onMouseUp={() => { this.itemMouseDown = false }}
  326. onMouseEnter={() => { this.handleItemMouseEnter(i) }}
  327. onMouseLeave={() => { this.handleItemMouseLeave(i) }}
  328. onClick={() => { this.handleItemClick(item) }}
  329. selected={value !== null && value === selectedValue}
  330. dim={this.props.dimNullValue && value == null}
  331. >
  332. {label}
  333. {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
  334. </ListItem>
  335. )
  336. return listItem
  337. })}
  338. </ListItems>
  339. )
  340. }
  341. renderSearchNotFound() {
  342. if (this.state.searchValue === '' || !this.props.items || this.props.items.length === 0 || this.state.filteredItems.length > 0) {
  343. return null
  344. }
  345. return (
  346. <ListItems>
  347. <SearchNotFound onClick={() => { this.setState({ showDropdownList: false }) }}>
  348. {this.props.noItemsMessage}
  349. </SearchNotFound>
  350. </ListItems>
  351. )
  352. }
  353. renderList() {
  354. if (!this.state.showDropdownList) {
  355. return null
  356. }
  357. const body: any = document.body
  358. const selectedItemValue = this.getValue(this.props.selectedItem)
  359. const firstItemValue = this.state.filteredItems.length > 0 ? this.getValue(this.state.filteredItems[0]) : null
  360. const isFirstItemSelected = selectedItemValue !== null && selectedItemValue === firstItemValue
  361. let list = ReactDOM.createPortal((
  362. <List {...this.props} innerRef={ref => { this.listRef = ref }}>
  363. <Tip
  364. innerRef={ref => { this.tipRef = ref }}
  365. primary={this.state.firstItemHover || isFirstItemSelected}
  366. dangerouslySetInnerHTML={{ __html: tipImage }}
  367. />
  368. {this.renderItems()}
  369. {this.renderSearchNotFound()}
  370. </List>
  371. ), body)
  372. return list
  373. }
  374. render() {
  375. let nullLabel = this.props.items && this.getValue(this.props.items[0]) === null ? this.getLabel(this.props.items[0]) : ''
  376. let inputValue = this.getValue(this.props.selectedItem) === null && this.state.searchValue === nullLabel ? '' : this.state.searchValue
  377. return (
  378. <Wrapper
  379. data-test-id={this.props['data-test-id'] || 'acDropdown-wrapper'}
  380. className={this.props.className}
  381. onMouseDown={() => { this.itemMouseDown = true }}
  382. onMouseUp={() => { this.itemMouseDown = false }}
  383. width={this.props.width}
  384. embedded={this.props.embedded}
  385. >
  386. <AutocompleteInput
  387. width={this.props.width}
  388. innerRef={ref => { this.buttonRef = ref }}
  389. onMouseDown={() => { this.itemMouseDown = true }}
  390. onMouseUp={() => { this.itemMouseDown = false }}
  391. value={inputValue}
  392. onClick={() => this.handleButtonClick()}
  393. onChange={searchValue => { this.handleSearchInputChange(searchValue) }}
  394. onFocus={() => { this.handleSearchInputChange(this.state.searchValue, true) }}
  395. highlight={this.props.highlight}
  396. disabled={this.props.disabled}
  397. disabledLoading={this.props.disabledLoading}
  398. embedded={this.props.embedded}
  399. />
  400. {this.props.required ? <Required right={this.props.embedded ? -24 : -16} /> : null}
  401. {this.renderList()}
  402. </Wrapper>
  403. )
  404. }
  405. }
  406. export default AutocompleteDropdown