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::before,
|
||||||
.gradient-blob-bg::after {
|
.gradient-blob-bg::after,
|
||||||
|
.gradient-blob-bg-inner::before,
|
||||||
|
.gradient-blob-bg-inner::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -423,14 +425,28 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-blob-bg::before {
|
.gradient-blob-bg::before,
|
||||||
|
.gradient-blob-bg-inner::before {
|
||||||
left: max(-7rem, calc(50% - 52rem));
|
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));
|
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) */
|
/* Globaler Focus-Ring – wald-Akzent für Tastatur-Nutzer (a11y + Polish) */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid var(--color-wald-500);
|
outline: 2px solid var(--color-wald-500);
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
interface NavLink {
|
interface NavLink {
|
||||||
href: string;
|
href: string;
|
||||||
label: 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 {
|
interface SocialLink {
|
||||||
@@ -46,6 +52,11 @@
|
|||||||
}
|
}
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let searchOpen = $state(false);
|
let searchOpen = $state(false);
|
||||||
|
let openSubmenu = $state<string | null>(null);
|
||||||
|
|
||||||
|
function toggleSubmenu(label: string) {
|
||||||
|
openSubmenu = openSubmenu === label ? null : label;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
if (menuOpen) {
|
if (menuOpen) {
|
||||||
@@ -100,9 +111,10 @@
|
|||||||
|
|
||||||
<header
|
<header
|
||||||
id="horizontal-navigation"
|
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
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
aria-label={t(T.site_name_fallback)}
|
aria-label={t(T.site_name_fallback)}
|
||||||
@@ -126,7 +138,52 @@
|
|||||||
aria-label={t(T.header_nav_aria)}
|
aria-label={t(T.header_nav_aria)}
|
||||||
>
|
>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
{@const active = isActiveLink(link.href, $page.url.pathname)}
|
{@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
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
|
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
|
||||||
@@ -134,6 +191,7 @@
|
|||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<div class="w-1 h-4"></div>
|
<div class="w-1 h-4"></div>
|
||||||
{#each socialLinks as social}
|
{#each socialLinks as social}
|
||||||
@@ -241,10 +299,47 @@
|
|||||||
aria-label={t(T.header_nav_aria)}
|
aria-label={t(T.header_nav_aria)}
|
||||||
>
|
>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
{@const active = isActiveLink(
|
{@const active = isActiveTree(link, $page.url.pathname)}
|
||||||
link.href,
|
{#if link.children && link.children.length > 0}
|
||||||
$page.url.pathname,
|
{@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
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="block py-4 px-3 rounded-xs transition-colors text-base {active
|
class="block py-4 px-3 rounded-xs transition-colors text-base {active
|
||||||
@@ -255,6 +350,7 @@
|
|||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if socialLinks.length > 0}
|
{#if socialLinks.length > 0}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { logWarn } from '$lib/log.server';
|
|||||||
interface NavLink {
|
interface NavLink {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Sub-links für Dropdown/Submenu (aus navgroup-Entries). */
|
||||||
|
children?: NavLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SocialLink {
|
interface SocialLink {
|
||||||
@@ -93,7 +95,9 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
collection: 'navigation',
|
collection: 'navigation',
|
||||||
slug: NavigationKeys.header,
|
slug: NavigationKeys.header,
|
||||||
locale: 'de',
|
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',
|
id: 'navSocial',
|
||||||
@@ -192,11 +196,60 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
|
|||||||
slugToPostHref.set(keyUnderscore, canonical);
|
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) {
|
for (const entry of rawLinks) {
|
||||||
const asObj =
|
const asObj =
|
||||||
typeof entry === 'object' && entry !== null
|
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;
|
: 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) {
|
if (asObj?.url) {
|
||||||
const href =
|
const href =
|
||||||
asObj.url.startsWith('/') || asObj.url.startsWith('http')
|
asObj.url.startsWith('/') || asObj.url.startsWith('http')
|
||||||
|
|||||||
Reference in New Issue
Block a user