Files
windwiderstand/src/lib/components/Header.svelte
T
Peter Meier 3908c117df
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 1m27s
style(header mobile): Menü-Button farbiger hinterlegt (wald-600/25)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:06:16 +02:00

414 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import "$lib/iconify-offline";
import { slide } from "svelte/transition";
import Icon from "@iconify/svelte";
import Search from "./Search.svelte";
import { overlayOpen, overlayClose } from "$lib/overlay-store";
import { t as tStatic, T } from "$lib/translations";
import type { Translations } from "$lib/translations";
import { page } from "$app/stores";
function isActiveLink(href: string, pathname: string): boolean {
if (!href) return false;
const h = href.replace(/\/$/, "") || "/";
const p = pathname.replace(/\/$/, "") || "/";
if (h === "/") return p === "/";
return p === h || p.startsWith(h + "/");
}
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 {
href: string;
icon: string;
label?: string;
}
let {
links = [],
socialLinks = [],
logoUrl = null,
logoSvgHtml = null,
translations = {},
}: {
links?: NavLink[];
socialLinks?: SocialLink[];
logoUrl?: string | null;
logoSvgHtml?: string | null;
translations?: Translations | null;
} = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
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) {
menuOpen = false;
} else {
searchOpen = false;
menuOpen = true;
// Sektion der aktuellen Seite direkt aufgeklappt zeigen.
openSubmenu =
links.find(
(l) => l.children && l.children.length > 0 && isActiveTree(l, $page.url.pathname),
)?.label ?? null;
}
}
function closeMenu() {
menuOpen = false;
}
function closeOverlays() {
menuOpen = false;
searchOpen = false;
}
$effect(() => {
if (searchOpen) menuOpen = false;
});
$effect(() => {
if (menuOpen) searchOpen = false;
});
$effect(() => {
if (typeof window === "undefined") return;
const open = menuOpen || searchOpen;
overlayOpen.set(open);
overlayClose.set(open ? closeOverlays : null);
});
$effect(() => {
if (typeof window === "undefined") return;
const open = menuOpen || searchOpen;
if (open) window.scrollTo({ top: 0, behavior: "smooth" });
document.documentElement.style.overflow = open ? "hidden" : "";
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
};
});
$effect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMenu();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
</script>
<header
id="horizontal-navigation"
class="relative sticky top-0 z-20 border-b border-white/30 shadow-2xl bg-wald-50"
>
<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)}
class="logo flex items-center origin-left font-bold text-lg tracking-wide hover:scale-[1.12] transition-transform duration-200 ease-out relative"
>
{#if logoSvgHtml}
<span
class="logo-svg h-8 lg:h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-8 lg:[&_svg]:max-h-10"
aria-hidden="true">{@html logoSvgHtml}</span
>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-8 lg:h-10 w-auto object-contain" />
{:else}
<span>{t(T.site_name_fallback)}</span>
{/if}
</a>
<!-- Desktop: horizontale Nav (ab lg) -->
<nav
class="hidden lg:flex items-center gap-4"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
{@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="lucide: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="lucide: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="ml-2 flex items-center gap-1.5">
{#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
{@const sActive = isInternal && isActiveLink(social.href, $page.url.pathname)}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center gap-1.5 {sActive ? 'font-bold' : ''}"
aria-current={sActive ? "page" : undefined}
aria-label={social.label || t(T.header_social_aria)}
title={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} class="text-sm" aria-hidden="true" />
{#if social.label}
<span class="text-[.7rem] leading-none">{social.label}</span>
{/if}
</a>
{/each}
<button
type="button"
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
title={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon
icon="lucide:x"
class="text-sm text-error!"
aria-hidden="true"
/>
{:else}
<Icon icon="lucide:search" class="text-sm" aria-hidden="true" />
{/if}
</button>
</div>
</nav>
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="rounded-full border border-wald-700/15 bg-wald-700/8 px-2.5 py-1 transition-opacity hover:opacity-70 inline-flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon
icon="lucide:x"
class="text-sm text-error!"
aria-hidden="true"
/>
{:else}
<Icon
icon="lucide:search"
class="text-sm"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="-mr-1 inline-flex items-center gap-1.5 rounded-full border border-wald-600/40 bg-wald-600/25 px-2.5 py-1 text-wald-800 transition-opacity hover:opacity-70"
aria-label={menuOpen
? t(T.header_menu_close)
: t(T.header_menu_open)}
aria-controls={menuOpen ? "mobile-nav" : undefined}
aria-expanded={menuOpen}
onclick={toggleMenu}
>
<span class="sr-only"
>{menuOpen
? t(T.header_menu_close)
: t(T.header_menu_open)}</span
>
{#if menuOpen}
<Icon icon="lucide:x" class="text-sm" aria-hidden="true" />
{:else}
<Icon icon="lucide:menu" class="text-sm" aria-hidden="true" />
{/if}
<span class="text-[.7rem] leading-none" aria-hidden="true">{menuOpen ? "Schließen" : "Menü"}</span>
</button>
</div>
</div>
<Search bind:open={searchOpen} />
<!-- Mobile: Dropdown-Menü (unter lg) -->
{#if menuOpen}
<div
id="mobile-nav"
class="lg:hidden absolute left-0 right-0 top-full z-30 shadow-2xl border-t border-stein-0/10 bg-stein-900/90 backdrop-blur-lg max-h-[calc(100dvh-3.5rem)] overflow-y-auto overscroll-contain"
role="dialog"
aria-label="Navigation"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<nav
class="container-custom pt-4 pb-[calc(4rem+env(safe-area-inset-bottom))] flex flex-col gap-1"
aria-label={t(T.header_nav_aria)}
>
<div class="grid grid-cols-2 gap-2.5 pb-4 mb-2 border-b border-stein-0/10">
<a href="/mitmachen/" class="inline-flex items-center justify-center gap-1.5 rounded-full border border-wald-400/40 px-3 py-2 text-sm font-medium text-wald-200 transition-colors hover:bg-stein-0/10" onclick={closeMenu}>
<Icon icon="lucide:heart-handshake" class="size-4 shrink-0" aria-hidden="true" />
Mitmachen
</a>
<a href="/termin-melden" class="inline-flex items-center justify-center gap-1.5 rounded-full border border-gelb-400/40 px-3 py-2 text-sm font-medium text-gelb-300 transition-colors hover:bg-stein-0/10" onclick={closeMenu}>
<Icon icon="lucide:calendar-plus" class="size-4 shrink-0" aria-hidden="true" />
Termin melden
</a>
</div>
{#each links as link}
{@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-2.5 px-3 rounded-xs transition-colors text-sm text-stein-100 hover:bg-stein-0/10 border-l-4 {active ? 'border-wald-400 text-wald-300' : 'border-transparent'}"
aria-expanded={expanded}
onclick={() => toggleSubmenu(link.label)}
>
<span>{link.label}</span>
<Icon icon={expanded ? "lucide:chevron-up" : "lucide: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 ? '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 ? '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-2.5 px-3 rounded-xs transition-colors text-sm {active
? '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 class="flex flex-col gap-1 pt-2 mt-2 border-t border-stein-0/10">
{#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
{@const sActive =
isInternal &&
isActiveLink(social.href, $page.url.pathname)}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal
? undefined
: "noopener noreferrer"}
class="flex items-center gap-3 py-2.5 px-3 rounded-xs transition-colors text-sm border-l-4 {sActive
? 'text-wald-300 bg-wald-700/20 border-wald-400'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-transparent'}"
aria-current={sActive ? "page" : undefined}
aria-label={social.label ||
t(T.header_social_aria)}
onclick={closeMenu}
>
<Icon
icon={social.icon}
class="text-sm"
aria-hidden="true"
/>
{#if social.label}
<span>{social.label}</span>
{/if}
</a>
{/each}
</div>
{/if}
</nav>
</div>
{/if}
</header>