UNPKG

@carbon/react

Version:

React components for the Carbon Design System

559 lines (554 loc) 18 kB
/** * 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. */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import { CaretDown } from '@carbon/icons-react'; import cx from 'classnames'; import PropTypes from 'prop-types'; import React, { useRef, useState, useEffect, useCallback } from 'react'; import { ArrowLeft, ArrowRight, Enter, Space } from '../../internal/keyboard/keys.js'; import { matches, match } from '../../internal/keyboard/match.js'; import { useControllableState } from '../../internal/useControllableState.js'; import { usePrefix } from '../../internal/usePrefix.js'; import { useId } from '../../internal/useId.js'; import { useFeatureFlag } from '../FeatureFlags/index.js'; import { IconButton } from '../IconButton/index.js'; 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.isValidElement(node)) { const element = node; const children = element.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) { const isTextTruncated = element.scrollWidth > checkElement.offsetWidth; setIsEllipsisApplied(isTextTruncated); } else { setIsEllipsisApplied(false); } }, [detailsWrapperRef]); 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.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 = useRef(null); const { labelTextRef, isEllipsisApplied, tooltipText } = useEllipsisCheck(label, detailsWrapperRef); const enableTreeviewControllable = useFeatureFlag('enable-treeview-controllable'); const { current: id } = useRef(nodeId || useId()); const controllableExpandedState = useControllableState({ value: isExpanded, onChange: newValue => { onToggle?.(undefined, { id, isExpanded: newValue, label, value }); }, 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 renderLabelText = () => { if (isEllipsisApplied && tooltipText) { return /*#__PURE__*/React.createElement(IconButton, { label: tooltipText, kind: "ghost", align: align, autoAlign: autoAlign, className: `${prefix}--tree-node__label__text-button`, wrapperClasses: `${prefix}--popover-container` }, /*#__PURE__*/React.createElement("span", { ref: labelTextRef, className: `${prefix}--tree-node__label__text` }, label)); } return /*#__PURE__*/React.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.Children.map(children, node => { if (! /*#__PURE__*/React.isValidElement(node)) return node; const isTreeNode = node.type === TreeNode; if (isTreeNode) { return /*#__PURE__*/React.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.cloneElement(node, { children: newChildren }); }); } const nodesWithProps = enhanceTreeNodes(children); const isActive = active === id; const isSelected = selected.includes(id); const treeNodeClasses = cx(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(`${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 (matches(event, [ArrowLeft, ArrowRight, 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.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(event, 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 (matches(event, [Enter, Space])) { event.preventDefault(); if (match(event, 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); } 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.createElement("li", { role: "none" }, /*#__PURE__*/React.createElement("a", _extends({}, treeNodeProps, { ref: setRefs, href: !disabled ? href : undefined }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--tree-node__label`, ref: currentNodeLabel }, Icon && /*#__PURE__*/React.createElement(Icon, { className: `${prefix}--tree-node__icon` }), renderLabelText()))); } else { return /*#__PURE__*/React.createElement("li", _extends({}, treeNodeProps, { ref: setRefs }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--tree-node__label`, ref: currentNodeLabel }, Icon && /*#__PURE__*/React.createElement(Icon, { className: `${prefix}--tree-node__icon` }), renderLabelText())); } } if (href) { return /*#__PURE__*/React.createElement("li", { role: "none", className: `${prefix}--tree-node-link-parent` }, /*#__PURE__*/React.createElement("a", _extends({}, treeNodeProps, { "aria-expanded": !!expanded, ref: setRefs, href: !disabled ? href : undefined }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--tree-node__label`, ref: currentNodeLabel }, /*#__PURE__*/React.createElement("span", { className: `${prefix}--tree-parent-node__toggle` // @ts-ignore , disabled: disabled, onClick: handleToggleClick }, /*#__PURE__*/React.createElement(CaretDown, { className: toggleClasses })), /*#__PURE__*/React.createElement("span", { className: `${prefix}--tree-node__label__details`, ref: detailsWrapperRef }, Icon && /*#__PURE__*/React.createElement(Icon, { className: `${prefix}--tree-node__icon` }), renderLabelText()))), /*#__PURE__*/React.createElement("ul", { id: `${id}-subtree`, role: "group", className: cx(`${prefix}--tree-node__children`, { [`${prefix}--tree-node--hidden`]: !expanded }) }, nodesWithProps)); } else { return /*#__PURE__*/React.createElement("li", _extends({}, treeNodeProps, { "aria-expanded": !!expanded, ref: setRefs }), /*#__PURE__*/React.createElement("div", { className: `${prefix}--tree-node__label`, ref: currentNodeLabel }, /*#__PURE__*/React.createElement("span", { className: `${prefix}--tree-parent-node__toggle` // @ts-ignore , disabled: disabled, onClick: handleToggleClick }, /*#__PURE__*/React.createElement(CaretDown, { className: toggleClasses })), /*#__PURE__*/React.createElement("span", { className: `${prefix}--tree-node__label__details`, ref: detailsWrapperRef }, Icon && /*#__PURE__*/React.createElement(Icon, { className: `${prefix}--tree-node__icon` }), renderLabelText())), /*#__PURE__*/React.createElement("ul", { id: `${id}-subtree`, role: "group", className: cx(`${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.oneOfType([PropTypes.string, PropTypes.number]), /** * Specify the children of the TreeNode */ children: PropTypes.node, /** * Specify an optional className to be applied to the TreeNode */ className: PropTypes.string, /** * **[Experimental]** The default expansion state of the node. * *This is only supported with the `enable-treeview-controllable` feature flag!* */ defaultIsExpanded: PropTypes.bool, /** * **Note:** this is controlled by the parent TreeView component, do not set manually. * TreeNode depth to determine spacing */ depth: PropTypes.number, /** * Specify if the TreeNode is disabled */ disabled: PropTypes.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.string, /** * Specify if the TreeNode is expanded (only applicable to parent nodes) */ isExpanded: PropTypes.bool, /** * Rendered label for the TreeNode */ label: PropTypes.node, /** * Callback function for when the node receives or loses focus */ onNodeFocusEvent: PropTypes.func, /** * Callback function for when the node is selected */ onSelect: PropTypes.func, /** * Callback function for when a parent node is expanded or collapsed */ onToggle: PropTypes.func, /** * Callback function for when any node in the tree is selected */ onTreeSelect: PropTypes.func, /** * A component used to render an icon. */ // @ts-ignore renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.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.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), /** * Specify the value of the TreeNode */ value: PropTypes.string, /** * Optional: The URL the TreeNode is linking to */ href: PropTypes.string, /** * Specify how the tooltip should align when text is truncated */ align: PropTypes.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.bool }; TreeNode.displayName = 'TreeNode'; export { TreeNode as default };