withQueryParams.tsx131 lines · main
1'use client'
2
3import { useSearchParamsShallow } from 'common'
4import { xor } from 'lodash'
5import { Children, isValidElement, useEffect, useRef, type FC, type PropsWithChildren } from 'react'
6import { type TabsProps } from 'ui'
7
8const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
9
10const LOCAL_STORAGE_KEY = 'briven.ui-patterns.ComplexTabs.withQueryParams.v0'
11
12interface QueryParamsProps {
13 queryGroup?: string
14}
15
16/**
17 * Wraps the basic `Tabs` component from the `ui` library so it stores
18 * selection state in query params.
19 */
20const withQueryParams =
21 <Props extends PropsWithChildren<TabsProps>>(
22 Component: FC<Omit<Props, 'children' | 'queryGroup' | 'onClick'>>
23 ) =>
24 ({
25 children: childrenUnvalidated,
26 queryGroup: queryGroupTemp,
27 onClick,
28 ...props
29 }: Props & QueryParamsProps) => {
30 // Avoid Children.toArray — it clones elements (accessing element.ref) which
31 // triggers a React 19 warning. Children.forEach iterates without cloning.
32 const tabIdsTemp: string[] = []
33 Children.forEach(childrenUnvalidated, (child) => {
34 if (isValidElement(child) && isString((child.props as any).id)) {
35 tabIdsTemp.push((child.props as any).id)
36 }
37 })
38 // Store in ref to avoid stale data in later timeout
39 const tabIdsRef = useRef(tabIdsTemp)
40 tabIdsRef.current = tabIdsTemp
41
42 // Store in ref to avoid stale data in later timeout
43 const queryGroupRef = useRef(queryGroupTemp)
44 queryGroupRef.current = queryGroupTemp
45
46 const searchParams = useSearchParamsShallow()
47 const queryTabMaybe = queryGroupRef.current && searchParams.get(queryGroupRef.current)
48 const queryTab =
49 queryTabMaybe && tabIdsRef.current.includes(queryTabMaybe) ? queryTabMaybe : undefined
50
51 const checkedLocalStorage = useRef(false)
52 useEffect(() => {
53 if (!checkedLocalStorage.current) {
54 // Timeout to avoid something (I think the router) overwriting it
55 setTimeout(() => {
56 if (
57 queryGroupRef.current &&
58 !new URLSearchParams(window.location.search).has(queryGroupRef.current)
59 ) {
60 try {
61 const storedValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
62 if (storedValues === null || typeof storedValues !== 'object') return
63
64 let storedValue: any = null
65 let maxDiff = tabIdsRef.current.length
66 Object.entries(storedValues).forEach(([key, value]) => {
67 const arr = key.split(',')
68 const diff = xor(arr, tabIdsRef.current)
69 if (diff.length < maxDiff) {
70 maxDiff = diff.length
71 storedValue = value
72 }
73 })
74
75 if (storedValue && tabIdsRef.current.includes(storedValue)) {
76 switchTab(storedValue)
77 }
78 } catch {
79 // ignore
80 }
81 }
82 }, 300)
83
84 checkedLocalStorage.current = true
85 }
86
87 if (queryGroupRef.current && queryTab) {
88 let updatedValues: Record<string, unknown> = {}
89 try {
90 const oldValues = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '')
91 if (oldValues && typeof oldValues === 'object') {
92 updatedValues = oldValues
93 }
94 } catch {
95 // ignore
96 }
97
98 updatedValues[tabIdsRef.current.sort().join(',')] = queryTab
99
100 try {
101 localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updatedValues))
102 } catch {
103 // ignore
104 }
105 }
106 }, [queryTab])
107
108 const switchTab = (id: string) => {
109 if (queryGroupRef.current) {
110 if (!searchParams.getAll('queryGroups').includes(queryGroupRef.current)) {
111 searchParams.append('queryGroups', queryGroupRef.current)
112 }
113 searchParams.set(queryGroupRef.current, id)
114 }
115 }
116
117 const onTabClick = (id: string) => {
118 switchTab(id)
119 onClick?.(id)
120 }
121
122 return (
123 <Component {...props} activeId={queryTab} onClick={onTabClick}>
124 {/* Tabs does its own validation */}
125 {childrenUnvalidated}
126 </Component>
127 )
128 }
129
130export { withQueryParams }
131export type { QueryParamsProps }