import React, {
HTMLAttributes,
ReactElement,
ReactNode,
Ref,
useContext,
useEffect,
} from 'react';
import {useFocusManager} from '@react-aria/focus';
import {TreeContext} from './tree-context';
import {createEventHandler} from '../../utils/dom/create-event-handler';
import clsx from 'clsx';
import {AnimatePresence, m} from 'framer-motion';
import {TreeNode} from './tree';
import {renderTree} from './render-tree';
import {TreeLabel} from './tree-label';
export type TreeItemRenderer = (
node: any,
) => ReactElement>;
export interface TreeItemProps
extends HTMLAttributes {
label: ReactNode;
icon: ReactNode;
node?: T;
parentNode?: T;
level?: number;
index?: number;
itemRenderer?: TreeItemRenderer;
labelRef?: Ref;
labelClassName?: string;
className?: string;
}
export function TreeItem({
label,
icon,
node,
level,
index,
itemRenderer,
labelRef,
labelClassName,
className,
parentNode,
...domProps
}: TreeItemProps) {
const focusManager = useFocusManager();
const {
expandedKeys,
selectedKeys,
focusedNode,
setFocusedNode,
setExpandedKeys,
setSelectedKeys,
} = useContext(TreeContext);
// clear focused node on unmount
useEffect(() => {
return () => {
if (focusedNode === node?.id) {
setFocusedNode(undefined);
}
};
}, [focusedNode, node?.id, setFocusedNode]);
if (!node || !itemRenderer) return null;
const hasChildren = node.children.length;
const isExpanded = hasChildren && expandedKeys.includes(node.id);
const isSelected = selectedKeys.includes(node.id);
const isFirstNode = level === 0 && index === 0;
const isFocused =
focusedNode == undefined ? isFirstNode : focusedNode === node.id;
const onKeyDown = (e: React.KeyboardEvent) => {
if (focusedNode == null) return;
switch (e.key) {
// select the node
case 'Enter':
case ' ':
e.stopPropagation();
e.preventDefault();
setSelectedKeys([focusedNode]);
break;
// expand node, or move focus to first (and only first) child
case 'ArrowRight':
e.stopPropagation();
e.preventDefault();
if (!hasChildren) return;
if (!isExpanded) {
setExpandedKeys([...expandedKeys, focusedNode]);
} else {
focusManager?.focusNext();
}
break;
// collapse node, or move focus to parent node
case 'ArrowLeft':
e.stopPropagation();
e.preventDefault();
if (isExpanded) {
const index = expandedKeys.indexOf(focusedNode);
const newKeys = [...expandedKeys];
newKeys.splice(index, 1);
setExpandedKeys(newKeys);
} else if (parentNode) {
const parentEl =
document.activeElement?.parentElement?.closest('[tabindex]');
if (parentEl) {
(parentEl as HTMLElement).focus();
}
}
break;
// focus next visible node, recursively
case 'ArrowDown':
e.stopPropagation();
e.preventDefault();
focusManager?.focusNext();
break;
// focus previous visible node, recursively
case 'ArrowUp':
e.stopPropagation();
e.preventDefault();
focusManager?.focusPrevious();
break;
// focus first visible node
case 'Home':
e.stopPropagation();
e.preventDefault();
focusManager?.focusFirst();
break;
// focus last visible node
case 'End':
e.stopPropagation();
e.preventDefault();
focusManager?.focusLast();
break;
// expand all sibling nodes
case '*':
e.stopPropagation();
e.preventDefault();
if (parentNode?.children) {
const newKeys = [...expandedKeys];
parentNode.children.forEach(childNode => {
if (
childNode.children.length &&
!expandedKeys.includes(childNode.id)
) {
newKeys.push(childNode.id);
}
});
if (newKeys.length !== expandedKeys.length) {
setExpandedKeys(newKeys);
}
}
break;
}
};
return (
{
e.stopPropagation();
setFocusedNode(node.id);
}}
onBlur={e => {
e.stopPropagation();
// only clear focus state when focus moves outside the tree
if (!e.currentTarget.contains(e.relatedTarget)) {
setFocusedNode(undefined);
}
}}
className={clsx(
'outline-none',
// focus direct .tree-label child when this element has :focus-visible
'[&>.tree-label]:focus-visible:ring [&>.tree-label]:focus-visible:ring-2 [&>.tree-label]:focus-visible:ring-inset',
className,
)}
>
{isExpanded ? (
{renderTree({
nodes: node.children,
parentNode: node,
itemRenderer,
level,
})}
) : null}
);
}