From bd67f0c5684b916fdab31e4000fb181ab5f5bfea Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sat, 30 May 2026 08:50:51 +0200 Subject: [PATCH] feat(nav): navgroup-Dropdowns im Header + Fix Overflow-Clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - layout.server.ts: NavLink um optional children?: NavLink[] erweitert. navHeader-Resolve von 'links' auf 'all' (navgroup-sub-links werden mit aufgelöst). Resolver-Loop erkennt navgroup-Entries (_type==='navgroup' oder Shape label+links) und baut NavLink mit Children + Parent-URL. - Header.svelte: Desktop-Dropdown mit Hover/Focus-Open + ARIA (haspopup, expanded, role=menu/menuitem). Mobile: collapsible Submenu via openSubmenu-State, slide-animiert; "– Übersicht"-Link wenn Parent selbst eine URL hat. isActiveTree(): Parent gilt aktiv wenn ein Child aktiv ist. - Overflow-Fix: .gradient-blob-bg setzt overflow:hidden → clipte den Desktop-Dropdown. Neue Variante .gradient-blob-bg-inner als absoluter Inner-Layer (eigenes overflow-hidden), Header selbst bleibt overflow-visible. Bestehende .gradient-blob-bg-Nutzungen (z.B. DeadlineBannerBlock) unverändert. Co-Authored-By: Claude Opus 4.7 --- src/app.css | 22 ++++- src/lib/components/Header.svelte | 144 +++++++++++++++++++++++++------ src/routes/+layout.server.ts | 57 +++++++++++- 3 files changed, 194 insertions(+), 29 deletions(-) diff --git a/src/app.css b/src/app.css index 52e1c38..e148973 100644 --- a/src/app.css +++ b/src/app.css @@ -404,7 +404,9 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li { } .gradient-blob-bg::before, -.gradient-blob-bg::after { +.gradient-blob-bg::after, +.gradient-blob-bg-inner::before, +.gradient-blob-bg-inner::after { content: ""; position: absolute; top: 50%; @@ -423,14 +425,28 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li { pointer-events: none; } -.gradient-blob-bg::before { +.gradient-blob-bg::before, +.gradient-blob-bg-inner::before { left: max(-7rem, calc(50% - 52rem)); } -.gradient-blob-bg::after { +.gradient-blob-bg::after, +.gradient-blob-bg-inner::after { left: max(45rem, calc(50% + 8rem)); } +/* Variante für Container die SELBST nicht clippen sollen (z.B. Header mit + absolut positioniertem Dropdown): Blobs leben in einem inneren absoluten + Layer, der overflow-hidden hat, der Parent bleibt overflow-visible. */ +.gradient-blob-bg-inner { + position: absolute; + inset: 0; + overflow: hidden; + isolation: isolate; + pointer-events: none; + z-index: 0; +} + /* Globaler Focus-Ring – wald-Akzent für Tastatur-Nutzer (a11y + Polish) */ *:focus-visible { outline: 2px solid var(--color-wald-500); diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 7cbc13c..bd02bfb 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -19,6 +19,12 @@ interface NavLink { href: string; label: string; + children?: NavLink[]; + } + + function isActiveTree(link: NavLink, pathname: string): boolean { + if (isActiveLink(link.href, pathname)) return true; + return (link.children ?? []).some((c) => isActiveLink(c.href, pathname)); } interface SocialLink { @@ -46,6 +52,11 @@ } let menuOpen = $state(false); let searchOpen = $state(false); + let openSubmenu = $state(null); + + function toggleSubmenu(label: string) { + openSubmenu = openSubmenu === label ? null : label; + } function toggleMenu() { if (menuOpen) { @@ -100,9 +111,10 @@
-
+ +
{#each links as link} - {@const active = isActiveLink(link.href, $page.url.pathname)} - - {link.label} - + {@const active = isActiveTree(link, $page.url.pathname)} + {#if link.children && link.children.length > 0} +
+ {#if link.href && link.href !== "#"} + + {:else} + + {/if} + +
+ {:else} + + {link.label} + + {/if} {/each}
{#each socialLinks as social} @@ -241,20 +299,58 @@ aria-label={t(T.header_nav_aria)} > {#each links as link} - {@const active = isActiveLink( - link.href, - $page.url.pathname, - )} - - {link.label} - + {@const active = isActiveTree(link, $page.url.pathname)} + {#if link.children && link.children.length > 0} + {@const expanded = openSubmenu === link.label} +
+ + {#if expanded} +
+ {#if link.href && link.href !== "#"} + {@const pActive = isActiveLink(link.href, $page.url.pathname)} + + {link.label} – Übersicht + + {/if} + {#each link.children as child} + {@const cActive = isActiveLink(child.href, $page.url.pathname)} + + {child.label} + + {/each} +
+ {/if} +
+ {:else} + + {link.label} + + {/if} {/each} {#if socialLinks.length > 0}
{ collection: 'navigation', slug: NavigationKeys.header, locale: 'de', - resolve: 'links', + // 'all' damit navgroup-Entries und deren sub-links komplett aufgelöst + // sind — sonst kommen die navgroup.links als nackte Slug-Strings. + resolve: 'all', }, { id: 'navSocial', @@ -192,11 +196,60 @@ export const load: LayoutServerLoad = async ({ locals, route }) => { slugToPostHref.set(keyUnderscore, canonical); } } + // Helper: resolved link-Entry → NavLink. Akzeptiert sowohl Top-Level- + // Links als auch navgroup-Children (gleiche Shape). + const linkToNav = (obj: { + url?: string; + linkName?: string; + name?: string; + }): NavLink => { + const url = obj.url ?? ''; + const href = + url.startsWith('/') || url.startsWith('http') + ? url + : url + ? `/${url.replace(/^\//, '')}` + : '#'; + return { href, label: obj.linkName ?? obj.name ?? url ?? '' }; + }; + for (const entry of rawLinks) { const asObj = typeof entry === 'object' && entry !== null - ? (entry as { url?: string; linkName?: string; name?: string }) + ? (entry as { + _type?: string; + url?: string; + linkName?: string; + name?: string; + label?: string; + links?: Array<{ url?: string; linkName?: string; name?: string }>; + }) : null; + // navgroup: hat label + links-Array → Dropdown mit Children. + if ( + asObj && + (asObj._type === 'navgroup' || + (typeof asObj.label === 'string' && Array.isArray(asObj.links))) + ) { + const children: NavLink[] = (asObj.links ?? []) + .filter((l): l is { url?: string; linkName?: string; name?: string } => + typeof l === 'object' && l !== null, + ) + .map(linkToNav) + .filter((nl) => nl.label); + const parentUrl = asObj.url ?? ''; + const parentHref = parentUrl + ? parentUrl.startsWith('/') || parentUrl.startsWith('http') + ? parentUrl + : `/${parentUrl.replace(/^\//, '')}` + : ''; + headerLinks.push({ + href: parentHref || '#', + label: asObj.label ?? '', + children: children.length > 0 ? children : undefined, + }); + continue; + } if (asObj?.url) { const href = asObj.url.startsWith('/') || asObj.url.startsWith('http')