Dropdown.jsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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 DropdownButton from '../../atoms/DropdownButton'
  21. import Palette from '../../styleUtils/Palette'
  22. import DomUtils from '../../../utils/DomUtils'
  23. import StyleProps from '../../styleUtils/StyleProps'
  24. import checkmarkImage from './images/checkmark'
  25. import tipImage from './images/tip'
  26. import requiredImage from './images/required.svg'
  27. const getWidth = props => {
  28. if (props.width) {
  29. return props.width - 2
  30. }
  31. return StyleProps.inputSizes.regular.width - 2
  32. }
  33. const Wrapper = styled.div`
  34. position: relative;
  35. ${props => props.embedded ? 'width: 100%;' : ''}
  36. `
  37. const Required = styled.div`
  38. position: absolute;
  39. width: 8px;
  40. height: 8px;
  41. right: ${props => props.right}px;
  42. top: 12px;
  43. background: url('${requiredImage}') center no-repeat;
  44. ${props => props.disabledLoading ? StyleProps.animations.disabledLoading : ''}
  45. `
  46. const List = styled.div`
  47. position: absolute;
  48. background: white;
  49. cursor: pointer;
  50. width: ${props => getWidth(props)}px;
  51. border: 1px solid ${Palette.grayscale[3]};
  52. border-radius: ${StyleProps.borderRadius};
  53. z-index: 1000;
  54. ${StyleProps.boxShadow}
  55. `
  56. const ListItems = styled.div`
  57. max-height: 400px;
  58. overflow: auto;
  59. `
  60. export const Tip = styled.div`
  61. position: absolute;
  62. width: 16px;
  63. height: 8px;
  64. right: 8px;
  65. top: -8px;
  66. z-index: 11;
  67. transition: all ${StyleProps.animations.swift};
  68. overflow: hidden;
  69. svg {
  70. #path {
  71. transition: all ${StyleProps.animations.swift};
  72. fill: ${props => props.primary ? Palette.primary : 'white'};
  73. }
  74. }
  75. `
  76. const Checkmark = styled.div`
  77. ${StyleProps.exactWidth('16px')}
  78. height: 16px;
  79. margin-right: 8px;
  80. margin-top: 1px;
  81. display: flex;
  82. justify-content: center;
  83. align-items: center;
  84. #symbol {
  85. transition: stroke ${StyleProps.animations.swift};
  86. stroke-dasharray: 12;
  87. stroke-dashoffset: ${props => props.show ? 24 : 12};
  88. animation-duration: 100ms;
  89. animation-timing-function: ease-in-out;
  90. animation-fill-mode: forwards;
  91. @keyframes dashOn {
  92. from { stroke-dashoffset: 12; }
  93. to { stroke-dashoffset: 24; }
  94. }
  95. @keyframes dashOff {
  96. from { stroke-dashoffset: 24; }
  97. to { stroke-dashoffset: 12; }
  98. }
  99. }
  100. `
  101. const ListItem = styled.div`
  102. position: relative;
  103. display: flex;
  104. color: ${props => props.multipleSelected ? Palette.primary : props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
  105. ${props => props.selected ? css`background: ${Palette.primary};` : ''}
  106. ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
  107. padding: 8px 16px;
  108. transition: all ${StyleProps.animations.swift};
  109. padding-left: ${props => props.paddingLeft}px;
  110. word-break: break-word;
  111. &:first-child {
  112. border-top-left-radius: ${StyleProps.borderRadius};
  113. border-top-right-radius: ${StyleProps.borderRadius};
  114. }
  115. &:last-child {
  116. border-bottom-left-radius: ${StyleProps.borderRadius};
  117. border-bottom-right-radius: ${StyleProps.borderRadius};
  118. }
  119. &:hover {
  120. background: ${Palette.primary};
  121. color: white;
  122. ${Checkmark} #symbol {
  123. stroke: white;
  124. }
  125. }
  126. `
  127. const DuplicatedLabel = styled.div`
  128. display: flex;
  129. font-size: 11px;
  130. span {
  131. text-overflow: ellipsis;
  132. white-space: nowrap;
  133. overflow: hidden;
  134. }
  135. `
  136. const Separator = styled.div`
  137. width: calc(100% - 32px);
  138. height: 1px;
  139. margin: 8px 16px;
  140. background: ${Palette.grayscale[3]};
  141. `
  142. const Labels = styled.div`
  143. word-break: break-word;
  144. max-width: 100%;
  145. `
  146. export const updateTipStyle = (listItemsRef: HTMLElement, tipRef: HTMLElement, firstItemRef: HTMLElement) => {
  147. if (tipRef && firstItemRef) {
  148. let svgPath = tipRef.querySelector('#path')
  149. if (svgPath) {
  150. if (listItemsRef.clientHeight < listItemsRef.scrollHeight) {
  151. // $FlowIssue
  152. svgPath.style.fill = 'white'
  153. firstItemRef.style.borderTopRightRadius = '0'
  154. } else {
  155. // $FlowIssue
  156. svgPath.style.fill = ''
  157. firstItemRef.style.borderTopRightRadius = ''
  158. }
  159. }
  160. }
  161. }
  162. export const scrollItemIntoView = (
  163. listRef: HTMLElement,
  164. listItemsRef: HTMLElement,
  165. itemIndex: number
  166. ) => {
  167. if (!listRef || !listItemsRef) {
  168. return
  169. }
  170. if (itemIndex === -1 || !listItemsRef.children[itemIndex]) {
  171. return
  172. }
  173. // $FlowIssue
  174. listItemsRef.children[itemIndex].parentNode.scrollTop = listItemsRef.children[itemIndex].offsetTop - listItemsRef.children[itemIndex].parentNode.offsetTop - 32
  175. }
  176. type Props = {
  177. selectedItem: any,
  178. items: any[],
  179. labelField: string,
  180. valueField: string,
  181. className: string,
  182. onChange: (item: any) => void,
  183. noItemsMessage: string,
  184. noSelectionMessage: string,
  185. disabled: boolean,
  186. disabledLoading: boolean,
  187. width: number,
  188. 'data-test-id'?: string,
  189. embedded?: boolean,
  190. dimFirstItem?: boolean,
  191. multipleSelection?: boolean,
  192. selectedItems?: ?any[],
  193. highlight?: boolean,
  194. required?: boolean,
  195. }
  196. type State = {
  197. showDropdownList: boolean,
  198. firstItemHover: boolean
  199. }
  200. @observer
  201. class Dropdown extends React.Component<Props, State> {
  202. static defaultProps: $Shape<Props> = {
  203. noSelectionMessage: 'Select an item',
  204. }
  205. state = {
  206. showDropdownList: false,
  207. firstItemHover: false,
  208. }
  209. buttonRef: HTMLElement
  210. listRef: HTMLElement
  211. listItemsRef: HTMLElement
  212. firstItemRef: HTMLElement
  213. tipRef: HTMLElement
  214. scrollableParent: HTMLElement
  215. buttonRect: ClientRect
  216. itemMouseDown: boolean
  217. checkmarkRefs: { [string]: HTMLElement } = {}
  218. componentDidMount() {
  219. window.addEventListener('mousedown', this.handlePageClick, false)
  220. if (this.buttonRef) {
  221. this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
  222. this.scrollableParent.addEventListener('scroll', this.handleScroll)
  223. window.addEventListener('resize', this.handleScroll)
  224. this.buttonRect = this.buttonRef.getBoundingClientRect()
  225. }
  226. }
  227. componentWillReceiveProps(newProps: Props) {
  228. if (!this.props.multipleSelection) {
  229. return
  230. }
  231. // Clear checkmark if items are removed in newProps
  232. let newSelectedItems = newProps.selectedItems || []
  233. let oldSelectedItems = this.props.selectedItems || []
  234. let hash = item => `${this.getLabel(item)}-${this.getValue(item) || ''}`
  235. let needsCheckmarkClear = oldSelectedItems.filter(oldItem => !newSelectedItems.find(newItem => hash(oldItem) === hash(newItem)))
  236. needsCheckmarkClear.forEach(clearItem => {
  237. this.toggleCheckmarkAnimation(clearItem, this.checkmarkRefs[hash(clearItem)], true)
  238. })
  239. }
  240. componentWillUpdate() {
  241. if (this.buttonRef) this.buttonRect = this.buttonRef.getBoundingClientRect()
  242. }
  243. componentDidUpdate() {
  244. this.updateListPosition()
  245. }
  246. componentWillUnmount() {
  247. window.removeEventListener('mousedown', this.handlePageClick, false)
  248. window.removeEventListener('resize', this.handleScroll, false)
  249. this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
  250. }
  251. getLabel(item: any) {
  252. let labelField = this.props.labelField || 'label'
  253. if (item == null) {
  254. return this.props.noSelectionMessage
  255. }
  256. if (item[labelField] != null) {
  257. return item[labelField].toString()
  258. }
  259. if (item.value != null) {
  260. return item.value.toString()
  261. }
  262. return item.toString()
  263. }
  264. getValue(item: any) {
  265. let valueField = this.props.valueField || 'value'
  266. if (item == null) {
  267. return null
  268. }
  269. return (item[valueField] != null && item[valueField].toString()) || this.getLabel(item)
  270. }
  271. @autobind
  272. handleScroll() {
  273. if (this.buttonRef) {
  274. if (DomUtils.isElementInViewport(this.buttonRef, this.scrollableParent)) {
  275. this.buttonRect = this.buttonRef.getBoundingClientRect()
  276. this.updateListPosition()
  277. } else if (this.state.showDropdownList) {
  278. this.setState({ showDropdownList: false })
  279. }
  280. }
  281. }
  282. @autobind
  283. handlePageClick() {
  284. if (!this.itemMouseDown) {
  285. this.setState({ showDropdownList: false })
  286. }
  287. }
  288. handleButtonClick() {
  289. if (this.props.disabled) {
  290. return
  291. }
  292. this.setState({ showDropdownList: !this.state.showDropdownList }, () => {
  293. this.scrollIntoView()
  294. })
  295. }
  296. handleItemClick(item: any) {
  297. if (!this.props.multipleSelection) {
  298. this.setState({ showDropdownList: false, firstItemHover: false })
  299. } else {
  300. let selected = Boolean(this.props.selectedItems && this.props.selectedItems.find(i =>
  301. this.getValue(i) === this.getValue(item)))
  302. this.toggleCheckmarkAnimation(
  303. item,
  304. this.checkmarkRefs[`${this.getLabel(item)}-${this.getValue(item) || ''}`],
  305. selected
  306. )
  307. }
  308. if (this.props.onChange) {
  309. this.props.onChange(item)
  310. }
  311. }
  312. handleItemMouseEnter(index: number) {
  313. if (index === 0) {
  314. this.setState({ firstItemHover: true })
  315. }
  316. }
  317. handleItemMouseLeave(index: number) {
  318. if (index === 0) {
  319. this.setState({ firstItemHover: false })
  320. }
  321. }
  322. toggleCheckmarkAnimation(item: any, checkmarkRef: HTMLElement, selected: boolean) {
  323. if (!item || !checkmarkRef) {
  324. return
  325. }
  326. let symbol = checkmarkRef.querySelector('#symbol')
  327. if (symbol) {
  328. symbol.style.animationName = selected ? 'dashOff' : 'dashOn'
  329. }
  330. }
  331. updateListPosition() {
  332. if (!this.state.showDropdownList || !this.listRef || !this.buttonRef || !document.body) {
  333. return
  334. }
  335. let buttonHeight = this.buttonRef.offsetHeight
  336. let tipHeight = 8
  337. let listTop = this.buttonRect.top + buttonHeight + tipHeight
  338. let listHeight = this.listRef.offsetHeight
  339. if (listTop + listHeight > window.innerHeight) {
  340. listTop = window.innerHeight - listHeight - 16
  341. this.tipRef.style.display = 'none'
  342. } else {
  343. this.tipRef.style.display = 'block'
  344. }
  345. // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
  346. let scrollOffset = 0
  347. if (parseInt(document.body.style.top, 10) < 0) {
  348. scrollOffset = -parseInt(document.body && document.body.style.top, 10)
  349. }
  350. let widthDiff = this.listRef.offsetWidth - this.buttonRef.offsetWidth
  351. this.listRef.style.top = `${listTop + (window.pageYOffset || scrollOffset)}px`
  352. this.listRef.style.left = `${(this.buttonRect.left + window.pageXOffset) - widthDiff}px`
  353. updateTipStyle(this.listItemsRef, this.tipRef, this.firstItemRef)
  354. }
  355. scrollIntoView() {
  356. let itemIndex = this.props.items.findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
  357. scrollItemIntoView(this.listRef, this.listItemsRef, itemIndex)
  358. }
  359. renderList() {
  360. if (!this.props.items || this.props.items.length === 0 || !this.state.showDropdownList) {
  361. return null
  362. }
  363. const body: any = document.body
  364. let selectedValue = this.getValue(this.props.selectedItem)
  365. let duplicatedLabels = []
  366. this.props.items.forEach((item, i) => {
  367. let label = this.getLabel(item)
  368. for (let j = i + 1; j < this.props.items.length; j += 1) {
  369. if (label === this.getLabel(this.props.items[j]) && !duplicatedLabels.find(item2 => this.getLabel(item2) === label)) {
  370. duplicatedLabels.push(label)
  371. }
  372. }
  373. })
  374. const firstItemValue = this.props.items.length > 0 ? this.getValue(this.props.items[0]) : null
  375. const isFirstItemSelected = selectedValue === firstItemValue
  376. let list = ReactDOM.createPortal((
  377. <List {...this.props} innerRef={ref => { this.listRef = ref }}>
  378. <Tip
  379. innerRef={ref => { this.tipRef = ref }}
  380. primary={this.state.firstItemHover || isFirstItemSelected}
  381. dangerouslySetInnerHTML={{ __html: tipImage }}
  382. />
  383. <ListItems innerRef={ref => { this.listItemsRef = ref }}>
  384. {this.props.items.map((item, i) => {
  385. if (item.separator === true) {
  386. return <Separator key={`sep-${i}`} />
  387. }
  388. let label = this.getLabel(item)
  389. let value = this.getValue(item)
  390. let duplicatedLabel = duplicatedLabels.find(l => l === label)
  391. let multipleSelected = this.props.selectedItems && this.props.selectedItems
  392. .find(i => this.getValue(i) === value)
  393. let listItem = (
  394. <ListItem
  395. data-test-id="dropdownListItem"
  396. innerRef={ref => { if (i === 0) { this.firstItemRef = ref } }}
  397. key={value}
  398. onMouseDown={() => { this.itemMouseDown = true }}
  399. onMouseUp={() => { this.itemMouseDown = false }}
  400. onMouseEnter={() => { this.handleItemMouseEnter(i) }}
  401. onMouseLeave={() => { this.handleItemMouseLeave(i) }}
  402. onClick={() => { this.handleItemClick(item) }}
  403. selected={!this.props.multipleSelection && value === selectedValue}
  404. multipleSelected={this.props.multipleSelection && multipleSelected}
  405. dim={this.props.dimFirstItem && i === 0}
  406. paddingLeft={this.props.multipleSelection ? 8 : 16}
  407. >
  408. {this.props.multipleSelection ? (
  409. <Checkmark
  410. innerRef={ref => { this.checkmarkRefs[`${label}-${value || ''}`] = ref }}
  411. dangerouslySetInnerHTML={{ __html: checkmarkImage }}
  412. show={multipleSelected}
  413. />
  414. ) : null}
  415. <Labels>
  416. {label === '' ? '\u00A0' : label}
  417. {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
  418. </Labels>
  419. </ListItem>
  420. )
  421. return listItem
  422. })}
  423. </ListItems>
  424. </List>
  425. ), body)
  426. return list
  427. }
  428. render() {
  429. let buttonValue = () => {
  430. if (this.props.items && this.props.items.length) {
  431. if (this.props.multipleSelection && this.props.selectedItems && this.props.selectedItems.length > 0) {
  432. return this.props.selectedItems.map(i => this.getLabel(this.props.items.find(item =>
  433. this.getValue(item) === this.getValue(i)))).join(', ')
  434. }
  435. return this.getLabel(this.props.selectedItem)
  436. }
  437. return this.props.noItemsMessage || ''
  438. }
  439. return (
  440. <Wrapper
  441. className={this.props.className}
  442. onMouseDown={() => { this.itemMouseDown = true }}
  443. onMouseUp={() => { this.itemMouseDown = false }}
  444. data-test-id={this.props['data-test-id'] || 'dropdown'}
  445. embedded={this.props.embedded}
  446. >
  447. <DropdownButton
  448. {...this.props}
  449. data-test-id="dropdown-dropdownButton"
  450. innerRef={ref => { this.buttonRef = ref }}
  451. onMouseDown={() => { this.itemMouseDown = true }}
  452. onMouseUp={() => { this.itemMouseDown = false }}
  453. value={buttonValue()}
  454. onClick={() => this.handleButtonClick()}
  455. />
  456. {this.props.required ? (
  457. <Required
  458. disabledLoading={this.props.disabledLoading}
  459. right={this.props.embedded ? -24 : -16}
  460. />
  461. ) : null}
  462. {this.renderList()}
  463. </Wrapper>
  464. )
  465. }
  466. }
  467. export default Dropdown