UNPKG

@carbon/react

Version:

React components for the Carbon Design System

375 lines (373 loc) 13.8 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. */ import { usePrefix } from "../../internal/usePrefix.js"; import { ArrowLeft, ArrowRight as ArrowRight$1, Enter, Space } from "../../internal/keyboard/keys.js"; import { match, matches } from "../../internal/keyboard/match.js"; import { useId } from "../../internal/useId.js"; import { deprecate } from "../../prop-types/deprecate.js"; import { useFeatureFlag } from "../FeatureFlags/index.js"; import { IconButton } from "../IconButton/index.js"; import { useControllableState } from "../../internal/useControllableState.js"; import { DepthContext, TreeContext } from "./TreeContext.js"; import classNames from "classnames"; import React, { useCallback, useContext, useEffect, useRef, useState } from "react"; import PropTypes from "prop-types"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { CaretDown } from "@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.isValidElement(node)) { const children = node.props.children; return extractTextContent(children); } return ""; }; const useEllipsisCheck = (label, detailsWrapperRef) => { const [isEllipsisApplied, setIsEllipsisApplied] = useState(false); const labelTextRef = useRef(null); const checkEllipsis = 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]); 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.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 = useContext(TreeContext); const contextDepth = useContext(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, useRef(null)); const enableTreeviewControllable = useFeatureFlag("enable-treeview-controllable"); const generatedId = useId(); const { current: id } = useRef(nodeId ?? generatedId); const controllableExpandedState = useControllableState({ value: isExpanded, onChange: onToggle, defaultValue: defaultIsExpanded ?? false }); const uncontrollableExpandedState = useState(isExpanded ?? false); const [expanded, setExpanded] = enableTreeviewControllable ? controllableExpandedState : uncontrollableExpandedState; const currentNode = useRef(null); const currentNodeLabel = useRef(null); const prefix = usePrefix(); const nodeLabelId = `${id}__label`; const renderLabelText = () => { if (isEllipsisApplied && tooltipText) return /* @__PURE__ */ jsx(IconButton, { label: tooltipText, kind: "ghost", align, autoAlign, className: `${prefix}--tree-node__label__text-button`, wrapperClasses: `${prefix}--popover-container`, children: /* @__PURE__ */ jsx("span", { id: nodeLabelId, ref: labelTextRef, className: `${prefix}--tree-node__label__text`, children: label }) }); return /* @__PURE__ */ 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 = classNames(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 = classNames(`${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 (matches(event, [ ArrowLeft, ArrowRight$1, Enter ])) event.stopPropagation(); if (match(event, 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 && match(event, ArrowRight$1)) 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 (matches(event, [Enter, Space])) { event.preventDefault(); if (match(event, 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); } 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__ */ jsxs("div", { className: `${prefix}--tree-node__label`, ref: currentNodeLabel, children: [children && /* @__PURE__ */ jsx("span", { className: `${prefix}--tree-parent-node__toggle`, onClick: handleToggleClick, children: /* @__PURE__ */ jsx(CaretDown, { className: toggleClasses }) }), /* @__PURE__ */ jsxs("span", { className: `${prefix}--tree-node__label__details`, children: [Icon && /* @__PURE__ */ jsx(Icon, { className: `${prefix}--tree-node__icon` }), renderLabelText()] })] }); if (href) return /* @__PURE__ */ jsxs("li", { role: "none", className: children ? `${prefix}--tree-node-link-parent` : "", children: [/* @__PURE__ */ jsx("a", { ...treeNodeProps, "aria-expanded": !!expanded, ref: setRefs, href: !disabled ? href : void 0, children: nodeContent }), children && /* @__PURE__ */ jsx("ul", { id: `${id}-subtree`, role: "group", "aria-labelledby": nodeLabelId, className: classNames(`${prefix}--tree-node__children`, { [`${prefix}--tree-node--hidden`]: !expanded }), children: /* @__PURE__ */ jsx(DepthContext.Provider, { value: depth + 1, children }) })] }); return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("li", { ...treeNodeProps, "aria-expanded": children ? !!expanded : void 0, ref: setRefs, children: [nodeContent, children && /* @__PURE__ */ jsx("ul", { id: `${id}-subtree`, role: "group", "aria-labelledby": nodeLabelId, className: classNames(`${prefix}--tree-node__children`, { [`${prefix}--tree-node--hidden`]: !expanded }), children: /* @__PURE__ */ jsx(DepthContext.Provider, { value: depth + 1, children }) })] }) }); }); TreeNode.propTypes = { active: deprecate(PropTypes.oneOfType([PropTypes.string, PropTypes.number]), "The `active` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."), children: PropTypes.node, className: PropTypes.string, defaultIsExpanded: PropTypes.bool, depth: deprecate(PropTypes.number, "The `depth` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."), disabled: PropTypes.bool, id: PropTypes.string, isExpanded: PropTypes.bool, label: PropTypes.node, onNodeFocusEvent: deprecate(PropTypes.func, "The `onNodeFocusEvent` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."), onSelect: PropTypes.func, onToggle: PropTypes.func, onTreeSelect: deprecate(PropTypes.func, "The `onTreeSelect` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."), renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), selected: deprecate(PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), "The `selected` prop for `TreeNode` is no longer needed and has been deprecated. It will be removed in the next major release."), value: PropTypes.string, href: PropTypes.string, align: PropTypes.oneOf([ "top", "bottom", "left", "right", "top-start", "top-end", "bottom-start", "bottom-end", "left-end", "left-start", "right-end", "right-start" ]), autoAlign: PropTypes.bool }; TreeNode.displayName = "TreeNode"; //#endregion export { TreeNode as default };