Skip to content

Commit

Permalink
feat: Add NestedSelect (#1270)
Browse files Browse the repository at this point in the history
feat: Add NestedSelect
  • Loading branch information
ptbrowne authored Nov 28, 2019
2 parents 867410d + ac04542 commit 7e482d3
Show file tree
Hide file tree
Showing 14 changed files with 623 additions and 36 deletions.
3 changes: 2 additions & 1 deletion docs/styleguide.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ module.exports = {
'../react/Textarea/index.jsx',
'../react/Toggle/index.jsx',
'../react/FileInput/index.jsx',
'../react/DateMonthPicker/index.jsx'
'../react/DateMonthPicker/index.jsx',
'../react/NestedSelect/NestedSelect.jsx'
]
},
{
Expand Down
17 changes: 11 additions & 6 deletions react/CompositeRow/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import cx from 'classnames'
import PropTypes from 'prop-types'
import { Media, Bd, Img } from '../Media'
import { Text, Caption } from '../Text'
import styles from './styles.styl'

const denseStyle = { height: '48px' }

Expand All @@ -22,7 +23,11 @@ const CompositeRow = ({
}) => {
return (
<Media
className={cx(className, dense ? 'u-ph-1' : 'u-p-1')}
className={cx(
className,
styles.CompositeRow,
dense ? styles.CompositeRow__dense : null
)}
style={dense ? Object.assign({}, denseStyle, style) : style}
{...rest}
>
Expand All @@ -49,18 +54,18 @@ CompositeRow.propTypes = {
/** Custom class */
className: PropTypes.string,
/** First line */
primaryText: PropTypes.element,
primaryText: PropTypes.node,
/** Second line */
secondaryText: PropTypes.element,
secondaryText: PropTypes.node,
/** Image to the left of the row */
image: PropTypes.element,
image: PropTypes.node,
/**
* Actions are shown below primary and secondary texts. Pass fragment for multiple elements.
* Good to use with Chips.
*/
actions: PropTypes.element,
actions: PropTypes.node,
/* Element(s) to the show to the right of the CompositeRow */
right: PropTypes.element,
right: PropTypes.node,
/** Row height will be fixed to 48px */
dense: PropTypes.bool
}
Expand Down
13 changes: 13 additions & 0 deletions react/CompositeRow/styles.styl
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@require 'settings/spaces.styl'

// Cannot use spacing_values.m directly otherwise stylus
// does not pick it up, we have to use a temporary variable
row_padding=spacing_values.m

.CompositeRow
min-height 3rem
padding row_padding

&__dense
padding-top 0
padding-bottom 0
53 changes: 53 additions & 0 deletions react/NestedSelect/Modal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react'
import Icon from 'cozy-ui/react/Icon'
import Modal, {
ModalHeader as UIModalHeader,
ModalContent as UIModalContent
} from 'cozy-ui/react/Modal'
import { Media, Bd, Img } from 'cozy-ui/react/Media'
import palette from 'cozy-ui/react/palette'
import NestedSelect from './NestedSelect'

import styles from './styles.styl'

const ModalTitle = ({ showBack, onClickBack, title }) => (
<Media>
{showBack && (
<Img className={styles.Modal__back} onClick={onClickBack}>
<Icon icon="left" color={palette['coolGrey']} />
</Img>
)}
<Bd>
<h2>{title}</h2>
</Bd>
</Media>
)

const ModalHeader = ({ showBack, onClickBack, title }) => (
<UIModalHeader className={styles.Modal__title}>
<ModalTitle showBack={showBack} onClickBack={onClickBack} title={title} />
</UIModalHeader>
)

const ModalContent = ({ children }) => (
<UIModalContent className={styles.Modal__content}>{children}</UIModalContent>
)

const NestedSelectModal = props => {
return (
<Modal
closeBtnClassName={props.closeBtnClassName}
overflowHidden
dismissAction={props.onCancel}
into="body"
>
<NestedSelect
{...props}
HeaderComponent={ModalHeader}
ContentComponent={ModalContent}
/>
</Modal>
)
}

export default NestedSelectModal
202 changes: 202 additions & 0 deletions react/NestedSelect/NestedSelect.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import CompositeRow from '../CompositeRow'
import Icon from '../Icon'
import styles from './styles.styl'
import UIRadio from '../Radio'
import cx from 'classnames'
import omit from 'lodash/omit'
import palette from 'cozy-ui/react/palette'

/**
* Select like component to choose an option among a list of options.
* Options can have children; selecting an option that has children
* will show the children of the chosen option instead of selecting
* the option.
*/
class NestedSelect extends Component {
constructor(props) {
super(props)
this.state = {
history: [props.options]
}
}

componentWillUnmount() {
this.unmounted = true
}

resetHistory() {
if (this.unmounted) {
return
}
this.setState({ history: [this.props.options] })
}

handleBack = () => {
const [item, ...newHistory] = this.state.history
this.setState({
history: newHistory
})
return item
}

handleNavToChildren = item => {
const newHistory = [item, ...this.state.history]
this.setState({
history: newHistory
})
}

handleSelect = item => {
this.props.onSelect(item)
// It is important to reset history if the NestedSelected is used
// multiple times in a row without being dismounted. For example
// if it displayed in Carousel that slides in the NestedSelect
// and slides it out on selection.
// But, we want in this case that the resetting does not happen
// while the animation is running.
// There is probably a better way to do this.
setTimeout(() => {
this.resetHistory()
}, 500)
}

handleClickItem = item => {
if (item.children && item.children.length > 0) {
this.handleNavToChildren(item)
} else {
this.handleSelect(item)
}
}

render() {
const {
ContentComponent,
HeaderComponent,
canSelectParent,
isSelected,
title,
transformParentItem
} = this.props
const { history } = this.state
const current = history[0]
const children = current.children || []
const level = history.length - 1
const isSelectedWithLevel = item => isSelected(item, level)
const parentItem = transformParentItem(omit(current, 'children'))

return (
<>
{HeaderComponent ? (
<HeaderComponent
title={current.title || title}
showBack={history.length > 1}
onClickBack={this.handleBack}
/>
) : null}
<ContentComponent>
{canSelectParent && level > 0 ? (
<>
<ItemRow
item={parentItem}
onClick={this.handleClickItem}
isSelected={isSelectedWithLevel(parentItem)}
/>
<Divider />
</>
) : null}
{children.map(item => (
<ItemRow
key={item.title}
item={item}
onClick={this.handleClickItem}
isSelected={isSelectedWithLevel(item)}
/>
))}
</ContentComponent>
</>
)
}
}

NestedSelect.defaultProps = {
ContentComponent: 'div',
HeaderComponent: null,
transformParentItem: x => x
}

const ItemPropType = PropTypes.shape({
icon: PropTypes.element,
title: PropTypes.string.isRequired,
children: PropTypes.array
})

NestedSelect.propTypes = {
/**
* The whole option item is passed to this function when selected
*/
onSelect: PropTypes.func.isRequired,

/**
* Determines if the row looks selected. The `option` is
* passed as an argument.
*/
isSelected: PropTypes.func.isRequired,

/**
* Options that will be rendered as nested lists of choices
*/
options: PropTypes.shape({
children: PropTypes.arrayOf(ItemPropType)
}),

/** If true, parent option will be shown at the top of its children */
canSelectParent: PropTypes.bool,

/**
* `parentItem` is passed into this function before being used to render
* the parent item row (canSelectParent must be true).
* Use this if you want the parent to have a different text on the "outer"
* row than inside the "inner" row.
*
* @example
* ```
* const transformParentItem = item => ({ ...item, title: "Everything"})
* ````
*/
transformParentItem: PropTypes.func
}

export default NestedSelect

export const Radio = ({ className, ...props }) => (
<UIRadio label="" className={cx(styles.Radio, className)} {...props} />
)

const Divider = () => <div className={styles.Divider} />

export const ItemRow = ({ item, onClick, isSelected }) => {
return (
<div className={cx(styles.Row, isSelected ? styles.Row__selected : null)}>
<CompositeRow
dense
image={item.icon}
primaryText={item.title}
onClick={() => onClick(item)}
right={
item.children && item.children.length > 0 ? (
<Icon icon="right" color={palette.coolGrey} />
) : (
<Radio
readOnly
name={item.title}
value={item.title}
checked={!!isSelected}
/>
)
}
/>
</div>
)
}
Loading

0 comments on commit 7e482d3

Please sign in to comment.