PHP-style cascade mega-menu: separator-leaves become hover-expand groups
Was: tried to flatten 포인트게임 sub-menu into 4 columns side-by-side, which collapsed into a narrow strip on smaller widths and overlapped text. Now (PHP gnuboard parity, matching the screenshot user provided): - buildHierarchy() walks the flat menu list and converts every separator leaf (label like "─ X ─" / "── X ──" with href='#') into a synthetic group, attaching the following sibling leaves as its children - Items with native children stay as-is - MegaPanel renders a single-column list; group rows show a ChevronDown▸ marker and on hover open a *separate* second panel positioned absolute left-full (right-side cascade), exactly like the PHP screenshot - Each level uses inline color:#1f2937 with onMouseOver/Out fallback so the hover gradient (violet→fuchsia) flips text white reliably Verify: 10 iter × 102 = 1020/1020 PASS. /free /review anon 200 (lvl=1 parity). Sub-panel hover spawns "크로스배팅 / 스페셜배팅" markers visible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,66 +163,86 @@ function IconLink({ href, Icon, emoji, label, badge }: { href: string; Icon?: Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Group submenu items: items with children render as section headers with their kids inline below. */
|
/** Group submenu items: items with children render as section headers with their kids inline below. */
|
||||||
function MegaPanel({ items }: { items: MenuItem[] }) {
|
// Convert flat menu (with separator-leaves like "─ 스포츠 ─") into a
|
||||||
// Two grouping strategies — pick whichever gnuboard's data hints at:
|
// hierarchical structure: separators become sub-groups whose following
|
||||||
// (a) child-grouped: items with `children` become titled section columns
|
// leaves become their children. This matches the PHP gnuboard cascade UX.
|
||||||
// (b) separator-grouped: a leaf with href='#' or label like "─ X ─" / "── X ──"
|
function buildHierarchy(items: MenuItem[]): MenuItem[] {
|
||||||
// starts a new section with that label as the column title (used by the
|
const out: MenuItem[] = [];
|
||||||
// gnuboard 슬생 data, which flattens 포인트게임 sub-menu)
|
|
||||||
const sections: { title?: string; href?: string; links: MenuItem[] }[] = [];
|
|
||||||
let cur: { title?: string; href?: string; links: MenuItem[] } = { links: [] };
|
|
||||||
function flush() { if (cur.links.length > 0 || cur.title) sections.push(cur); cur = { links: [] }; }
|
|
||||||
|
|
||||||
const isSeparator = (it: MenuItem) => {
|
const isSeparator = (it: MenuItem) => {
|
||||||
const lab = (it.label || '').replace(/[─\-=ㅡ]/g, '').trim();
|
const labelHasDash = /[─\-=ㅡ]/.test(it.label || '');
|
||||||
return (it.href === '#' || !it.href) && lab.length <= 8 && (it.label || '').match(/[─\-=ㅡ]/);
|
return (it.href === '#' || !it.href) && labelHasDash;
|
||||||
};
|
};
|
||||||
|
let curGroup: MenuItem | null = null;
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (it.children && it.children.length > 0) {
|
if (it.children && it.children.length > 0) {
|
||||||
flush();
|
curGroup = null;
|
||||||
sections.push({ title: it.label, href: it.href, links: it.children });
|
out.push(it);
|
||||||
} else if (isSeparator(it)) {
|
} else if (isSeparator(it)) {
|
||||||
flush();
|
|
||||||
const label = (it.label || '').replace(/[─\-=ㅡ\s]/g, '').trim();
|
const label = (it.label || '').replace(/[─\-=ㅡ\s]/g, '').trim();
|
||||||
cur = { title: label || it.label, links: [] };
|
curGroup = { label: label || it.label, href: '#', children: [] };
|
||||||
|
out.push(curGroup);
|
||||||
|
} else if (curGroup) {
|
||||||
|
curGroup.children!.push(it);
|
||||||
} else {
|
} else {
|
||||||
cur.links.push(it);
|
out.push(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush();
|
return out;
|
||||||
|
}
|
||||||
// Layout: pick column count by total sections; cap 4
|
|
||||||
const colCount = sections.length <= 1 ? 1 : sections.length === 2 ? 2 : sections.length === 3 ? 3 : Math.min(4, sections.length);
|
|
||||||
const colClass = colCount === 1 ? 'grid-cols-1' : colCount === 2 ? 'grid-cols-2' : colCount === 3 ? 'grid-cols-3' : 'grid-cols-4';
|
|
||||||
|
|
||||||
|
function MegaPanel({ items }: { items: MenuItem[] }) {
|
||||||
|
const tree = buildHierarchy(items);
|
||||||
|
const [openSub, setOpenSub] = useState<number | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className={`grid gap-x-5 gap-y-3 ${colClass}`} style={{ color: '#1f2937' }}>
|
<ul className="m-0 grid gap-0.5 p-0 list-none" style={{ color: '#1f2937' }}>
|
||||||
{sections.map((s, si) => (
|
{tree.map((it, i) => {
|
||||||
<div key={si} className="min-w-[160px]">
|
const hasChildren = !!(it.children && it.children.length > 0);
|
||||||
{s.title && (
|
return (
|
||||||
<div className="mb-1 border-b border-brand-100 pb-1 text-[11px] font-bold uppercase tracking-widest" style={{ color: '#7c3aed' }}>
|
<li
|
||||||
{s.href && s.href !== '#' ? <Link href={s.href} style={{ color: '#7c3aed' }}>{s.title}</Link> : s.title}
|
key={i}
|
||||||
</div>
|
className="relative"
|
||||||
)}
|
onMouseEnter={() => hasChildren && setOpenSub(i)}
|
||||||
<ul className="m-0 grid gap-0.5 p-0 list-none">
|
onMouseLeave={() => setOpenSub((v) => (v === i ? null : v))}
|
||||||
{s.links.map((c, ci) => (
|
>
|
||||||
<li key={ci}>
|
<Link
|
||||||
<Link
|
href={it.href || '#'}
|
||||||
href={c.href || '#'}
|
style={{ color: '#1f2937', display: 'flex' }}
|
||||||
style={{ color: '#1f2937', display: 'block' }}
|
className="items-center justify-between rounded-lg px-3 py-2 text-[13.5px] font-medium transition whitespace-nowrap hover:!text-white hover:bg-gradient-to-r hover:from-violet-600 hover:to-fuchsia-600 hover:shadow-[0_4px_14px_rgba(124,82,224,0.25)]"
|
||||||
className="rounded-lg px-3 py-2 text-[13.5px] font-medium transition whitespace-nowrap hover:!text-white hover:bg-gradient-to-r hover:from-violet-600 hover:to-fuchsia-600 hover:shadow-[0_4px_14px_rgba(124,82,224,0.25)]"
|
onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; }}
|
||||||
onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; }}
|
onMouseOut={(e) => { e.currentTarget.style.color = '#1f2937'; }}
|
||||||
onMouseOut={(e) => { e.currentTarget.style.color = '#1f2937'; }}
|
>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
{hasChildren && <ChevronDown size={12} className="-rotate-90 opacity-60" />}
|
||||||
|
</Link>
|
||||||
|
<AnimatePresence>
|
||||||
|
{hasChildren && openSub === i && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -6 }} transition={{ duration: 0.14 }}
|
||||||
|
className="absolute left-full top-0 z-50 ml-1 rounded-2xl border border-brand-100 p-2 shadow-[0_18px_38px_rgba(60,30,120,0.22)]"
|
||||||
|
style={{ minWidth: 220, background: '#ffffff' }}
|
||||||
>
|
>
|
||||||
{c.label}
|
<ul className="m-0 grid gap-0.5 p-0 list-none">
|
||||||
</Link>
|
{it.children!.map((c, ci) => (
|
||||||
</li>
|
<li key={ci}>
|
||||||
))}
|
<Link
|
||||||
</ul>
|
href={c.href || '#'}
|
||||||
</div>
|
style={{ color: '#1f2937', display: 'block' }}
|
||||||
))}
|
className="rounded-lg px-3 py-2 text-[13.5px] font-medium transition whitespace-nowrap hover:!text-white hover:bg-gradient-to-r hover:from-violet-600 hover:to-fuchsia-600"
|
||||||
</div>
|
onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; }}
|
||||||
|
onMouseOut={(e) => { e.currentTarget.style.color = '#1f2937'; }}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +273,8 @@ function MegaNav({ menus, isDark: _isDark }: { menus: MenuItem[]; isDark: boolea
|
|||||||
{open === i && hasChildren && (
|
{open === i && hasChildren && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.16 }}
|
initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.16 }}
|
||||||
className="absolute left-0 top-full z-50 mt-0 rounded-2xl border border-brand-100 p-3 shadow-[0_18px_38px_rgba(60,30,120,0.22)]"
|
className="absolute left-0 top-full z-50 mt-0 rounded-2xl border border-brand-100 p-4 shadow-[0_18px_38px_rgba(60,30,120,0.22)]"
|
||||||
style={{ minWidth: 240, background: '#ffffff', color: '#1f2937' }}
|
style={{ minWidth: 220, maxWidth: 'min(95vw, 1100px)', background: '#ffffff', color: '#1f2937' }}
|
||||||
>
|
>
|
||||||
<MegaPanel items={m.children!} />
|
<MegaPanel items={m.children!} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user