Files
windwiderstand/src/lib/components/Header.svelte
T
Peter Meier 88b2df0c05
Deploy / verify (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m13s
feat: location-Feld an Organisation, Header-Tooltips
- block-types: OrganisationEntry.location { text, lat, lng }
- Header: title-Attribut auf Social-Icons + Suche-Button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:54:13 +02:00

389 lines
16 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;
}
}
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 backdrop-blur-md 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-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-10"
aria-hidden="true">{@html logoSvgHtml}</span
>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="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="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}
{@const isInternal = social.href.startsWith("/")}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
class="p-1.5 -m-1.5 rounded-xs transition-colors flex items-center justify-center"
aria-label={social.label || t(T.header_social_aria)}
title={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} aria-hidden="true" />
</a>
{/each}
<button
type="button"
class="p-2 -mr-2 rounded-xs transition-colors 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="mdi:close"
class="text-error!"
aria-hidden="true"
/>
{:else}
<Icon icon="mdi:magnify" aria-hidden="true" />
{/if}
</button>
</nav>
<!-- Mobile: Such- und Burger-Buttons (unter lg) -->
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="p-2 rounded-xs transition-colors 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="mdi:close"
class="text-2xl text-error!"
aria-hidden="true"
/>
{:else}
<Icon
icon="mdi:magnify"
class="text-2xl"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-xs transition-colors flex items-center justify-center"
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="mdi:close"
class="text-2xl"
aria-hidden="true"
/>
{:else}
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
{/if}
</button>
</div>
</div>
<Search bind:open={searchOpen} />
<!-- Mobile: Dropdown-Menü (unter lg) -->
{#if menuOpen}
<div
id="mobile-nav"
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
role="dialog"
aria-label="Navigation"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<nav
class="container-custom py-4 flex flex-col gap-1"
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}
{@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
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
>
{#each socialLinks as social}
{@const isInternal = social.href.startsWith("/")}
<a
href={social.href}
target={isInternal ? undefined : "_blank"}
rel={isInternal
? undefined
: "noopener noreferrer"}
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-xs transition-colors"
aria-label={social.label ||
t(T.header_social_aria)}
onclick={closeMenu}
>
<Icon
icon={social.icon}
class="text-[1.5rem]"
aria-hidden="true"
/>
</a>
{/each}
</div>
{/if}
</nav>
</div>
{/if}
</header>