202 lines
8.6 KiB
TypeScript
202 lines
8.6 KiB
TypeScript
import { Folder, FolderOpen } from "lucide-react";
|
|
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
|
import { i18nMessage } from "../i18n/LanguageContext";
|
|
|
|
export type ExplorerTreeNodeContext = {
|
|
depth: number;
|
|
active: boolean;
|
|
expanded: boolean;
|
|
hasChildren: boolean;
|
|
disabled: boolean;
|
|
};
|
|
|
|
type ExplorerTreeCommonProps<T> = {
|
|
nodes: T[];
|
|
getNodeId: (node: T) => string;
|
|
getNodeLabel: (node: T) => string;
|
|
getNodeChildren: (node: T) => T[];
|
|
activeId?: string;
|
|
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
|
disabled?: boolean;
|
|
depth?: number;
|
|
className?: string;
|
|
childrenBaseClassName?: string;
|
|
nodeContainerClassName?: string;
|
|
nodeWrapBaseClassName?: string;
|
|
toggleBaseClassName?: string;
|
|
nodeButtonBaseClassName?: string;
|
|
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
|
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
|
renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
|
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
|
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
|
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
|
getNodeDraggable?: (node: T, context: ExplorerTreeNodeContext) => boolean;
|
|
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
|
onDragStart?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
|
onDragEnd?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
|
onDragOver?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
|
onDragLeave?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
|
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
|
};
|
|
|
|
type CollapsibleExplorerTreeProps<T> = {
|
|
collapsible?: true;
|
|
expandedIds: ReadonlySet<string>;
|
|
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
|
};
|
|
|
|
type StaticExplorerTreeProps = {
|
|
collapsible: false;
|
|
expandedIds?: never;
|
|
onToggle?: never;
|
|
};
|
|
|
|
export type ExplorerTreeProps<T> = ExplorerTreeCommonProps<T> & (
|
|
CollapsibleExplorerTreeProps<T> | StaticExplorerTreeProps
|
|
);
|
|
|
|
type SafeNode<T> = {
|
|
id: string;
|
|
node: T;
|
|
};
|
|
|
|
const EMPTY_EXPANDED_IDS: ReadonlySet<string> = new Set();
|
|
|
|
export default function ExplorerTree<T>(props: ExplorerTreeProps<T>) {
|
|
const {
|
|
nodes,
|
|
getNodeId,
|
|
getNodeLabel,
|
|
getNodeChildren,
|
|
activeId = "",
|
|
onOpen,
|
|
disabled = false,
|
|
depth = 1,
|
|
className = "",
|
|
childrenBaseClassName = "explorer-tree-children file-tree-children",
|
|
nodeContainerClassName = "",
|
|
nodeWrapBaseClassName = "explorer-tree-node-wrap file-tree-node-wrap",
|
|
toggleBaseClassName = "explorer-tree-toggle file-tree-toggle",
|
|
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
|
renderToggleIcon,
|
|
renderNodeContent,
|
|
renderNodeActions,
|
|
getNodeWrapClassName,
|
|
getNodeWrapStyle,
|
|
getNodeButtonClassName,
|
|
getNodeDraggable,
|
|
onContextMenu,
|
|
onDragStart,
|
|
onDragEnd,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDrop
|
|
} = props;
|
|
const collapsible = props.collapsible !== false;
|
|
const expandedIds = props.collapsible === false ? EMPTY_EXPANDED_IDS : props.expandedIds;
|
|
const onToggle = props.collapsible === false ? undefined : props.onToggle;
|
|
|
|
function pathSafeNodes(levelNodes: T[], ancestorIds: ReadonlySet<string>): SafeNode<T>[] {
|
|
const siblingIds = new Set<string>();
|
|
const safeNodes: SafeNode<T>[] = [];
|
|
for (const node of levelNodes) {
|
|
const id = getNodeId(node);
|
|
if (ancestorIds.has(id) || siblingIds.has(id)) continue;
|
|
siblingIds.add(id);
|
|
safeNodes.push({ id, node });
|
|
}
|
|
return safeNodes;
|
|
}
|
|
|
|
function renderLevel(levelNodes: T[], currentDepth: number, ancestorIds: ReadonlySet<string>, root = false): ReactNode {
|
|
const safeNodes = pathSafeNodes(levelNodes, ancestorIds);
|
|
if (safeNodes.length === 0) return null;
|
|
|
|
return (
|
|
<div className={[childrenBaseClassName, root ? className : ""].filter(Boolean).join(" ")}>
|
|
{safeNodes.map(({ id: nodeId, node }) => {
|
|
const nextAncestorIds = new Set(ancestorIds);
|
|
nextAncestorIds.add(nodeId);
|
|
const children = getNodeChildren(node);
|
|
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
|
|
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
|
|
const active = activeId === nodeId;
|
|
const context: ExplorerTreeNodeContext = { depth: currentDepth, active, expanded, hasChildren, disabled };
|
|
const draggable = getNodeDraggable?.(node, context) ?? false;
|
|
const nodeActions = renderNodeActions?.(node, context);
|
|
const hasNodeActions = nodeActions !== null && nodeActions !== undefined && nodeActions !== false;
|
|
|
|
return (
|
|
<div key={nodeId} className={nodeContainerClassName}>
|
|
<div
|
|
className={[
|
|
nodeWrapBaseClassName,
|
|
active ? "is-active" : "",
|
|
hasNodeActions ? "has-actions" : "",
|
|
getNodeWrapClassName?.(node, context) ?? ""
|
|
].filter(Boolean).join(" ")}
|
|
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(currentDepth * 14, 56)}px` }}
|
|
draggable={draggable && !disabled}
|
|
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
|
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
|
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
|
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
|
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
|
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
|
>
|
|
{collapsible ? (
|
|
<button
|
|
type="button"
|
|
className={toggleBaseClassName}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (hasChildren) onToggle?.(node, context);
|
|
}}
|
|
disabled={disabled || !hasChildren}
|
|
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
|
value0: expanded ? "i18n:govoplan-core.collapse.9cf188d3" : "i18n:govoplan-core.expand.9869e506",
|
|
value1: getNodeLabel(node)
|
|
})}
|
|
aria-expanded={hasChildren ? expanded : undefined}
|
|
>
|
|
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
|
</button>
|
|
) : (
|
|
<span className={`${toggleBaseClassName} explorer-tree-toggle-static`} aria-hidden="true">
|
|
{renderToggleIcon?.(node, context) ?? (hasChildren ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
|
onClick={() => onOpen(node, context)}
|
|
disabled={disabled}
|
|
aria-current={active ? "true" : undefined}
|
|
>
|
|
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
|
</button>
|
|
{hasNodeActions && (
|
|
<div
|
|
className="explorer-tree-actions"
|
|
role="group"
|
|
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
|
value0: "i18n:govoplan-core.actions.c3cd636a",
|
|
value1: getNodeLabel(node)
|
|
})}
|
|
>
|
|
{nodeActions}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return renderLevel(nodes, depth, new Set(), true);
|
|
}
|