215 lines
6.3 KiB
TypeScript
215 lines
6.3 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { createPortal } from 'react-dom';
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import type { MenuNode } from '@/lib/api/types/menu';
|
||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||
import {
|
||
isLayoutOverviewNode,
|
||
normalizeHref,
|
||
isActivePath,
|
||
visibleChildren,
|
||
linkClass,
|
||
} from '@/components/layout/nav-shared';
|
||
|
||
function CascadeFolderPanel({
|
||
title,
|
||
nodes,
|
||
pathname,
|
||
onMenuNavigate,
|
||
depth = 0,
|
||
}: {
|
||
title: string;
|
||
nodes: MenuNode[];
|
||
pathname: string;
|
||
onMenuNavigate?: (path: string, title: string) => void;
|
||
depth?: number;
|
||
}) {
|
||
const [openId, setOpenId] = useState<string | null>(null);
|
||
|
||
const handleFolderKey = useCallback(
|
||
(e: React.KeyboardEvent, nodeId: string) => {
|
||
if (e.key === 'Enter' || e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
setOpenId(nodeId);
|
||
} else if (e.key === 'Escape') {
|
||
setOpenId(null);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
if (nodes.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="w-56 overflow-visible rounded-md border border-neutral-200 bg-white py-1 shadow-xl">
|
||
<div className="border-b border-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-900">{title}</div>
|
||
<div className="relative overflow-visible py-1">
|
||
{nodes.map((node) => {
|
||
if (node.menu_type === 3) {
|
||
return null;
|
||
}
|
||
const kids = visibleChildren(node);
|
||
const hasKids = kids.length > 0;
|
||
const pad = 8;
|
||
|
||
const label = (
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
{node.icon ? (
|
||
<span className="shrink-0 text-neutral-500" aria-hidden>
|
||
{node.icon}
|
||
</span>
|
||
) : null}
|
||
<span className="truncate">{node.menu_name}</span>
|
||
</span>
|
||
);
|
||
|
||
if (hasKids) {
|
||
return (
|
||
<div
|
||
key={node.id}
|
||
className="relative"
|
||
onMouseEnter={() => setOpenId(node.id)}
|
||
>
|
||
<NavTooltip label={node.menu_name} delayDuration={0}>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm text-neutral-800 hover:bg-neutral-100"
|
||
style={{ paddingLeft: pad }}
|
||
aria-expanded={openId === node.id}
|
||
onKeyDown={(e) => handleFolderKey(e, node.id)}
|
||
>
|
||
{label}
|
||
<span className="shrink-0 text-neutral-400" aria-hidden>
|
||
›
|
||
</span>
|
||
</button>
|
||
</NavTooltip>
|
||
{openId === node.id ? (
|
||
<div
|
||
key={node.id}
|
||
className="cascade-flyout-sub absolute left-full top-0 ml-0 overflow-visible pl-0"
|
||
style={{ zIndex: 50 + depth }}
|
||
>
|
||
<CascadeFolderPanel
|
||
title={node.menu_name}
|
||
nodes={kids}
|
||
pathname={pathname}
|
||
onMenuNavigate={onMenuNavigate}
|
||
depth={depth + 1}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const href = normalizeHref(node);
|
||
const external = Boolean(node.external_link);
|
||
const active = isLayoutOverviewNode(node)
|
||
? (pathname.replace(/\/$/, '') || '/') === '/dashboard'
|
||
: isActivePath(pathname, href);
|
||
|
||
if (href === '#') {
|
||
return (
|
||
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
|
||
<div className="rounded-md px-2 py-2 text-sm text-neutral-500" style={{ paddingLeft: pad }}>
|
||
{label}
|
||
</div>
|
||
</NavTooltip>
|
||
);
|
||
}
|
||
|
||
if (external) {
|
||
return (
|
||
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={linkClass(false)}
|
||
style={{ paddingLeft: pad }}
|
||
>
|
||
{label}
|
||
</a>
|
||
</NavTooltip>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
|
||
<Link
|
||
href={href}
|
||
className={linkClass(active)}
|
||
style={{ paddingLeft: pad }}
|
||
onClick={() => onMenuNavigate?.(href, node.menu_name)}
|
||
aria-current={active ? 'page' : undefined}
|
||
>
|
||
{label}
|
||
</Link>
|
||
</NavTooltip>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** L2 起:fixed 根与一级行顶对齐;更深层级在面板内 absolute,与对应行顶对齐;无内部滚动 */
|
||
export function ClassicCascadeFlyoutPortal({
|
||
rootNode,
|
||
anchorRect,
|
||
pathname,
|
||
onMenuNavigate,
|
||
onHoverEnter,
|
||
onHoverLeave,
|
||
}: {
|
||
rootNode: MenuNode;
|
||
anchorRect: DOMRectReadOnly;
|
||
pathname: string;
|
||
onMenuNavigate?: (path: string, title: string) => void;
|
||
onHoverEnter: () => void;
|
||
onHoverLeave: () => void;
|
||
}) {
|
||
const [mounted, setMounted] = useState(false);
|
||
useEffect(() => setMounted(true), []);
|
||
|
||
if (!mounted || typeof document === 'undefined') {
|
||
return null;
|
||
}
|
||
|
||
const nodes = visibleChildren(rootNode);
|
||
if (nodes.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
/** 左缘与一级导航右缘对齐,不向左叠在窄轨上(不再用负 overlap) */
|
||
const left = anchorRect.right;
|
||
|
||
return createPortal(
|
||
<div
|
||
className="pointer-events-auto fixed z-50 outline-none"
|
||
style={{
|
||
top: anchorRect.top,
|
||
left,
|
||
maxWidth: `calc(100vw - ${left}px - 8px)`,
|
||
}}
|
||
onMouseEnter={onHoverEnter}
|
||
onMouseLeave={onHoverLeave}
|
||
>
|
||
<div key={rootNode.id} className="cascade-flyout-panel">
|
||
<CascadeFolderPanel
|
||
title={rootNode.menu_name}
|
||
nodes={nodes}
|
||
pathname={pathname}
|
||
onMenuNavigate={onMenuNavigate}
|
||
/>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|