Skip to content

Commit 26789c8

Browse files
committed
wip
1 parent c8f8cc5 commit 26789c8

File tree

3 files changed

+463
-166
lines changed

3 files changed

+463
-166
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import type { ReactNode } from "react"
2+
import { useEffect, useMemo, useState } from "react"
3+
import { clsx } from "clsx"
4+
5+
import { ChevronLeftIcon } from "@/icons"
6+
7+
export interface CheckboxTreeItem {
8+
id: string
9+
label: string
10+
value?: string
11+
count?: number
12+
description?: string
13+
children?: CheckboxTreeItem[]
14+
}
15+
16+
interface CheckboxTreeProps {
17+
items: CheckboxTreeItem[]
18+
selectedValues: string[]
19+
onSelectionChange: (next: string[]) => void
20+
searchQuery?: string
21+
emptyFallback?: ReactNode
22+
}
23+
24+
type PreparedItem = CheckboxTreeItem & { depth: number }
25+
26+
type PreparedTree = PreparedItem & {
27+
children?: PreparedTree[]
28+
matchesSearch: boolean
29+
hasVisibleChildren: boolean
30+
}
31+
32+
export function CheckboxTree({
33+
items,
34+
selectedValues,
35+
onSelectionChange,
36+
searchQuery,
37+
emptyFallback,
38+
}: CheckboxTreeProps) {
39+
const normalizedSearch = searchQuery?.trim().toLowerCase() ?? ""
40+
41+
const { allParentIds, defaultExpanded, preparedItems } = useMemo(() => {
42+
const parentIds = new Set<string>()
43+
const defaultOpen = new Set<string>()
44+
45+
function enhance(
46+
itemsToEnhance: CheckboxTreeItem[],
47+
depth: number,
48+
): PreparedTree[] {
49+
return itemsToEnhance.map(item => {
50+
const prepared: PreparedTree = {
51+
...item,
52+
depth,
53+
matchesSearch: normalizedSearch
54+
? item.label.toLowerCase().includes(normalizedSearch)
55+
: true,
56+
hasVisibleChildren: false,
57+
}
58+
59+
if (item.children && item.children.length > 0) {
60+
parentIds.add(item.id)
61+
if (depth === 0) {
62+
defaultOpen.add(item.id)
63+
}
64+
prepared.children = enhance(item.children, depth + 1)
65+
}
66+
67+
return prepared
68+
})
69+
}
70+
71+
return {
72+
allParentIds: parentIds,
73+
defaultExpanded: defaultOpen,
74+
preparedItems: enhance(items, 0),
75+
}
76+
}, [items, normalizedSearch])
77+
78+
const [expandedItems, setExpandedItems] = useState(
79+
() => new Set(defaultExpanded),
80+
)
81+
82+
useEffect(() => {
83+
if (!normalizedSearch) {
84+
setExpandedItems(new Set(defaultExpanded))
85+
return
86+
}
87+
setExpandedItems(new Set(allParentIds))
88+
}, [normalizedSearch, allParentIds, defaultExpanded])
89+
90+
const filteredTree = useMemo(() => {
91+
function markVisibility(node: PreparedTree): PreparedTree | null {
92+
const { children } = node
93+
const visibleChildren = children
94+
?.map(child => markVisibility(child))
95+
.filter((child): child is PreparedTree => Boolean(child))
96+
97+
const hasVisibleChildren = Boolean(visibleChildren?.length)
98+
99+
const shouldKeepNode =
100+
node.matchesSearch || !normalizedSearch || hasVisibleChildren
101+
102+
if (!shouldKeepNode) return null
103+
104+
return {
105+
...node,
106+
children: visibleChildren,
107+
hasVisibleChildren,
108+
}
109+
}
110+
111+
return preparedItems
112+
.map(node => markVisibility(node))
113+
.filter((node): node is PreparedTree => Boolean(node))
114+
}, [preparedItems, normalizedSearch])
115+
116+
const toggleValue = (value: string) => {
117+
if (selectedValues.includes(value)) {
118+
onSelectionChange(selectedValues.filter(tag => tag !== value))
119+
} else {
120+
onSelectionChange([...selectedValues, value])
121+
}
122+
}
123+
124+
const toggleExpand = (id: string) => {
125+
setExpandedItems(prev => {
126+
const next = new Set(prev)
127+
if (next.has(id)) {
128+
next.delete(id)
129+
} else {
130+
next.add(id)
131+
}
132+
return next
133+
})
134+
}
135+
136+
const renderTree = (nodes: PreparedTree[]): ReactNode => {
137+
return nodes.map(node => {
138+
const isExpanded = expandedItems.has(node.id)
139+
const isSelectable = Boolean(node.value)
140+
const checkboxId = `checkbox-tree-${node.id}`
141+
142+
return (
143+
<div key={node.id}>
144+
<div
145+
className="flex items-start gap-2 py-1"
146+
style={{ paddingInlineStart: node.depth * 16 }}
147+
>
148+
{node.children && node.children.length > 0 ? (
149+
<button
150+
type="button"
151+
aria-expanded={isExpanded}
152+
onClick={() => toggleExpand(node.id)}
153+
className="mt-0.5 flex size-5 items-center justify-center text-neu-500 transition-colors hover:text-pri-base"
154+
>
155+
<ChevronLeftIcon
156+
className={clsx(
157+
"size-4 transition-transform",
158+
isExpanded ? "-rotate-90" : "rotate-180",
159+
)}
160+
/>
161+
</button>
162+
) : (
163+
<span className="mt-0.5 block size-5" aria-hidden />
164+
)}
165+
166+
{isSelectable ? (
167+
<label
168+
htmlFor={checkboxId}
169+
className="flex grow cursor-pointer items-center gap-2 text-sm"
170+
>
171+
<input
172+
id={checkboxId}
173+
type="checkbox"
174+
checked={selectedValues.includes(node.value!)}
175+
onChange={() => toggleValue(node.value!)}
176+
className="size-4 border border-neu-400 bg-transparent [accent-color:hsl(var(--color-pri-base))]"
177+
/>
178+
<span className="min-w-0 grow truncate text-left">
179+
{node.label}
180+
</span>
181+
{typeof node.count === "number" && (
182+
<span className="ml-auto shrink-0 text-xs text-neu-500">
183+
{node.count}
184+
</span>
185+
)}
186+
</label>
187+
) : (
188+
<div className="flex grow flex-col text-sm">
189+
<span className="font-medium text-neu-600">{node.label}</span>
190+
{node.description ? (
191+
<span className="text-xs text-neu-500">
192+
{node.description}
193+
</span>
194+
) : null}
195+
</div>
196+
)}
197+
</div>
198+
199+
{node.children && node.children.length > 0 && isExpanded ? (
200+
<div>{renderTree(node.children)}</div>
201+
) : null}
202+
</div>
203+
)
204+
})
205+
}
206+
207+
if (filteredTree.length === 0) {
208+
return (
209+
<div className="py-4 text-sm text-neu-500">
210+
{emptyFallback ?? "No matches"}
211+
</div>
212+
)
213+
}
214+
215+
return <div>{renderTree(filteredTree)}</div>
216+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { CheckboxTree } from "./checkbox-tree"
2+
export type { CheckboxTreeItem } from "./checkbox-tree"

0 commit comments

Comments
 (0)