From 016f73d284ad1b757a4aee141801880729fa9314 Mon Sep 17 00:00:00 2001 From: venwork-dev Date: Mon, 23 Feb 2026 22:07:30 -0600 Subject: [PATCH 1/3] feat: add mobile hamburger navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hamburger toggle button rendered in header when nav content exists; hidden via CSS on desktop (>640px), shown on mobile (≤640px) - Toggle uses aria-expanded to drive open/closed state entirely through CSS — no JS class toggling; sibling selector shows/hides the drawer - Icon animates three bars → × on open using CSS transform - Closes on Escape key, outside pointer click, and toggle re-click - React: useState + useEffect for keyboard/outside handlers - Web component: _menuOpen on BrandHeaderElement instance; event listeners wired in build(), cleaned up on re-render and disconnect - Vue and Svelte inherit behavior through the web component automatically - 4 new web component tests (toggle renders, click toggles, Escape closes, no toggle when no nav content) - New stories: Header/MobileHamburger, Shell/MobileHamburger - prefers-reduced-motion support for hamburger icon animation Co-Authored-By: Claude Opus 4.6 --- src/Header.tsx | 50 ++++++++++++++++++-- src/stories/Header.stories.tsx | 17 +++++++ src/stories/Shell.stories.tsx | 26 +++++++++++ src/web/index.test.ts | 71 ++++++++++++++++++++++++++++ src/web/index.ts | 77 +++++++++++++++++++++++++++++-- styles/default.css | 84 +++++++++++++++++++++++++++++++++- 6 files changed, 318 insertions(+), 7 deletions(-) diff --git a/src/Header.tsx b/src/Header.tsx index fceea2b..3d39af4 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; import type { BrandDetails, BrandTheme } from "./types"; import { assertValidBrandDetails, @@ -49,6 +49,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 +107,27 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) { ); return ( -
+
Skip to main content
{brandIdentity} -
+ {hasNavContent && ( + + )} +
{navLinks.length > 0 && (