react-checkbox-tree-enhanced
Version:
A simple, elegant, and enhanced checkbox tree for React.
345 lines (291 loc) • 10.7 kB
JavaScript
import classNames from 'classnames';
import isEqual from 'lodash/isEqual';
import nanoid from 'nanoid';
import PropTypes from 'prop-types';
import React from 'react';
import Button from './Button';
import NodeModel from './NodeModel';
import TreeNode from './TreeNode';
import iconsShape from './shapes/iconsShape';
import languageShape from './shapes/languageShape';
import listShape from './shapes/listShape';
import nodeShape from './shapes/nodeShape';
class CheckboxTree extends React.Component {
static propTypes = {
nodes: PropTypes.arrayOf(nodeShape).isRequired,
checked: listShape,
disabled: PropTypes.bool,
expandDisabled: PropTypes.bool,
expandOnClick: PropTypes.bool,
expanded: listShape,
icons: iconsShape,
id: PropTypes.string,
initialExpand: PropTypes.bool,
lang: languageShape,
name: PropTypes.string,
nameAsArray: PropTypes.bool,
nativeCheckboxes: PropTypes.bool,
noCascade: PropTypes.bool,
onlyLeafCheckboxes: PropTypes.bool,
optimisticToggle: PropTypes.bool,
showExpandAll: PropTypes.bool,
showNodeIcon: PropTypes.bool,
showNodeTitle: PropTypes.bool,
onCheck: PropTypes.func,
onClick: PropTypes.func,
onExpand: PropTypes.func,
};
static defaultProps = {
checked: [],
disabled: false,
expandDisabled: false,
expandOnClick: false,
expanded: [],
icons: {
check: <span className='rct-icon rct-icon-check' />,
uncheck: <span className='rct-icon rct-icon-uncheck' />,
halfCheck: <span className='rct-icon rct-icon-half-check' />,
expandClose: <span className='rct-icon rct-icon-expand-close' />,
expandOpen: <span className='rct-icon rct-icon-expand-open' />,
expandAll: <span className='rct-icon rct-icon-expand-all' />,
collapseAll: <span className='rct-icon rct-icon-collapse-all' />,
parentClose: <span className='rct-icon rct-icon-parent-close' />,
parentOpen: <span className='rct-icon rct-icon-parent-open' />,
leaf: <span className='rct-icon rct-icon-leaf' />,
},
id: null,
lang: {
collapseAll: 'Collapse all',
expandAll: 'Expand all',
toggle: 'Toggle',
},
name: undefined,
nameAsArray: false,
nativeCheckboxes: false,
noCascade: false,
onlyLeafCheckboxes: false,
optimisticToggle: true,
initialExpand: false,
showExpandAll: false,
showNodeIcon: true,
showNodeTitle: false,
onCheck: () => {},
onClick: null,
onExpand: () => {},
};
constructor(props) {
super(props);
const model = new NodeModel(props);
model.flattenNodes(props.nodes);
model.deserializeLists({
checked: props.checked,
expanded: props.expanded,
});
this.state = {
id: props.id || `rct-${nanoid(7)}`,
model,
prevProps: props,
};
if (props.initialExpand) {
this.expandAllNodes();
}
this.onCheck = this.onCheck.bind(this);
this.onExpand = this.onExpand.bind(this);
this.onNodeClick = this.onNodeClick.bind(this);
this.onExpandAll = this.onExpandAll.bind(this);
this.onCollapseAll = this.onCollapseAll.bind(this);
}
componentWillReceiveProps(nextProps) {
const { model } = this.state;
const {
nodes, checked, expanded, disabled,
} = nextProps;
model.setProps(nextProps);
if (!isEqual(this.props.nodes, nodes) || this.props.disabled !== disabled) {
model.flattenNodes(nodes);
}
model.deserializeLists({ checked, expanded });
}
onCheck(nodeInfo) {
const { noCascade, onCheck } = this.props;
const model = this.state.model.clone();
const node = model.getNode(nodeInfo.value);
model.toggleChecked(nodeInfo, nodeInfo.checked, noCascade);
onCheck(model.serializeList('checked'), { ...node, ...nodeInfo });
}
onExpand(nodeInfo) {
const { onExpand } = this.props;
const model = this.state.model.clone();
const node = model.getNode(nodeInfo.value);
model.toggleNode(nodeInfo.value, 'expanded', nodeInfo.expanded);
onExpand(model.serializeList('expanded'), { ...node, ...nodeInfo });
}
onNodeClick(nodeInfo) {
const { onClick } = this.props;
const { model } = this.state;
const node = model.getNode(nodeInfo.value);
onClick({ ...node, ...nodeInfo });
}
onExpandAll() {
this.expandAllNodes();
}
onCollapseAll() {
this.expandAllNodes(false);
}
expandAllNodes(expand = true) {
const { onExpand } = this.props;
onExpand(
this.state.model
.clone()
.expandAllNodes(expand)
.serializeList('expanded'),
);
}
determineShallowCheckState(node, noCascade) {
const flatNode = this.state.model.getNode(node.value);
if (flatNode.isLeaf || noCascade) {
return flatNode.checked ? 1 : 0;
}
if (this.isEveryChildChecked(node)) {
return 1;
}
if (this.isSomeChildChecked(node)) {
return 2;
}
return 0;
}
isEveryChildChecked(node) {
return node.children.every(child => this.state.model.getNode(child.value).checkState === 1);
}
isSomeChildChecked(node) {
return node.children.some(child => this.state.model.getNode(child.value).checkState > 0);
}
renderTreeNodes(nodes, parent = {}) {
const {
expandDisabled,
expandOnClick,
icons,
lang,
noCascade,
onClick,
onlyLeafCheckboxes,
optimisticToggle,
showNodeTitle,
showNodeIcon,
} = this.props;
const { id, model } = this.state;
const { icons: defaultIcons } = CheckboxTree.defaultProps;
const treeNodes = nodes.map((node) => {
const key = node.value;
const flatNode = model.getNode(node.value);
const children = flatNode.isParent ? this.renderTreeNodes(node.children, node) : null;
// Determine the check state after all children check states have been determined
// This is done during rendering as to avoid an additional loop during the
// deserialization of the `checked` property
flatNode.checkState = this.determineShallowCheckState(node, noCascade);
// Show checkbox only if this is a leaf node or showCheckbox is true
const showCheckbox = onlyLeafCheckboxes ? flatNode.isLeaf : flatNode.showCheckbox;
// Render only if parent is expanded or if there is no root parent
const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;
if (!parentExpanded) {
return null;
}
return (
<TreeNode
key={key}
checked={flatNode.checkState}
className={node.className}
disabled={flatNode.disabled}
expandDisabled={expandDisabled}
expandOnClick={expandOnClick}
expanded={flatNode.expanded}
icon={node.icon}
icons={{ ...defaultIcons, ...icons }}
label={node.label}
lang={lang}
optimisticToggle={optimisticToggle}
isLeaf={flatNode.isLeaf}
isParent={flatNode.isParent}
showCheckbox={showCheckbox}
showNodeIcon={showNodeIcon}
title={showNodeTitle ? node.title || node.label : node.title}
treeId={id}
value={node.value}
onCheck={this.onCheck}
onClick={onClick && this.onNodeClick}
onExpand={this.onExpand}
>
{children}
</TreeNode>
);
});
return <ol>{treeNodes}</ol>;
}
renderExpandAll() {
const {
icons: { expandAll, collapseAll },
lang,
showExpandAll,
} = this.props;
if (!showExpandAll) {
return null;
}
return (
<div className='rct-options'>
<Button
className='rct-option rct-option-expand-all'
title={lang.expandAll}
onClick={this.onExpandAll}
>
{expandAll}
</Button>
<Button
className='rct-option rct-option-collapse-all'
title={lang.collapseAll}
onClick={this.onCollapseAll}
>
{collapseAll}
</Button>
</div>
);
}
renderHiddenInput() {
const { name, nameAsArray } = this.props;
if (name === undefined) {
return null;
}
if (nameAsArray) {
return this.renderArrayHiddenInput();
}
return this.renderJoinedHiddenInput();
}
renderArrayHiddenInput() {
const { checked, name: inputName } = this.props;
return checked.map((value) => {
const name = `${inputName}[]`;
return <input key={value} name={name} type='hidden' value={value} />;
});
}
renderJoinedHiddenInput() {
const { checked, name } = this.props;
const inputValue = checked.join(',');
return <input name={name} type='hidden' value={inputValue} />;
}
render() {
const { disabled, nodes, nativeCheckboxes } = this.props;
const treeNodes = this.renderTreeNodes(nodes);
const className = classNames({
'react-checkbox-tree': true,
'rct-disabled': disabled,
'rct-native-display': nativeCheckboxes,
});
return (
<div className={className}>
{this.renderExpandAll()}
{this.renderHiddenInput()}
{treeNodes}
</div>
);
}
}
export default CheckboxTree;