feat: 컴포넌트 추가, 스타일 수정
This commit is contained in:
481
web-app/app/shared/components/tree/Tree.tsx
Normal file
481
web-app/app/shared/components/tree/Tree.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
'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';
|
||||
Reference in New Issue
Block a user