A site header with a brand slot, navigation links and optional actions; once the header itself is at least `64rem` wide the links sit inline, and narrower a menu button opens them in a right-hand Drawer.
registry/behivetech/organisms/site-header · depends on @behivetech/atoms.button, @behivetech/atoms.drawer, @behivetech/icons.base, @behivetech/atoms.nav-item, @behivetech/icons.menu
site-header.tsx
1import {2 type AnchorHTMLAttributes,3 type ElementType,4 type HTMLAttributes,5 type ReactNode,6 Suspense,7 lazy,8 useEffect,9 useRef,10 useState,11} from "react";12import { getClassName } from "@behivetech/get-class-name";13import { Button } from "@behivetech/atoms.button";14import { MenuIcon } from "@behivetech/icons.menu";15import { NavItem } from "@behivetech/atoms.nav-item";16import styles from "./site-header.module.scss";1718/** Loads the Drawer module; repeat calls share the one request. */19const loadDrawer = () =>20 import("@behivetech/atoms.drawer").then((module) => ({21 default: module.Drawer,22 }));2324// The Drawer (Radix Dialog and its focus handling) is about 10 KB gzipped and25// opens only after a tap at phone width, so it is fetched on demand: ahead of26// the tap when the reader's pointer or focus reaches the menu button, and at27// the latest when the button is pressed.28const Drawer = lazy(loadDrawer);2930/** Starts the request ahead of the tap; a failure here is retried by the tap itself. */31const prefetchDrawer = () => {32 loadDrawer().catch(() => undefined);33};3435/** One navigation link of the header. */36export interface SiteHeaderLink {37 /** Destination of the link */38 href: string;39 /** Visible text of the link, already translated */40 label: string;41 /** Marks the link for the current page: renders `aria-current="page"` */42 current?: boolean;43}4445/** Props the header passes to `linkComponent` for every link. */46export type SiteHeaderLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {47 href: string;48};4950/** Props of the `SiteHeader` component. */51export interface SiteHeaderProps extends Omit<52 HTMLAttributes<HTMLElement>,53 "children"54> {55 /** Additional class names to merge with the component root element */56 className?: string;57 /** Brand slot at the start of the bar; rendered as given, so pass your own home link */58 brand: ReactNode;59 /** Navigation links: inline once the header itself is at least `64rem` wide (a container query), in a Drawer when narrower */60 links: SiteHeaderLink[];61 /** Optional actions that stay in the bar at every breakpoint, e.g. a booking button */62 actions?: ReactNode;63 /**64 * Component that renders each link, so a router link such as Next's `Link`65 * can be injected. Receives `href`, `children` and the item's props. Defaults to `a`.66 */67 linkComponent?: ElementType<SiteHeaderLinkProps>;68 /** Accessible name of the menu button, the navigation landmarks and the Drawer title, already translated */69 menuLabel: string;70}7172interface HeaderLinksProps {73 links: SiteHeaderLink[];74 linkComponent: ElementType<SiteHeaderLinkProps>;75 onNavigate?: () => void;76}7778const HeaderLinks = ({79 links,80 linkComponent: LinkComponent,81 onNavigate,82}: HeaderLinksProps) => (83 <>84 {links.map(({ href, label, current }) => (85 <NavItem86 asChild87 key={`${href}:${label}`}88 active={current}89 onClick={onNavigate}90 >91 <LinkComponent href={href}>{label}</LinkComponent>92 </NavItem>93 ))}94 </>95);9697interface MenuDrawerProps {98 links: SiteHeaderLink[];99 linkComponent: ElementType<SiteHeaderLinkProps>;100 menuLabel: string;101 navClassName: string;102 onClose: () => void;103}104105/** The Drawer that holds the links while the header is narrow; nothing shows while its code loads. */106const MenuDrawer = ({107 links,108 linkComponent,109 menuLabel,110 navClassName,111 onClose,112}: MenuDrawerProps) => (113 <Suspense fallback={null}>114 <Drawer115 placement="right"116 title={menuLabel}117 headingLevel={2}118 padding="sm"119 onClose={onClose}120 >121 <nav aria-label={menuLabel} className={navClassName}>122 <HeaderLinks123 links={links}124 linkComponent={linkComponent}125 onNavigate={onClose}126 />127 </nav>128 </Drawer>129 </Suspense>130);131132interface HeaderMenuProps {133 links: SiteHeaderLink[];134 linkComponent: ElementType<SiteHeaderLinkProps>;135 menuLabel: string;136}137138/**139 * Returns a ref for the menu button and puts focus back on it once the Drawer140 * closes. The Drawer unmounts with its Radix Dialog, so Radix has no trigger141 * to hand focus back to.142 */143const useReturnFocus = (isOpen: boolean) => {144 const menuButtonRef = useRef<HTMLElement | null>(null);145146 useEffect(() => {147 if (!isOpen) {148 menuButtonRef.current?.focus();149 }150 }, [isOpen]);151152 return menuButtonRef;153};154155/** Menu button and the Drawer it opens; shown only while the header is narrow. */156const HeaderMenu = ({ links, linkComponent, menuLabel }: HeaderMenuProps) => {157 const [isMenuOpen, setIsMenuOpen] = useState(false);158 const [, getChildClass] = getClassName({159 rootClass: "BHT__SiteHeader",160 styles,161 });162 const menuButtonRef = useReturnFocus(isMenuOpen);163164 const closeMenu = () => setIsMenuOpen(false);165166 return (167 <div className={getChildClass("menu")}>168 <Button169 variant="ghost"170 size="lg"171 iconOnly172 aria-label={menuLabel}173 aria-haspopup="dialog"174 aria-expanded={isMenuOpen}175 onPointerEnter={prefetchDrawer}176 onFocus={prefetchDrawer}177 onClick={(event) => {178 const button = event.currentTarget;179180 // The menu counts as open (and `aria-expanded` turns true) only once181 // the Drawer's code is here, so assistive technology is never told182 // about a dialog that is not there yet. A failed request leaves the183 // menu closed, and the next tap asks again.184 loadDrawer()185 .then(() => {186 menuButtonRef.current = button;187 setIsMenuOpen(true);188 })189 .catch(() => undefined);190 }}191 >192 <MenuIcon />193 </Button>194 {isMenuOpen ? (195 <MenuDrawer196 links={links}197 linkComponent={linkComponent}198 menuLabel={menuLabel}199 navClassName={getChildClass("drawer-nav")}200 onClose={closeMenu}201 />202 ) : null}203 </div>204 );205};206207/**208 * Site header with a brand slot, navigation links and optional actions. While209 * the header is at least `64rem` wide the links sit inline; narrower, a menu210 * button opens them in a right-hand `Drawer`, which keeps the links in reach of211 * one thumb on a phone. The switch follows the header's own width (a container212 * query), not the viewport.213 */214export const SiteHeader = ({215 className,216 brand,217 links,218 actions,219 linkComponent = "a",220 menuLabel,221 ...rest222}: SiteHeaderProps) => {223 const [rootClass, getChildClass] = getClassName({224 className,225 rootClass: "BHT__SiteHeader",226 styles,227 });228229 return (230 <header {...rest} className={rootClass}>231 <div className={getChildClass("bar")}>232 <div className={getChildClass("brand")}>{brand}</div>233 <div className={getChildClass("end")}>234 <nav aria-label={menuLabel} className={getChildClass("nav")}>235 <HeaderLinks links={links} linkComponent={linkComponent} />236 </nav>237 {actions ? (238 <div className={getChildClass("actions")}>{actions}</div>239 ) : null}240 <HeaderMenu241 links={links}242 linkComponent={linkComponent}243 menuLabel={menuLabel}244 />245 </div>246 </div>247 </header>248 );249};
site-header.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;23// Surface bar with a hairline divider. The root is a named inline-size4// container, so the links go inline once the header itself is 64rem wide and5// the menu button opens them in a Drawer below that. A container cannot style6// itself, so the layout lives on the inner `__bar`.7.BHT__SiteHeader {8 container: site-header / inline-size;9 background: var(--md-sys-color-surface);10 color: var(--md-sys-color-on-surface);11 border-bottom: 1px solid var(--md-sys-color-outline-variant);1213 &__bar {14 display: flex;15 align-items: center;16 justify-content: space-between;17 gap: var(--spacing-4, 1rem);18 min-height: 4rem;19 padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);20 }2122 &__brand {23 display: flex;24 align-items: center;25 min-width: 0;26 }2728 &__end {29 display: flex;30 align-items: center;31 gap: var(--spacing-2, 0.5rem);32 }3334 &__nav {35 display: none;36 }3738 &__actions {39 display: flex;40 align-items: center;41 }4243 &__menu {44 display: flex;45 }4647 // Full-width stacked items; the gap keeps their 48px touch targets apart.48 &__drawer-nav {49 display: flex;50 flex-direction: column;51 gap: var(--spacing-2, 0.5rem);52 }5354 // A container query on the header's own width, not a media query (base-styles'55 // `lg` mixin): the header can sit in a narrow column of a wide page. The56 // threshold is the shared `$breakpoint-lg`. It sits last so it wins over the57 // narrow rules of the same elements.58 @container site-header (min-width: #{breakpoints.$breakpoint-lg}) {59 &__bar {60 padding-inline: var(--spacing-8, 2rem);61 }6263 &__end {64 gap: var(--spacing-6, 1.5rem);65 }6667 &__nav {68 display: flex;69 align-items: center;70 gap: var(--spacing-1, 0.25rem);71 }7273 &__menu {74 display: none;75 }76 }77}
index.ts
1export { SiteHeader } from "./site-header.js";2export type {3 SiteHeaderLink,4 SiteHeaderLinkProps,5 SiteHeaderProps,6} from "./site-header.js";
site-header.example.tsx
1import { Button } from "@behivetech/atoms.button";2import { SiteHeader } from "./site-header.js";34const LINKS = [5 { href: "/guide", label: "Guide", current: true },6 { href: "/gallery", label: "Gallery" },7 { href: "/art", label: "Art" },8 { href: "/artists", label: "Artists" },9];1011/** Brand, four links and no actions. Narrow the header below 64rem to get the menu button and Drawer. */12export const BasicSiteHeader = () => (13 <SiteHeader14 brand={<a href="/">The BEhive</a>}15 links={LINKS}16 menuLabel="Menu"17 />18);1920/** An action stays in the bar at every breakpoint. */21export const SiteHeaderWithActions = () => (22 <SiteHeader23 brand={<a href="/">The BEhive</a>}24 links={LINKS}25 menuLabel="Menu"26 actions={<Button size="sm">Book direct</Button>}27 />28);
site-header-loading.spec.tsx
1import { act, fireEvent, render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { vi } from "vitest";45const DRAWER = "@behivetech/atoms.drawer";6const LINKS = [{ href: "/guide", label: "Guide" }];78/** The header with a fresh module graph, so each test decides when the Drawer's chunk arrives. */9const loadHeader = async () => {10 vi.resetModules();11 const { SiteHeader } = await import("./site-header.js");1213 return () =>14 render(15 <SiteHeader16 brand={<a href="/">Home</a>}17 links={LINKS}18 menuLabel="Menu"19 />,20 );21};2223afterEach(() => {24 vi.doUnmock(DRAWER);25});2627describe("SiteHeader while the Drawer loads", () => {28 it("keeps the menu button collapsed until the Drawer's code has arrived", async () => {29 let arrive: () => void = () => undefined;30 const pending = new Promise<void>((resolve) => {31 arrive = resolve;32 });33 vi.doMock(DRAWER, async () => {34 await pending;35 return {36 Drawer: ({ title }: { title: string }) => (37 <div role="dialog" aria-label={title} />38 ),39 };40 });41 const renderHeader = await loadHeader();42 renderHeader();43 const button = screen.getByRole("button", { name: "Menu" });4445 await userEvent.click(button);46 expect(button).toHaveAttribute("aria-expanded", "false");47 expect(screen.queryByRole("dialog")).toBeNull();4849 await act(async () => {50 arrive();51 await pending;52 });5354 expect(await screen.findByRole("dialog", { name: "Menu" })).toBeTruthy();55 expect(button).toHaveAttribute("aria-expanded", "true");56 });5758 it("stays closed when the Drawer's code fails to load, and opens on the next tap", async () => {59 vi.doMock(DRAWER, () => {60 throw new Error("Loading chunk failed");61 });62 const renderHeader = await loadHeader();63 renderHeader();64 const button = screen.getByRole("button", { name: "Menu" });6566 fireEvent.click(button);67 await act(async () => {68 await Promise.resolve();69 });70 expect(button).toHaveAttribute("aria-expanded", "false");7172 vi.resetModules();73 vi.doMock(DRAWER, () => ({74 Drawer: ({ title }: { title: string }) => (75 <div role="dialog" aria-label={title} />76 ),77 }));78 await userEvent.click(button);7980 expect(await screen.findByRole("dialog", { name: "Menu" })).toBeTruthy();81 });82});
site-header.spec.tsx
1import { forwardRef } from "react";2import { render, screen, within } from "@testing-library/react";3import userEvent from "@testing-library/user-event";4import { SiteHeader, type SiteHeaderLinkProps } from "./site-header.js";56const LINKS = [7 { href: "/guide", label: "Guide", current: true },8 { href: "/gallery", label: "Gallery" },9];1011const renderHeader = (props: Partial<Parameters<typeof SiteHeader>[0]> = {}) =>12 render(13 <SiteHeader14 brand={<a href="/">The BEhive</a>}15 links={LINKS}16 menuLabel="Menu"17 {...props}18 />,19 );2021describe("SiteHeader", () => {22 it("renders the brand, the links and the actions in a banner", () => {23 renderHeader({ actions: <button type="button">Book direct</button> });24 expect(screen.getByRole("banner")).toBeTruthy();25 expect(screen.getByRole("link", { name: "The BEhive" })).toBeTruthy();26 expect(screen.getByRole("link", { name: "Gallery" })).toHaveAttribute(27 "href",28 "/gallery",29 );30 expect(screen.getByRole("button", { name: "Book direct" })).toBeTruthy();31 });3233 it("renders the inline navigation landmark named by menuLabel", () => {34 renderHeader({ menuLabel: "Main menu" });35 expect(screen.getByRole("navigation", { name: "Main menu" })).toBeTruthy();36 });3738 it("marks only the current link with aria-current=page", () => {39 renderHeader();40 expect(screen.getByRole("link", { name: "Guide" })).toHaveAttribute(41 "aria-current",42 "page",43 );44 expect(screen.getByRole("link", { name: "Gallery" })).not.toHaveAttribute(45 "aria-current",46 );47 });4849 it("opens a Drawer listing the links when the menu button is pressed", async () => {50 renderHeader();51 const menuButton = screen.getByRole("button", { name: "Menu" });52 expect(menuButton).toHaveAttribute("aria-expanded", "false");53 expect(screen.queryByRole("dialog")).toBeNull();5455 await userEvent.click(menuButton);5657 const dialog = await screen.findByRole("dialog", { name: "Menu" });58 expect(menuButton).toHaveAttribute("aria-expanded", "true");59 expect(60 within(dialog).getByRole("heading", { level: 2, name: "Menu" }),61 ).toBeTruthy();62 expect(within(dialog).getByRole("link", { name: "Guide" })).toHaveAttribute(63 "aria-current",64 "page",65 );66 expect(within(dialog).getByRole("link", { name: "Gallery" })).toBeTruthy();67 });6869 it("moves focus into the Drawer on open", async () => {70 renderHeader();71 await userEvent.click(screen.getByRole("button", { name: "Menu" }));72 const dialog = await screen.findByRole("dialog", { name: "Menu" });73 expect(dialog.contains(document.activeElement)).toBe(true);74 });7576 it("closes the Drawer on Escape and returns focus to the menu button", async () => {77 renderHeader();78 const menuButton = screen.getByRole("button", { name: "Menu" });79 await userEvent.click(menuButton);80 await screen.findByRole("dialog", { name: "Menu" });81 await userEvent.keyboard("{Escape}");82 expect(screen.queryByRole("dialog")).toBeNull();83 expect(menuButton).toHaveFocus();84 });8586 it("closes the Drawer from its Close button", async () => {87 renderHeader();88 await userEvent.click(screen.getByRole("button", { name: "Menu" }));89 await userEvent.click(await screen.findByRole("button", { name: "Close" }));90 expect(screen.queryByRole("dialog")).toBeNull();91 });9293 it("closes the Drawer when a link in it is followed", async () => {94 // Keeps jsdom from attempting a real navigation; the header's own onClick95 // still runs first, which is what this test asserts.96 const NonNavigatingLink = forwardRef<97 HTMLAnchorElement,98 SiteHeaderLinkProps99 >(({ onClick, ...props }, ref) => (100 <a101 {...props}102 ref={ref}103 onClick={(event) => {104 onClick?.(event);105 event.preventDefault();106 }}107 />108 ));109 NonNavigatingLink.displayName = "NonNavigatingLink";110 renderHeader({ linkComponent: NonNavigatingLink });111 await userEvent.click(screen.getByRole("button", { name: "Menu" }));112 const dialog = await screen.findByRole("dialog", { name: "Menu" });113 await userEvent.click(114 within(dialog).getByRole("link", { name: "Gallery" }),115 );116 expect(screen.queryByRole("dialog")).toBeNull();117 });118119 it("renders every link through linkComponent, with aria-current on the current one", async () => {120 const RouterLink = forwardRef<HTMLAnchorElement, SiteHeaderLinkProps>(121 (props, ref) => <a {...props} ref={ref} data-router-link="true" />,122 );123 RouterLink.displayName = "RouterLink";124 renderHeader({ linkComponent: RouterLink });125126 const guide = screen.getByRole("link", { name: "Guide" });127 expect(guide).toHaveAttribute("data-router-link", "true");128 expect(guide).toHaveAttribute("aria-current", "page");129 expect(screen.getByRole("link", { name: "Gallery" })).toHaveAttribute(130 "data-router-link",131 "true",132 );133134 await userEvent.click(screen.getByRole("button", { name: "Menu" }));135 const dialog = await screen.findByRole("dialog", { name: "Menu" });136 expect(within(dialog).getByRole("link", { name: "Guide" })).toHaveAttribute(137 "data-router-link",138 "true",139 );140 });141});