feat(nav): navgroup-Dropdowns im Header + Fix Overflow-Clipping
- 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:
+19
-3
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user