| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699 |
- /*
- 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 <http://www.gnu.org/licenses/>.
- */
- import * as 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: any) => {
- if (props.width) {
- return props.width - 2
- }
- return StyleProps.inputSizes.regular.width - 2
- }
- const Wrapper = styled.div<any>`
- position: relative;
- ${(props: any) => (props.embedded ? 'width: 100%;' : '')}
- &:focus {
- outline: none;
- }
- `
- const Required = styled.div<any>`
- position: absolute;
- width: 8px;
- height: 8px;
- right: ${(props: any) => props.right}px;
- top: 12px;
- background: url('${requiredImage}') center no-repeat;
- ${(props: any) => (props.disabledLoading ? StyleProps.animations.disabledLoading : '')}
- `
- const List = styled.div<any>`
- position: absolute;
- background: white;
- cursor: pointer;
- width: ${(props: any) => getWidth(props)}px;
- border: 1px solid ${Palette.grayscale[3]};
- border-radius: ${StyleProps.borderRadius};
- z-index: 1000;
- ${StyleProps.boxShadow}
- `
- const ListItems = styled.div<any>`
- max-height: 400px;
- overflow: auto;
- `
- export const Tip = styled.div<any>`
- 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<any>`
- ${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: any) => (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 getListItemColor = (props: any) => {
- if (props.disabled) {
- return Palette.grayscale[3]
- }
- if (props.multipleSelected) {
- return Palette.primary
- }
- if (props.selected) {
- return 'white'
- }
- if (props.dim) {
- return Palette.grayscale[3]
- }
- return Palette.grayscale[4]
- }
- const getListBackgroundColor = (props: any) => {
- if (props.arrowSelected) {
- return css`background: ${Palette.primary}44;`
- }
- if (props.selected) {
- return css`background: ${Palette.primary};`
- }
- return ''
- }
- const ListItem = styled.div<any>`
- position: relative;
- display: flex;
- color: ${(props: any) => getListItemColor(props)};
- ${(props: any) => getListBackgroundColor(props)}
- ${(props: any) => (props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : '')}
- padding: 8px 16px;
- transition: all ${StyleProps.animations.swift};
- padding-left: ${(props: any) => props.paddingLeft}px;
- word-break: break-word;
- ${props => (props.disabled ? css`cursor: default;` : '')}
- &: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 SubtitleLabel = styled.div`
- display: flex;
- font-size: 11px;
- `
- const DuplicatedLabel = styled.div`
- display: flex;
- font-size: 11px;
- span {
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
- }
- `
- const Separator = styled.div<any>`
- width: calc(100% - 32px);
- height: 1px;
- margin: 8px 16px;
- background: ${Palette.grayscale[3]};
- `
- const Labels = styled.div<any>`
- word-break: break-word;
- max-width: 100%;
- `
- export const updateTipStyle = (
- listItemsRef: HTMLElement,
- tipRef: HTMLElement,
- firstItemRef: HTMLElement,
- ) => {
- const usableFirstItemRef = firstItemRef
- if (listItemsRef && tipRef && usableFirstItemRef) {
- const svgPath: SVGPathElement | null = tipRef.querySelector('#path')
- if (svgPath) {
- if (listItemsRef.clientHeight < listItemsRef.scrollHeight) {
- svgPath.style.fill = 'white'
- usableFirstItemRef.style.borderTopRightRadius = '0'
- } else {
- svgPath.style.fill = ''
- usableFirstItemRef.style.borderTopRightRadius = ''
- }
- }
- }
- }
- export const scrollItemIntoView = (
- listRef: HTMLElement,
- listItemsRef: HTMLElement,
- itemIndex: number,
- ) => {
- const usableListItemsRef = listItemsRef
- if (!listRef || !usableListItemsRef) {
- return
- }
- if (itemIndex === -1 || !listItemsRef.children[itemIndex]) {
- return
- }
- const child = usableListItemsRef.children[itemIndex] as HTMLElement | null
- const parentNode = child && (child.parentNode as HTMLElement | null)
- if (!parentNode || !child) {
- return
- }
- parentNode.scrollTop = child.offsetTop - parentNode.offsetTop - 32
- }
- export const handleKeyNavigation = (options: {
- submitKeys: string[],
- keyboardEvent: React.KeyboardEvent<HTMLInputElement | HTMLDivElement>,
- arrowSelection: number | null,
- items: any[],
- selectedItem: any,
- onSubmit: (item: any) => void,
- onGetValue: (item: any) => any,
- onSelection: (arrowSelection: number) => void,
- }) => {
- const {
- submitKeys, keyboardEvent, arrowSelection,
- items, onSubmit, onGetValue, selectedItem, onSelection,
- } = options
- if (submitKeys.find(k => k === keyboardEvent.key)) {
- keyboardEvent.preventDefault()
- if (arrowSelection == null) {
- return
- }
- window.handlingEnterKey = true // Needed for KeyboardManager conflict resolution
- const arrowSelectedItem = items[arrowSelection]
- if (arrowSelectedItem) {
- onSubmit(arrowSelectedItem)
- }
- setTimeout(() => { window.handlingEnterKey = false }, 100)
- return
- }
- if (keyboardEvent.key !== 'ArrowUp' && keyboardEvent.key !== 'ArrowDown') {
- return
- }
- keyboardEvent.preventDefault()
- const itemIndex = items.findIndex(i => onGetValue(i) === onGetValue(selectedItem))
- const currentIndex = arrowSelection == null ? itemIndex : arrowSelection
- const maxIndex = items.length - 1
- if (keyboardEvent.key === 'ArrowUp') {
- onSelection(currentIndex === 0 ? maxIndex : currentIndex - 1)
- }
- if (keyboardEvent.key === 'ArrowDown') {
- onSelection(currentIndex === maxIndex ? 0 : currentIndex + 1)
- }
- }
- 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[] | null,
- highlight?: boolean,
- required?: boolean,
- centered?: boolean,
- useBold?: boolean,
- primary?: boolean
- }
- type State = {
- showDropdownList: boolean,
- firstItemHover: boolean,
- arrowSelection: number | null,
- }
- @observer
- class Dropdown extends React.Component<Props, State> {
- static defaultProps = {
- noSelectionMessage: 'Select an item',
- }
- state: State = {
- showDropdownList: false,
- firstItemHover: false,
- arrowSelection: null,
- }
- buttonRef: HTMLElement | null | undefined
- listRef: HTMLElement | null | undefined
- listItemsRef: HTMLElement | null | undefined
- firstItemRef: HTMLElement | null | undefined
- tipRef: HTMLElement | null | undefined
- scrollableParent: HTMLElement | null | undefined
- wrapperRef: HTMLElement | null | undefined
- buttonRect: ClientRect | null | undefined
- itemMouseDown: boolean | undefined
- justFocused: boolean | undefined
- ignoreFocusHandler: boolean | undefined
- checkmarkRefs: { [ref: string]: HTMLElement } = {}
- componentDidMount() {
- 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()
- }
- }
- UNSAFE_componentWillReceiveProps(newProps: Props) {
- if (!this.props.multipleSelection) {
- return
- }
- // Clear checkmark if items are removed in newProps
- const newSelectedItems = newProps.selectedItems || []
- const oldSelectedItems = this.props.selectedItems || []
- const hash = (item: any) => `${this.getLabel(item)}-${this.getValue(item) || ''}`
- const needsCheckmarkClear = oldSelectedItems.filter(
- oldItem => !newSelectedItems.find(newItem => hash(oldItem) === hash(newItem)),
- )
- needsCheckmarkClear.forEach(clearItem => {
- this.toggleCheckmarkAnimation(clearItem, this.checkmarkRefs[hash(clearItem)], true)
- })
- }
- UNSAFE_componentWillUpdate() {
- if (this.buttonRef) this.buttonRect = this.buttonRef.getBoundingClientRect()
- }
- componentDidUpdate() {
- this.updateListPosition()
- }
- componentWillUnmount() {
- window.removeEventListener('resize', this.handleScroll, false)
- if (this.scrollableParent) {
- this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
- }
- }
- getLabel(item: any) {
- const 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) {
- const 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 })
- }
- }
- }
- toggleDropdownList(show: boolean = true) {
- if (this.props.disabled && show) {
- return
- }
- this.setState({ showDropdownList: show }, () => {
- this.scrollIntoView()
- })
- }
- handleFocus() {
- if (this.ignoreFocusHandler || this.props.disabled || this.props.disabledLoading) {
- return
- }
- this.justFocused = true
- this.toggleDropdownList(true)
- setTimeout(() => { this.justFocused = false }, 1000)
- }
- handleBlur() {
- if (!this.itemMouseDown) {
- this.setState({ showDropdownList: false })
- }
- }
- handleKeyPress(e: React.KeyboardEvent<HTMLDivElement>) {
- if (!this.state.showDropdownList) {
- return
- }
- handleKeyNavigation({
- submitKeys: ['Enter', ' '],
- keyboardEvent: e,
- arrowSelection: this.state.arrowSelection,
- items: this.props.items,
- selectedItem: this.props.selectedItem,
- onSubmit: item => { this.handleItemClick(item) },
- onGetValue: item => this.getValue(item),
- onSelection: arrowSelection => {
- this.setState({ arrowSelection }, () => {
- this.scrollIntoView(arrowSelection)
- })
- },
- })
- }
- handleButtonClick() {
- if (this.justFocused) {
- return
- }
- this.toggleDropdownList(!this.state.showDropdownList)
- }
- handleItemClick(item: any) {
- const resetFocus = () => {
- if (!this.wrapperRef) {
- return
- }
- this.ignoreFocusHandler = true
- this.wrapperRef.focus()
- setTimeout(() => { this.ignoreFocusHandler = false }, 100)
- }
- if (item.disabled) {
- resetFocus()
- return
- }
- if (!this.props.multipleSelection) {
- this.setState({ showDropdownList: false, firstItemHover: false }, () => {
- resetFocus()
- })
- } else {
- const 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,
- )
- resetFocus()
- }
- 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
- }
- const symbol = checkmarkRef.querySelector('#symbol') as HTMLElement
- if (symbol) {
- symbol.style.animationName = selected ? 'dashOff' : 'dashOn'
- }
- }
- updateListPosition() {
- if (!this.state.showDropdownList || !this.listRef || !this.buttonRef || !this.firstItemRef
- || !document.body || !this.buttonRect || !this.tipRef || !this.listItemsRef) {
- return
- }
- const buttonHeight = this.buttonRef.offsetHeight
- const tipHeight = 8
- let listTop = this.buttonRect.top + buttonHeight + tipHeight
- const 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)
- }
- const 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(itemIndex?: number) {
- const selectedItemIndex = this.props.items
- .findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
- const actualItemIndex = itemIndex != null ? itemIndex : selectedItemIndex
- if (this.listRef && this.listItemsRef) {
- scrollItemIntoView(this.listRef, this.listItemsRef, actualItemIndex)
- }
- }
- renderList() {
- if (!this.props.items || this.props.items.length === 0 || !this.state.showDropdownList) {
- return null
- }
- const { body } = document
- const selectedValue = this.getValue(this.props.selectedItem)
- const duplicatedLabels: any[] = []
- this.props.items.forEach((item, i) => {
- const 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
- const list = ReactDOM.createPortal((
- <List
- // eslint-disable-next-line react/jsx-props-no-spreading
- {...this.props}
- ref={(ref: HTMLElement | null | undefined) => { this.listRef = ref }}
- >
- <Tip
- ref={(ref: HTMLElement | null | undefined) => { this.tipRef = ref }}
- primary={this.state.firstItemHover || isFirstItemSelected}
- dangerouslySetInnerHTML={{ __html: tipImage }}
- />
- <ListItems ref={(ref: HTMLElement | null | undefined) => { this.listItemsRef = ref }}>
- {this.props.items.map((item, i) => {
- if (item.separator === true) {
- // eslint-disable-next-line react/no-array-index-key
- return <Separator key={`sep-${i}`} />
- }
- const label = this.getLabel(item)
- const value = this.getValue(item)
- const duplicatedLabel = duplicatedLabels.find(l => l === label)
- const multipleSelected = this.props.selectedItems && this.props.selectedItems
- .find(j => this.getValue(j) === value)
- const listItem = (
- <ListItem
- data-test-id="dropdownListItem"
- ref={(ref: HTMLElement | null | undefined) => {
- 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}
- arrowSelected={i === this.state.arrowSelection}
- disabled={item.disabled}
- >
- {this.props.multipleSelection ? (
- <Checkmark
- ref={(ref: HTMLElement) => { this.checkmarkRefs[`${label}-${value || ''}`] = ref }}
- dangerouslySetInnerHTML={{ __html: checkmarkImage }}
- show={multipleSelected}
- />
- ) : null}
- <Labels>
- {label === '' ? '\u00A0' : label}
- {item.subtitleLabel ? (
- <SubtitleLabel>{item.subtitleLabel}</SubtitleLabel>
- ) : null}
- {duplicatedLabel ? <DuplicatedLabel>(<span>{value || ''}</span>)</DuplicatedLabel> : ''}
- </Labels>
- </ListItem>
- )
- return listItem
- })}
- </ListItems>
- </List>
- ), body)
- return list
- }
- render() {
- const 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 (
- <Wrapper
- className={this.props.className}
- data-test-id={this.props['data-test-id'] || 'dropdown'}
- embedded={this.props.embedded}
- tabIndex={0}
- ref={(ref: HTMLElement | null | undefined) => { this.wrapperRef = ref }}
- onFocus={() => { this.handleFocus() }}
- onBlur={() => { this.handleBlur() }}
- onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => { this.handleKeyPress(e) }}
- >
- <DropdownButton
- // eslint-disable-next-line react/jsx-props-no-spreading
- {...this.props}
- data-test-id="dropdown-dropdownButton"
- customRef={ref => { this.buttonRef = ref }}
- value={buttonValue()}
- onClick={() => { this.handleButtonClick() }}
- outline={this.state.showDropdownList}
- />
- {this.props.required ? (
- <Required
- disabledLoading={this.props.disabledLoading}
- right={this.props.embedded ? -24 : -16}
- />
- ) : null}
- {this.renderList()}
- </Wrapper>
- )
- }
- }
- export default Dropdown
|