Initial release — Astro Rocket v1.0.0
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
---
|
||||
/**
|
||||
* Header Component
|
||||
* Flexible navigation header with variant-based configuration
|
||||
*
|
||||
* Variants:
|
||||
* - layout: 'default' | 'centered' | 'minimal'
|
||||
* - position: 'fixed' | 'sticky' | 'static'
|
||||
* - size: 'sm' | 'md' | 'lg'
|
||||
* - variant: 'default' | 'solid' | 'transparent'
|
||||
* - colorScheme: 'default' | 'invert' (use 'invert' for dark backgrounds)
|
||||
* - shape: 'bar' | 'floating' (use 'floating' for capsule header)
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
|
||||
* - Optional CTA button with customization
|
||||
* - Mobile menu with Escape key support
|
||||
* - Theme toggle
|
||||
* - GitHub/action buttons
|
||||
* - Full slot support for customization
|
||||
* - Inverted color scheme for use on dark/image backgrounds
|
||||
* - Floating capsule shape with scroll-reactive bg + color flip
|
||||
*/
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
|
||||
import { headerVariants, headerInnerVariants } from './header.variants';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import ThemeToggle from '@/components/layout/ThemeToggle.astro';
|
||||
import ThemeSelector from '@/components/layout/ThemeSelector.astro';
|
||||
import ThemeSelectorDropdown from '@/components/layout/ThemeSelectorDropdown.astro';
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface HeaderAction {
|
||||
icon: string;
|
||||
href: string;
|
||||
label: string;
|
||||
iconOnly?: boolean;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<'header'> {
|
||||
/** Layout style: default (logo left, nav right), centered (logo center), minimal (logo + cta only) */
|
||||
layout?: 'default' | 'centered' | 'minimal';
|
||||
/** Position behavior */
|
||||
position?: 'fixed' | 'sticky' | 'static';
|
||||
/** Header height */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Background variant */
|
||||
variant?: 'default' | 'solid' | 'transparent';
|
||||
/** Color scheme for text/icons - use 'invert' for dark backgrounds */
|
||||
colorScheme?: 'default' | 'invert';
|
||||
/** Shape: 'bar' (full-width, default) or 'floating' (centered capsule) */
|
||||
shape?: 'bar' | 'floating';
|
||||
/** Override default navigation (replaces getNavRoutes()) */
|
||||
nav?: NavItem[];
|
||||
/** Additional navigation items (e.g., #features for landing pages) */
|
||||
extraNav?: NavItem[];
|
||||
/** Show CTA button */
|
||||
showCta?: boolean;
|
||||
/** CTA button configuration */
|
||||
cta?: { label?: string; href?: string; icon?: string };
|
||||
/** Action buttons (GitHub, etc.) */
|
||||
actions?: HeaderAction[];
|
||||
/** Show theme toggle (default: true) */
|
||||
showThemeToggle?: boolean;
|
||||
/** Show colour-theme selector swatches */
|
||||
showThemeSelector?: boolean;
|
||||
/** Show mobile menu (default: true) */
|
||||
showMobileMenu?: boolean;
|
||||
/** Show active state for current page (default: true) */
|
||||
showActiveState?: boolean;
|
||||
/** Logo text override */
|
||||
logoText?: string;
|
||||
/** Hide logo entirely */
|
||||
hideLogo?: boolean;
|
||||
/** Show language switcher */
|
||||
showLanguageSwitcher?: boolean;
|
||||
/** Show social icon links (desktop/tablet only, reads from siteConfig.socialLinks) */
|
||||
showSocialLinks?: boolean;
|
||||
/** Show scroll progress bar at the bottom of the header */
|
||||
showScrollProgress?: boolean;
|
||||
/** Position of the scroll progress bar: 'top' (above header) or 'bottom' (below header, default) */
|
||||
scrollProgressPosition?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const {
|
||||
layout = 'default',
|
||||
position = 'fixed',
|
||||
size = 'lg',
|
||||
variant = 'solid',
|
||||
colorScheme = 'default',
|
||||
shape = 'bar',
|
||||
nav,
|
||||
extraNav = [],
|
||||
showCta = false,
|
||||
cta = { label: 'Start a project', href: '/contact' },
|
||||
actions = [],
|
||||
showThemeToggle = true,
|
||||
showThemeSelector = false,
|
||||
showMobileMenu = true,
|
||||
showSocialLinks = false,
|
||||
showActiveState = true,
|
||||
showScrollProgress = false,
|
||||
scrollProgressPosition = 'bottom',
|
||||
logoText,
|
||||
hideLogo = false,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
// Shape + color scheme helpers
|
||||
const isFloating = shape === 'floating';
|
||||
const isInvert = colorScheme === 'invert';
|
||||
|
||||
// Get navigation items
|
||||
const defaultNav = getNavItems().map((item: NavConfigItem) => ({
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
}));
|
||||
const navItems: NavItem[] = nav || [...extraNav, ...defaultNav];
|
||||
|
||||
// Current path for active state
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
// Check if we're on the landing page
|
||||
const isLandingPage = currentPath === '/';
|
||||
|
||||
// Process CTA href for landing page anchor links
|
||||
const ctaHref = cta.href?.startsWith('#') && !isLandingPage ? `/${cta.href}` : cta.href;
|
||||
|
||||
// Check slots
|
||||
const hasLogoSlot = Astro.slots.has('logo');
|
||||
const hasNavSlot = Astro.slots.has('nav');
|
||||
const hasActionsSlot = Astro.slots.has('actions');
|
||||
const hasMobileMenuSlot = Astro.slots.has('mobile-menu');
|
||||
|
||||
// Compute header classes
|
||||
const headerClasses = cn(
|
||||
headerVariants({ position, variant, shape }),
|
||||
isInvert && !isFloating && 'invert-section',
|
||||
className
|
||||
);
|
||||
|
||||
// Compute inner container classes
|
||||
const innerClasses = headerInnerVariants({ size, shape });
|
||||
|
||||
// Check if a nav item is active
|
||||
function isActive(href: string): boolean {
|
||||
if (!showActiveState) return false;
|
||||
if (href.startsWith('#')) return false;
|
||||
return currentPath === href || currentPath.startsWith(href + '/');
|
||||
}
|
||||
|
||||
// Map a social URL to its icon name + accessible label
|
||||
function getSocialIconData(url: string): { icon: string; label: string } {
|
||||
if (url.includes('github.com')) return { icon: 'github', label: 'GitHub' };
|
||||
if (url.includes('instagram.com')) return { icon: 'instagram', label: 'Instagram' };
|
||||
if (url.includes('x.com') || url.includes('twitter.com')) return { icon: 'x-twitter', label: 'X' };
|
||||
if (url.includes('linkedin.com')) return { icon: 'linkedin', label: 'LinkedIn' };
|
||||
if (url.includes('bsky.app')) return { icon: 'bluesky', label: 'Bluesky' };
|
||||
return { icon: 'link', label: 'Social' };
|
||||
}
|
||||
|
||||
// Generate unique ID for this header instance
|
||||
const menuId = `mobile-menu-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const buttonId = `${menuId}-button`;
|
||||
---
|
||||
|
||||
<header
|
||||
class={headerClasses}
|
||||
data-menu-id={menuId}
|
||||
data-button-id={buttonId}
|
||||
data-header-shape={shape}
|
||||
data-header-variant={variant}
|
||||
data-header-color-scheme={colorScheme}
|
||||
{...attrs}
|
||||
>
|
||||
<div class={innerClasses}>
|
||||
{/* Logo */}
|
||||
{
|
||||
!hideLogo &&
|
||||
(hasLogoSlot ? (
|
||||
<slot name="logo" />
|
||||
) : (
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<Logo size={size === 'lg' ? 'lg' : 'md'} forceDark={isInvert} />
|
||||
<span
|
||||
class={cn(
|
||||
'font-display text-xl font-bold tracking-tight',
|
||||
isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-brand-500')
|
||||
)}
|
||||
>
|
||||
{logoText || siteConfig.name}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{
|
||||
layout !== 'minimal' &&
|
||||
(hasNavSlot ? (
|
||||
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
<slot name="nav" />
|
||||
</nav>
|
||||
) : (
|
||||
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
||||
{navItems.map(({ label, href }) => (
|
||||
<a
|
||||
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
||||
class={cn(
|
||||
'nav-link relative rounded-md px-3 py-2 text-sm',
|
||||
'transition-all duration-(--transition-fast)',
|
||||
isFloating && 'hdr-invert-text',
|
||||
isFloating
|
||||
? (isActive(href)
|
||||
? 'hdr-nav-active font-semibold'
|
||||
: 'font-medium opacity-80 hover:opacity-100')
|
||||
: (isActive(href)
|
||||
? 'nav-link-active font-semibold text-foreground bg-secondary'
|
||||
: 'nav-link-inactive font-medium text-foreground-muted hover:text-foreground hover:bg-secondary/70')
|
||||
)}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Actions Area */}
|
||||
<div class="flex items-center gap-2 justify-self-end">
|
||||
{
|
||||
hasActionsSlot ? (
|
||||
<slot name="actions" />
|
||||
) : (
|
||||
<>
|
||||
{showThemeToggle && (
|
||||
<ThemeToggle class={isFloating ? 'hdr-invert-text' : undefined} />
|
||||
)}
|
||||
|
||||
{showThemeSelector && (
|
||||
<div class="hidden md:flex">
|
||||
<ThemeSelectorDropdown class={isFloating ? 'hdr-invert-text' : undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSocialLinks && siteConfig.socialLinks.length > 0 && (
|
||||
<div class="hidden md:flex items-center gap-0.5">
|
||||
{siteConfig.socialLinks.map((url) => {
|
||||
const { icon, label } = getSocialIconData(url);
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
class={cn(
|
||||
'rounded-md p-2 transition-colors duration-(--transition-fast)',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isFloating
|
||||
? 'hdr-invert-text'
|
||||
: 'text-foreground-muted hover:text-foreground hover:bg-secondary/70'
|
||||
)}
|
||||
>
|
||||
<Icon name={icon} size="md" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={action.iconOnly}
|
||||
href={action.href}
|
||||
target={action.target}
|
||||
aria-label={action.label}
|
||||
class={isFloating ? 'hdr-invert-text' : undefined}
|
||||
>
|
||||
<Icon name={action.icon} size="sm" />
|
||||
{!action.iconOnly && action.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{showCta && (
|
||||
<div class="hidden md:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
href={ctaHref}
|
||||
target={ctaHref?.startsWith('http') ? '_blank' : undefined}
|
||||
class={cn('hdr-cta-brand', isFloating ? 'hdr-invert-cta' : undefined)}
|
||||
>
|
||||
{cta.icon && <Icon name={cta.icon} size="sm" />}
|
||||
{cta.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
{
|
||||
showMobileMenu && layout !== 'minimal' && (
|
||||
<button
|
||||
type="button"
|
||||
id={buttonId}
|
||||
class={cn(
|
||||
'inline-flex items-center justify-center rounded-md p-2 md:hidden',
|
||||
'transition-colors',
|
||||
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
||||
isFloating
|
||||
? 'hdr-invert-text'
|
||||
: 'text-foreground-muted hover:text-foreground hover:bg-secondary'
|
||||
)}
|
||||
aria-expanded="false"
|
||||
aria-controls={menuId}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<span class="menu-icon">
|
||||
<Icon name="menu" size="md" />
|
||||
</span>
|
||||
<span class="close-icon hidden">
|
||||
<Icon name="x" size="md" />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Progress Bar */}
|
||||
{showScrollProgress && (
|
||||
<div
|
||||
id="scroll-progress-bar"
|
||||
class={`absolute left-0 h-[2px] w-0 bg-brand-500 transition-none ${scrollProgressPosition === 'top' ? 'top-0' : 'bottom-0'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{
|
||||
showMobileMenu &&
|
||||
layout !== 'minimal' &&
|
||||
(hasMobileMenuSlot ? (
|
||||
<div
|
||||
id={menuId}
|
||||
class={cn(
|
||||
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
||||
isFloating
|
||||
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
||||
: 'border-border bg-background border-t'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<slot name="mobile-menu" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
id={menuId}
|
||||
class={cn(
|
||||
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
||||
isFloating
|
||||
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
||||
: 'border-border bg-background border-t'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div class={cn(
|
||||
'space-y-1 py-4',
|
||||
isFloating ? 'px-4' : 'mx-auto max-w-6xl px-6'
|
||||
)}>
|
||||
{navItems.map(({ label, href }) => (
|
||||
<a
|
||||
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
||||
class={cn(
|
||||
'mobile-nav-link block rounded-md px-3 py-2 text-sm',
|
||||
'transition-all duration-(--transition-fast)',
|
||||
isActive(href)
|
||||
? 'mobile-nav-link-active bg-secondary text-foreground font-semibold'
|
||||
: 'mobile-nav-link-inactive text-foreground-muted hover:bg-secondary/70 hover:text-foreground font-medium'
|
||||
)}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{showCta && (
|
||||
<div class="border-border mt-3 border-t pt-3">
|
||||
<Button fullWidth href={ctaHref} target={ctaHref?.startsWith('http') ? '_blank' : undefined}>
|
||||
{cta.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showThemeSelector && (
|
||||
<div class="border-border mt-3 border-t pt-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-sm text-foreground-muted">Colour theme</span>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Backdrop - positioned outside header to blur page content */}
|
||||
{
|
||||
showMobileMenu && layout !== 'minimal' && (
|
||||
<div
|
||||
id={`${menuId}-backdrop`}
|
||||
class="pointer-events-none fixed inset-0 z-40 opacity-0 transition-opacity duration-200 md:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
function initMobileMenu() {
|
||||
const menuHeaders = document.querySelectorAll<HTMLElement>('header[data-menu-id]');
|
||||
menuHeaders.forEach((header) => {
|
||||
const menuId = header.dataset.menuId!;
|
||||
const buttonId = header.dataset.buttonId!;
|
||||
const isFloating = header.dataset.headerShape === 'floating';
|
||||
|
||||
const button = document.getElementById(buttonId);
|
||||
const menu = document.getElementById(menuId);
|
||||
const backdrop = document.getElementById(`${menuId}-backdrop`);
|
||||
const menuIcon = button?.querySelector('.menu-icon');
|
||||
const closeIcon = button?.querySelector('.close-icon');
|
||||
|
||||
if (!button || !menu || !menuIcon || !closeIcon) return;
|
||||
if (button.dataset.menuInit) return;
|
||||
button.dataset.menuInit = 'true';
|
||||
|
||||
let isOpen = false;
|
||||
let isAnimating = false;
|
||||
|
||||
function open() {
|
||||
if (isOpen || isAnimating) return;
|
||||
isAnimating = true;
|
||||
isOpen = true;
|
||||
|
||||
button!.setAttribute('aria-expanded', 'true');
|
||||
menuIcon!.classList.add('hidden');
|
||||
closeIcon!.classList.remove('hidden');
|
||||
|
||||
if (isFloating) {
|
||||
// Force scrolled state + flatten bottom corners
|
||||
header.setAttribute('data-scrolled', '');
|
||||
header.classList.remove('rounded-2xl');
|
||||
header.classList.add('rounded-t-2xl');
|
||||
} else {
|
||||
header.classList.add('!bg-background');
|
||||
}
|
||||
|
||||
// Fade out and blur the page content
|
||||
const mainContent = document.querySelector('main');
|
||||
const footer = document.querySelector('footer');
|
||||
if (mainContent) mainContent.classList.add('mobile-menu-blur');
|
||||
if (footer) footer.classList.add('mobile-menu-blur');
|
||||
|
||||
// Show menu and backdrop with animations
|
||||
menu!.classList.remove('hidden', 'animate-menu-up', 'opacity-0', 'scale-y-0');
|
||||
menu!.classList.add('animate-menu-down');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('pointer-events-none', 'animate-backdrop-out');
|
||||
backdrop.classList.add('animate-backdrop');
|
||||
}
|
||||
|
||||
isAnimating = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen || isAnimating) return;
|
||||
isAnimating = true;
|
||||
|
||||
button!.setAttribute('aria-expanded', 'false');
|
||||
menuIcon!.classList.remove('hidden');
|
||||
closeIcon!.classList.add('hidden');
|
||||
|
||||
// Start closing animation
|
||||
menu!.classList.remove('animate-menu-down');
|
||||
menu!.classList.add('animate-menu-up');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('animate-backdrop');
|
||||
backdrop.classList.add('animate-backdrop-out');
|
||||
}
|
||||
|
||||
// Restore page content
|
||||
const mainContent = document.querySelector('main');
|
||||
const footer = document.querySelector('footer');
|
||||
if (mainContent) mainContent.classList.remove('mobile-menu-blur');
|
||||
if (footer) footer.classList.remove('mobile-menu-blur');
|
||||
|
||||
// Wait for animation to complete before hiding
|
||||
setTimeout(() => {
|
||||
menu!.classList.add('hidden', 'opacity-0', 'scale-y-0');
|
||||
if (backdrop) {
|
||||
backdrop.classList.add('pointer-events-none');
|
||||
}
|
||||
|
||||
if (isFloating) {
|
||||
// Restore rounded corners
|
||||
header.classList.remove('rounded-t-2xl');
|
||||
header.classList.add('rounded-2xl');
|
||||
// Only remove scrolled if actually at top
|
||||
if (window.scrollY <= 60) {
|
||||
header.removeAttribute('data-scrolled');
|
||||
}
|
||||
} else {
|
||||
header.classList.remove('!bg-background');
|
||||
}
|
||||
|
||||
isOpen = false;
|
||||
isAnimating = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener('click', toggle);
|
||||
|
||||
// Close on backdrop click
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', close);
|
||||
}
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close when clicking on mobile menu links
|
||||
menu.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', close);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initMobileMenu();
|
||||
document.addEventListener('astro:page-load', initMobileMenu);
|
||||
document.addEventListener('astro:after-swap', initMobileMenu);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const SCROLL_THRESHOLD = 60;
|
||||
const BAR_SCROLLED_CLASSES = ['bg-background/80', 'backdrop-blur-lg', 'border-b', 'border-border/50'];
|
||||
|
||||
function initScrollWatcher() {
|
||||
const scrollHeaders = document.querySelectorAll<HTMLElement>('header[data-header-shape="floating"], header[data-header-shape="bar"]');
|
||||
scrollHeaders.forEach((header) => {
|
||||
if (header.dataset.scrollInit) return;
|
||||
header.dataset.scrollInit = 'true';
|
||||
|
||||
const isBar = header.dataset.headerShape === 'bar';
|
||||
const isTransparentBar = isBar && header.dataset.headerVariant === 'transparent';
|
||||
let ticking = false;
|
||||
|
||||
function onScroll() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (window.scrollY > SCROLL_THRESHOLD) {
|
||||
header.setAttribute('data-scrolled', '');
|
||||
if (isTransparentBar) {
|
||||
header.classList.add(...BAR_SCROLLED_CLASSES);
|
||||
header.classList.remove('bg-transparent');
|
||||
}
|
||||
} else {
|
||||
// Don't remove if mobile menu is open
|
||||
const menuId = header.dataset.menuId;
|
||||
const menu = menuId ? document.getElementById(menuId) : null;
|
||||
const menuOpen = menu && !menu.classList.contains('hidden');
|
||||
if (!menuOpen) {
|
||||
header.removeAttribute('data-scrolled');
|
||||
if (isTransparentBar) {
|
||||
header.classList.remove(...BAR_SCROLLED_CLASSES);
|
||||
header.classList.add('bg-transparent');
|
||||
}
|
||||
}
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Set initial state
|
||||
onScroll();
|
||||
});
|
||||
}
|
||||
|
||||
initScrollWatcher();
|
||||
document.addEventListener('astro:page-load', initScrollWatcher);
|
||||
document.addEventListener('astro:after-swap', initScrollWatcher);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function initScrollProgress() {
|
||||
const bar = document.getElementById('scroll-progress-bar');
|
||||
if (!bar) return;
|
||||
if (bar.dataset.progressInit) return;
|
||||
bar.dataset.progressInit = 'true';
|
||||
|
||||
let ticking = false;
|
||||
|
||||
function update() {
|
||||
if (!bar) return;
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||
bar!.style.width = `${pct}%`;
|
||||
ticking = false;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
ticking = true;
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
initScrollProgress();
|
||||
document.addEventListener('astro:page-load', initScrollProgress);
|
||||
document.addEventListener('astro:after-swap', initScrollProgress);
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
.mobile-menu-blur {
|
||||
opacity: 0.3;
|
||||
filter: blur(4px);
|
||||
transition: opacity 200ms, filter 200ms;
|
||||
}
|
||||
|
||||
/* ===== Floating header: scroll state ===== */
|
||||
[data-header-shape="floating"][data-scrolled] {
|
||||
background: color-mix(in oklch, var(--color-background) 92%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: 0 4px 20px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ===== Floating header: color flip (invert → normal on scroll) ===== */
|
||||
|
||||
/* Text elements: on-invert → foreground */
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-text {
|
||||
color: var(--color-on-invert);
|
||||
transition: color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-text {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Logo text: on-invert → brand-500 */
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-logo-text {
|
||||
color: var(--color-on-invert);
|
||||
transition: color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-logo-text {
|
||||
color: var(--color-brand-500);
|
||||
}
|
||||
|
||||
/* Non-invert floating: use normal colors */
|
||||
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-logo-text {
|
||||
color: var(--color-brand-500);
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text {
|
||||
color: var(--color-foreground-muted);
|
||||
transition: color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* ===== Floating nav link underline indicators ===== */
|
||||
[data-header-shape="floating"] .nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transition: left 200ms, right 200ms;
|
||||
}
|
||||
|
||||
[data-header-shape="floating"] .nav-link:hover::after,
|
||||
[data-header-shape="floating"] .nav-link.hdr-nav-active::after {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* ===== CTA: invert color flip ===== */
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta {
|
||||
background: white;
|
||||
color: #111;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
transition: background 300ms, color 300ms, border-color 300ms;
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== Reduced motion ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-header-shape="floating"],
|
||||
[data-header-shape="floating"] .hdr-invert-text,
|
||||
[data-header-shape="floating"] .hdr-logo-text,
|
||||
[data-header-shape="floating"] .hdr-invert-cta,
|
||||
[data-header-shape="floating"] .nav-link::after {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user