diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 0e53df0816fd..d20b5cae50bf 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -8889,6 +8889,7 @@ Map { }, }, "TreeNode" => Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "active": Object { "args": Array [ @@ -8968,9 +8969,11 @@ Map { "type": "string", }, }, + "render": [Function], }, "TreeView" => Object { "TreeNode": Object { + "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "active": Object { "args": Array [ @@ -9050,6 +9053,7 @@ Map { "type": "string", }, }, + "render": [Function], }, "propTypes": Object { "active": Object { diff --git a/packages/react/src/components/TreeView/TreeNode.js b/packages/react/src/components/TreeView/TreeNode.js index b29a85c77f15..14852225d747 100644 --- a/packages/react/src/components/TreeView/TreeNode.js +++ b/packages/react/src/components/TreeView/TreeNode.js @@ -13,212 +13,220 @@ import { keys, match, matches } from '../../internal/keyboard'; import uniqueId from '../../tools/uniqueId'; import { usePrefix } from '../../internal/usePrefix'; -export default function TreeNode({ - active, - children, - className, - depth, - disabled, - isExpanded, - label, - onNodeFocusEvent, - onSelect: onNodeSelect, - onToggle, - onTreeSelect, - renderIcon: Icon, - selected, - value, - ...rest -}) { - const { current: id } = useRef(rest.id || uniqueId()); - const [expanded, setExpanded] = useState(isExpanded); - const currentNode = useRef(null); - const currentNodeLabel = useRef(null); - const prefix = usePrefix(); - const nodesWithProps = React.Children.map(children, (node) => { - if (React.isValidElement(node)) { - return React.cloneElement(node, { - active, - depth: depth + 1, - disabled, - onTreeSelect, - selected, - tabIndex: (!node.props.disabled && -1) || null, - }); - } - }); - const isActive = active === id; - const isSelected = selected.includes(id); - 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; - } - 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) { - if (disabled) { - return; +const TreeNode = React.forwardRef( + ( + { + active, + children, + className, + depth, + disabled, + isExpanded, + label, + onNodeFocusEvent, + onSelect: onNodeSelect, + onToggle, + onTreeSelect, + renderIcon: Icon, + selected, + value, + ...rest + }, + ref + ) => { + const { current: id } = useRef(rest.id || uniqueId()); + const [expanded, setExpanded] = useState(isExpanded); + const currentNode = useRef(null); + const currentNodeLabel = useRef(null); + const prefix = usePrefix(); + const nodesWithProps = React.Children.map(children, (node) => { + if (React.isValidElement(node)) { + return React.cloneElement(node, { + active, + depth: depth + 1, + disabled, + onTreeSelect, + selected, + tabIndex: (!node.props.disabled && -1) || null, + }); + } + }); + const isActive = active === id; + const isSelected = selected.includes(id); + 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; + } + onToggle?.(event, { id, isExpanded: !expanded, label, value }); + setExpanded(!expanded); } - if (matches(event, [keys.ArrowLeft, keys.ArrowRight, keys.Enter])) { + function handleClick(event) { event.stopPropagation(); + if (!disabled) { + onTreeSelect?.(event, { id, label, value }); + onNodeSelect?.(event, { id, label, value }); + rest?.onClick?.(event); + } } - if (match(event, keys.ArrowLeft)) { - const findParentTreeNode = (node) => { - if (node.classList.contains(`${prefix}--tree-parent-node`)) { - return node; + function handleKeyDown(event) { + if (disabled) { + return; + } + if (matches(event, [keys.ArrowLeft, keys.ArrowRight, keys.Enter])) { + event.stopPropagation(); + } + if (match(event, keys.ArrowLeft)) { + const findParentTreeNode = (node) => { + if (node.classList.contains(`${prefix}--tree-parent-node`)) { + return node; + } + if (node.classList.contains(`${prefix}--tree`)) { + return null; + } + return findParentTreeNode(node.parentNode); + }; + if (children && expanded) { + 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) + */ + findParentTreeNode(currentNode.current.parentNode)?.focus(); } - if (node.classList.contains(`${prefix}--tree`)) { - return null; + } + if (children && match(event, keys.ArrowRight)) { + if (expanded) { + /** + * When focus is on an expanded parent node, move focus to the first + * child node + */ + currentNode.current.lastChild.firstChild.focus(); + } else { + onToggle?.(event, { id, isExpanded: true, label, value }); + setExpanded(true); } - return findParentTreeNode(node.parentNode); - }; - if (children && expanded) { - 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) - */ - findParentTreeNode(currentNode.current.parentNode)?.focus(); } - } - if (children && match(event, keys.ArrowRight)) { - if (expanded) { - /** - * When focus is on an expanded parent node, move focus to the first - * child node - */ - currentNode.current.lastChild.firstChild.focus(); - } else { - onToggle?.(event, { id, isExpanded: true, label, value }); - setExpanded(true); + if (matches(event, [keys.Enter, keys.Space])) { + event.preventDefault(); + handleClick(event); } + rest?.onKeyDown?.(event); } - if (matches(event, [keys.Enter, keys.Space])) { - event.preventDefault(); - 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; + function handleFocusEvent(event) { + if (event.type === 'blur') { + rest?.onBlur?.(event); } - // leaf node with icon - if (Icon) { - return depth + 2 + depth * 0.5; + if (event.type === 'focus') { + rest?.onFocus?.(event); } - // leaf node without icon - return depth + 2.5; - }; - - if (currentNodeLabel.current) { - currentNodeLabel.current.style.marginInlineStart = `-${calcOffset()}rem`; - currentNodeLabel.current.style.paddingInlineStart = `${calcOffset()}rem`; + onNodeFocusEvent?.(event); } - // sync props and state - setExpanded(isExpanded); - }, [children, depth, Icon, isExpanded]); + 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`; + } - const treeNodeProps = { - ...rest, - ['aria-current']: isActive || null, - ['aria-selected']: disabled ? null : isSelected, - ['aria-disabled']: disabled, - className: treeNodeClasses, - id, - onBlur: handleFocusEvent, - onClick: handleClick, - onFocus: handleFocusEvent, - onKeyDown: handleKeyDown, - ref: currentNode, - role: 'treeitem', - }; + // sync props and state + setExpanded(isExpanded); + }, [children, depth, Icon, isExpanded]); - if (!children) { + const treeNodeProps = { + ...rest, + ['aria-current']: isActive || null, + ['aria-selected']: disabled ? null : isSelected, + ['aria-disabled']: disabled, + className: treeNodeClasses, + id, + onBlur: handleFocusEvent, + onClick: handleClick, + onFocus: handleFocusEvent, + onKeyDown: handleKeyDown, + ref: currentNode, + role: 'treeitem', + }; + + if (!children) { + return ( +