diff --git a/.changeset/mobile-hamburger-nav.md b/.changeset/mobile-hamburger-nav.md new file mode 100644 index 0000000..18d97ec --- /dev/null +++ b/.changeset/mobile-hamburger-nav.md @@ -0,0 +1,7 @@ +--- +"brand-shell": minor +--- + +Add mobile hamburger navigation to the Header component. + +All adapters (React, Web Component, Vue, Svelte) now render a hamburger toggle button when nav content is present. The menu opens and closes via `aria-expanded`, which drives CSS show/hide with no extra JS class manipulation. Keyboard (Escape) and outside-click gestures close the drawer. No API changes — existing `navLinks`, CTA, and social-link props continue to work as before. diff --git a/scripts/check-pack.cjs b/scripts/check-pack.cjs index adca94f..2ca1fdd 100644 --- a/scripts/check-pack.cjs +++ b/scripts/check-pack.cjs @@ -32,12 +32,14 @@ if (disallowedFiles.length > 0) { ); } -const sizeMatch = /Unpacked size:\s+([\d.]+)KB/.exec(output); +const sizeMatch = /Unpacked size:\s+([\d.]+)(KB|MB)/.exec(output); if (!sizeMatch) { throw new Error("Pack check failed. Could not determine unpacked package size."); } -const unpackedSizeKb = Number(sizeMatch[1]); +const rawSize = Number(sizeMatch[1]); +const unit = sizeMatch[2]; +const unpackedSizeKb = unit === "MB" ? Math.round(rawSize * 1024) : rawSize; if (!Number.isFinite(unpackedSizeKb)) { throw new Error("Pack check failed. Parsed unpacked package size is not a valid number."); } diff --git a/src/Header.tsx b/src/Header.tsx index fceea2b..64fa0ac 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,4 +1,6 @@ -import type { ReactElement, ReactNode } from "react"; +"use client"; + +import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; import type { BrandDetails, BrandTheme } from "./types"; import { assertValidBrandDetails, @@ -49,6 +51,34 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) { const { navLinks, ctaLinks, socialLinks } = buildShellViewModelFromNormalized(normalizedDetails); const combinedClassName = ["brand-shell-header", className].filter(Boolean).join(" "); + const hasNavContent = navLinks.length > 0 || ctaLinks.length > 0 || socialLinks.length > 0; + const [menuOpen, setMenuOpen] = useState(false); + const headerRef = useRef(null); + + const closeMenu = useCallback(() => setMenuOpen(false), []); + + // Close on Escape + useEffect(() => { + if (!menuOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeMenu(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [menuOpen, closeMenu]); + + // Close on outside click + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (e: PointerEvent) => { + if (headerRef.current && !headerRef.current.contains(e.target as Node)) { + closeMenu(); + } + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [menuOpen, closeMenu]); + const LinkEl = renderLink ? renderLink : ({ href, className: cls, "aria-label": ariaLabel, target, rel, children }: LinkRenderProps) => ( @@ -79,11 +109,27 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) { ); return ( -
+
Skip to main content
{brandIdentity} -
+ {hasNavContent && ( + + )} +
{navLinks.length > 0 && (