179 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|