Skip to content

Commit fbb1d08

Browse files
committed
feat: states to persist for accordion items
1 parent c49fa4b commit fbb1d08

File tree

4 files changed

+135
-3
lines changed

4 files changed

+135
-3
lines changed

apps/dashboard/components/layout/navigation/navigation-section.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { CaretDownIcon } from '@phosphor-icons/react';
22
import { AnimatePresence, MotionConfig, motion } from 'framer-motion';
33
import { useSearchParams } from 'next/navigation';
4-
import { memo, useState } from 'react';
4+
import { memo } from 'react';
5+
import { useAccordionStates } from '@/hooks/use-persistent-state';
56
import { NavigationItem } from './navigation-item';
67
import type { NavigationSection as NavigationSectionType } from './types';
78

@@ -11,6 +12,7 @@ interface NavigationSectionProps {
1112
items: NavigationSectionType['items'];
1213
pathname: string;
1314
currentWebsiteId?: string | null;
15+
accordionStates: ReturnType<typeof useAccordionStates>;
1416
}
1517

1618
const buildCurrentUrl = (
@@ -90,8 +92,10 @@ export const NavigationSection = memo(function NavigationSectionComponent({
9092
items,
9193
pathname,
9294
currentWebsiteId,
95+
accordionStates,
9396
}: NavigationSectionProps) {
94-
const [isExpanded, setIsExpanded] = useState(true);
97+
const { getAccordionState, toggleAccordion } = accordionStates;
98+
const isExpanded = getAccordionState(title, true); // Default to expanded
9599
const searchParams = useSearchParams();
96100

97101
const visibleItems = items.filter((item) => {
@@ -109,7 +113,7 @@ export const NavigationSection = memo(function NavigationSectionComponent({
109113
<div className="border-muted-foreground/20 border-b border-dotted last:border-b-0">
110114
<button
111115
className="flex w-full items-center gap-3 px-3 py-2.5 text-left font-medium text-foreground text-sm transition-colors hover:bg-muted/50 focus:outline-none"
112-
onClick={() => setIsExpanded(!isExpanded)}
116+
onClick={() => toggleAccordion(title, true)}
113117
type="button"
114118
>
115119
<Icon className="size-5 flex-shrink-0 text-foreground" weight="fill" />

apps/dashboard/components/layout/sidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
66
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
77
import { Button } from '@/components/ui/button';
88
import { ScrollArea } from '@/components/ui/scroll-area';
9+
import { useAccordionStates } from '@/hooks/use-persistent-state';
910
import { useWebsites } from '@/hooks/use-websites';
1011
import { cn } from '@/lib/utils';
1112
import { CategorySidebar } from './category-sidebar';
@@ -30,6 +31,7 @@ export function Sidebar() {
3031
const [isMobileOpen, setIsMobileOpen] = useState(false);
3132
const [selectedCategory, setSelectedCategory] = useState<string>();
3233
const { websites, isLoading: isLoadingWebsites } = useWebsites();
34+
const accordionStates = useAccordionStates();
3335
const sidebarRef = useRef<HTMLDivElement>(null);
3436
const previousFocusRef = useRef<HTMLElement | null>(null);
3537

@@ -228,6 +230,7 @@ export function Sidebar() {
228230
<nav aria-label="Main navigation" className="flex flex-col">
229231
{navigation.map((section) => (
230232
<NavigationSection
233+
accordionStates={accordionStates}
231234
currentWebsiteId={currentWebsiteId}
232235
icon={section.icon}
233236
items={section.items}

apps/dashboard/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './use-domain-info';
22
export * from './use-goals';
33
export * from './use-media-query';
44
export * from './use-organizations';
5+
export * from './use-persistent-state';
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
3+
/**
4+
* Hook to detect if we're running on the client side after hydration
5+
*/
6+
function useIsClient() {
7+
const [isClient, setIsClient] = useState(false);
8+
9+
useEffect(() => {
10+
setIsClient(true);
11+
}, []);
12+
13+
return isClient;
14+
}
15+
16+
/**
17+
* Custom hook for persisting state to localStorage with SSR compatibility.
18+
* Prevents hydration mismatches by using default values during SSR.
19+
*/
20+
export function usePersistentState<T>(
21+
key: string,
22+
defaultValue: T
23+
): [T, (value: T | ((prev: T) => T)) => void] {
24+
const isClient = useIsClient();
25+
26+
// Initialize state with localStorage value only on client, default on server
27+
const [state, setState] = useState<T>(() => {
28+
if (!isClient) {
29+
return defaultValue;
30+
}
31+
32+
try {
33+
const item = window.localStorage.getItem(key);
34+
return item ? JSON.parse(item) : defaultValue;
35+
} catch (error) {
36+
console.error(`Error reading localStorage key "${key}":`, error);
37+
return defaultValue;
38+
}
39+
});
40+
41+
// Sync with localStorage when client-side and key changes
42+
useEffect(() => {
43+
if (!isClient) return;
44+
45+
try {
46+
const item = window.localStorage.getItem(key);
47+
if (item) {
48+
const parsedValue = JSON.parse(item);
49+
setState(parsedValue);
50+
}
51+
} catch (error) {
52+
console.error(`Error reading localStorage key "${key}":`, error);
53+
}
54+
}, [key, isClient]);
55+
56+
const setPersistentState = useCallback(
57+
(value: T | ((prev: T) => T)) => {
58+
try {
59+
setState((prevState) => {
60+
// Allow function updates
61+
const valueToStore = value instanceof Function ? value(prevState) : value;
62+
63+
// Only persist to localStorage on client side
64+
if (isClient && typeof window !== 'undefined') {
65+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
66+
}
67+
68+
return valueToStore;
69+
});
70+
} catch (error) {
71+
console.error(`Error setting localStorage key "${key}":`, error);
72+
}
73+
},
74+
[key, isClient]
75+
);
76+
77+
return [state, setPersistentState];
78+
}
79+
80+
/**
81+
* Specialized hook for accordion states in the sidebar navigation.
82+
* Manages multiple accordion sections with their expanded/collapsed states.
83+
*/
84+
export function useAccordionStates(storageKey = 'sidebar-accordion-states') {
85+
const [accordionStates, setAccordionStates] = usePersistentState<
86+
Record<string, boolean>
87+
>(storageKey, {});
88+
89+
const toggleAccordion = useCallback(
90+
(sectionTitle: string, defaultState = true) => {
91+
setAccordionStates((prev) => {
92+
const currentState = prev[sectionTitle] ?? defaultState;
93+
return {
94+
...prev,
95+
[sectionTitle]: !currentState,
96+
};
97+
});
98+
},
99+
[setAccordionStates]
100+
);
101+
102+
const getAccordionState = useCallback(
103+
(sectionTitle: string, defaultState = true) => {
104+
return accordionStates[sectionTitle] ?? defaultState;
105+
},
106+
[accordionStates]
107+
);
108+
109+
const setAccordionState = useCallback(
110+
(sectionTitle: string, isExpanded: boolean) => {
111+
setAccordionStates((prev) => ({
112+
...prev,
113+
[sectionTitle]: isExpanded,
114+
}));
115+
},
116+
[setAccordionStates]
117+
);
118+
119+
return {
120+
toggleAccordion,
121+
getAccordionState,
122+
setAccordionState,
123+
};
124+
}

0 commit comments

Comments
 (0)