first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import {TreeItemRenderer} from './tree-item';
import {cloneElement} from 'react';
import {TreeNode} from './tree';
interface RenderTreeProps<T extends TreeNode> {
nodes: T[];
parentNode?: T;
itemRenderer: TreeItemRenderer<T>;
level?: number;
}
export function renderTree<T extends TreeNode>({
nodes,
itemRenderer,
parentNode,
level,
}: RenderTreeProps<T>) {
return nodes.map((node, index) => {
return cloneElement(itemRenderer(node), {
level: level == undefined ? 0 : level + 1,
index,
node,
parentNode,
key: node.id,
itemRenderer,
});
});
}

View File

@@ -0,0 +1,12 @@
import {createContext, Key} from 'react';
export interface TreeContextValue {
expandedKeys: Key[];
setExpandedKeys: (value: Key[]) => void;
selectedKeys: Key[];
setSelectedKeys: (value: Key[]) => void;
focusedNode?: Key;
setFocusedNode: (node?: Key) => void;
}
export const TreeContext = createContext<TreeContextValue>(null!);

View File

@@ -0,0 +1,229 @@
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<T extends TreeNode> = (
node: any,
) => ReactElement<TreeItemProps<T>>;
export interface TreeItemProps<T extends TreeNode>
extends HTMLAttributes<HTMLElement> {
label: ReactNode;
icon: ReactNode;
node?: T;
parentNode?: T;
level?: number;
index?: number;
itemRenderer?: TreeItemRenderer<T>;
labelRef?: Ref<HTMLDivElement>;
labelClassName?: string;
className?: string;
}
export function TreeItem<T extends TreeNode>({
label,
icon,
node,
level,
index,
itemRenderer,
labelRef,
labelClassName,
className,
parentNode,
...domProps
}: TreeItemProps<T>) {
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<HTMLUListElement>) => {
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 (
<li
role="treeitem"
aria-expanded={isExpanded ? 'true' : 'false'}
aria-selected={isSelected}
tabIndex={isFocused ? 0 : -1}
onKeyDown={createEventHandler(onKeyDown)}
onFocus={e => {
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,
)}
>
<TreeLabel
ref={labelRef}
className={labelClassName}
node={node}
level={level}
label={label}
icon={icon}
{...domProps}
/>
<AnimatePresence initial={false}>
{isExpanded ? (
<m.ul
key={`${node.id}-group`}
role="group"
initial="closed"
animate="open"
exit="closed"
variants={{
open: {opacity: 1, height: 'auto'},
closed: {opacity: 0, height: 0, overflow: 'hidden'},
}}
>
{renderTree({
nodes: node.children,
parentNode: node,
itemRenderer,
level,
})}
</m.ul>
) : null}
</AnimatePresence>
</li>
);
}

View File

@@ -0,0 +1,73 @@
import React, {
forwardRef,
MouseEventHandler,
ReactNode,
useContext,
} from 'react';
import {TreeContext} from './tree-context';
import clsx from 'clsx';
import {ArrowRightIcon} from '../../icons/material/ArrowRight';
interface TreeLabelProps {
level?: number;
node: any;
icon?: ReactNode;
label?: ReactNode;
className?: string;
}
export const TreeLabel = forwardRef<HTMLDivElement, TreeLabelProps>(
({icon, label, level = 0, node, className, ...domProps}, ref) => {
const {expandedKeys, setExpandedKeys, selectedKeys, setSelectedKeys} =
useContext(TreeContext);
const isExpanded = expandedKeys.includes(node.id);
const isSelected = selectedKeys.includes(node.id);
const handleExpandIconClick: MouseEventHandler = e => {
e.stopPropagation();
const index = expandedKeys.indexOf(node.id);
const newExpandedKeys = [...expandedKeys];
if (index > -1) {
newExpandedKeys.splice(index, 1);
} else {
newExpandedKeys.push(node.id);
}
setExpandedKeys(newExpandedKeys);
};
return (
<div
{...domProps}
ref={ref}
onClick={e => {
e.stopPropagation();
setSelectedKeys([node.id]);
}}
className={clsx(
'flex flex-nowrap whitespace-nowrap items-center gap-4 py-6 rounded header cursor-pointer overflow-hidden text-ellipsis tree-label',
className,
isSelected && 'bg-primary/selected text-primary font-bold',
!isSelected && 'hover:bg-hover'
)}
>
{level > 0 && (
<div className="flex">
{Array.from({length: level}).map((_, i) => {
return <div key={i} className="w-24 h-24" />;
})}
</div>
)}
<div onClick={handleExpandIconClick}>
<ArrowRightIcon
className={clsx(
'icon-sm cursor-default transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
{icon}
<div className="overflow-hidden text-ellipsis pr-6">{label}</div>
</div>
);
}
);
TreeLabel.displayName = 'TreeLabel';

View File

@@ -0,0 +1,68 @@
import React, {Key, useState} from 'react';
import {useControlledState} from '@react-stately/utils';
import {FocusScope} from '@react-aria/focus';
import {TreeContext, TreeContextValue} from './tree-context';
import {TreeItemRenderer} from './tree-item';
import {renderTree} from './render-tree';
export interface TreeNode {
id: number | string;
children: TreeNode[];
}
interface TreeProps<T extends TreeNode> {
children: TreeItemRenderer<T>;
nodes: T[];
selectedKeys?: Key[];
expandedKeys?: Key[];
defaultExpandedKeys?: Key[];
onExpandedKeysChange?: (value: Key[]) => void;
defaultSelectedKeys?: Key[];
onSelectedKeysChange?: (value: Key[]) => void;
}
export function Tree<T extends TreeNode>({
children,
nodes,
...props
}: TreeProps<T>) {
const [expandedKeys, setExpandedKeys] = useControlledState(
props.expandedKeys,
props.defaultSelectedKeys,
props.onExpandedKeysChange
);
const [selectedKeys, setSelectedKeys] = useControlledState(
props.selectedKeys,
props.defaultSelectedKeys,
props.onSelectedKeysChange
);
const [focusedNode, setFocusedNode] = useState<Key | undefined>();
const value: TreeContextValue = {
expandedKeys,
setExpandedKeys,
selectedKeys,
setSelectedKeys,
focusedNode,
setFocusedNode,
};
return (
<TreeContext.Provider value={value}>
<FocusScope>
<TreeRoot nodes={nodes} itemRenderer={children} />
</FocusScope>
</TreeContext.Provider>
);
}
interface TreeRootProps<T extends TreeNode> {
nodes: TreeNode[];
itemRenderer: TreeItemRenderer<T>;
}
function TreeRoot<T extends TreeNode>(props: TreeRootProps<T>) {
return (
<ul className="overflow-hidden text-sm" role="tree">
{renderTree(props)}
</ul>
);
}