Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/mobile-hamburger-nav.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions scripts/check-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
52 changes: 49 additions & 3 deletions src/Header.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<HTMLElement>(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) => (
Expand Down Expand Up @@ -79,11 +109,27 @@ export function Header({ details, theme, className, renderLink }: HeaderProps) {
);

return (
<header className={combinedClassName} data-brand-cta-layout={ctaLayout} style={style} role="banner">
<header ref={headerRef} className={combinedClassName} data-brand-cta-layout={ctaLayout} style={style} role="banner">
<a href="#main-content" className="brand-shell-skip-nav">Skip to main content</a>
<div className="brand-shell-header__inner">
{brandIdentity}
<div className="brand-shell-header__actions">
{hasNavContent && (
<button
type="button"
className="brand-shell-header__menu-toggle"
aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
aria-controls="brand-shell-nav-drawer"
onClick={() => setMenuOpen((o) => !o)}
>
<span className="brand-shell-header__menu-icon" aria-hidden="true">
<span />
<span />
<span />
</span>
</button>
)}
<div id="brand-shell-nav-drawer" className="brand-shell-header__actions">
{navLinks.length > 0 && (
<nav className="brand-shell-header__nav" aria-label="Primary">
<ul className="brand-shell-header__list">
Expand Down
17 changes: 17 additions & 0 deletions src/stories/Header.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,20 @@ export const SkipNavFocused: Story = {
},
},
};

export const MobileHamburger: Story = {
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
viewport: { defaultViewport: "mobile2" },
docs: {
description: {
story: "At ≤640px the hamburger toggle appears. Click it to open/close the nav drawer. Press Escape or click outside to close.",
},
},
},
args: {
stickyHeader: true,
},
};
26 changes: 26 additions & 0 deletions src/stories/Shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,32 @@ export const MobileLayout: Story = {
),
};

export const MobileHamburger: Story = {
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
viewport: { defaultViewport: "mobile2" },
},
render: () => (
<div style={shellRootStyle}>
<style>{`.storybook-sticky-header{position:sticky;top:0;left:0;right:0;width:100%;z-index:40;}`}</style>
<Header
className="storybook-sticky-header"
details={fullDetails}
theme={{ socialIconSize: "2.2rem", ctaLayout: "inline" }}
/>
<main id="main-content" style={{ ...shellMainStyle, padding: "1rem 0.9rem" }}>
<h1 style={shellHeadingStyle}>Mobile hamburger nav</h1>
<p style={shellParagraphStyle}>
Tap the ☰ button to open the nav drawer. Tap again, press Escape, or tap outside to close.
</p>
</main>
<Footer details={fullDetails} theme={{ socialIconSize: "2.2rem", ctaLayout: "inline" }} />
</div>
),
};

export const StickyHeaderScroll: Story = {
render: () => (
<div style={stickyViewportStyle}>
Expand Down
71 changes: 71 additions & 0 deletions src/web/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,77 @@ describe("web adapter smoke", () => {
expect(attrs["shell-class"]).toBe("shell-attrs");
});

it("renders hamburger toggle button when header has nav content", () => {
registerBrandShellElements();

const header = document.createElement("brand-header");
header.setAttribute("details", JSON.stringify({
name: "Brand Shell",
navLinks: [{ label: "Docs", href: "/docs" }],
}));
document.body.append(header);

const toggle = header.querySelector<HTMLButtonElement>(".brand-shell-header__menu-toggle");
expect(toggle).not.toBeNull();
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(toggle?.getAttribute("aria-label")).toBe("Open menu");
expect(toggle?.getAttribute("aria-controls")).toBe("brand-shell-nav-drawer");

const drawer = header.querySelector("#brand-shell-nav-drawer");
expect(drawer).not.toBeNull();
});

it("toggles aria-expanded on hamburger button click", () => {
registerBrandShellElements();

const header = document.createElement("brand-header");
header.setAttribute("details", JSON.stringify({
name: "Brand Shell",
navLinks: [{ label: "Docs", href: "/docs" }],
}));
document.body.append(header);

const toggle = header.querySelector<HTMLButtonElement>(".brand-shell-header__menu-toggle")!;
expect(toggle.getAttribute("aria-expanded")).toBe("false");

toggle.click();
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(toggle.getAttribute("aria-label")).toBe("Close menu");

toggle.click();
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(toggle.getAttribute("aria-label")).toBe("Open menu");
});

it("closes hamburger menu on Escape key", () => {
registerBrandShellElements();

const header = document.createElement("brand-header");
header.setAttribute("details", JSON.stringify({
name: "Brand Shell",
navLinks: [{ label: "Docs", href: "/docs" }],
}));
document.body.append(header);

const toggle = header.querySelector<HTMLButtonElement>(".brand-shell-header__menu-toggle")!;
toggle.click();
expect(toggle.getAttribute("aria-expanded")).toBe("true");

document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});

it("does not render hamburger toggle when header has no nav content", () => {
registerBrandShellElements();

const header = document.createElement("brand-header");
header.setAttribute("details", JSON.stringify({ name: "Brand Shell" }));
document.body.append(header);

const toggle = header.querySelector(".brand-shell-header__menu-toggle");
expect(toggle).toBeNull();
});

it("supports custom tag names", () => {
const names = registerBrandShellElements({
headerTagName: "brand-header-smoke",
Expand Down
77 changes: 74 additions & 3 deletions src/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,63 @@ abstract class BaseBrandShellElement extends HTMLElementBase {
}

export class BrandHeaderElement extends BaseBrandShellElement {
private _menuOpen = false;
private _cleanupMenu: (() => void) | null = null;

disconnectedCallback() {
this._cleanupMenu?.();
this._cleanupMenu = null;
}

protected build(
details: BrandDetails,
theme: BrandTheme | null,
shellClass: string | null,
linkFactory: ((options: LinkFactoryOptions) => HTMLAnchorElement) | null,
): HTMLElement {
return createHeader(details, theme, shellClass, linkFactory ?? undefined);
// Clean up previous event listeners before rebuilding
this._cleanupMenu?.();
this._cleanupMenu = null;

const header = createHeader(details, theme, shellClass, linkFactory ?? undefined, this._menuOpen);

const toggle = header.querySelector<HTMLButtonElement>(".brand-shell-header__menu-toggle");
if (toggle) {
const onToggle = () => {
this._menuOpen = !this._menuOpen;
toggle.setAttribute("aria-expanded", String(this._menuOpen));
toggle.setAttribute("aria-label", this._menuOpen ? "Close menu" : "Open menu");
};

const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && this._menuOpen) {
this._menuOpen = false;
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Open menu");
toggle.focus();
}
};

const onPointerDown = (e: PointerEvent) => {
if (this._menuOpen && !header.contains(e.target as Node)) {
this._menuOpen = false;
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Open menu");
}
};

toggle.addEventListener("click", onToggle);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown);

this._cleanupMenu = () => {
toggle.removeEventListener("click", onToggle);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown);
};
}

return header;
}
}

Expand Down Expand Up @@ -292,6 +342,7 @@ function createHeader(
theme: BrandTheme | null,
shellClass: string | null,
linkFactory?: (options: LinkFactoryOptions) => HTMLAnchorElement,
menuOpen = false,
): HTMLElement {
const header = document.createElement("header");
header.className = joinClassNames("brand-shell-header", shellClass);
Expand Down Expand Up @@ -321,11 +372,31 @@ function createHeader(
}
inner.append(identity);

const { navLinks, ctaLinks, socialLinks } = buildShellViewModel(details);
const hasNavContent = navLinks.length > 0 || ctaLinks.length > 0 || socialLinks.length > 0;

if (hasNavContent) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "brand-shell-header__menu-toggle";
toggle.setAttribute("aria-label", menuOpen ? "Close menu" : "Open menu");
toggle.setAttribute("aria-expanded", String(menuOpen));
toggle.setAttribute("aria-controls", "brand-shell-nav-drawer");

const icon = document.createElement("span");
icon.className = "brand-shell-header__menu-icon";
icon.setAttribute("aria-hidden", "true");
for (let i = 0; i < 3; i++) {
icon.append(document.createElement("span"));
}
toggle.append(icon);
inner.append(toggle);
}

const actions = document.createElement("div");
actions.id = "brand-shell-nav-drawer";
actions.className = "brand-shell-header__actions";

const { navLinks, ctaLinks, socialLinks } = buildShellViewModel(details);

if (navLinks.length > 0) {
actions.append(createNav(navLinks, "brand-shell-header", "Primary", linkFactory));
}
Expand Down
Loading