482 lines
14 KiB
TypeScript
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';
|