feat(webui): extend shared explorer tree actions
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
||||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||||
|
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
|
||||||
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
|
||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
|
|||||||
@@ -10,15 +10,13 @@ export type ExplorerTreeNodeContext = {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExplorerTreeProps<T> = {
|
type ExplorerTreeCommonProps<T> = {
|
||||||
nodes: T[];
|
nodes: T[];
|
||||||
getNodeId: (node: T) => string;
|
getNodeId: (node: T) => string;
|
||||||
getNodeLabel: (node: T) => string;
|
getNodeLabel: (node: T) => string;
|
||||||
getNodeChildren: (node: T) => T[];
|
getNodeChildren: (node: T) => T[];
|
||||||
activeId?: string;
|
activeId?: string;
|
||||||
expandedIds: Set<string>;
|
|
||||||
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -29,6 +27,7 @@ export type ExplorerTreeProps<T> = {
|
|||||||
nodeButtonBaseClassName?: string;
|
nodeButtonBaseClassName?: string;
|
||||||
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
renderToggleIcon?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||||
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
renderNodeContent?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||||
|
renderNodeActions?: (node: T, context: ExplorerTreeNodeContext) => ReactNode;
|
||||||
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
getNodeWrapClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||||
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
getNodeWrapStyle?: (node: T, context: ExplorerTreeNodeContext) => CSSProperties | undefined;
|
||||||
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
getNodeButtonClassName?: (node: T, context: ExplorerTreeNodeContext) => string | undefined;
|
||||||
@@ -41,15 +40,37 @@ export type ExplorerTreeProps<T> = {
|
|||||||
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
onDrop?: (event: ReactDragEvent<HTMLElement>, node: T, context: ExplorerTreeNodeContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExplorerTree<T>({
|
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,
|
nodes,
|
||||||
getNodeId,
|
getNodeId,
|
||||||
getNodeLabel,
|
getNodeLabel,
|
||||||
getNodeChildren,
|
getNodeChildren,
|
||||||
activeId = "",
|
activeId = "",
|
||||||
expandedIds,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
onToggle,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
depth = 1,
|
depth = 1,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -60,6 +81,7 @@ export default function ExplorerTree<T>({
|
|||||||
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
nodeButtonBaseClassName = "explorer-tree-node file-tree-node",
|
||||||
renderToggleIcon,
|
renderToggleIcon,
|
||||||
renderNodeContent,
|
renderNodeContent,
|
||||||
|
renderNodeActions,
|
||||||
getNodeWrapClassName,
|
getNodeWrapClassName,
|
||||||
getNodeWrapStyle,
|
getNodeWrapStyle,
|
||||||
getNodeButtonClassName,
|
getNodeButtonClassName,
|
||||||
@@ -70,19 +92,40 @@ export default function ExplorerTree<T>({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDrop
|
onDrop
|
||||||
}: ExplorerTreeProps<T>) {
|
} = props;
|
||||||
if (nodes.length === 0) return null;
|
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 (
|
return (
|
||||||
<div className={[childrenBaseClassName, className].filter(Boolean).join(" ")}>
|
<div className={[childrenBaseClassName, root ? className : ""].filter(Boolean).join(" ")}>
|
||||||
{nodes.map((node) => {
|
{safeNodes.map(({ id: nodeId, node }) => {
|
||||||
const nodeId = getNodeId(node);
|
const nextAncestorIds = new Set(ancestorIds);
|
||||||
|
nextAncestorIds.add(nodeId);
|
||||||
const children = getNodeChildren(node);
|
const children = getNodeChildren(node);
|
||||||
const hasChildren = children.length > 0;
|
const hasChildren = pathSafeNodes(children, nextAncestorIds).length > 0;
|
||||||
const expanded = expandedIds.has(nodeId);
|
const expanded = collapsible ? expandedIds.has(nodeId) : hasChildren;
|
||||||
const active = activeId === nodeId;
|
const active = activeId === nodeId;
|
||||||
const context: ExplorerTreeNodeContext = { depth, active, expanded, hasChildren, disabled };
|
const context: ExplorerTreeNodeContext = { depth: currentDepth, active, expanded, hasChildren, disabled };
|
||||||
const draggable = getNodeDraggable?.(node, context) ?? false;
|
const draggable = getNodeDraggable?.(node, context) ?? false;
|
||||||
|
const nodeActions = renderNodeActions?.(node, context);
|
||||||
|
const hasNodeActions = nodeActions !== null && nodeActions !== undefined && nodeActions !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={nodeId} className={nodeContainerClassName}>
|
<div key={nodeId} className={nodeContainerClassName}>
|
||||||
@@ -90,73 +133,69 @@ export default function ExplorerTree<T>({
|
|||||||
className={[
|
className={[
|
||||||
nodeWrapBaseClassName,
|
nodeWrapBaseClassName,
|
||||||
active ? "is-active" : "",
|
active ? "is-active" : "",
|
||||||
getNodeWrapClassName?.(node, context) ?? ""].
|
hasNodeActions ? "has-actions" : "",
|
||||||
filter(Boolean).join(" ")}
|
getNodeWrapClassName?.(node, context) ?? ""
|
||||||
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
].filter(Boolean).join(" ")}
|
||||||
|
style={getNodeWrapStyle?.(node, context) ?? { paddingLeft: `${Math.min(currentDepth * 14, 56)}px` }}
|
||||||
draggable={draggable && !disabled}
|
draggable={draggable && !disabled}
|
||||||
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
onContextMenu={onContextMenu ? (event) => onContextMenu(event, node, context) : undefined}
|
||||||
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
onDragStart={draggable && onDragStart ? (event) => onDragStart(event, node, context) : undefined}
|
||||||
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
onDragEnd={draggable && onDragEnd ? (event) => onDragEnd(event, node, context) : undefined}
|
||||||
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
onDragOver={onDragOver ? (event) => onDragOver(event, node, context) : undefined}
|
||||||
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
onDragLeave={onDragLeave ? (event) => onDragLeave(event, node, context) : undefined}
|
||||||
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}>
|
onDrop={onDrop ? (event) => onDrop(event, node, context) : undefined}
|
||||||
|
>
|
||||||
|
{collapsible ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={toggleBaseClassName}
|
className={toggleBaseClassName}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (hasChildren) onToggle(node, context);
|
if (hasChildren) onToggle?.(node, context);
|
||||||
}}
|
}}
|
||||||
disabled={disabled || !hasChildren}
|
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-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
||||||
aria-expanded={hasChildren ? expanded : undefined}>
|
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" />)}
|
{renderToggleIcon?.(node, context) ?? (expanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />)}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
className={[nodeButtonBaseClassName, getNodeButtonClassName?.(node, context) ?? ""].filter(Boolean).join(" ")}
|
||||||
onClick={() => onOpen(node, context)}
|
onClick={() => onOpen(node, context)}
|
||||||
disabled={disabled}>
|
disabled={disabled}
|
||||||
|
aria-current={active ? "true" : undefined}
|
||||||
|
>
|
||||||
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
{renderNodeContent?.(node, context) ?? <span>{getNodeLabel(node)}</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{hasNodeActions && (
|
||||||
{hasChildren && expanded &&
|
<div
|
||||||
<ExplorerTree
|
className="explorer-tree-actions"
|
||||||
nodes={children}
|
role="group"
|
||||||
getNodeId={getNodeId}
|
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", {
|
||||||
getNodeLabel={getNodeLabel}
|
value0: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
getNodeChildren={getNodeChildren}
|
value1: getNodeLabel(node)
|
||||||
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>);
|
>
|
||||||
|
{nodeActions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasChildren && expanded && renderLevel(children, currentDepth + 1, nextAncestorIds)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLevel(nodes, depth, new Set(), true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2256,6 +2256,10 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-tree-node-wrap.has-actions {
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-tree-node,
|
.explorer-tree-node,
|
||||||
.explorer-tree-toggle {
|
.explorer-tree-toggle {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -2278,6 +2282,11 @@
|
|||||||
opacity: .55;
|
opacity: .55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-tree-toggle.explorer-tree-toggle-static {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-tree-node {
|
.explorer-tree-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2297,6 +2306,44 @@
|
|||||||
white-space: nowrap;
|
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:hover,
|
||||||
.explorer-tree-node-wrap:focus-within,
|
.explorer-tree-node-wrap:focus-within,
|
||||||
.explorer-tree-node-wrap.is-active {
|
.explorer-tree-node-wrap.is-active {
|
||||||
|
|||||||
55
webui/tests/explorer-tree.test.tsx
Normal file
55
webui/tests/explorer-tree.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(markup: string, pattern: RegExp): number {
|
||||||
|
return markup.match(pattern)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import ExplorerTree from "../src/components/ExplorerTree";
|
||||||
|
import IconButton from "../src/components/IconButton";
|
||||||
|
|
||||||
|
type TestNode = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
children: TestNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function noop() {}
|
||||||
|
|
||||||
|
const alpha: TestNode = { id: "alpha", label: "Alpha", children: [] };
|
||||||
|
const beta: TestNode = { id: "beta", label: "Beta", children: [] };
|
||||||
|
alpha.children.push(beta);
|
||||||
|
beta.children.push(alpha);
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<ExplorerTree
|
||||||
|
nodes={[alpha, alpha]}
|
||||||
|
getNodeId={(node) => node.id}
|
||||||
|
getNodeLabel={(node) => node.label}
|
||||||
|
getNodeChildren={(node) => node.children}
|
||||||
|
activeId="beta"
|
||||||
|
collapsible={false}
|
||||||
|
onOpen={noop}
|
||||||
|
renderNodeContent={(node) => <span>{`node-${node.id}`}</span>}
|
||||||
|
renderNodeActions={(node) => (
|
||||||
|
<IconButton label={`Add below ${node.label}`} icon={<span>+</span>} onClick={noop} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(markup.includes("node-alpha"), "the root node is rendered");
|
||||||
|
assert(markup.includes("node-beta"), "non-collapsible trees render descendants without expansion state");
|
||||||
|
assert(count(markup, /node-alpha/g) === 1, "a cycle does not render an ancestor again");
|
||||||
|
assert(count(markup, /node-beta/g) === 1, "duplicate paths do not duplicate descendants");
|
||||||
|
assert(count(markup, /explorer-tree-toggle-static/g) === 2, "non-collapsible nodes render static tree affordances");
|
||||||
|
assert(!markup.includes('<button type="button" class="explorer-tree-toggle'), "non-collapsible nodes do not expose dead toggle buttons");
|
||||||
|
assert(markup.includes("explorer-tree-node-wrap file-tree-node-wrap has-actions"), "rows reserve a sibling action column only when used");
|
||||||
|
assert(markup.includes('class="explorer-tree-actions" role="group"'), "node actions expose group semantics");
|
||||||
|
assert(markup.includes('aria-label="Add below Alpha"'), "node action controls retain accessible labels");
|
||||||
|
assert(
|
||||||
|
/node-alpha<\/span><\/button><div class="explorer-tree-actions"/.test(markup),
|
||||||
|
"node actions are siblings of the node button instead of nested interactive content"
|
||||||
|
);
|
||||||
|
assert(markup.includes('aria-current="true"'), "the active node is exposed to assistive technology");
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"tests/data-grid-actions.test.tsx",
|
"tests/data-grid-actions.test.tsx",
|
||||||
"tests/dialog-focus.test.tsx",
|
"tests/dialog-focus.test.tsx",
|
||||||
|
"tests/explorer-tree.test.tsx",
|
||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
"tests/resource-access-explanation.test.tsx",
|
"tests/resource-access-explanation.test.tsx",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"src/components/Button.tsx",
|
"src/components/Button.tsx",
|
||||||
"src/components/DismissibleAlert.tsx",
|
"src/components/DismissibleAlert.tsx",
|
||||||
"src/components/Dialog.tsx",
|
"src/components/Dialog.tsx",
|
||||||
|
"src/components/ExplorerTree.tsx",
|
||||||
"src/components/dialogInteractions.ts",
|
"src/components/dialogInteractions.ts",
|
||||||
"src/components/dialogStack.ts",
|
"src/components/dialogStack.ts",
|
||||||
"src/components/FormField.tsx",
|
"src/components/FormField.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user