feat(webui): extend shared explorer tree actions
This commit is contained in:
@@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = {
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export type ExplorerTreeProps<T> = {
|
||||
type ExplorerTreeCommonProps<T> = {
|
||||
nodes: T[];
|
||||
getNodeId: (node: T) => string;
|
||||
getNodeLabel: (node: T) => string;
|
||||
getNodeChildren: (node: T) => T[];
|
||||
activeId?: string;
|
||||
expandedIds: Set<string>;
|
||||
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
@@ -29,6 +27,7 @@ export type ExplorerTreeProps<T> = {
|
||||
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;
|
||||
@@ -41,122 +40,162 @@ export type ExplorerTreeProps<T> = {
|
||||
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
export default function ExplorerTree<T>({
|
||||
nodes,
|
||||
getNodeId,
|
||||
getNodeLabel,
|
||||
getNodeChildren,
|
||||
activeId = "",
|
||||
expandedIds,
|
||||
onOpen,
|
||||
onToggle,
|
||||
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,
|
||||
getNodeWrapClassName,
|
||||
getNodeWrapStyle,
|
||||
getNodeButtonClassName,
|
||||
getNodeDraggable,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop
|
||||
}: ExplorerTreeProps<T>) {
|
||||
if (nodes.length === 0) return null;
|
||||
type CollapsibleExplorerTreeProps<T> = {
|
||||
collapsible?: true;
|
||||
expandedIds: ReadonlySet<string>;
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
|
||||
{nodes.map((node) => {
|
||||
const nodeId = getNodeId(node);
|
||||
const children = getNodeChildren(node);
|
||||
const hasChildren = children.length > 0;
|
||||
const expanded = expandedIds.has(nodeId);
|
||||
const active = activeId === nodeId;
|
||||
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
|
||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||
type StaticExplorerTreeProps = {
|
||||
collapsible: false;
|
||||
expandedIds?: never;
|
||||
onToggle?: never;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={nodeId} className={nodeContainerClassName}>
|
||||
<div
|
||||
className={[
|
||||
nodeWrapBaseClassName,
|
||||
active ? "is-active" : "",
|
||||
getNodeWrapClassName?.(node, context) ?? ""].
|
||||
filter(Boolean).join(" ")}
|
||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 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}>
|
||||
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||
onClick={() => onOpen(node, context)}
|
||||
disabled={disabled}>
|
||||
|
||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||
</button>
|
||||
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>
|
||||
{hasChildren && expanded &&
|
||||
<ExplorerTree
|
||||
nodes={children}
|
||||
getNodeId={getNodeId}
|
||||
getNodeLabel={getNodeLabel}
|
||||
getNodeChildren={getNodeChildren}
|
||||
activeId={activeId}
|
||||
expandedIds={expandedIds}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
childrenBaseClassName={childrenBaseClassName}
|
||||
nodeContainerClassName={nodeContainerClassName}
|
||||
nodeWrapBaseClassName={nodeWrapBaseClassName}
|
||||
toggleBaseClassName={toggleBaseClassName}
|
||||
nodeButtonBaseClassName={nodeButtonBaseClassName}
|
||||
renderToggleIcon={renderToggleIcon}
|
||||
renderNodeContent={renderNodeContent}
|
||||
getNodeWrapClassName={getNodeWrapClassName}
|
||||
getNodeWrapStyle={getNodeWrapStyle}
|
||||
getNodeButtonClassName={getNodeButtonClassName}
|
||||
getNodeDraggable={getNodeDraggable}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderLevel(nodes, depth, new Set(), true);
|
||||
}
|
||||
|
||||
@@ -2256,6 +2256,10 @@
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.explorer-tree-node-wrap.has-actions {
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.explorer-tree-node,
|
||||
.explorer-tree-toggle {
|
||||
border: 0;
|
||||
@@ -2278,6 +2282,11 @@
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.explorer-tree-toggle.explorer-tree-toggle-static {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.explorer-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2297,6 +2306,44 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.explorer-tree-node-content {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer-tree-node-content strong,
|
||||
.explorer-tree-node-content small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.explorer-tree-node-content small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.explorer-tree-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.explorer-tree-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.explorer-tree-scroll-region {
|
||||
max-height: 640px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.explorer-tree-node-wrap:hover,
|
||||
.explorer-tree-node-wrap:focus-within,
|
||||
.explorer-tree-node-wrap.is-active {
|
||||
|
||||
Reference in New Issue
Block a user