@pubsweet/ui
Version:
React component library for use in pubsweet apps
396 lines (343 loc) • 9.73 kB
JavaScript
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { th, override } from '@pubsweet/ui-toolkit'
import Button from '../atoms/Button'
// TODO: match the width of the container to the width of the widest option?
// TODO: use a <select> element instead of divs?
// TODO: determine the correct color for non-selected options
// TODO: add z-indexes in theme object?
// FIXME: putting markup inside a <button> is invalid
// #region styled components
const Root = styled.div``
const CloseOverlay = styled.div`
background-color: transparent;
position: fixed;
bottom: 0;
left: 0;
top: 0;
right: 0;
z-index: 10;
${override('ui.Menu.CloseOverlay')};
`
const Label = styled.label`
font-size: ${th('fontSizeBaseSmall')};
line-height: ${th('lineHeightBaseSmall')};
display: block;
${override('ui.Label')};
${override('ui.Menu.Label')};
`
const Opener = styled.button.attrs(() => ({
type: 'button',
}))`
background: transparent;
border: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')};
border-radius: ${th('borderRadius')};
cursor: pointer;
font-family: inherit;
width: 100%;
height: calc(${th('gridUnit')} * 6);
padding: 0;
display: flex;
align-items: center;
&:hover {
border-color: ${th('colorPrimary')};
}
${override('ui.Menu.Opener')};
`
const Value = styled.span`
border-right: ${th('borderWidth')} ${th('borderStyle')}
${th('colorFurniture')};
flex-grow: 1;
white-space: nowrap;
flex-wrap: wrap;
display: flex;
text-align: left;
line-height: ${th('gridUnit')};
padding: 0 ${th('gridUnit')};
&:hover {
color: ${th('colorPrimary')};
}
${override('ui.Menu.Value')};
`
const MultipleValue = styled.span`
text-align: left;
padding: 0 calc(${th('gridUnit')} / 2);
color: ${th('colorPrimary')};
background-color: ${th('colorSecondary')};
margin: calc(${th('gridUnit')} / 6);
button {
margin-left: calc(${th('gridUnit')} / 2);
min-width: 0px;
padding: calc(${th('gridUnit')} / 2);
}
`
const Placeholder = styled(Value)`
color: ${th('colorTextPlaceholder')};
font-style: italic;
padding: calc(${th('gridUnit')} * 2);
${override('ui.Menu.Placeholder')};
`
const ArrowContainer = styled.span`
width: calc(${th('gridUnit')} * 2);
height: calc(${th('gridUnit')} * 2 - ${th('borderWidth')} * 2);
display: flex;
align-items: center;
justify-content: center;
padding: calc(${th('gridUnit')} * 2);
${override('ui.Menu.ArrowContainer')};
`
const Arrow = styled.span`
font-size: 50%;
transition: transform 0.2s;
transform: scaleX(2) scaleY(${props => (props.open ? -1.2 : 1.2)});
${override('ui.Menu.Arrow')};
`
const Main = styled.div.attrs(() => ({
role: 'listbox',
}))`
position: relative;
${override('ui.Menu.Main')};
`
const OptionsContainer = styled.div`
position: absolute;
left: 0;
right: 0;
${override('ui.Menu.OptionsContainer')};
`
const Options = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
background-color: ${th('colorBackground')};
border: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')};
border-radius: ${th('borderRadius')};
overflow-y: auto;
max-height: ${({ maxHeight }) => `${maxHeight}px`};
z-index: 100;
${override('ui.Menu.Options')};
`
const Option = styled.div.attrs(props => ({
role: 'option',
tabIndex: '0',
'aria-selected': props.active,
}))`
color: ${props => (props.active ? props.theme.textColor : '#444')};
font-weight: ${props => (props.active ? '600' : 'inherit')};
cursor: pointer;
font-family: ${th('fontAuthor')};
padding: calc(${th('gridUnit')} - ${th('borderWidth')} * 2)
calc(${th('gridUnit')} * 2);
border: ${th('borderWidth')} ${th('borderStyle')} transparent;
border-width: ${th('borderWidth')} 0 ${th('borderWidth')} 0;
white-space: nowrap;
&:hover {
background: ${th('colorBackgroundHue')};
border-color: ${th('colorBorder')};
}
&:first-child:hover {
border-top-color: ${th('colorBackgroundHue')};
}
&:last-child:hover {
border-bottom-color: ${th('colorBackgroundHue')};
}
${override('ui.Menu.Option')};
`
// #endregion
class Menu extends React.Component {
constructor(props) {
super(props)
this.state = {
open: false,
selected: props.value,
selectOneOfMultiElement: undefined,
}
}
toggleMenu = () => {
this.setState({
open: !this.state.open,
})
}
resetMenu = props => {
this.state = {
open: false,
selected: undefined,
}
}
selectOneOfMultiElement = (event, value) => {
event.stopPropagation()
const selectOneOfMultiElement = value
this.setState({ selectOneOfMultiElement })
if (this.props.selectElement) this.props.selectElement(value)
}
removeSelect = (event, value) => {
event.stopPropagation()
let { selected } = this.state
const index = selected.indexOf(value)
selected = [...selected.slice(0, index), ...selected.slice(index + 1)]
this.setState({ selected })
if (this.props.onChange) this.props.onChange(selected)
}
handleSelect = ({ selected, open }) => {
const { multi } = this.props
let values
if (multi) {
values = this.state.selected ? this.state.selected : []
if (values.indexOf(selected) === -1) values.push(selected)
} else {
values = selected
}
this.setState({
open,
selected: values,
})
if (this.props.onChange) this.props.onChange(values)
}
handleKeyPress = (event, selected, open) => {
if (event.which === 13) {
this.handleSelect(selected, open)
}
}
optionLabel = value => {
const { options } = this.props
return options.find(option => option.value === value)
? options.find(option => option.value === value).label
: ''
}
render() {
const {
maxHeight = 250,
label,
options,
inline,
placeholder,
renderOption: RenderOption,
renderOpener: RenderOpener,
className,
multi,
reset,
...rest
} = this.props
const { open, selected } = this.state
if (reset === true) this.resetMenu(this.props)
return (
<Root className={className} inline={inline} open={open}>
{label && <Label>{label}</Label>}
{open && <CloseOverlay onClick={this.toggleMenu} />}
<Main>
<RenderOpener
open={open}
optionLabel={this.optionLabel}
placeholder={placeholder}
removeSelect={this.removeSelect}
selected={selected}
selectOneOfMultiElement={this.selectOneOfMultiElement}
toggleMenu={this.toggleMenu}
{...rest}
/>
<OptionsContainer>
{open && (
<Options maxHeight={maxHeight} open={open}>
{options.map(option => (
<RenderOption
handleKeyPress={this.handleKeyPress}
handleSelect={this.handleSelect}
key={option.value}
label={option.label}
multi={multi}
selected={selected}
value={option.value}
/>
))}
</Options>
)}
</OptionsContainer>
</Main>
</Root>
)
}
}
const DefaultMenuOption = ({
selected,
label,
value,
handleSelect,
handleKeyPress,
multi,
}) => {
const option = (
<Option
active={value === selected}
key={value}
onClick={() => handleSelect({ open: false, selected: value })}
onKeyPress={event => handleKeyPress(event, value)}
>
{label || value}
</Option>
)
if (!multi) return option
return multi && !selected.includes(value) ? option : null
}
const DefaultOpener = ({
toggleMenu,
open,
selected,
placeholder,
optionLabel,
removeSelect,
validationStatus,
selectOneOfMultiElement,
}) => (
<Opener onClick={toggleMenu} open={open} validationStatus={validationStatus}>
{(!selected || selected.length === 0) && (
<Placeholder>{placeholder}</Placeholder>
)}
{selected && !Array.isArray(selected) && (
<Value>{optionLabel(selected)}</Value>
)}
{selected && selected.length > 0 && Array.isArray(selected) && (
<Value>
{selected.map(select => (
<MultipleValue
key={select}
onClick={event => selectOneOfMultiElement(event, select)}
>
{optionLabel(select)}
<Button onClick={event => removeSelect(event, select)}>x</Button>
</MultipleValue>
))}
</Value>
)}
<ArrowContainer>
<Arrow open={open}>▼</Arrow>
</ArrowContainer>
</Opener>
)
Menu.propTypes = {
/** Menu items. */
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.any.isRequired,
}),
).isRequired,
/** Custom component for the selected option. */
renderOpener: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
/** Custom option component. The component will be rendered with *optionProps*. */
renderOption: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
reset: PropTypes.bool,
/** Optional label to be shown above the menu. */
label: PropTypes.string,
/** Placeholder until a value is selected. */
placeholder: PropTypes.string,
/** Maximum height of the options container. */
maxHeight: PropTypes.number,
}
Menu.defaultProps = {
renderOption: DefaultMenuOption,
renderOpener: DefaultOpener,
reset: false,
placeholder: 'Choose in the list',
}
export default Menu