Files
smart-go/web/components/layout/IconSidebarLayout.tsx
T
2026-04-23 18:58:13 +08:00

181 lines
6.2 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { MenuNode } from '@/lib/api/types/menu';
import { NavTooltip } from '@/components/layout/nav-tooltip';
import {
NavIconOrLabel,
NavStateGuard,
NavTreeItem,
normalizeHref,
layoutNavRootsFromApi,
subtreeContainsActivePath,
visibleChildren,
} from '@/components/layout/nav-shared';
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
export function IconSidebarLayout({
items,
loading,
error,
authed,
onCloseMobile,
}: {
items: MenuNode[];
loading: boolean;
error: string | null;
authed: boolean;
onMenuNavigate?: (path: string, title: string) => void;
onCloseMobile?: () => void;
}) {
const pathname = usePathname() ?? '/';
const roots = useMemo(() => layoutNavRootsFromApi(items), [items]);
const onMenuNavigate = useMenuNavigation();
const findFlyoutRootForPath = useCallback(
(path: string): MenuNode | null => {
for (const r of roots) {
const kids = visibleChildren(r);
if (kids.length === 0) continue;
if (subtreeContainsActivePath(r, path)) return r;
}
return null;
},
[roots],
);
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
useEffect(() => {
const next = findFlyoutRootForPath(pathname);
setFlyoutRoot((prev) => {
if (next) return next;
if (prev && subtreeContainsActivePath(prev, pathname)) return prev;
return null;
});
}, [pathname, findFlyoutRootForPath]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFlyoutRoot(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const toggleFlyout = (root: MenuNode) => {
setFlyoutRoot((r) => (r?.id === root.id ? null : root));
};
const railShell = (inner: React.ReactNode) => (
<div className="flex h-full min-h-0 w-[72px] shrink-0 flex-col border-r border-neutral-100 bg-white">
{inner}
</div>
);
if (!authed || loading || error) {
return railShell(
<NavStateGuard authed={authed} loading={loading} error={error} onMenuNavigate={onMenuNavigate} />,
);
}
return (
<div className="flex h-full min-h-0">
<div className="flex w-[72px] shrink-0 flex-col border-r border-neutral-100 bg-white">
<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
<nav className="flex flex-col items-stretch gap-0.5" aria-label="主导航">
{roots.map((node) => {
const kids = visibleChildren(node);
if (kids.length > 0) {
const open = flyoutRoot?.id === node.id;
return (
<NavTooltip key={node.id} label={node.menu_name}>
<button
type="button"
aria-expanded={open}
className={[
'flex h-10 w-full items-center justify-center rounded-md text-sm',
open
? 'bg-neutral-200 text-neutral-900'
: 'text-neutral-700 hover:bg-neutral-100',
].join(' ')}
onClick={() => toggleFlyout(node)}
>
<span className="truncate px-1 text-center leading-tight">
<NavIconOrLabel node={node} />
</span>
</button>
</NavTooltip>
);
}
const href = normalizeHref(node);
const external = Boolean(node.external_link);
if (href === '#') {
return (
<NavTooltip key={node.id} label={node.menu_name}>
<div className="flex h-10 items-center justify-center rounded-md px-1 text-center text-[11px] text-neutral-400">
<NavIconOrLabel node={node} className="text-base" />
</div>
</NavTooltip>
);
}
if (external) {
return (
<NavTooltip key={node.id} label={node.menu_name}>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
>
<NavIconOrLabel node={node} />
</a>
</NavTooltip>
);
}
return (
<NavTooltip key={node.id} label={node.menu_name}>
<Link
href={href}
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
onClick={() => {
onMenuNavigate(href, node.menu_name);
onCloseMobile?.();
setFlyoutRoot(null);
}}
>
<NavIconOrLabel node={node} />
</Link>
</NavTooltip>
);
})}
</nav>
</div>
</div>
{flyoutRoot && visibleChildren(flyoutRoot).length > 0 ? (
<div className="flex w-56 shrink-0 flex-col border-r border-neutral-100 bg-white shadow-sm">
<div className="sticky top-0 z-10 flex h-10 shrink-0 items-center border-b border-neutral-100 bg-white px-3">
<div className="truncate text-sm font-semibold text-neutral-900">{flyoutRoot.menu_name}</div>
</div>
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 py-2">
{visibleChildren(flyoutRoot).map((c) => (
<NavTreeItem
key={c.id}
node={c}
depth={0}
pathname={pathname}
iconFlyout
onMenuNavigate={onMenuNavigate}
/>
))}
</div>
</div>
) : null}
</div>
);
}