@salesforce/design-system-react
Version:
Salesforce Lightning Design System for React
348 lines (323 loc) • 9 kB
JSX
/* Copyright (c) 2015-present, salesforce.com, inc. All rights reserved */
/* Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license */
// # Tree Branch Component
// Implements the [Tree design pattern](https://www.lightningdesignsystem.com/components/tree/) in React.
// ## Dependencies
import React from 'react';
import PropTypes from 'prop-types';
import findIndex from 'lodash.findindex';
import isFunction from 'lodash.isfunction';
import classNames from 'classnames';
// Child components
import Button from '../../button';
import Highlighter from '../../utilities/highlighter';
import EventUtil from '../../../utilities/event';
import KEYS from '../../../utilities/key-code';
import mapKeyEventCallbacks from '../../../utilities/key-callbacks';
const propTypes = {
/**
* HTML `id` of primary element that has `.slds-tree` on it. This component has a wrapping container element outside of `.slds-tree`.
*/
htmlId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
/**
* The text of the tree item.
*/
label: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
/**
* The number of nestings. Determines the ARIA level and style alignment.
*/
level: PropTypes.number.isRequired,
/**
* The current node that is being rendered.
*/
node: PropTypes.object.isRequired,
/**
* This function triggers when the expand or collapse icon is clicked or due to keyboard navigation.
*/
onExpand: PropTypes.func.isRequired,
/**
* Function that will run whenever an item or branch is clicked.
*/
onSelect: PropTypes.func,
/**
* Highlights term if found in node label
*/
searchTerm: PropTypes.string,
/**
* Location of node (zero index). First node is `0`. It's first child is `0-0`. This can be used to modify your nodes without searching for the node. This index is only valid if the `nodes` prop is the same as at the time of the event.
*/
treeIndex: PropTypes.string,
/**
* Flattened tree structure.
*/
flattenedNodes: PropTypes.arrayOf(PropTypes.object),
/**
* Tree indexes of nodes that are currently selected.
*/
selectedNodeIndexes: PropTypes.arrayOf(PropTypes.string),
/**
* Tree index of the node that is currently focused.
*/
focusedNodeIndex: PropTypes.string,
/**
* Callback for when a node is blurred.
*/
onNodeBlur: PropTypes.func,
/**
* Sets focus on render.
*/
treeHasFocus: PropTypes.bool,
/**
* This node's parent.
*/
parent: PropTypes.object,
};
const handleExpand = (event, props) => {
EventUtil.trap(event);
if (isFunction(props.onExpand)) {
props.onExpand({
event,
data: {
node: props.node,
expand: !props.node.expanded,
treeIndex: props.treeIndex,
},
});
}
};
const handleSelect = ({ event, props, fromFocus }) => {
EventUtil.trap(event);
if (isFunction(props.onSelect)) {
props.onSelect({
event,
data: {
node: props.node,
select: !props.node.selected,
treeIndex: props.treeIndex,
},
fromFocus,
});
}
};
const findNextNode = (flattenedNodes, node) => {
const nodes = flattenedNodes.map((flattenedNode) => flattenedNode.node);
const index = findIndex(nodes, { id: node.id });
return flattenedNodes[(index + 1) % flattenedNodes.length];
};
const findPreviousNode = (flattenedNodes, node) => {
const nodes = flattenedNodes.map((flattenedNode) => flattenedNode.node);
let index = findIndex(nodes, { id: node.id }) - 1;
if (index < 0) {
index += flattenedNodes.length;
}
return flattenedNodes[index];
};
const handleKeyDownDown = (event, props) => {
if (props.focusedNodeIndex === props.treeIndex) {
// Select the next visible node
const flattenedNode = findNextNode(props.flattenedNodes, props.node);
props.onSelect({
event,
data: {
node: flattenedNode.node,
select: true,
treeIndex: flattenedNode.treeIndex,
},
clearSelectedNodes: true,
});
}
};
const handleKeyDownUp = (event, props) => {
if (props.focusedNodeIndex === props.treeIndex) {
// Go to the previous visible node
const flattenedNode = findPreviousNode(props.flattenedNodes, props.node);
props.onSelect({
event,
data: {
node: flattenedNode.node,
select: true,
treeIndex: flattenedNode.treeIndex,
},
clearSelectedNodes: true,
});
}
};
const handleKeyDownRight = (event, props) => {
if (props.node.expanded) {
if (props.getNodes(props.node) && props.getNodes(props.node).length > 0) {
handleKeyDownDown(event, props);
}
} else {
handleExpand(event, props);
}
};
const handleKeyDownLeft = (event, props) => {
if (props.node.expanded) {
handleExpand(event, props);
} else {
const nodes = props.flattenedNodes.map(
(flattenedNode) => flattenedNode.node
);
const index = findIndex(nodes, { id: props.parent.id });
if (index !== -1) {
props.onExpand({
event,
data: {
node: props.parent,
select: true,
expand: !props.parent.expanded,
treeIndex: props.flattenedNodes[index].treeIndex,
},
});
}
}
};
const handleKeyDownEnter = (event, props) => {
handleSelect({ event, props });
};
const handleKeyDown = (event, props) => {
mapKeyEventCallbacks(event, {
callbacks: {
[KEYS.DOWN]: { callback: (evt) => handleKeyDownDown(evt, props) },
[KEYS.UP]: { callback: (evt) => handleKeyDownUp(evt, props) },
[KEYS.RIGHT]: { callback: (evt) => handleKeyDownRight(evt, props) },
[KEYS.LEFT]: { callback: (evt) => handleKeyDownLeft(evt, props) },
[KEYS.ENTER]: { callback: (evt) => handleKeyDownEnter(evt, props) },
},
});
};
const handleFocus = (event, props) => {
if (
!props.treeHasFocus &&
!props.focusedNodeIndex &&
event.target === event.currentTarget
) {
// did it happen by mouse?
handleSelect({ event, props, fromFocus: true });
}
};
const getTabIndex = (props) => {
const initialFocus =
props.selectedNodeIndexes.length === 0 &&
props.treeIndex === props.flattenedNodes[0].treeIndex;
return props.treeIndex === props.focusedNodeIndex || initialFocus ? 0 : -1;
};
// Most of these props come from the nodes array, not from the Tree props
const RenderBranch = (children, props) => {
const isExpanded = props.node.expanded;
const isSelected = props.node.selected;
const isFocused = props.treeIndex === props.focusedNodeIndex;
const isLoading = props.node.loading;
const loader = (
<div
style={{
display: 'block',
paddingLeft: `${1.5 * props.level + 1.5}rem`,
marginTop: '.5rem',
}}
>
<div
style={{
borderRadius: '15rem',
display: 'block',
marginBottom: '.75rem',
height: '.5rem',
backgroundColor: 'rgb(224, 229, 238)',
width: '40%',
}}
/>
<div
style={{
borderRadius: '15rem',
display: 'block',
marginBottom: '.75rem',
height: '.5rem',
backgroundColor: 'rgb(224, 229, 238)',
width: '80%',
}}
/>
<div
style={{
borderRadius: '15rem',
display: 'block',
marginBottom: '.75rem',
height: '.5rem',
backgroundColor: 'rgb(224, 229, 238)',
width: '60%',
}}
/>
</div>
);
const label =
props.node.assistiveText ||
(typeof props.node.label === 'string' ? props.node.label : null);
return (
<li
id={props.htmlId}
role="treeitem"
aria-level={props.level}
aria-expanded={isExpanded ? 'true' : 'false'}
aria-label={
props.node.nodes && props.node.nodes.length > 0 ? label : null
}
tabIndex={getTabIndex(props)}
onKeyDown={(event) => handleKeyDown(event, props)}
onFocus={(event) => handleFocus(event, props)}
onBlur={props.onNodeBlur}
ref={(component) => {
if (props.treeHasFocus && component && isFocused) {
component.focus();
}
}}
>
{/* eslint-disable jsx-a11y/no-static-element-interactions */}
<div
className={classNames('slds-tree__item', {
'slds-is-selected': isSelected,
})}
onClick={(event) => {
handleSelect({ event, props });
}}
>
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
<Button
aria-hidden
assistiveText={{ icon: 'Expand Tree Branch' }}
iconCategory="utility"
iconName="chevronright"
iconSize="small"
variant="icon"
className="slds-m-right_small"
role="presentation"
aria-controls={props.htmlId}
onClick={(event) => {
handleExpand(event, props);
}}
tabIndex="-1"
/>
<span className="slds-size_1-of-1" id={`${props.htmlId}__label`}>
<Highlighter
search={props.searchTerm}
className="slds-tree__item-label slds-truncate"
>
{props.label}
</Highlighter>
</span>
</div>
{isLoading ? loader : null}
<ul
className={classNames({
'slds-is-expanded': isExpanded,
'slds-is-collapsed': !isExpanded,
})}
role="group"
aria-labelledby={`${props.htmlId}__label`}
>
{isExpanded && !isLoading ? children : null}
</ul>
</li>
);
};
RenderBranch.displayName = 'Branch';
RenderBranch.propTypes = propTypes;
export default RenderBranch;