Files
DABEEO-DETECTION-APPLICATION/web-app/app/shared/components/tree/Tree.tsx

482 lines
14 KiB
TypeScript

'use client';
import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import type { DropTarget } from 'react-aria-components';
import {
Button,
Collection,
DropIndicator,
Tree as ReactAriaTree,
TreeItem,
TreeItemContent,
type TreeItemContentProps,
type TreeItemContentRenderProps,
type TreeItemProps,
useDragAndDrop,
} from 'react-aria-components';
import type { Key, Selection } from 'react-stately';
import { useTreeData } from 'react-stately';
import clsx from 'clsx';
type TreeContextType = {
onExpand?: (key: Key, item: TreeItemType) => void;
onCollapse?: (key: Key, item: TreeItemType) => void;
treeData?: ReturnType<typeof useTreeData<TreeItemType>>;
treeOpenIcon?: React.ReactNode;
treeCloseIcon?: React.ReactNode;
};
const TreeContext = createContext<TreeContextType>({});
interface MyTreeItemProps extends Partial<TreeItemProps> {
title: string;
isLastChild?: boolean;
isRoot?: boolean;
}
export type TreeItemType = {
id: number | string;
title: string;
type: 'directory' | 'file';
value?: any;
children?: TreeItemType[];
};
export type TreeRef = {
updateItem: (item: TreeItemType) => void;
addItem: (item: TreeItemType, parentKey?: Key | null) => void;
removeItem: (itemId: number) => void;
expandAllItems: () => void;
collapseAllItems: () => void;
applyReorder: (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => void;
clearItemsByParentKey: (parentKey: Key) => void;
clearAllTrees: () => void;
};
export type TreeProps = {
items: TreeItemType[];
selectionMode?: 'single' | 'multiple' | 'none';
selectedKeys?: 'all' | Set<number | string>;
selectedParentKey?: Key | null;
onSelectionChange?: (keys: Selection) => void;
enableDragAndDrop?: boolean;
onReorder?: (
sourceKey: number,
targetKey: number,
dropPosition: 'before' | 'after',
ParentKey: number | null
) => void;
preventAutoReorder?: boolean;
onExpand?: (key: Key, item: TreeItemType) => void;
onCollapse?: (key: Key, item: TreeItemType) => void;
onSelect?: (key: Key | null, item: TreeItemType | null) => void;
'aria-label'?: string;
className?: string;
treeOpenIcon?: React.ReactNode;
treeCloseIcon?: React.ReactNode;
persistSelectionOnCollapse?: boolean;
};
const IconTreeBranch = () => (
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0.5" y1="0" x2="0.5" y2="48" stroke="#343434" strokeDasharray="2 2" />
<line x1="25" y1="23.5" x2="1" y2="23.5" stroke="#343434" strokeDasharray="2 2" />
</svg>
);
const IconTreeBranchLast = () => (
<svg width="24" height="48" viewBox="0 0 24 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0.5" y1="0" x2="0.5" y2="24" stroke="#343434" strokeDasharray="2 2" />
<line x1="25" y1="23.5" x2="1" y2="23.5" stroke="#343434" strokeDasharray="2 2" />
</svg>
);
const MyTreeItemContentInner = ({
level,
hasChildItems,
isExpanded,
isSelected,
lastChild,
children,
isRoot,
onExpandChange,
}: {
level: number;
hasChildItems: boolean;
isExpanded: boolean;
isSelected: boolean;
lastChild?: boolean;
children?: React.ReactNode;
isRoot?: boolean;
actualChildCount?: number;
onExpandChange?: (isExpanded: boolean) => void;
}) => {
const { treeOpenIcon, treeCloseIcon } = useContext(TreeContext);
if (hasChildItems || isRoot) {
return (
<Button
className={clsx(
'cursor-pointer flex items-center h-10 hover:bg-dabeeo-gray-eb w-full text-dabeeo-black-34 text-sm',
isSelected && 'bg-dabeeo-yellow'
)}
style={{ paddingLeft: `${level * 16}px` }}
slot="chevron"
onClick={() => {
onExpandChange?.(!isExpanded);
}}
>
{treeOpenIcon && treeCloseIcon ? (
<div className="mr-3">{isExpanded ? treeOpenIcon : treeCloseIcon}</div>
) : (
<div className="w-6 h-6 bg-dabeeo-gray-eb border border-dabeeo-black-34 hover:bg-dabeeo-gray-da mr-4 shrink-0">
{isExpanded ? '-' : '+'}
</div>
)}
<span className="font-semibold">{children}</span>
</Button>
);
}
return (
<div
className={clsx(
'cursor-pointer flex items-center h-10 hover:bg-dabeeo-gray-eb w-full text-dabeeo-black-34 text-sm pl-9',
isSelected && 'bg-dabeeo-yellow'
)}
>
<div className="mr-3 flex-shrink-0">{lastChild ? <IconTreeBranchLast /> : <IconTreeBranch />}</div>
<span className="font-normal">{children}</span>
</div>
);
};
const MyTreeItemContent = (
props: Omit<TreeItemContentProps, 'children'> & {
children?: React.ReactNode;
lastChild?: boolean;
isRoot?: boolean;
onExpandChange?: (isExpanded: boolean) => void;
}
) => {
return (
<TreeItemContent>
{(renderProps: TreeItemContentRenderProps) => {
const { hasChildItems, isExpanded, isSelected, level } = renderProps;
return (
<>
<MyTreeItemContentInner
level={level}
hasChildItems={hasChildItems}
isExpanded={isExpanded}
isSelected={isSelected}
lastChild={props.lastChild}
isRoot={props.isRoot}
onExpandChange={props.onExpandChange}
>
{props.children}
</MyTreeItemContentInner>
<Button slot="drag" className="sr-only">
</Button>
</>
);
}}
</TreeItemContent>
);
};
const TreeDropIndicator = ({ target }: { target: DropTarget }) => {
return (
<DropIndicator
target={target}
className={({ isDropTarget }) =>
clsx('w-full h-0 outline-2 outline-primary/50', isDropTarget ? 'opacity-100' : 'opacity-0')}
/>
);
};
const MyTreeItem = (props: MyTreeItemProps) => {
const { onExpand, onCollapse, treeData } = useContext(TreeContext);
const handleExpandChange = (isExpanded: boolean) => {
if (!props.id || !treeData) return;
const item = treeData.getItem(props.id);
if (!item) return;
if (isExpanded) {
onExpand?.(props.id, item.value);
} else {
onCollapse?.(props.id, item.value);
}
};
return (
<TreeItem
textValue={props.title}
{...props}
hasChildItems={props.isRoot ? true : undefined}
data-item-id={props.id}
className="
data-[dragging]:opacity-60
data-[drop-target]:outline
data-[drop-target]:outline-2
data-[drop-target]:outline-[var(--highlight-background)]
data-[drop-target]:bg-[var(--highlight-overlay)]
"
>
<MyTreeItemContent lastChild={props.isLastChild} isRoot={props.isRoot} onExpandChange={handleExpandChange}>
{props.title}
</MyTreeItemContent>
{props.children}
</TreeItem>
);
};
export const Tree = forwardRef<TreeRef, TreeProps>(
(
{
'aria-label': ariaLabel = 'Tree',
items,
selectionMode = 'single',
selectedKeys,
selectedParentKey,
enableDragAndDrop = true,
className,
onSelectionChange,
onReorder,
preventAutoReorder = false,
onExpand,
onCollapse,
onSelect,
treeOpenIcon,
treeCloseIcon,
persistSelectionOnCollapse = false,
},
ref
) => {
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(new Set());
const [treeKey, setTreeKey] = useState(0);
const handleExpandedChange = (keys: Set<Key>) => {
setExpandedKeys(keys);
};
const tree = useTreeData({
initialItems: items,
getKey: (item) => item.id,
getChildren: (item) => item.children || [],
});
const updateItem = (item: TreeItemType) => {
tree.update(item.id, item);
};
const addItem = (item: TreeItemType, parentKey?: Key | null) => {
tree.getItem(-1) && tree.remove(-1);
// 중복 체크: 이미 존재하는 아이템은 추가하지 않음
const targetParentKey = parentKey ?? selectedParentKey ?? null;
const existingItem = tree.getItem(item.id);
if (
(existingItem && existingItem.parentKey === targetParentKey)
|| (targetParentKey && targetParentKey === item.id)
) {
return;
}
tree.append(targetParentKey, item);
};
const removeItem = (itemId: number) => {
tree.remove(itemId);
const childrens = tree.getItem(selectedParentKey as Key)?.children;
itemId !== -1 && childrens?.length === 1 && updateMyParentItem();
};
const expandAllItems = () => {
setExpandedKeys(new Set([...expandedKeys, ...tree.items.map((item) => item.key)]));
};
const collapseAllItems = () => {
setExpandedKeys(new Set());
};
const clearAllTrees = () => {
const keysToRemove = tree.items.map((item) => item.key);
keysToRemove.forEach((key) => {
tree.remove(key);
});
};
const clearItemsByParentKey = (parentKey: Key) => {
const removeRecursively = (key: Key) => {
const item = tree.getItem(key);
if (item && item.children) {
item.children.forEach((child) => {
removeRecursively(child.key);
});
}
tree.remove(key);
};
const itemsToRemove: Key[] = [];
tree.items.forEach((item) => {
if (item.parentKey === parentKey) {
itemsToRemove.push(item.key);
}
});
itemsToRemove.forEach((key) => {
removeRecursively(key);
});
};
const applyReorder = (sourceKey: number, targetKey: number, dropPosition: 'before' | 'after') => {
if (dropPosition === 'before') {
tree.moveBefore(targetKey, new Set([sourceKey]));
} else {
tree.moveAfter(targetKey, new Set([sourceKey]));
}
};
const updateMyParentItem = () => {
if (selectedParentKey) {
const parentItem = tree.getItem(selectedParentKey);
if (parentItem) {
tree.update(selectedParentKey, {
...parentItem.value,
children: [],
...(parentItem.value.value && { value: { ...parentItem.value.value, children: [] } }),
});
}
}
};
useImperativeHandle(ref, () => ({
updateItem,
addItem,
removeItem,
expandAllItems,
collapseAllItems,
applyReorder,
clearItemsByParentKey,
clearAllTrees,
}));
useEffect(() => {
if (persistSelectionOnCollapse) return;
if (selectedKeys) {
setExpandedKeys(new Set([...expandedKeys, ...selectedKeys]));
}
}, [selectedKeys, persistSelectionOnCollapse]);
useEffect(() => {
if (items) {
setTreeKey((prev) => prev + 1);
}
}, [items]);
const handleSelectionChange = (keys: Selection) => {
onSelectionChange?.(keys);
if (onSelect && selectionMode === 'single' && keys !== 'all') {
const selectedKey = keys instanceof Set ? Array.from(keys)[0] : null;
if (selectedKey) {
const item = tree.getItem(selectedKey);
if (item) {
onSelect(selectedKey, item ? item.value : null);
}
} else {
onSelect(selectedKey, null);
}
}
};
const { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].map((key) => ({
'text/plain': tree.getItem(key)?.value.title || '',
})),
renderDropIndicator(target) {
return <TreeDropIndicator target={target} />;
},
onReorder(e) {
const targetItem = tree.getItem(e.target.key);
const sourceKey = Array.from(e.keys)[0] as number;
const dropPosition = e.target.dropPosition === 'before' ? 'before' : 'after';
const targetParentKey = Number(targetItem?.parentKey) || null;
if (preventAutoReorder) {
onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey);
} else {
if (dropPosition === 'before') {
tree.moveBefore(e.target.key, e.keys);
} else {
tree.moveAfter(e.target.key, e.keys);
}
onReorder?.(sourceKey, Number(e.target.key), dropPosition, targetParentKey);
}
},
});
const calculateIsLastChild = (item: ReturnType<typeof tree.getItem>) => {
if (!item || !item.parentKey) return false;
const parent = tree.getItem(item.parentKey);
if (!parent) return false;
const siblings = parent.children || [];
const index = siblings.findIndex((sibling) => sibling.key === item.key);
return index === siblings.length - 1;
};
// MEMO: tree.items가 변경될 때 전체 트리를 다시 렌더링하기 위해 사용하는 key
// 현재는 하나의 item이 변경되어도 전체 트리가 다시 렌더링됨. 변경된 item과 같은 부모 아래의 아이템들만 선택적으로 렌더링되도록 개선 필요함
const treeRenderKey = useMemo(() => {
const generateTreeHash = (items: typeof tree.items): string => {
return items
.map((item) => {
const childrenHash = item.children ? generateTreeHash(item.children) : '';
return `${item.key}-${item.children?.length || 0}-${childrenHash}`;
})
.join('|');
};
return generateTreeHash(tree.items);
}, [tree.items]);
return (
<TreeContext.Provider value={{ onExpand, onCollapse, treeData: tree, treeOpenIcon, treeCloseIcon }}>
<ReactAriaTree
key={`${treeKey}-${treeRenderKey}`}
aria-label={ariaLabel}
selectionMode={selectionMode}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
items={tree.items}
dragAndDropHooks={enableDragAndDrop ? dragAndDropHooks : undefined}
className={clsx(className, 'w-full')}
expandedKeys={expandedKeys}
onExpandedChange={handleExpandedChange}
>
{function renderItem(item) {
const isLastChild = calculateIsLastChild(item);
return (
<MyTreeItem
title={item.value.title || ''}
isLastChild={isLastChild}
isRoot={item.value.type === 'directory'}
>
<Collection items={item.children || []}>{renderItem}</Collection>
</MyTreeItem>
);
}}
</ReactAriaTree>
</TreeContext.Provider>
);
}
);
Tree.displayName = 'Tree';