Files
road-monitoring-ui/src/components/ui/tree.tsx

179 lines
4.8 KiB
TypeScript

'use client';
import { ChevronDown } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
export interface TreeItem {
id: number;
label: string;
description?: string;
children: TreeItem[];
}
export function collectTreeItemIds<TItem extends TreeItem>(
item: TItem,
): number[] {
return [
item.id,
...item.children.flatMap((child) => collectTreeItemIds(child as TItem)),
];
}
function TreeCheckbox({
checked,
indeterminate,
onChange,
}: {
checked: boolean;
indeterminate: boolean;
onChange: (checked: boolean) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.indeterminate = indeterminate;
}
}, [indeterminate]);
return (
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="size-4 shrink-0 rounded border-border accent-primary"
/>
);
}
function CheckboxTreeNode<TItem extends TreeItem>({
item,
selectedIds,
onToggle,
renderMeta,
showDescription = true,
}: {
item: TItem;
selectedIds: Set<number>;
onToggle: (item: TItem, checked: boolean) => void;
renderMeta?: (item: TItem) => ReactNode;
showDescription?: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(true);
const itemIds = collectTreeItemIds(item);
const selectedCount = itemIds.filter((id) => selectedIds.has(id)).length;
const checked = selectedCount === itemIds.length;
const indeterminate = selectedCount > 0 && !checked;
const hasChildren = item.children.length > 0;
return (
<li className="space-y-2">
<div className="rounded-md border border-border/60 bg-background px-3 py-2">
<div className="flex items-center gap-2">
{hasChildren ? (
<button
type="button"
onClick={() => setIsExpanded((current) => !current)}
aria-label={
isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`
}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<ChevronDown
className={`size-4 transition-transform ${isExpanded ? '' : '-rotate-90'}`}
/>
</button>
) : (
<span className="size-5 shrink-0" aria-hidden="true" />
)}
<TreeCheckbox
checked={checked}
indeterminate={indeterminate}
onChange={(isChecked) => onToggle(item, isChecked)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-sm font-medium leading-none">
{item.label}
</span>
{renderMeta?.(item)}
</div>
</div>
{showDescription && item.description ? (
<p className="mt-1.5 pl-11 text-xs text-muted-foreground">
{item.description}
</p>
) : null}
</div>
{hasChildren && isExpanded ? (
<ul className="ml-5 space-y-2 border-l border-border/70 pl-3">
{item.children.map((child) => (
<CheckboxTreeNode
key={child.id}
item={child as TItem}
selectedIds={selectedIds}
onToggle={onToggle}
renderMeta={renderMeta}
showDescription={showDescription}
/>
))}
</ul>
) : null}
</li>
);
}
export function CheckboxTree<TItem extends TreeItem>({
items,
selectedIds,
onSelectedIdsChange,
renderMeta,
emptyMessage = 'No items available.',
showDescription = true,
}: {
items: TItem[];
selectedIds: number[];
onSelectedIdsChange: (ids: number[]) => void;
renderMeta?: (item: TItem) => ReactNode;
emptyMessage?: string;
showDescription?: boolean;
}) {
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const handleToggle = (item: TItem, checked: boolean) => {
const next = new Set(selectedSet);
collectTreeItemIds(item).forEach((id) => {
if (checked) {
next.add(id);
} else {
next.delete(id);
}
});
onSelectedIdsChange(Array.from(next));
};
if (items.length === 0) {
return (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
);
}
return (
<ul className="space-y-2">
{items.map((item) => (
<CheckboxTreeNode
key={item.id}
item={item}
selectedIds={selectedSet}
onToggle={handleToggle}
renderMeta={renderMeta}
showDescription={showDescription}
/>
))}
</ul>
);
}