react-checkbox-tree
Version:
A simple and elegant checkbox tree for React.
362 lines (308 loc) • 11.7 kB
JSX
import classNames from 'classnames';
import { deepEqual } from 'fast-equals';
import memoize from 'lodash.memoize';
import PropTypes from 'prop-types';
import React from 'react';
import GlobalActions from '#js/components/GlobalActions.jsx';
import HiddenInput from '#js/components/HiddenInput.jsx';
import TreeNode from '#js/components/TreeNode.jsx';
import defaultLang from '#js/lang/default.js';
import iconsShape from '#js/shapes/iconsShape.js';
import languageShape from '#js/shapes/languageShape.js';
import listShape from '#js/shapes/listShape.js';
import nodeShape from '#js/shapes/nodeShape.js';
import { CHECK_MODEL, KEYS } from '#js/constants.js';
import { IconContext, LanguageContext } from '#js/contexts.js';
import NodeModel from '#js/NodeModel.js';
const combineMemoized = memoize((newValue, defaultValue) => ({ ...defaultValue, ...newValue }));
const defaultIcons = {
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" />,
};
class CheckboxTree extends React.Component {
static propTypes = {
nodes: PropTypes.arrayOf(nodeShape).isRequired,
checkKeys: PropTypes.arrayOf(PropTypes.string),
checkModel: PropTypes.oneOf([CHECK_MODEL.LEAF, CHECK_MODEL.ALL]),
checked: listShape,
direction: PropTypes.string,
disabled: PropTypes.bool,
expandDisabled: PropTypes.bool,
expandOnClick: PropTypes.bool,
expanded: listShape,
icons: iconsShape,
iconsClass: PropTypes.string,
id: PropTypes.string,
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,
onContextMenu: PropTypes.func,
onExpand: PropTypes.func,
};
static defaultProps = {
checkKeys: [KEYS.SPACEBAR, KEYS.ENTER],
checkModel: CHECK_MODEL.LEAF,
checked: [],
direction: 'ltr',
disabled: false,
expandDisabled: false,
expandOnClick: false,
expanded: [],
icons: defaultIcons,
iconsClass: 'fa5',
id: null,
lang: defaultLang,
name: undefined,
nameAsArray: false,
nativeCheckboxes: false,
noCascade: false,
onlyLeafCheckboxes: false,
optimisticToggle: true,
showExpandAll: false,
showNodeIcon: true,
showNodeTitle: false,
onCheck: () => {},
onClick: null,
onContextMenu: 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 = {
model,
prevProps: props,
};
this.onCheck = this.onCheck.bind(this);
this.onContextMenu = this.onContextMenu.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);
}
static getDerivedStateFromProps(newProps, prevState) {
const { model, prevProps } = prevState;
const { disabled, nodes } = newProps;
const newState = { ...prevState, prevProps: newProps };
// Apply new properties to model
model.setProps(newProps);
// Since flattening nodes is an expensive task, only update when there is a node change
if (!deepEqual(prevProps.nodes, nodes) || prevProps.disabled !== disabled) {
model.reset();
model.flattenNodes(nodes);
}
model.deserializeLists({
checked: newProps.checked,
expanded: newProps.expanded,
});
return newState;
}
onContextMenu(node) {
const { onContextMenu } = this.props;
return (event) => {
onContextMenu(event, node);
};
}
onCheck(nodeInfo) {
const { checkModel, noCascade, onCheck } = this.props;
const { model } = this.state;
const newModel = model.clone();
const node = newModel.getNode(nodeInfo.value);
newModel.toggleChecked(nodeInfo, nodeInfo.checked, checkModel, noCascade);
onCheck(newModel.serializeList('checked'), { ...node, ...nodeInfo });
}
onExpand(nodeInfo) {
const { onExpand } = this.props;
const { model } = this.state;
const newModel = model.clone();
const node = newModel.getNode(nodeInfo.value);
newModel.toggleNode(nodeInfo.value, 'expanded', nodeInfo.expanded);
onExpand(newModel.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;
const { model } = this.state;
onExpand(
model.clone()
.expandAllNodes(expand)
.serializeList('expanded'),
);
}
determineShallowCheckState(node, noCascade) {
const { model } = this.state;
const flatNode = model.getNode(node.value);
if (flatNode.isLeaf || noCascade || node.children.length === 0) {
// Note that an empty parent node tracks its own state
return flatNode.checked ? 1 : 0;
}
if (this.isEveryChildChecked(node)) {
return 1;
}
if (this.isSomeChildChecked(node)) {
return 2;
}
return 0;
}
isEveryChildChecked(node) {
const { model } = this.state;
return node.children.every(
(child) => model.getNode(child.value).checkState === 1,
);
}
isSomeChildChecked(node) {
const { model } = this.state;
return node.children.some(
(child) => model.getNode(child.value).checkState > 0,
);
}
renderTreeNodes(nodes, parent = {}) {
const {
checkKeys,
expandDisabled,
expandOnClick,
id,
noCascade,
onClick,
onlyLeafCheckboxes,
optimisticToggle,
showNodeTitle,
showNodeIcon,
} = this.props;
const { model } = this.state;
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;
}
// Prepare node information for context menu usage
const nodeContext = {
...node,
checked: flatNode.checkState,
expanded: flatNode.expanded,
};
return (
<TreeNode
key={key}
checkKeys={checkKeys}
checked={flatNode.checkState}
className={node.className}
disabled={flatNode.disabled}
expandDisabled={expandDisabled}
expandOnClick={expandOnClick}
expanded={flatNode.expanded}
icon={node.icon}
isLeaf={flatNode.isLeaf}
isParent={flatNode.isParent}
label={node.label}
optimisticToggle={optimisticToggle}
showCheckbox={showCheckbox}
showNodeIcon={showNodeIcon}
title={showNodeTitle ? node.title || node.label : node.title}
treeId={id}
value={node.value}
onCheck={this.onCheck}
onClick={onClick && this.onNodeClick}
onContextMenu={this.onContextMenu(nodeContext)}
onExpand={this.onExpand}
>
{children}
</TreeNode>
);
});
return (
<ol>
{treeNodes}
</ol>
);
}
renderGlobalOptions() {
const { showExpandAll } = this.props;
return showExpandAll ? (
<GlobalActions onCollapseAll={this.onCollapseAll} onExpandAll={this.onExpandAll} />
) : null;
}
renderHiddenInput() {
const { checked, name, nameAsArray } = this.props;
return name !== undefined ? (
<HiddenInput checked={checked} name={name} nameAsArray={nameAsArray} />
) : null;
}
render() {
const {
direction,
disabled,
icons,
iconsClass,
id,
lang,
nodes,
nativeCheckboxes,
} = this.props;
const mergedLang = combineMemoized(lang, defaultLang);
const mergedIcons = combineMemoized(icons, defaultIcons);
const treeNodes = this.renderTreeNodes(nodes);
const className = classNames({
'react-checkbox-tree': true,
'rct-disabled': disabled,
[`rct-icons-${iconsClass}`]: true,
'rct-native-display': nativeCheckboxes,
'rct-direction-rtl': direction === 'rtl',
});
return (
<LanguageContext.Provider value={mergedLang}>
<IconContext.Provider value={mergedIcons}>
<div className={className} id={id}>
{this.renderGlobalOptions()}
{this.renderHiddenInput()}
{treeNodes}
</div>
</IconContext.Provider>
</LanguageContext.Provider>
);
}
}
export default CheckboxTree;