@carbon/react
Version:
React components for the Carbon Design System
569 lines (560 loc) • 19.8 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
;
Object.defineProperty(exports, '__esModule', { value: true });
var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js');
var iconsReact = require('@carbon/icons-react');
var cx = require('classnames');
var PropTypes = require('prop-types');
var React = require('react');
var keys = require('../../internal/keyboard/keys.js');
var match = require('../../internal/keyboard/match.js');
var useControllableState = require('../../internal/useControllableState.js');
var usePrefix = require('../../internal/usePrefix.js');
var useId = require('../../internal/useId.js');
var index = require('../FeatureFlags/index.js');
var index$1 = require('../IconButton/index.js');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var cx__default = /*#__PURE__*/_interopDefaultLegacy(cx);
var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes);
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
const extractTextContent = node => {
if (node === null || node === undefined) return '';
if (typeof node === 'string') return node;
if (typeof node === 'number') return String(node);
if (typeof node === 'boolean') return String(node);
if (Array.isArray(node)) {
return node.map(extractTextContent).join('');
}
if (/*#__PURE__*/React__default["default"].isValidElement(node)) {
const element = node;
const children = element.props.children;
return extractTextContent(children);
}
return '';
};
const useEllipsisCheck = (label, detailsWrapperRef) => {
const [isEllipsisApplied, setIsEllipsisApplied] = React.useState(false);
const labelTextRef = React.useRef(null);
const checkEllipsis = React.useCallback(() => {
const element = labelTextRef.current;
if (!element) {
setIsEllipsisApplied(false);
return;
}
if (element.offsetWidth === 0) {
setIsEllipsisApplied(false);
return;
}
const checkElement = detailsWrapperRef.current || element;
if (checkElement && checkElement.offsetWidth > 0) {
const isTextTruncated = element.scrollWidth > checkElement.offsetWidth;
setIsEllipsisApplied(isTextTruncated);
} else {
setIsEllipsisApplied(false);
}
}, [detailsWrapperRef]);
React.useEffect(() => {
let animationFrameId;
animationFrameId = requestAnimationFrame(checkEllipsis);
let resizeObserver;
if (typeof window !== 'undefined' && typeof window.ResizeObserver !== 'undefined' && labelTextRef.current) {
resizeObserver = new window.ResizeObserver(() => {
requestAnimationFrame(checkEllipsis);
});
resizeObserver.observe(labelTextRef.current);
if (detailsWrapperRef.current) {
resizeObserver.observe(detailsWrapperRef.current);
}
}
return () => {
cancelAnimationFrame(animationFrameId);
if (resizeObserver) {
if (labelTextRef.current) {
resizeObserver.unobserve(labelTextRef.current);
}
if (detailsWrapperRef.current) {
resizeObserver.unobserve(detailsWrapperRef.current);
}
resizeObserver.disconnect();
}
};
}, [checkEllipsis, detailsWrapperRef]);
return {
labelTextRef,
isEllipsisApplied,
tooltipText: extractTextContent(label)
};
};
const TreeNode = /*#__PURE__*/React__default["default"].forwardRef(({
active,
children,
className,
depth: propDepth,
disabled,
id: nodeId,
isExpanded,
defaultIsExpanded,
label,
onNodeFocusEvent,
onSelect: onNodeSelect,
onToggle,
onTreeSelect,
renderIcon: Icon,
selected: propSelected,
value,
href,
align = 'bottom',
autoAlign = false,
...rest
}, forwardedRef) => {
const depth = propDepth;
const selected = propSelected;
const detailsWrapperRef = React.useRef(null);
const {
labelTextRef,
isEllipsisApplied,
tooltipText
} = useEllipsisCheck(label, detailsWrapperRef);
const enableTreeviewControllable = index.useFeatureFlag('enable-treeview-controllable');
const {
current: id
} = React.useRef(nodeId || useId.useId());
const controllableExpandedState = useControllableState.useControllableState({
value: isExpanded,
onChange: newValue => {
onToggle?.(undefined, {
id,
isExpanded: newValue,
label,
value
});
},
defaultValue: defaultIsExpanded ?? false
});
const uncontrollableExpandedState = React.useState(isExpanded ?? false);
const [expanded, setExpanded] = enableTreeviewControllable ? controllableExpandedState : uncontrollableExpandedState;
const currentNode = React.useRef(null);
const currentNodeLabel = React.useRef(null);
const prefix = usePrefix.usePrefix();
const renderLabelText = () => {
if (isEllipsisApplied && tooltipText) {
return /*#__PURE__*/React__default["default"].createElement(index$1.IconButton, {
label: tooltipText,
kind: "ghost",
align: align,
autoAlign: autoAlign,
className: `${prefix}--tree-node__label__text-button`,
wrapperClasses: `${prefix}--popover-container`
}, /*#__PURE__*/React__default["default"].createElement("span", {
ref: labelTextRef,
className: `${prefix}--tree-node__label__text`
}, label));
}
return /*#__PURE__*/React__default["default"].createElement("span", {
ref: labelTextRef,
className: `${prefix}--tree-node__label__text`
}, label);
};
const setRefs = element => {
currentNode.current = element;
if (typeof forwardedRef === 'function') {
forwardedRef(element);
} else if (forwardedRef) {
forwardedRef.current = element;
}
};
function enhanceTreeNodes(children) {
return React__default["default"].Children.map(children, node => {
if (! /*#__PURE__*/React__default["default"].isValidElement(node)) return node;
const isTreeNode = node.type === TreeNode;
if (isTreeNode) {
return /*#__PURE__*/React__default["default"].cloneElement(node, {
active,
depth: depth + 1,
disabled: disabled || node.props.disabled,
onTreeSelect,
onNodeFocusEvent,
selected,
tabIndex: node.props.disabled ? null : -1
});
}
const newChildren = enhanceTreeNodes(node.props.children);
return /*#__PURE__*/React__default["default"].cloneElement(node, {
children: newChildren
});
});
}
const nodesWithProps = enhanceTreeNodes(children);
const isActive = active === id;
const isSelected = selected.includes(id);
const treeNodeClasses = cx__default["default"](className, `${prefix}--tree-node`, {
[`${prefix}--tree-node--active`]: isActive,
[`${prefix}--tree-node--disabled`]: disabled,
[`${prefix}--tree-node--selected`]: isSelected,
[`${prefix}--tree-node--with-icon`]: Icon,
[`${prefix}--tree-leaf-node`]: !children,
[`${prefix}--tree-parent-node`]: children
});
const toggleClasses = cx__default["default"](`${prefix}--tree-parent-node__toggle-icon`, {
[`${prefix}--tree-parent-node__toggle-icon--expanded`]: expanded
});
function handleToggleClick(event) {
if (disabled) {
return;
}
// Prevent the node from being selected
event.stopPropagation();
if (href) {
event.preventDefault();
}
if (!enableTreeviewControllable) {
onToggle?.(event, {
id,
isExpanded: !expanded,
label,
value
});
}
setExpanded(!expanded);
}
function handleClick(event) {
event.stopPropagation();
if (!disabled) {
onTreeSelect?.(event, {
id,
label,
value
});
onNodeSelect?.(event, {
id,
label,
value
});
rest?.onClick?.(event);
}
}
function handleKeyDown(event) {
function getFocusableNode(node) {
if (node?.classList.contains(`${prefix}--tree-node`)) {
return node;
}
return node?.firstChild;
}
if (disabled) {
return;
}
if (match.matches(event, [keys.ArrowLeft, keys.ArrowRight, keys.Enter])) {
event.stopPropagation();
}
if (match.match(event, keys.ArrowLeft)) {
const findParentTreeNode = node => {
if (!node) return null;
if (node.classList.contains(`${prefix}--tree-parent-node`)) {
return node;
}
if (node.classList.contains(`${prefix}--tree-node-link-parent`)) {
return node.firstChild;
}
if (node.classList.contains(`${prefix}--tree`)) {
return null;
}
return findParentTreeNode(node.parentElement);
};
if (children && expanded) {
if (!enableTreeviewControllable) {
onToggle?.(event, {
id,
isExpanded: false,
label,
value
});
}
setExpanded(false);
} else {
/**
* When focus is on a leaf node or a closed parent node, move focus to
* its parent node (unless its depth is level 1)
*/
const parentNode = findParentTreeNode(href ? currentNode.current?.parentElement?.parentElement : currentNode.current?.parentElement);
if (parentNode instanceof HTMLElement) {
parentNode.focus();
}
}
}
if (children && match.match(event, keys.ArrowRight)) {
if (expanded) {
/**
* When focus is on an expanded parent node, move focus to the first
* child node
*/
getFocusableNode(href ? currentNode.current?.parentElement?.lastChild?.firstChild : currentNode.current?.lastChild?.firstChild).focus();
} else {
if (!enableTreeviewControllable) {
onToggle?.(event, {
id,
isExpanded: true,
label,
value
});
}
setExpanded(true);
}
}
if (match.matches(event, [keys.Enter, keys.Space])) {
event.preventDefault();
if (match.match(event, keys.Enter) && children) {
// Toggle expansion state for parent nodes
if (!enableTreeviewControllable) {
onToggle?.(event, {
id,
isExpanded: !expanded,
label,
value
});
}
setExpanded(!expanded);
}
if (href) {
currentNode.current?.click();
}
handleClick(event);
}
rest?.onKeyDown?.(event);
}
function handleFocusEvent(event) {
if (event.type === 'blur') {
rest?.onBlur?.(event);
}
if (event.type === 'focus') {
rest?.onFocus?.(event);
}
onNodeFocusEvent?.(event);
}
React.useEffect(() => {
/**
* Negative margin shifts node to align with the left side boundary of the
* tree
* Dynamically calculate padding to recreate tree node indentation
* - parent nodes with icon have (depth + 1rem + depth * 0.5) left padding
* - parent nodes have (depth + 1rem) left padding
* - leaf nodes have (depth + 2.5rem) left padding without icons (because
* of expand icon + spacing)
* - leaf nodes have (depth + 2rem + depth * 0.5) left padding with icons (because of
* reduced spacing between the expand icon and the node icon + label)
*/
const calcOffset = () => {
// parent node with icon
if (children && Icon) {
return depth + 1 + depth * 0.5;
}
// parent node without icon
if (children) {
return depth + 1;
}
// leaf node with icon
if (Icon) {
return depth + 2 + depth * 0.5;
}
// leaf node without icon
return depth + 2.5;
};
if (currentNodeLabel.current) {
currentNodeLabel.current.style.marginInlineStart = `-${calcOffset()}rem`;
currentNodeLabel.current.style.paddingInlineStart = `${calcOffset()}rem`;
}
if (!enableTreeviewControllable) {
// sync props and state
setExpanded(isExpanded ?? false);
}
}, [children, depth, Icon, isExpanded, enableTreeviewControllable, setExpanded]);
const treeNodeProps = {
...rest,
['aria-current']: !href ? isActive || undefined : isActive ? 'page' : undefined,
['aria-selected']: !href ? disabled ? undefined : isSelected : undefined,
['aria-disabled']: disabled,
['aria-owns']: children ? `${id}-subtree` : undefined,
className: treeNodeClasses,
id,
onBlur: handleFocusEvent,
onClick: handleClick,
onFocus: handleFocusEvent,
onKeyDown: handleKeyDown,
role: 'treeitem'
};
if (!children) {
if (href) {
return /*#__PURE__*/React__default["default"].createElement("li", {
role: "none"
}, /*#__PURE__*/React__default["default"].createElement("a", _rollupPluginBabelHelpers["extends"]({}, treeNodeProps, {
ref: setRefs,
href: !disabled ? href : undefined
}), /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--tree-node__label`,
ref: currentNodeLabel
}, Icon && /*#__PURE__*/React__default["default"].createElement(Icon, {
className: `${prefix}--tree-node__icon`
}), renderLabelText())));
} else {
return /*#__PURE__*/React__default["default"].createElement("li", _rollupPluginBabelHelpers["extends"]({}, treeNodeProps, {
ref: setRefs
}), /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--tree-node__label`,
ref: currentNodeLabel
}, Icon && /*#__PURE__*/React__default["default"].createElement(Icon, {
className: `${prefix}--tree-node__icon`
}), renderLabelText()));
}
}
if (href) {
return /*#__PURE__*/React__default["default"].createElement("li", {
role: "none",
className: `${prefix}--tree-node-link-parent`
}, /*#__PURE__*/React__default["default"].createElement("a", _rollupPluginBabelHelpers["extends"]({}, treeNodeProps, {
"aria-expanded": !!expanded,
ref: setRefs,
href: !disabled ? href : undefined
}), /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--tree-node__label`,
ref: currentNodeLabel
}, /*#__PURE__*/React__default["default"].createElement("span", {
className: `${prefix}--tree-parent-node__toggle`
// @ts-ignore
,
disabled: disabled,
onClick: handleToggleClick
}, /*#__PURE__*/React__default["default"].createElement(iconsReact.CaretDown, {
className: toggleClasses
})), /*#__PURE__*/React__default["default"].createElement("span", {
className: `${prefix}--tree-node__label__details`,
ref: detailsWrapperRef
}, Icon && /*#__PURE__*/React__default["default"].createElement(Icon, {
className: `${prefix}--tree-node__icon`
}), renderLabelText()))), /*#__PURE__*/React__default["default"].createElement("ul", {
id: `${id}-subtree`,
role: "group",
className: cx__default["default"](`${prefix}--tree-node__children`, {
[`${prefix}--tree-node--hidden`]: !expanded
})
}, nodesWithProps));
} else {
return /*#__PURE__*/React__default["default"].createElement("li", _rollupPluginBabelHelpers["extends"]({}, treeNodeProps, {
"aria-expanded": !!expanded,
ref: setRefs
}), /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--tree-node__label`,
ref: currentNodeLabel
}, /*#__PURE__*/React__default["default"].createElement("span", {
className: `${prefix}--tree-parent-node__toggle`
// @ts-ignore
,
disabled: disabled,
onClick: handleToggleClick
}, /*#__PURE__*/React__default["default"].createElement(iconsReact.CaretDown, {
className: toggleClasses
})), /*#__PURE__*/React__default["default"].createElement("span", {
className: `${prefix}--tree-node__label__details`,
ref: detailsWrapperRef
}, Icon && /*#__PURE__*/React__default["default"].createElement(Icon, {
className: `${prefix}--tree-node__icon`
}), renderLabelText())), /*#__PURE__*/React__default["default"].createElement("ul", {
id: `${id}-subtree`,
role: "group",
className: cx__default["default"](`${prefix}--tree-node__children`, {
[`${prefix}--tree-node--hidden`]: !expanded
})
}, nodesWithProps));
}
});
TreeNode.propTypes = {
/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* The ID of the active node in the tree
*/
active: PropTypes__default["default"].oneOfType([PropTypes__default["default"].string, PropTypes__default["default"].number]),
/**
* Specify the children of the TreeNode
*/
children: PropTypes__default["default"].node,
/**
* Specify an optional className to be applied to the TreeNode
*/
className: PropTypes__default["default"].string,
/**
* **[Experimental]** The default expansion state of the node.
* *This is only supported with the `enable-treeview-controllable` feature flag!*
*/
defaultIsExpanded: PropTypes__default["default"].bool,
/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* TreeNode depth to determine spacing
*/
depth: PropTypes__default["default"].number,
/**
* Specify if the TreeNode is disabled
*/
disabled: PropTypes__default["default"].bool,
/**
* Specify the TreeNode's ID. Must be unique in the DOM and is used for props.active, props.selected and aria-owns
*/
id: PropTypes__default["default"].string,
/**
* Specify if the TreeNode is expanded (only applicable to parent nodes)
*/
isExpanded: PropTypes__default["default"].bool,
/**
* Rendered label for the TreeNode
*/
label: PropTypes__default["default"].node,
/**
* Callback function for when the node receives or loses focus
*/
onNodeFocusEvent: PropTypes__default["default"].func,
/**
* Callback function for when the node is selected
*/
onSelect: PropTypes__default["default"].func,
/**
* Callback function for when a parent node is expanded or collapsed
*/
onToggle: PropTypes__default["default"].func,
/**
* Callback function for when any node in the tree is selected
*/
onTreeSelect: PropTypes__default["default"].func,
/**
* A component used to render an icon.
*/
// @ts-ignore
renderIcon: PropTypes__default["default"].oneOfType([PropTypes__default["default"].func, PropTypes__default["default"].object]),
/**
* **Note:** this is controlled by the parent TreeView component, do not set manually.
* Array containing all selected node IDs in the tree
*/
// @ts-ignore
selected: PropTypes__default["default"].arrayOf(PropTypes__default["default"].oneOfType([PropTypes__default["default"].string, PropTypes__default["default"].number])),
/**
* Specify the value of the TreeNode
*/
value: PropTypes__default["default"].string,
/**
* Optional: The URL the TreeNode is linking to
*/
href: PropTypes__default["default"].string,
/**
* Specify how the tooltip should align when text is truncated
*/
align: PropTypes__default["default"].oneOf(['top', 'bottom', 'left', 'right', 'top-start', 'top-end', 'bottom-start', 'bottom-end', 'left-end', 'left-start', 'right-end', 'right-start']),
/**
* **Experimental**: Will attempt to automatically align the floating
* element to avoid collisions with the viewport and being clipped by
* ancestor elements.
*/
autoAlign: PropTypes__default["default"].bool
};
TreeNode.displayName = 'TreeNode';
exports["default"] = TreeNode;