diff --git a/src/components/Header.module.css b/src/components/Header.module.css index 34740a61..e6abaf78 100644 --- a/src/components/Header.module.css +++ b/src/components/Header.module.css @@ -16,11 +16,13 @@ .logo { display: inline-flex; + flex-shrink: 0; align-items: center; gap: 0.8rem; font-weight: 800; font-size: 1.6rem; letter-spacing: -0.01em; + white-space: nowrap; } .navDesktop { @@ -31,25 +33,102 @@ font-size: 1.45rem; font-weight: 600; color: var(--text-muted); + white-space: nowrap; } -.navDesktop a:hover { +.navDesktop a:hover, +.moreButton:hover, +.moreButton[aria-expanded='true'] { color: var(--text); } +.header a:focus-visible, +.header button:focus-visible { + outline: 2px solid var(--crest); + outline-offset: 4px; + border-radius: 4px; +} + +.overflow { + position: relative; +} + +.moreButton { + display: inline-flex; + align-items: center; + gap: 0.6rem; + min-height: 44px; + border: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +.moreButton[aria-expanded='true'] .chevron { + transform: rotate(180deg); +} + +.dropdown { + position: absolute; + top: calc(100% + 1.2rem); + right: 0; + display: flex; + flex-direction: column; + min-width: 22rem; + max-height: calc(100dvh - 8rem); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.8rem; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.dropdown a, +.mobile a { + display: flex; + align-items: center; + min-height: 44px; + padding: 0.9rem 1.2rem; + border-radius: 6px; +} + +.dropdown a:hover, +.dropdown a:focus-visible, +.mobile a:not(:global(.btn-primary)):hover, +.mobile a:not(:global(.btn-primary)):focus-visible { + background: var(--bg-elev-2); + color: var(--text); +} + +.dropdown .github { + justify-content: space-between; + margin-top: 0.6rem; + border-top: 1px solid var(--border-soft); + border-radius: 0 0 6px 6px; +} + .actions { display: flex; + flex-shrink: 0; align-items: center; } .hamburger { display: none; + flex-shrink: 0; flex-direction: column; + align-items: center; + justify-content: center; gap: 4px; background: none; border: none; cursor: pointer; padding: 0.6rem; + width: 44px; + height: 44px; } .hamburger span { @@ -59,17 +138,46 @@ border-radius: 2px; } +.hamburger[aria-expanded='true'] span:first-child { + transform: translateY(6px) rotate(45deg); +} + +.hamburger[aria-expanded='true'] span:nth-child(2) { + opacity: 0; +} + +.hamburger[aria-expanded='true'] span:last-child { + transform: translateY(-6px) rotate(-45deg); +} + .mobile { + position: absolute; + top: 100%; + left: 0; + right: 0; display: none; flex-direction: column; - gap: 1.6rem; - padding: 2rem 2.4rem 2.6rem; + gap: 0.4rem; + max-height: calc(100dvh - 6.8rem); + overflow-y: auto; + overscroll-behavior: contain; + padding: 1.2rem 1.8rem 1.8rem; + background: var(--bg-elev); border-bottom: 1px solid var(--border-soft); font-size: 1.6rem; font-weight: 600; + box-shadow: var(--shadow); +} + +.dropdown[hidden], +.mobile[hidden] { + display: none; } @media (max-width: 880px) { + .inner { + gap: 1.2rem; + } .navDesktop, .actions { display: none; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 41dfc0f7..610d276f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,49 +1,113 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { nav, site } from '@/data/site'; import styles from './Header.module.css'; +const primaryPaths: readonly string[] = ['/cards', '/collect', '/contribute']; +const primaryNav = nav.filter((item) => primaryPaths.includes(item.href)); +const overflowNav = nav.filter((item) => !primaryPaths.includes(item.href)); + export default function Header() { - const [open, setOpen] = useState(false); + const [menu, setMenu] = useState<'more' | 'mobile' | null>(null); + const headerRef = useRef(null); + const overflowRef = useRef(null); + const moreButtonRef = useRef(null); + const mobileButtonRef = useRef(null); + + useEffect(() => { + if (!menu) return; + + function dismissOutside(event: Event) { + const container = menu === 'more' ? overflowRef.current : headerRef.current; + if (event.target instanceof Node && !container?.contains(event.target)) { + setMenu(null); + } + } + + function dismissOnEscape(event: KeyboardEvent) { + if (event.key !== 'Escape') return; + event.preventDefault(); + setMenu(null); + (menu === 'more' ? moreButtonRef : mobileButtonRef).current?.focus(); + } + + document.addEventListener('pointerdown', dismissOutside); + document.addEventListener('focusin', dismissOutside); + document.addEventListener('keydown', dismissOnEscape); + return () => { + document.removeEventListener('pointerdown', dismissOutside); + document.removeEventListener('focusin', dismissOutside); + document.removeEventListener('keydown', dismissOnEscape); + }; + }, [menu]); + + useEffect(() => { + const breakpoint = window.matchMedia('(max-width: 880px)'); + const closeMenu = () => setMenu(null); + breakpoint.addEventListener('change', closeMenu); + return () => breakpoint.removeEventListener('change', closeMenu); + }, []); + return ( -
+
- setOpen(false)}> + setMenu(null)}> Open Source Legends -
- {open && ( - - )} +
); }