181 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|