withQueryParams.tsx131 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { useSearchParamsShallow } from 'common' |
| 4 | import { xor } from 'lodash' |
| 5 | import { Children, isValidElement, useEffect, useRef, type FC, type PropsWithChildren } from 'react' |
| 6 | import { type TabsProps } from 'ui' |
| 7 | |
| 8 | const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string' |
| 9 | |
| 10 | const LOCAL_STORAGE_KEY = 'briven.ui-patterns.ComplexTabs.withQueryParams.v0' |
| 11 | |
| 12 | interface 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 | */ |
| 20 | const 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 | |
| 130 | export { withQueryParams } |
| 131 | export type { QueryParamsProps } |