Skip to content

Commit 7f73048

Browse files
committed
Remove the knowledge of URLSearchParams from checkbox-tree
1 parent e1f21f4 commit 7f73048

File tree

3 files changed

+319
-106
lines changed

3 files changed

+319
-106
lines changed

src/components/checkbox-tree/checkbox-tree.tsx

Lines changed: 107 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,138 @@
11
import type { ReactNode } from "react"
2-
import { useMemo } from "react"
2+
import { useCallback } from "react"
3+
import { clsx } from "clsx"
34
import { CheckboxIcon } from "@/app/conf/_design-system/pixelarticons/checkbox-icon"
45

56
export interface CheckboxTreeItem {
67
id: string
78
label: string
89
value?: string
910
count?: number
11+
disabled?: boolean
1012
children?: CheckboxTreeItem[]
1113
}
1214

1315
interface CheckboxTreeProps {
1416
items: CheckboxTreeItem[]
1517
selectedValues: string[]
1618
onSelectionChange: (next: string[]) => void
17-
searchQuery?: string
1819
emptyFallback?: ReactNode
1920
}
2021

21-
type PreparedItem = CheckboxTreeItem & { depth: number }
22-
23-
type PreparedTree = PreparedItem & {
24-
children?: PreparedTree[]
25-
matchesSearch: boolean
26-
hasVisibleChildren: boolean
27-
}
28-
2922
export function CheckboxTree({
3023
items,
3124
selectedValues,
3225
onSelectionChange,
33-
searchQuery,
3426
emptyFallback,
3527
}: CheckboxTreeProps) {
36-
const normalizedSearch = searchQuery?.trim().toLowerCase() ?? ""
37-
38-
const preparedItems = useMemo(() => {
39-
function enhance(
40-
itemsToEnhance: CheckboxTreeItem[],
41-
depth: number,
42-
): PreparedTree[] {
43-
return itemsToEnhance.map(item => {
44-
const prepared: PreparedTree = {
45-
...item,
46-
depth,
47-
matchesSearch: normalizedSearch
48-
? item.label.toLowerCase().includes(normalizedSearch)
49-
: true,
50-
hasVisibleChildren: false,
51-
}
52-
53-
if (item.children && item.children.length > 0) {
54-
prepared.children = enhance(item.children, depth + 1)
55-
}
56-
57-
return prepared
58-
})
59-
}
60-
61-
return enhance(items, 0)
62-
}, [items, normalizedSearch])
63-
64-
const filteredTree = useMemo(() => {
65-
function markVisibility(node: PreparedTree): PreparedTree | null {
66-
const { children } = node
67-
const visibleChildren = children
68-
?.map(child => markVisibility(child))
69-
.filter((child): child is PreparedTree => Boolean(child))
70-
71-
const hasVisibleChildren = Boolean(visibleChildren?.length)
72-
73-
const shouldKeepNode =
74-
node.matchesSearch || !normalizedSearch || hasVisibleChildren
28+
const toggleValue = useCallback(
29+
(value: string) => {
30+
const next = selectedValues.includes(value)
31+
? selectedValues.filter(tag => tag !== value)
32+
: [...selectedValues, value]
33+
onSelectionChange(next)
34+
},
35+
[selectedValues, onSelectionChange],
36+
)
37+
38+
const renderTree = (nodes: CheckboxTreeItem[], depth: number): ReactNode => {
39+
return nodes.map(node => {
40+
const isSelectable = Boolean(node.value)
41+
const isDisabled = node.disabled
42+
const isChecked = isSelectable
43+
? selectedValues.includes(node.value!)
44+
: false
45+
const checkboxId = `checkbox-tree-${node.id}`
7546

76-
if (!shouldKeepNode) return null
47+
return (
48+
<div key={node.id}>
49+
<div
50+
className="flex items-start gap-2 py-1"
51+
style={{
52+
paddingInlineStart: depth > 0 ? (depth - 1) * 16 : 0,
53+
}}
54+
>
55+
{isSelectable ? (
56+
<label
57+
htmlFor={checkboxId}
58+
className={clsx(
59+
"flex grow items-center gap-2",
60+
isDisabled
61+
? "cursor-not-allowed text-neu-500"
62+
: "cursor-pointer",
63+
)}
64+
aria-disabled={isDisabled}
65+
>
66+
<span className="flex shrink-0 items-center">
67+
<input
68+
id={checkboxId}
69+
type="checkbox"
70+
checked={isChecked}
71+
onChange={() => {
72+
if (!isDisabled) toggleValue(node.value!)
73+
}}
74+
disabled={isDisabled}
75+
className="peer sr-only"
76+
/>
77+
<CheckboxIcon
78+
checked={isChecked}
79+
className={clsx(
80+
"pointer-events-none size-5 transition-colors peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-neu-900",
81+
isDisabled ? "text-neu-300" : undefined,
82+
)}
83+
aria-hidden
84+
/>
85+
</span>
86+
<span
87+
className={clsx(
88+
"min-w-0 grow truncate text-left",
89+
isDisabled ? "text-neu-500" : "text-neu-800",
90+
)}
91+
>
92+
{node.label}
93+
</span>
94+
{node.count ? ( // we intentionally don't display 0
95+
<span
96+
className={clsx(
97+
"ml-auto shrink-0 text-xs",
98+
isDisabled ? "text-neu-400" : "text-neu-700",
99+
)}
100+
>
101+
{node.count}
102+
</span>
103+
) : null}
104+
</label>
105+
) : (
106+
<div
107+
className={clsx(
108+
"typography-menu mt-4 text-sm xl:mt-10",
109+
isDisabled ? "text-neu-500" : "text-neu-900",
110+
)}
111+
aria-disabled={isDisabled}
112+
>
113+
{node.label}
114+
</div>
115+
)}
116+
</div>
77117

78-
return {
79-
...node,
80-
children: visibleChildren,
81-
hasVisibleChildren,
82-
}
83-
}
118+
{node.children && node.children.length > 0 ? (
119+
<div>{renderTree(node.children, depth + 1)}</div>
120+
) : null}
121+
</div>
122+
)
123+
})
124+
}
84125

85-
return preparedItems
86-
.map(node => markVisibility(node))
87-
.filter((node): node is PreparedTree => Boolean(node))
88-
}, [preparedItems, normalizedSearch])
126+
if (items.length === 0) {
127+
return (
128+
<div className="py-4 text-sm text-neu-500">
129+
{emptyFallback ?? "No matches"}
130+
</div>
131+
)
132+
}
89133

90-
const toggleValue = (value: string) => {
134+
return <div>{renderTree(items, 0)}</div>
135+
}
91136
if (selectedValues.includes(value)) {
92137
onSelectionChange(selectedValues.filter(tag => tag !== value))
93138
} else {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { useCallback, useMemo, useSyncExternalStore } from "react"
2+
3+
type SetSearchParamsAction =
4+
| string
5+
| URLSearchParams
6+
| ((prev: URLSearchParams) => URLSearchParams | void)
7+
8+
interface SetSearchParamsOptions {
9+
replace?: boolean
10+
}
11+
12+
const listeners = new Set<() => void>()
13+
let restoreHistory: (() => void) | null = null
14+
15+
const notifyListeners = () => {
16+
listeners.forEach(listener => listener())
17+
}
18+
19+
const subscribe = (listener: () => void) => {
20+
if (typeof window === "undefined") {
21+
return () => {}
22+
}
23+
24+
if (listeners.size === 0) {
25+
window.addEventListener("popstate", notifyListeners)
26+
patchHistory()
27+
}
28+
29+
listeners.add(listener)
30+
31+
return () => {
32+
listeners.delete(listener)
33+
34+
if (listeners.size === 0) {
35+
window.removeEventListener("popstate", notifyListeners)
36+
restorePatchedHistory()
37+
}
38+
}
39+
}
40+
41+
const patchHistory = () => {
42+
if (restoreHistory || typeof window === "undefined") return
43+
44+
const { history } = window
45+
const originalPushState = history.pushState
46+
const originalReplaceState = history.replaceState
47+
48+
const patchedPushState: History["pushState"] = (...args) => {
49+
const result = originalPushState.apply(history, args)
50+
notifyListeners()
51+
return result
52+
}
53+
54+
const patchedReplaceState: History["replaceState"] = (...args) => {
55+
const result = originalReplaceState.apply(history, args)
56+
notifyListeners()
57+
return result
58+
}
59+
60+
history.pushState = patchedPushState
61+
history.replaceState = patchedReplaceState
62+
63+
restoreHistory = () => {
64+
history.pushState = originalPushState
65+
history.replaceState = originalReplaceState
66+
restoreHistory = null
67+
}
68+
}
69+
70+
const restorePatchedHistory = () => {
71+
restoreHistory?.()
72+
}
73+
74+
const getClientSnapshot = () =>
75+
typeof window === "undefined" ? "" : window.location.search
76+
77+
const getServerSnapshot = () => ""
78+
79+
const formatSearchFromAction = (
80+
action: SetSearchParamsAction,
81+
current: URLSearchParams,
82+
) => {
83+
if (typeof action === "function") {
84+
const draft = new URLSearchParams(current)
85+
const result = action(draft)
86+
return result ?? draft
87+
}
88+
89+
if (typeof action === "string") {
90+
const normalized = action.startsWith("?") ? action.slice(1) : action
91+
return new URLSearchParams(normalized)
92+
}
93+
94+
return new URLSearchParams(action)
95+
}
96+
97+
/**
98+
* Next.js Pages Router doesn't have `useSearchParams`, and we need one for Tools and Libraries checkbox tree.
99+
*/
100+
export function useSearchParamsState() {
101+
const search = useSyncExternalStore(
102+
subscribe,
103+
getClientSnapshot,
104+
getServerSnapshot,
105+
)
106+
107+
const searchParams = useMemo(() => {
108+
const normalized = search.startsWith("?") ? search.slice(1) : search
109+
return new URLSearchParams(normalized)
110+
}, [search])
111+
112+
const setSearchParams = useCallback(
113+
(action: SetSearchParamsAction, options?: SetSearchParamsOptions) => {
114+
if (typeof window === "undefined") return
115+
116+
const current = new URLSearchParams(window.location.search)
117+
const nextParams = formatSearchFromAction(action, current)
118+
const nextQuery = nextParams.toString()
119+
const nextSearch = nextQuery ? `?${nextQuery}` : ""
120+
121+
if (nextSearch === window.location.search) return
122+
123+
const url = new URL(window.location.href)
124+
125+
url.search = nextQuery
126+
127+
const method = options?.replace ? "replaceState" : "pushState"
128+
129+
window.history[method](
130+
window.history.state,
131+
"",
132+
`${url.pathname}${url.search}${url.hash}`,
133+
)
134+
},
135+
[],
136+
)
137+
138+
return [searchParams, setSearchParams] as const
139+
}

0 commit comments

Comments
 (0)