fix: side bar height
This commit is contained in:
+154
-78
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
|
||||
export type SidebarPanel = {
|
||||
id: string;
|
||||
@@ -9,104 +10,179 @@ export type SidebarPanel = {
|
||||
component: React.ReactNode;
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
panels: SidebarPanel[];
|
||||
};
|
||||
|
||||
function useIsWide(): boolean {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
window.addEventListener('resize', cb);
|
||||
return () => window.removeEventListener('resize', cb);
|
||||
},
|
||||
() => window.innerWidth >= 768, // client snapshot
|
||||
() => false, // server snapshot — must match initial SSR HTML
|
||||
() => window.innerWidth >= 768,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
const CLOSED = '__closed__';
|
||||
|
||||
export default function Sidebar({ panels }: SidebarProps) {
|
||||
const isWide = useIsWide();
|
||||
|
||||
/* -----------------------------
|
||||
Shared sidebar logic
|
||||
------------------------------*/
|
||||
function useSidebarState() {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const effectiveId =
|
||||
activeId === CLOSED ? null
|
||||
: activeId ?? (isWide ? (panels[0]?.id ?? null) : null);
|
||||
const setClosed = () => setActiveId(null);
|
||||
|
||||
const toggle = (id: string, effectiveId: string | null) => {
|
||||
setActiveId((prev) =>
|
||||
prev === id || (effectiveId === id && prev === null) ? null : id,
|
||||
);
|
||||
};
|
||||
|
||||
const resolveEffectiveId = (isWide: boolean, panels: SidebarPanel[]) => {
|
||||
return activeId === null
|
||||
? null
|
||||
: (activeId ?? (isWide ? (panels[0]?.id ?? null) : null));
|
||||
};
|
||||
|
||||
return {
|
||||
activeId,
|
||||
setActiveId,
|
||||
setClosed,
|
||||
toggle,
|
||||
resolveEffectiveId,
|
||||
};
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Desktop UI
|
||||
------------------------------*/
|
||||
function DesktopSidebar({
|
||||
activePanel,
|
||||
isOpen,
|
||||
setClosed,
|
||||
}: {
|
||||
activePanel?: SidebarPanel;
|
||||
isOpen: boolean;
|
||||
setClosed: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex transition-all duration-300 ease-in-out overflow-hidden
|
||||
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
{activePanel && (
|
||||
<div className="w-64 flex flex-col border-r border-muted">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0">
|
||||
<span className="text-sm font-semibold text-accent">
|
||||
{activePanel.label}
|
||||
</span>
|
||||
<button onClick={setClosed}>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 min-h-0">
|
||||
{activePanel.component}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Mobile UI (drawer)
|
||||
------------------------------*/
|
||||
function MobileSidebar({
|
||||
activePanel,
|
||||
setClosed,
|
||||
}: {
|
||||
activePanel?: SidebarPanel;
|
||||
setClosed: () => void;
|
||||
}) {
|
||||
if (!activePanel) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/40 z-20" onClick={setClosed} />
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed left-14 top-[var(--header-height)] bottom-0 z-30 bg-primary overflow-y-auto border border-muted w-64">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
|
||||
<span className="text-sm font-semibold text-accent">
|
||||
{activePanel.label}
|
||||
</span>
|
||||
|
||||
<button onClick={setClosed}>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3">{activePanel.component}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Icon rail (shared)
|
||||
------------------------------*/
|
||||
function IconRail({
|
||||
panels,
|
||||
effectiveId,
|
||||
toggle,
|
||||
}: {
|
||||
panels: SidebarPanel[];
|
||||
effectiveId: string | null;
|
||||
toggle: (id: string, effectiveId: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-12 flex flex-col items-center py-3 gap-1 z-30">
|
||||
{panels.map((panel) => (
|
||||
<button
|
||||
key={panel.id}
|
||||
onClick={() => toggle(panel.id, effectiveId)}
|
||||
title={panel.label}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-theme ${
|
||||
effectiveId === panel.id ? 'bg-accent2' : ''
|
||||
}`}
|
||||
>
|
||||
{panel.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -----------------------------
|
||||
Main Sidebar (controller)
|
||||
------------------------------*/
|
||||
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
|
||||
const isWide = useIsWide();
|
||||
|
||||
const { setClosed, toggle, resolveEffectiveId } = useSidebarState();
|
||||
|
||||
const effectiveId = resolveEffectiveId(isWide, panels);
|
||||
const activePanel = panels.find((p) => p.id === effectiveId);
|
||||
const isOpen = !!activePanel;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setActiveId((prev) => (prev === id || (effectiveId === id && prev === null) ? CLOSED : id));
|
||||
|
||||
return (
|
||||
<div className="flex border-r border-t border-muted">
|
||||
{/* Expanded panel */}
|
||||
<div
|
||||
className={`
|
||||
${isWide
|
||||
? `flex transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'
|
||||
}`
|
||||
: isOpen
|
||||
? 'fixed left-14 top-(--var(header-height)) z-30 bg-primary'
|
||||
: 'hidden'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{activePanel && (
|
||||
<div
|
||||
className={`w-64 flex flex-col border-muted ${
|
||||
isWide ? 'border-r' : 'border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
|
||||
<span className="text-sm font-semibold tracking-wide text-accent">
|
||||
{activePanel.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveId(CLOSED)}
|
||||
className="text-primary hover:text-white transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{activePanel.component}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Back drop */}
|
||||
{isOpen && !isWide && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-20"
|
||||
onClick={() => setActiveId(CLOSED)}
|
||||
{/* Desktop */}
|
||||
{isWide && (
|
||||
<DesktopSidebar
|
||||
activePanel={activePanel}
|
||||
isOpen={isOpen}
|
||||
setClosed={setClosed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
{!isWide && (
|
||||
<MobileSidebar activePanel={activePanel} setClosed={setClosed} />
|
||||
)}
|
||||
|
||||
{/* Icon rail */}
|
||||
<div className="w-12 flex flex-col items-center py-3 gap-1 z-30">
|
||||
{panels.map((panel) => (
|
||||
<button
|
||||
key={panel.id}
|
||||
onClick={() => toggle(panel.id)}
|
||||
title={panel.label}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center text-primary transition-theme ${
|
||||
effectiveId === panel.id
|
||||
? 'bg-accent2'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{panel.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<IconRail panels={panels} effectiveId={effectiveId} toggle={toggle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user