SanYeCao-blog/src/components/Header.astro

470 lines
14 KiB
Text
Raw Normal View History

2026-03-24 18:01:45 +00:00
---
import Navigation from "./Navigation.astro";
import ThemeIcon from "./ThemeIcon.astro";
2026-03-24 22:28:23 +00:00
import { getLangFromUrl, getTranslations } from "@/i18n";
import Divider from "@/components/Divider.astro";
2026-03-24 22:28:23 +00:00
const lang = getLangFromUrl(Astro.url);
const t = getTranslations(lang);
2026-03-24 18:01:45 +00:00
---
2026-09-17 04:05:55 +00:00
<div class="header-slot">
2026-03-24 22:28:23 +00:00
<header class="site-header">
<div class="theme-icon-wrap">
<ThemeIcon />
</div>
<nav class="header-nav">
<div class="banner">
<a
href={`/${lang}/`}
data-astro-reload
class="banner-link"
aria-label="banner"
>
<span class="banner-logo"></span>
</a>
</div>
2026-03-24 18:01:45 +00:00
<Navigation />
</nav>
<div class="divider">
<Divider />
</div>
2026-03-24 18:01:45 +00:00
</header>
2026-09-17 04:05:55 +00:00
</div>
2026-03-24 18:01:45 +00:00
<style>
2026-09-17 04:05:55 +00:00
.site-header {
position: relative;
z-index: 30;
padding-top: 0.5rem;
}
.banner-link {
2026-09-17 04:05:55 +00:00
display: block;
}
.banner-logo {
display: block;
2026-09-17 04:05:55 +00:00
height: 55px;
background-color: #1f2328;
-webkit-mask: url("/images/banner-black.svg") no-repeat left / contain;
mask: url("/images/banner-black.svg") no-repeat left / contain;
2026-09-17 04:05:55 +00:00
transform-origin: left center;
}
:global(html.dark .banner-logo) {
background-color: #e6e6e6;
}
.header-nav .banner a {
2026-03-25 15:34:38 +00:00
color: inherit;
text-decoration: none;
2026-03-25 18:51:38 +00:00
display: block;
2026-03-25 15:34:38 +00:00
}
2026-03-24 22:28:23 +00:00
.theme-icon-wrap {
position: absolute;
top: 0;
right: 0;
2026-03-25 18:51:38 +00:00
z-index: 2;
2026-03-24 22:28:23 +00:00
}
.header-nav {
2026-03-24 18:01:45 +00:00
display: flex;
2026-03-24 22:28:23 +00:00
flex-direction: column;
gap: 1rem;
2026-09-17 04:05:55 +00:00
max-height: 12rem;
2026-03-24 22:28:23 +00:00
}
.header-nav .banner {
2026-03-24 22:28:23 +00:00
margin: 0;
font-style: italic;
2026-03-25 18:51:38 +00:00
line-height: 1.1;
max-width: calc(100% - 7rem);
2026-03-25 18:51:38 +00:00
font-size: clamp(1.6rem, 6vw, 2.5rem);
}
.divider {
margin-top: 0.2rem;
margin-bottom: 2rem;
}
2026-09-17 04:05:55 +00:00
@media (min-width: 769px) {
.header-slot {
/* Keep document height independent of scroll-driven header height. */
display: flow-root;
position: sticky;
top: 0;
z-index: 30;
height: var(--header-slot-height, auto);
pointer-events: none;
}
.site-header {
pointer-events: auto;
width: var(--header-width, 100%);
max-width: none;
margin-left: var(--header-offset, 0);
margin-right: var(--header-offset, 0);
padding: 0.5rem var(--header-inline-padding, 0) 0;
background-color: color-mix(
in srgb,
var(--background-color) var(--header-surface-weight, 0%),
transparent
);
backdrop-filter: blur(var(--header-blur, 0));
transform: translateY(0);
transition: transform 0.3s ease;
}
2026-09-17 04:12:43 +00:00
.header-slot.is-scroll-hidden .site-header,
.header-slot.is-footer-hidden .site-header {
2026-09-17 04:05:55 +00:00
transform: translateY(calc(-100% - 0.5rem));
}
:global(html.dark) .site-header {
background-color: color-mix(
in srgb,
#1e1e1e var(--header-surface-weight, 0%),
transparent
);
}
.theme-icon-wrap {
top: var(--theme-top, 0);
right: var(--theme-right, 0);
}
.header-nav {
gap: var(--header-gap, 1rem);
height: var(--header-nav-height, auto);
max-height: var(--header-nav-height, 12rem);
transform: translateY(var(--header-nav-y, 0));
}
.header-nav .banner {
flex-shrink: 0;
width: var(--banner-width, auto);
max-width: var(--banner-width, calc(100% - 7rem));
}
.banner-logo {
height: var(--logo-height, 55px);
}
.site-header :global(.site-nav) {
width: var(--site-nav-width, auto);
margin: var(--site-nav-margin, 1rem) 0;
padding-bottom: var(--site-nav-padding, 1rem);
transform: translate(
var(--site-nav-x, 0),
var(--site-nav-y, 0)
);
}
.site-header :global(.site-nav-desktop) {
gap: var(--site-nav-gap, 1.5rem);
}
.site-header :global(.site-nav a) {
font-size: var(--site-nav-font-size, 1.2rem);
}
.site-header > .divider {
width: var(--divider-width, 100%);
margin-left: var(--divider-offset, 0);
margin-right: var(--divider-offset, 0);
margin-top: var(--divider-margin-top, 0.2rem);
margin-bottom: var(--divider-margin-bottom, 2rem);
transform: translateY(var(--divider-y, 0));
}
.site-header > .divider :global(.divider) {
width: 100%;
margin-bottom: var(--divider-inner-margin-bottom, 1rem);
}
}
2026-03-25 18:51:38 +00:00
@media (max-width: 768px) {
2026-09-17 04:05:55 +00:00
.header-slot {
display: contents;
}
2026-03-25 18:51:38 +00:00
.site-header {
2026-09-17 04:05:55 +00:00
position: relative;
2026-03-25 18:51:38 +00:00
padding-top: 0.25rem;
}
.header-nav h1 {
max-width: calc(100% - 3.5rem);
font-size: clamp(1.25rem, 7vw, 2rem);
}
2026-03-24 18:01:45 +00:00
}
2026-09-17 04:05:55 +00:00
2026-03-24 18:01:45 +00:00
</style>
2026-09-17 04:05:55 +00:00
<script>
const desktopHeaderQuery = window.matchMedia("(min-width: 769px)");
const HEADER_SCROLL_DISTANCE = 120;
2026-09-17 04:12:43 +00:00
const DIRECTION_TRIGGER_DISTANCE = 6;
2026-09-17 04:05:55 +00:00
const headerStyleProperties = [
"--header-width",
"--header-offset",
"--header-inline-padding",
"--header-surface-weight",
"--header-blur",
"--theme-top",
"--theme-right",
"--header-gap",
"--header-nav-height",
"--header-nav-y",
"--banner-width",
"--logo-height",
"--site-nav-width",
"--site-nav-margin",
"--site-nav-padding",
"--site-nav-x",
"--site-nav-y",
"--site-nav-gap",
"--site-nav-font-size",
"--divider-margin-top",
"--divider-margin-bottom",
"--divider-inner-margin-bottom",
"--divider-width",
"--divider-offset",
"--divider-y",
];
type HeaderMetrics = {
header: HTMLElement;
expandedWidth: number;
expandedNavHeight: number;
expandedBannerWidth: number;
expandedSiteNavWidth: number;
rootFontSize: number;
};
let headerFrame = 0;
let headerMetrics: HeaderMetrics | null = null;
let footerDividerObserver: IntersectionObserver | null = null;
2026-09-17 04:12:43 +00:00
let lastScrollY = window.scrollY;
let scrollDirection = 0;
let directionDistance = 0;
2026-09-17 04:05:55 +00:00
const lerp = (start: number, end: number, progress: number) =>
start + (end - start) * progress;
function clearHeaderStyles(header: HTMLElement) {
headerStyleProperties.forEach((property) => {
header.style.removeProperty(property);
});
}
function measureHeader(header: HTMLElement): HeaderMetrics | null {
clearHeaderStyles(header);
const slot = header.parentElement;
if (!slot) return null;
slot.style.removeProperty("--header-slot-height");
const headerNav = header.querySelector<HTMLElement>(".header-nav");
const banner = header.querySelector<HTMLElement>(".banner");
const siteNav = header.querySelector<HTMLElement>(".site-nav");
if (!headerNav || !banner || !siteNav) return null;
// Measure the expanded footprint, including its bottom spacing, once.
// Only the contents animate; the following page content never reflows.
slot.style.setProperty(
"--header-slot-height",
`${slot.getBoundingClientRect().height}px`,
);
return {
header,
expandedWidth: header.getBoundingClientRect().width,
expandedNavHeight: headerNav.getBoundingClientRect().height,
expandedBannerWidth: banner.getBoundingClientRect().width,
expandedSiteNavWidth: siteNav.getBoundingClientRect().width,
rootFontSize: Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
),
};
}
function updateHeaderState() {
const header = document.querySelector<HTMLElement>(".site-header");
if (!header) return;
2026-09-17 04:12:43 +00:00
updateScrollVisibility(header.parentElement, window.scrollY);
2026-09-17 04:05:55 +00:00
if (!desktopHeaderQuery.matches) {
clearHeaderStyles(header);
header.parentElement?.style.removeProperty("--header-slot-height");
headerMetrics = null;
return;
}
if (!headerMetrics || headerMetrics.header !== header) {
headerMetrics = measureHeader(header);
}
if (!headerMetrics) return;
const {
expandedWidth,
expandedNavHeight,
expandedBannerWidth,
expandedSiteNavWidth,
rootFontSize,
} = headerMetrics;
const rawProgress = Math.min(
Math.max(window.scrollY / HEADER_SCROLL_DISTANCE, 0),
1,
);
const progress = rawProgress;
const viewportWidth = document.documentElement.clientWidth;
const compactLogoWidth = Math.min(
Math.max(9 * rootFontSize, viewportWidth * 0.22),
12 * rootFontSize,
);
const headerWidth = lerp(expandedWidth, viewportWidth, progress);
const headerOffset = -(headerWidth - expandedWidth) / 2;
const inlinePadding = lerp(0, rootFontSize, progress);
const compactSiteNavWidth = Math.max(
viewportWidth -
2 * rootFontSize -
compactLogoWidth -
7.5 * rootFontSize,
0,
);
const compactNavGap = Math.min(
Math.max(0.75 * rootFontSize, viewportWidth * 0.02),
1.5 * rootFontSize,
);
const values: Record<string, string> = {
"--header-width": `${headerWidth}px`,
"--header-offset": `${headerOffset}px`,
"--header-inline-padding": `${inlinePadding}px`,
"--header-surface-weight": `${lerp(0, 94, progress)}%`,
"--header-blur": `${lerp(0, 10, progress)}px`,
"--theme-top": `${lerp(0, 0.85 * rootFontSize, progress)}px`,
"--theme-right": `${inlinePadding}px`,
"--header-gap": `${lerp(rootFontSize, 0, progress)}px`,
"--header-nav-height": `${lerp(expandedNavHeight, 3 * rootFontSize, progress)}px`,
"--header-nav-y": `${lerp(0, 0.5 * rootFontSize, progress)}px`,
"--banner-width": `${lerp(expandedBannerWidth, compactLogoWidth, progress)}px`,
"--logo-height": `${lerp(55, 36, progress)}px`,
"--site-nav-width": `${lerp(expandedSiteNavWidth, compactSiteNavWidth, progress)}px`,
"--site-nav-margin": `${lerp(rootFontSize, 0, progress)}px`,
"--site-nav-padding": `${lerp(rootFontSize, 0, progress)}px`,
"--site-nav-x": `${lerp(0, compactLogoWidth + 1.5 * rootFontSize, progress)}px`,
"--site-nav-y": `${lerp(0, -36, progress)}px`,
"--site-nav-gap": `${lerp(1.5 * rootFontSize, compactNavGap, progress)}px`,
"--site-nav-font-size": `${lerp(1.2 * rootFontSize, 1.05 * rootFontSize, progress)}px`,
"--divider-margin-top": `${lerp(0.2 * rootFontSize, 0.45 * rootFontSize, progress)}px`,
"--divider-margin-bottom": `${lerp(2 * rootFontSize, 0, progress)}px`,
"--divider-inner-margin-bottom": `${lerp(rootFontSize, 0, progress)}px`,
"--divider-width": `${headerWidth}px`,
"--divider-offset": `${-inlinePadding}px`,
"--divider-y": `${lerp(0, -0.1 * rootFontSize, progress)}px`,
};
Object.entries(values).forEach(([property, value]) => {
header.style.setProperty(property, value);
});
}
2026-09-17 04:12:43 +00:00
function updateScrollVisibility(
slot: HTMLElement | null,
currentScrollY: number,
) {
if (!slot) return;
const delta = currentScrollY - lastScrollY;
lastScrollY = currentScrollY;
if (
!desktopHeaderQuery.matches ||
currentScrollY <= HEADER_SCROLL_DISTANCE
) {
slot.classList.remove("is-scroll-hidden");
scrollDirection = 0;
directionDistance = 0;
return;
}
if (Math.abs(delta) < 0.5) return;
const nextDirection = Math.sign(delta);
if (nextDirection !== scrollDirection) {
scrollDirection = nextDirection;
directionDistance = 0;
}
directionDistance += Math.abs(delta);
if (directionDistance < DIRECTION_TRIGGER_DISTANCE) return;
slot.classList.toggle("is-scroll-hidden", scrollDirection > 0);
directionDistance = 0;
}
2026-09-17 04:05:55 +00:00
function requestHeaderUpdate() {
if (headerFrame) return;
headerFrame = window.requestAnimationFrame(() => {
headerFrame = 0;
updateHeaderState();
});
}
function bindFooterDividerObserver() {
footerDividerObserver?.disconnect();
footerDividerObserver = null;
const slot = document.querySelector<HTMLElement>(".header-slot");
const footerDivider = document.querySelector<HTMLElement>(
"[data-footer-divider]",
);
if (!slot) return;
2026-09-17 04:12:43 +00:00
slot.classList.remove("is-footer-hidden");
2026-09-17 04:05:55 +00:00
if (!desktopHeaderQuery.matches || !footerDivider) return;
footerDividerObserver = new IntersectionObserver(([entry]) => {
2026-09-17 04:12:43 +00:00
slot.classList.toggle("is-footer-hidden", entry.isIntersecting);
2026-09-17 04:05:55 +00:00
});
footerDividerObserver.observe(footerDivider);
}
window.addEventListener("scroll", requestHeaderUpdate, { passive: true });
window.addEventListener("resize", () => {
headerMetrics = null;
requestHeaderUpdate();
});
desktopHeaderQuery.addEventListener("change", () => {
requestHeaderUpdate();
bindFooterDividerObserver();
});
document.addEventListener("astro:page-load", () => {
headerMetrics = null;
2026-09-17 04:12:43 +00:00
lastScrollY = window.scrollY;
scrollDirection = 0;
directionDistance = 0;
document
.querySelector<HTMLElement>(".header-slot")
?.classList.remove("is-scroll-hidden");
2026-09-17 04:05:55 +00:00
updateHeaderState();
bindFooterDividerObserver();
});
document.fonts.ready.then(() => {
headerMetrics = null;
requestHeaderUpdate();
});
updateHeaderState();
bindFooterDividerObserver();
</script>