@carbon/react
Version:
React components for the Carbon Design System
379 lines (377 loc) • 15.3 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const require_runtime = require("../../_virtual/_rolldown/runtime.js");
const require_usePrefix = require("../../internal/usePrefix.js");
const require_keys = require("../../internal/keyboard/keys.js");
const require_match = require("../../internal/keyboard/match.js");
const require_useId = require("../../internal/useId.js");
const require_deprecate = require("../../prop-types/deprecate.js");
const require_index = require("../FeatureFlags/index.js");
const require_index$1 = require("../IconButton/index.js");
const require_useControllableState = require("../../internal/useControllableState.js");
const require_TreeContext = require("./TreeContext.js");
let classnames = require("classnames");
classnames = require_runtime.__toESM(classnames);
let react = require("react");
react = require_runtime.__toESM(react);
let prop_types = require("prop-types");
prop_types = require_runtime.__toESM(prop_types);
let react_jsx_runtime = require("react/jsx-runtime");
let _carbon_icons_react = require("@carbon/icons-react");
//#region src/components/TreeView/TreeNode.tsx
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const extractTextContent = (node) => {
if (node === null || node === void 0) 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 (react.default.isValidElement(node)) {
const children = node.props.children;
return extractTextContent(children);
}
return "";
};
const useEllipsisCheck = (label, detailsWrapperRef) => {
const [isEllipsisApplied, setIsEllipsisApplied] = (0, react.useState)(false);
const labelTextRef = (0, react.useRef)(null);
const checkEllipsis = (0, 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) setIsEllipsisApplied(element.scrollWidth > checkElement.offsetWidth);
else setIsEllipsisApplied(false);
}, [detailsWrapperRef]);
(0, react.useEffect)(() => {
const 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 = react.default.forwardRef(({ children, className, disabled, id: nodeId, isExpanded, defaultIsExpanded, label, onSelect: onNodeSelect, onToggle, renderIcon: Icon, value, href, align = "bottom", autoAlign = false, active: propActive, depth: propDepth, selected: propSelected, onTreeSelect: propOnTreeSelect, onNodeFocusEvent, ...rest }, forwardedRef) => {
const treeContext = (0, react.useContext)(require_TreeContext.TreeContext);
const contextDepth = (0, react.useContext)(require_TreeContext.DepthContext);
const depth = propDepth ?? (contextDepth !== -1 ? contextDepth : 0);
const active = propActive ?? treeContext?.active;
const selected = propSelected ?? treeContext?.selected ?? [];
const onTreeSelect = propOnTreeSelect ?? treeContext?.onTreeSelect;
const { labelTextRef, isEllipsisApplied, tooltipText } = useEllipsisCheck(label, (0, react.useRef)(null));
const enableTreeviewControllable = require_index.useFeatureFlag("enable-treeview-controllable");
const generatedId = require_useId.useId();
const { current: id } = (0, react.useRef)(nodeId ?? generatedId);
const controllableExpandedState = require_useControllableState.useControllableState({
value: isExpanded,
onChange: onToggle,
defaultValue: defaultIsExpanded ?? false
});
const uncontrollableExpandedState = (0, react.useState)(isExpanded ?? false);
const [expanded, setExpanded] = enableTreeviewControllable ? controllableExpandedState : uncontrollableExpandedState;
const currentNode = (0, react.useRef)(null);
const currentNodeLabel = (0, react.useRef)(null);
const prefix = require_usePrefix.usePrefix();
const nodeLabelId = `${id}__label`;
const renderLabelText = () => {
if (isEllipsisApplied && tooltipText) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_index$1.IconButton, {
label: tooltipText,
kind: "ghost",
align,
autoAlign,
className: `${prefix}--tree-node__label__text-button`,
wrapperClasses: `${prefix}--popover-container`,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
id: nodeLabelId,
ref: labelTextRef,
className: `${prefix}--tree-node__label__text`,
children: label
})
});
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
id: nodeLabelId,
ref: labelTextRef,
className: `${prefix}--tree-node__label__text`,
children: label
});
};
const setRefs = (element) => {
currentNode.current = element;
if (typeof forwardedRef === "function") forwardedRef(element);
else if (forwardedRef) forwardedRef.current = element;
};
const isActive = active === id;
const isSelected = selected?.includes(id) ?? false;
const treeNodeClasses = (0, classnames.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 = (0, classnames.default)(`${prefix}--tree-parent-node__toggle-icon`, { [`${prefix}--tree-parent-node__toggle-icon--expanded`]: expanded });
function handleToggleClick(event) {
if (disabled) return;
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 (require_match.matches(event, [
require_keys.ArrowLeft,
require_keys.ArrowRight,
require_keys.Enter
])) event.stopPropagation();
if (require_match.match(event, require_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.firstElementChild;
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) ?? null);
if (parentNode instanceof HTMLElement) parentNode.focus();
}
}
if (children && require_match.match(event, require_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 (require_match.matches(event, [require_keys.Enter, require_keys.Space])) {
event.preventDefault();
if (require_match.match(event, require_keys.Enter) && children) {
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 === "focus") rest?.onFocus?.(event);
if (event.type === "blur") rest?.onBlur?.(event);
onNodeFocusEvent?.(event);
}
(0, 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 = () => {
if (children && Icon) return depth + 1 + depth * .5;
if (children) return depth + 1;
if (Icon) return depth + 2 + depth * .5;
return depth + 2.5;
};
if (currentNodeLabel.current) {
currentNodeLabel.current.style.marginInlineStart = `-${calcOffset()}rem`;
currentNodeLabel.current.style.paddingInlineStart = `${calcOffset()}rem`;
}
if (!enableTreeviewControllable) setExpanded(isExpanded ?? false);
}, [
children,
depth,
Icon,
isExpanded,
enableTreeviewControllable,
setExpanded
]);
const tabIndex = disabled ? void 0 : rest.tabIndex ?? -1;
const treeNodeProps = {
...rest,
["aria-current"]: !href ? isActive || void 0 : isActive ? "page" : void 0,
["aria-selected"]: !href ? disabled ? void 0 : isSelected : void 0,
["aria-disabled"]: disabled,
["aria-owns"]: children ? `${id}-subtree` : void 0,
className: treeNodeClasses,
id,
onClick: handleClick,
onKeyDown: handleKeyDown,
role: "treeitem",
tabIndex,
onFocus: handleFocusEvent,
onBlur: handleFocusEvent
};
const nodeContent = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
className: `${prefix}--tree-node__label`,
ref: currentNodeLabel,
children: [children && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
className: `${prefix}--tree-parent-node__toggle`,
onClick: handleToggleClick,
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_carbon_icons_react.CaretDown, { className: toggleClasses })
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
className: `${prefix}--tree-node__label__details`,
children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: `${prefix}--tree-node__icon` }), renderLabelText()]
})]
});
if (href) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
role: "none",
className: children ? `${prefix}--tree-node-link-parent` : "",
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
...treeNodeProps,
"aria-expanded": !!expanded,
ref: setRefs,
href: !disabled ? href : void 0,
children: nodeContent
}), children && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
id: `${id}-subtree`,
role: "group",
"aria-labelledby": nodeLabelId,
className: (0, classnames.default)(`${prefix}--tree-node__children`, { [`${prefix}--tree-node--hidden`]: !expanded }),
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_TreeContext.DepthContext.Provider, {
value: depth + 1,
children
})
})]
});
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
...treeNodeProps,
"aria-expanded": children ? !!expanded : void 0,
ref: setRefs,
children: [nodeContent, children && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
id: `${id}-subtree`,
role: "group",
"aria-labelledby": nodeLabelId,
className: (0, classnames.default)(`${prefix}--tree-node__children`, { [`${prefix}--tree-node--hidden`]: !expanded }),
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(require_TreeContext.DepthContext.Provider, {
value: depth + 1,
children
})
})]
}) });
});
TreeNode.propTypes = {
active: require_deprecate.deprecate(prop_types.default.oneOfType([prop_types.default.string, prop_types.default.number]), "The `active` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."),
children: prop_types.default.node,
className: prop_types.default.string,
defaultIsExpanded: prop_types.default.bool,
depth: require_deprecate.deprecate(prop_types.default.number, "The `depth` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."),
disabled: prop_types.default.bool,
id: prop_types.default.string,
isExpanded: prop_types.default.bool,
label: prop_types.default.node,
onNodeFocusEvent: require_deprecate.deprecate(prop_types.default.func, "The `onNodeFocusEvent` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."),
onSelect: prop_types.default.func,
onToggle: prop_types.default.func,
onTreeSelect: require_deprecate.deprecate(prop_types.default.func, "The `onTreeSelect` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."),
renderIcon: prop_types.default.oneOfType([prop_types.default.func, prop_types.default.object]),
selected: require_deprecate.deprecate(prop_types.default.arrayOf(prop_types.default.oneOfType([prop_types.default.string, prop_types.default.number])), "The `selected` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."),
value: prop_types.default.string,
href: prop_types.default.string,
align: prop_types.default.oneOf([
"top",
"bottom",
"left",
"right",
"top-start",
"top-end",
"bottom-start",
"bottom-end",
"left-end",
"left-start",
"right-end",
"right-start"
]),
autoAlign: prop_types.default.bool
};
TreeNode.displayName = "TreeNode";
//#endregion
exports.default = TreeNode;