UNPKG

@carbon/react

Version:

React components for the Carbon Design System

379 lines (377 loc) 15.3 kB
/** * 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;