27
common/resources/client/ui/tree/render-tree.ts
Executable file
27
common/resources/client/ui/tree/render-tree.ts
Executable 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
12
common/resources/client/ui/tree/tree-context.tsx
Executable file
12
common/resources/client/ui/tree/tree-context.tsx
Executable 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!);
|
||||
229
common/resources/client/ui/tree/tree-item.tsx
Executable file
229
common/resources/client/ui/tree/tree-item.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
73
common/resources/client/ui/tree/tree-label.tsx
Executable file
73
common/resources/client/ui/tree/tree-label.tsx
Executable 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';
|
||||
68
common/resources/client/ui/tree/tree.tsx
Executable file
68
common/resources/client/ui/tree/tree.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user