UNPKG

@carbon/react

Version:

React components for the Carbon Design System

569 lines (560 loc) 19.8 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. */ 'use strict'; 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;