feat(nav): navgroup-Dropdowns im Header + Fix Overflow-Clipping
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m12s

- 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 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-30 08:50:51 +02:00
parent c58f418c1a
commit bd67f0c568
3 changed files with 194 additions and 29 deletions
+19 -3
View File
@@ -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);
+120 -24
View File
@@ -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<string | null>(null);
function toggleSubmenu(label: string) {
openSubmenu = openSubmenu === label ? null : label;
}
function toggleMenu() {
if (menuOpen) {
@@ -100,9 +111,10 @@
<header
id="horizontal-navigation"
class="gradient-blob-bg sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-wald-50"
class="relative sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-wald-50"
>
<div class="flex items-center justify-between container-custom py-2">
<span class="gradient-blob-bg-inner" aria-hidden="true"></span>
<div class="relative flex items-center justify-between container-custom py-2">
<a
href="/"
aria-label={t(T.site_name_fallback)}
@@ -126,14 +138,60 @@
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
{@const active = isActiveLink(link.href, $page.url.pathname)}
<a
href={link.href}
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
aria-current={active ? "page" : undefined}
>
{link.label}
</a>
{@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0}
<div class="relative group">
{#if link.href && link.href !== "#"}
<a
href={link.href}
class="text-[.7rem] py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
aria-haspopup="menu"
aria-expanded="false"
>
{link.label}
<Icon icon="mdi:chevron-down" class="size-3" aria-hidden="true" />
</a>
{:else}
<button
type="button"
class="text-[.7rem] py-1 inline-flex items-center gap-0.5 {active ? 'font-bold' : ''}"
aria-haspopup="menu"
aria-expanded="false"
>
{link.label}
<Icon icon="mdi:chevron-down" class="size-3" aria-hidden="true" />
</button>
{/if}
<div
role="menu"
class="absolute left-0 top-full pt-1 min-w-[10rem] opacity-0 invisible translate-y-1 transition-all group-hover:opacity-100 group-hover:visible group-hover:translate-y-0 group-focus-within:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 z-50"
>
<ul class="bg-stein-0 shadow-lg rounded border border-stein-200 py-1 text-stein-800">
{#each link.children as child}
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
<li role="none">
<a
role="menuitem"
href={child.href}
class="block px-3 py-1.5 text-xs hover:bg-wald-50 {cActive ? 'font-bold text-wald-700' : ''}"
aria-current={cActive ? "page" : undefined}
>
{child.label}
</a>
</li>
{/each}
</ul>
</div>
</div>
{:else}
<a
href={link.href}
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
aria-current={active ? "page" : undefined}
>
{link.label}
</a>
{/if}
{/each}
<div class="w-1 h-4"></div>
{#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,
)}
<a
href={link.href}
class="block py-4 px-3 rounded-xs transition-colors text-base {active
? 'font-bold text-wald-300 bg-wald-700/20 border-l-4 border-wald-400'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-l-4 border-transparent'}"
aria-current={active ? "page" : undefined}
onclick={closeMenu}
>
{link.label}
</a>
{@const active = isActiveTree(link, $page.url.pathname)}
{#if link.children && link.children.length > 0}
{@const expanded = openSubmenu === link.label}
<div>
<button
type="button"
class="flex w-full items-center justify-between py-4 px-3 rounded-xs transition-colors text-base text-stein-100 hover:bg-stein-0/10 border-l-4 {active ? 'border-wald-400 font-bold text-wald-300' : 'border-transparent'}"
aria-expanded={expanded}
onclick={() => toggleSubmenu(link.label)}
>
<span>{link.label}</span>
<Icon icon={expanded ? "mdi:chevron-up" : "mdi:chevron-down"} class="size-4" aria-hidden="true" />
</button>
{#if expanded}
<div class="ml-6 border-l border-stein-0/10" in:slide={{ duration: 150 }} out:slide={{ duration: 100 }}>
{#if link.href && link.href !== "#"}
{@const pActive = isActiveLink(link.href, $page.url.pathname)}
<a
href={link.href}
class="block py-2 px-3 text-sm {pActive ? 'font-bold text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
aria-current={pActive ? "page" : undefined}
onclick={closeMenu}
>
{link.label} Übersicht
</a>
{/if}
{#each link.children as child}
{@const cActive = isActiveLink(child.href, $page.url.pathname)}
<a
href={child.href}
class="block py-2 px-3 text-sm {cActive ? 'font-bold text-wald-300' : 'text-stein-200 hover:text-stein-0'}"
aria-current={cActive ? "page" : undefined}
onclick={closeMenu}
>
{child.label}
</a>
{/each}
</div>
{/if}
</div>
{:else}
<a
href={link.href}
class="block py-4 px-3 rounded-xs transition-colors text-base {active
? 'font-bold text-wald-300 bg-wald-700/20 border-l-4 border-wald-400'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-l-4 border-transparent'}"
aria-current={active ? "page" : undefined}
onclick={closeMenu}
>
{link.label}
</a>
{/if}
{/each}
{#if socialLinks.length > 0}
<div
+55 -2
View File
@@ -30,6 +30,8 @@ import { logWarn } from '$lib/log.server';
interface NavLink {
href: string;
label: string;
/** Sub-links für Dropdown/Submenu (aus navgroup-Entries). */
children?: NavLink[];
}
interface SocialLink {
@@ -93,7 +95,9 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
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')