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:
chpark
2026-04-30 14:50:53 +09:00
parent 2a258be3da
commit 54bcc97a68
@@ -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. */
function MegaPanel({ items }: { items: MenuItem[] }) {
// Two grouping strategies — pick whichever gnuboard's data hints at:
// (a) child-grouped: items with `children` become titled section columns
// (b) separator-grouped: a leaf with href='#' or label like "─ X ─" / "── X ──"
// starts a new section with that label as the column title (used by the
// 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: [] }; }
// Convert flat menu (with separator-leaves like "─ 스포츠 ─") into a
// hierarchical structure: separators become sub-groups whose following
// leaves become their children. This matches the PHP gnuboard cascade UX.
function buildHierarchy(items: MenuItem[]): MenuItem[] {
const out: MenuItem[] = [];
const isSeparator = (it: MenuItem) => {
const lab = (it.label || '').replace(/[─\-=ㅡ]/g, '').trim();
return (it.href === '#' || !it.href) && lab.length <= 8 && (it.label || '').match(/[─\-=ㅡ]/);
const labelHasDash = /[─\-=ㅡ]/.test(it.label || '');
return (it.href === '#' || !it.href) && labelHasDash;
};
let curGroup: MenuItem | null = null;
for (const it of items) {
if (it.children && it.children.length > 0) {
flush();
sections.push({ title: it.label, href: it.href, links: it.children });
curGroup = null;
out.push(it);
} else if (isSeparator(it)) {
flush();
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 {
cur.links.push(it);
out.push(it);
}
}
flush();
// 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';
return out;
}
function MegaPanel({ items }: { items: MenuItem[] }) {
const tree = buildHierarchy(items);
const [openSub, setOpenSub] = useState<number | null>(null);
return (
<div className={`grid gap-x-5 gap-y-3 ${colClass}`} style={{ color: '#1f2937' }}>
{sections.map((s, si) => (
<div key={si} className="min-w-[160px]">
{s.title && (
<div className="mb-1 border-b border-brand-100 pb-1 text-[11px] font-bold uppercase tracking-widest" style={{ color: '#7c3aed' }}>
{s.href && s.href !== '#' ? <Link href={s.href} style={{ color: '#7c3aed' }}>{s.title}</Link> : s.title}
</div>
)}
<ul className="m-0 grid gap-0.5 p-0 list-none">
{s.links.map((c, ci) => (
<li key={ci}>
<Link
href={c.href || '#'}
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 hover:shadow-[0_4px_14px_rgba(124,82,224,0.25)]"
onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; }}
onMouseOut={(e) => { e.currentTarget.style.color = '#1f2937'; }}
<ul className="m-0 grid gap-0.5 p-0 list-none" style={{ color: '#1f2937' }}>
{tree.map((it, i) => {
const hasChildren = !!(it.children && it.children.length > 0);
return (
<li
key={i}
className="relative"
onMouseEnter={() => hasChildren && setOpenSub(i)}
onMouseLeave={() => setOpenSub((v) => (v === i ? null : v))}
>
<Link
href={it.href || '#'}
style={{ color: '#1f2937', display: 'flex' }}
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)]"
onMouseOver={(e) => { e.currentTarget.style.color = '#ffffff'; }}
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}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<ul className="m-0 grid gap-0.5 p-0 list-none">
{it.children!.map((c, ci) => (
<li key={ci}>
<Link
href={c.href || '#'}
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"
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 && (
<motion.div
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)]"
style={{ minWidth: 240, background: '#ffffff', color: '#1f2937' }}
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: 220, maxWidth: 'min(95vw, 1100px)', background: '#ffffff', color: '#1f2937' }}
>
<MegaPanel items={m.children!} />
</motion.div>