/*
Copyright (C) 2017 Cloudbase Solutions SRL
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
// @flow
import React from 'react'
import { observer } from 'mobx-react'
import styled, { css } from 'styled-components'
import ReactDOM from 'react-dom'
import autobind from 'autobind-decorator'
import DropdownButton from '../../atoms/DropdownButton'
import Palette from '../../styleUtils/Palette'
import DomUtils from '../../../utils/DomUtils'
import StyleProps from '../../styleUtils/StyleProps'
import checkmarkImage from './images/checkmark'
import tipImage from './images/tip'
import requiredImage from './images/required.svg'
const getWidth = props => {
if (props.width) {
return props.width - 2
}
return StyleProps.inputSizes.regular.width - 2
}
const Wrapper = styled.div`
position: relative;
${props => props.embedded ? 'width: 100%;' : ''}
`
const Required = styled.div`
position: absolute;
width: 8px;
height: 8px;
right: ${props => props.right}px;
top: 12px;
background: url('${requiredImage}') center no-repeat;
${props => props.disabledLoading ? StyleProps.animations.disabledLoading : ''}
`
const List = styled.div`
position: absolute;
background: white;
cursor: pointer;
width: ${props => getWidth(props)}px;
border: 1px solid ${Palette.grayscale[3]};
border-radius: ${StyleProps.borderRadius};
z-index: 1000;
${StyleProps.boxShadow}
`
const ListItems = styled.div`
max-height: 400px;
overflow: auto;
`
export const Tip = styled.div`
position: absolute;
width: 16px;
height: 8px;
right: 8px;
top: -8px;
z-index: 11;
transition: all ${StyleProps.animations.swift};
overflow: hidden;
svg {
#path {
transition: all ${StyleProps.animations.swift};
fill: ${props => props.primary ? Palette.primary : 'white'};
}
}
`
const Checkmark = styled.div`
${StyleProps.exactWidth('16px')}
height: 16px;
margin-right: 8px;
margin-top: 1px;
display: flex;
justify-content: center;
align-items: center;
#symbol {
transition: stroke ${StyleProps.animations.swift};
stroke-dasharray: 12;
stroke-dashoffset: ${props => props.show ? 24 : 12};
animation-duration: 100ms;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
@keyframes dashOn {
from { stroke-dashoffset: 12; }
to { stroke-dashoffset: 24; }
}
@keyframes dashOff {
from { stroke-dashoffset: 24; }
to { stroke-dashoffset: 12; }
}
}
`
const ListItem = styled.div`
position: relative;
display: flex;
color: ${props => props.multipleSelected ? Palette.primary : props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
${props => props.selected ? css`background: ${Palette.primary};` : ''}
${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
padding: 8px 16px;
transition: all ${StyleProps.animations.swift};
padding-left: ${props => props.paddingLeft}px;
word-break: break-word;
&:first-child {
border-top-left-radius: ${StyleProps.borderRadius};
border-top-right-radius: ${StyleProps.borderRadius};
}
&:last-child {
border-bottom-left-radius: ${StyleProps.borderRadius};
border-bottom-right-radius: ${StyleProps.borderRadius};
}
&:hover {
background: ${Palette.primary};
color: white;
${Checkmark} #symbol {
stroke: white;
}
}
`
const DuplicatedLabel = styled.div`
display: flex;
font-size: 11px;
span {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
`
const Separator = styled.div`
width: calc(100% - 32px);
height: 1px;
margin: 8px 16px;
background: ${Palette.grayscale[3]};
`
const Labels = styled.div`
word-break: break-word;
max-width: 100%;
`
export const updateTipStyle = (listItemsRef: HTMLElement, tipRef: HTMLElement, firstItemRef: HTMLElement) => {
if (tipRef && firstItemRef) {
let svgPath = tipRef.querySelector('#path')
if (svgPath) {
if (listItemsRef.clientHeight < listItemsRef.scrollHeight) {
// $FlowIssue
svgPath.style.fill = 'white'
firstItemRef.style.borderTopRightRadius = '0'
} else {
// $FlowIssue
svgPath.style.fill = ''
firstItemRef.style.borderTopRightRadius = ''
}
}
}
}
export const scrollItemIntoView = (
listRef: HTMLElement,
listItemsRef: HTMLElement,
itemIndex: number
) => {
if (!listRef || !listItemsRef) {
return
}
if (itemIndex === -1 || !listItemsRef.children[itemIndex]) {
return
}
// $FlowIssue
listItemsRef.children[itemIndex].parentNode.scrollTop = listItemsRef.children[itemIndex].offsetTop - listItemsRef.children[itemIndex].parentNode.offsetTop - 32
}
type Props = {
selectedItem: any,
items: any[],
labelField: string,
valueField: string,
className: string,
onChange: (item: any) => void,
noItemsMessage: string,
noSelectionMessage: string,
disabled: boolean,
disabledLoading: boolean,
width: number,
'data-test-id'?: string,
embedded?: boolean,
dimFirstItem?: boolean,
multipleSelection?: boolean,
selectedItems?: ?any[],
highlight?: boolean,
required?: boolean,
}
type State = {
showDropdownList: boolean,
firstItemHover: boolean
}
@observer
class Dropdown extends React.Component {
static defaultProps: $Shape = {
noSelectionMessage: 'Select an item',
}
state = {
showDropdownList: false,
firstItemHover: false,
}
buttonRef: HTMLElement
listRef: HTMLElement
listItemsRef: HTMLElement
firstItemRef: HTMLElement
tipRef: HTMLElement
scrollableParent: HTMLElement
buttonRect: ClientRect
itemMouseDown: boolean
checkmarkRefs: { [string]: HTMLElement } = {}
componentDidMount() {
window.addEventListener('mousedown', this.handlePageClick, false)
if (this.buttonRef) {
this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
this.scrollableParent.addEventListener('scroll', this.handleScroll)
window.addEventListener('resize', this.handleScroll)
this.buttonRect = this.buttonRef.getBoundingClientRect()
}
}
componentWillReceiveProps(newProps: Props) {
if (!this.props.multipleSelection) {
return
}
// Clear checkmark if items are removed in newProps
let newSelectedItems = newProps.selectedItems || []
let oldSelectedItems = this.props.selectedItems || []
let hash = item => `${this.getLabel(item)}-${this.getValue(item) || ''}`
let needsCheckmarkClear = oldSelectedItems.filter(oldItem => !newSelectedItems.find(newItem => hash(oldItem) === hash(newItem)))
needsCheckmarkClear.forEach(clearItem => {
this.toggleCheckmarkAnimation(clearItem, this.checkmarkRefs[hash(clearItem)], true)
})
}
componentWillUpdate() {
if (this.buttonRef) this.buttonRect = this.buttonRef.getBoundingClientRect()
}
componentDidUpdate() {
this.updateListPosition()
}
componentWillUnmount() {
window.removeEventListener('mousedown', this.handlePageClick, false)
window.removeEventListener('resize', this.handleScroll, false)
this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
}
getLabel(item: any) {
let labelField = this.props.labelField || 'label'
if (item == null) {
return this.props.noSelectionMessage
}
if (item[labelField] != null) {
return item[labelField].toString()
}
if (item.value != null) {
return item.value.toString()
}
return item.toString()
}
getValue(item: any) {
let valueField = this.props.valueField || 'value'
if (item == null) {
return null
}
return (item[valueField] != null && item[valueField].toString()) || this.getLabel(item)
}
@autobind
handleScroll() {
if (this.buttonRef) {
if (DomUtils.isElementInViewport(this.buttonRef, this.scrollableParent)) {
this.buttonRect = this.buttonRef.getBoundingClientRect()
this.updateListPosition()
} else if (this.state.showDropdownList) {
this.setState({ showDropdownList: false })
}
}
}
@autobind
handlePageClick() {
if (!this.itemMouseDown) {
this.setState({ showDropdownList: false })
}
}
handleButtonClick() {
if (this.props.disabled) {
return
}
this.setState({ showDropdownList: !this.state.showDropdownList }, () => {
this.scrollIntoView()
})
}
handleItemClick(item: any) {
if (!this.props.multipleSelection) {
this.setState({ showDropdownList: false, firstItemHover: false })
} else {
let selected = Boolean(this.props.selectedItems && this.props.selectedItems.find(i =>
this.getValue(i) === this.getValue(item)))
this.toggleCheckmarkAnimation(
item,
this.checkmarkRefs[`${this.getLabel(item)}-${this.getValue(item) || ''}`],
selected
)
}
if (this.props.onChange) {
this.props.onChange(item)
}
}
handleItemMouseEnter(index: number) {
if (index === 0) {
this.setState({ firstItemHover: true })
}
}
handleItemMouseLeave(index: number) {
if (index === 0) {
this.setState({ firstItemHover: false })
}
}
toggleCheckmarkAnimation(item: any, checkmarkRef: HTMLElement, selected: boolean) {
if (!item || !checkmarkRef) {
return
}
let symbol = checkmarkRef.querySelector('#symbol')
if (symbol) {
symbol.style.animationName = selected ? 'dashOff' : 'dashOn'
}
}
updateListPosition() {
if (!this.state.showDropdownList || !this.listRef || !this.buttonRef || !document.body) {
return
}
let buttonHeight = this.buttonRef.offsetHeight
let tipHeight = 8
let listTop = this.buttonRect.top + buttonHeight + tipHeight
let listHeight = this.listRef.offsetHeight
if (listTop + listHeight > window.innerHeight) {
listTop = window.innerHeight - listHeight - 16
this.tipRef.style.display = 'none'
} else {
this.tipRef.style.display = 'block'
}
// If a modal is opened, body scroll is removed and body top is set to replicate scroll position
let scrollOffset = 0
if (parseInt(document.body.style.top, 10) < 0) {
scrollOffset = -parseInt(document.body && document.body.style.top, 10)
}
let widthDiff = this.listRef.offsetWidth - this.buttonRef.offsetWidth
this.listRef.style.top = `${listTop + (window.pageYOffset || scrollOffset)}px`
this.listRef.style.left = `${(this.buttonRect.left + window.pageXOffset) - widthDiff}px`
updateTipStyle(this.listItemsRef, this.tipRef, this.firstItemRef)
}
scrollIntoView() {
let itemIndex = this.props.items.findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
scrollItemIntoView(this.listRef, this.listItemsRef, itemIndex)
}
renderList() {
if (!this.props.items || this.props.items.length === 0 || !this.state.showDropdownList) {
return null
}
const body: any = document.body
let selectedValue = this.getValue(this.props.selectedItem)
let duplicatedLabels = []
this.props.items.forEach((item, i) => {
let label = this.getLabel(item)
for (let j = i + 1; j < this.props.items.length; j += 1) {
if (label === this.getLabel(this.props.items[j]) && !duplicatedLabels.find(item2 => this.getLabel(item2) === label)) {
duplicatedLabels.push(label)
}
}
})
const firstItemValue = this.props.items.length > 0 ? this.getValue(this.props.items[0]) : null
const isFirstItemSelected = selectedValue === firstItemValue
let list = ReactDOM.createPortal((
{ this.listRef = ref }}>
{ this.tipRef = ref }}
primary={this.state.firstItemHover || isFirstItemSelected}
dangerouslySetInnerHTML={{ __html: tipImage }}
/>
{ this.listItemsRef = ref }}>
{this.props.items.map((item, i) => {
if (item.separator === true) {
return
}
let label = this.getLabel(item)
let value = this.getValue(item)
let duplicatedLabel = duplicatedLabels.find(l => l === label)
let multipleSelected = this.props.selectedItems && this.props.selectedItems
.find(i => this.getValue(i) === value)
let listItem = (
{ if (i === 0) { this.firstItemRef = ref } }}
key={value}
onMouseDown={() => { this.itemMouseDown = true }}
onMouseUp={() => { this.itemMouseDown = false }}
onMouseEnter={() => { this.handleItemMouseEnter(i) }}
onMouseLeave={() => { this.handleItemMouseLeave(i) }}
onClick={() => { this.handleItemClick(item) }}
selected={!this.props.multipleSelection && value === selectedValue}
multipleSelected={this.props.multipleSelection && multipleSelected}
dim={this.props.dimFirstItem && i === 0}
paddingLeft={this.props.multipleSelection ? 8 : 16}
>
{this.props.multipleSelection ? (
{ this.checkmarkRefs[`${label}-${value || ''}`] = ref }}
dangerouslySetInnerHTML={{ __html: checkmarkImage }}
show={multipleSelected}
/>
) : null}
{label === '' ? '\u00A0' : label}
{duplicatedLabel ? ({value || ''}) : ''}
)
return listItem
})}
), body)
return list
}
render() {
let buttonValue = () => {
if (this.props.items && this.props.items.length) {
if (this.props.multipleSelection && this.props.selectedItems && this.props.selectedItems.length > 0) {
return this.props.selectedItems.map(i => this.getLabel(this.props.items.find(item =>
this.getValue(item) === this.getValue(i)))).join(', ')
}
return this.getLabel(this.props.selectedItem)
}
return this.props.noItemsMessage || ''
}
return (
{ this.itemMouseDown = true }}
onMouseUp={() => { this.itemMouseDown = false }}
data-test-id={this.props['data-test-id'] || 'dropdown'}
embedded={this.props.embedded}
>
{ this.buttonRef = ref }}
onMouseDown={() => { this.itemMouseDown = true }}
onMouseUp={() => { this.itemMouseDown = false }}
value={buttonValue()}
onClick={() => this.handleButtonClick()}
/>
{this.props.required ? (
) : null}
{this.renderList()}
)
}
}
export default Dropdown