-
Notifications
You must be signed in to change notification settings - Fork 36
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add NestedSelect
- Loading branch information
Showing
14 changed files
with
623 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
Oops, something went wrong.