Cards in a deck share the deck's GUID, so GUID-based selection highlighted every card with that GUID and rendered the first match. Key selection by the node's unique index path instead.
105 lines
3.0 KiB
TypeScript
105 lines
3.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { Icon } from '@iconify/react';
|
|
import type { ObjectTreeNode } from '@tts/extract';
|
|
import { iconsForObject } from './objectIcons';
|
|
|
|
interface Props {
|
|
nodes: ObjectTreeNode[];
|
|
selectedPath: string | null;
|
|
onSelect: (path: string) => void;
|
|
}
|
|
|
|
export default function ObjectTree({ nodes, selectedPath, onSelect }: Props) {
|
|
return (
|
|
<ul className="space-y-0.5">
|
|
{nodes.map((node, index) => (
|
|
<TreeNode
|
|
key={index}
|
|
node={node}
|
|
depth={0}
|
|
path={`${index}`}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function TreeNode({
|
|
node,
|
|
depth,
|
|
path,
|
|
selectedPath,
|
|
onSelect,
|
|
}: {
|
|
node: ObjectTreeNode;
|
|
depth: number;
|
|
/** Index path from the root, used as a stable unique key and selection id. */
|
|
path: string;
|
|
selectedPath: string | null;
|
|
onSelect: (path: string) => void;
|
|
}) {
|
|
// Selection is keyed by the node's unique index path, not its GUID: cards in
|
|
// a deck frequently share a GUID (the deck's), so GUID-based selection would
|
|
// highlight and render the wrong card.
|
|
const selected = path === selectedPath;
|
|
const hasChildren = node.children.length > 0;
|
|
const [expanded, setExpanded] = useState(false);
|
|
return (
|
|
<li>
|
|
<div
|
|
className={`flex items-center rounded pr-2 ${
|
|
selected
|
|
? 'bg-zinc-700 text-zinc-100'
|
|
: 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100'
|
|
}`}
|
|
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
|
>
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={() => setExpanded((e) => !e)}
|
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-zinc-500 hover:text-zinc-200"
|
|
>
|
|
<span
|
|
className={`inline-block text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
|
>
|
|
▸
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<span className="h-6 w-6 shrink-0" />
|
|
)}
|
|
<button
|
|
onClick={() => onSelect(path)}
|
|
className="block min-w-0 flex-1 truncate py-1 text-left text-sm"
|
|
>
|
|
<span
|
|
title={node.object.Name}
|
|
className="mr-1.5 inline-flex items-center gap-0.5 align-middle text-zinc-400"
|
|
>
|
|
{iconsForObject(node.object.Name).map((icon) => (
|
|
<Icon key={icon} icon={icon} className="h-4 w-4" />
|
|
))}
|
|
</span>
|
|
<span className="truncate">{node.label}</span>
|
|
</button>
|
|
</div>
|
|
{hasChildren && expanded && (
|
|
<ul>
|
|
{node.children.map((child, index) => (
|
|
<TreeNode
|
|
key={`${path}-${index}`}
|
|
node={child}
|
|
depth={depth + 1}
|
|
path={`${path}-${index}`}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
} |