Site Header
v0.1.4DraftA 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/atoms.icon, @behivetech/atoms.nav-item
site-header.tsx
1import {2 type AnchorHTMLAttributes,3 type ElementType,4 type HTMLAttributes,5 type ReactNode,6 useEffect,7 useRef,8 useState,9} from "react";10import { getClassName } from "@bht-component-registry/class-names";11import { Button } from "@behivetech/atoms.button";12import { Drawer } from "@behivetech/atoms.drawer";13import { MenuIcon } from "@behivetech/atoms.icon";14import { NavItem } from "@behivetech/atoms.nav-item";15import styles from "./site-header.module.scss";1617/** One navigation link of the header. */18export interface SiteHeaderLink {19 /** Destination of the link */20 href: string;21 /** Visible text of the link, already translated */22 label: string;23 /** Marks the link for the current page: renders `aria-current="page"` */24 current?: boolean;25}2627/** Props the header passes to `linkComponent` for every link. */28export type SiteHeaderLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {29 href: string;30};3132/** Props of the `SiteHeader` component. */33export interface SiteHeaderProps extends Omit<34 HTMLAttributes<HTMLElement>,35 "children"36> {37 /** Additional class names to merge with the component root element */38 className?: string;39 /** Brand slot at the start of the bar; rendered as given, so pass your own home link */40 brand: ReactNode;41 /** Navigation links: inline once the header itself is at least `64rem` wide (a container query), in a Drawer when narrower */42 links: SiteHeaderLink[];43 /** Optional actions that stay in the bar at every breakpoint, e.g. a booking button */44 actions?: ReactNode;45 /**46 * Component that renders each link, so a router link such as Next's `Link`47 * can be injected. Receives `href`, `children` and the item's props. Defaults to `a`.48 */49 linkComponent?: ElementType<SiteHeaderLinkProps>;50 /** Accessible name of the menu button, the navigation landmarks and the Drawer title, already translated */51 menuLabel: string;52}5354interface HeaderLinksProps {55 links: SiteHeaderLink[];56 linkComponent: ElementType<SiteHeaderLinkProps>;57 onNavigate?: () => void;58}5960const HeaderLinks = ({61 links,62 linkComponent: LinkComponent,63 onNavigate,64}: HeaderLinksProps) => (65 <>66 {links.map(({ href, label, current }) => (67 <NavItem68 asChild69 key={`${href}:${label}`}70 active={current}71 onClick={onNavigate}72 >73 <LinkComponent href={href}>{label}</LinkComponent>74 </NavItem>75 ))}76 </>77);7879interface MenuDrawerProps {80 links: SiteHeaderLink[];81 linkComponent: ElementType<SiteHeaderLinkProps>;82 menuLabel: string;83 navClassName: string;84 onClose: () => void;85}8687/** The Drawer that holds the links while the header is narrow. */88const MenuDrawer = ({89 links,90 linkComponent,91 menuLabel,92 navClassName,93 onClose,94}: MenuDrawerProps) => (95 <Drawer96 placement="right"97 title={menuLabel}98 headingLevel={2}99 padding="sm"100 onClose={onClose}101 >102 <nav aria-label={menuLabel} className={navClassName}>103 <HeaderLinks104 links={links}105 linkComponent={linkComponent}106 onNavigate={onClose}107 />108 </nav>109 </Drawer>110);111112interface HeaderMenuProps {113 links: SiteHeaderLink[];114 linkComponent: ElementType<SiteHeaderLinkProps>;115 menuLabel: string;116}117118/**119 * Returns a ref for the menu button and puts focus back on it once the Drawer120 * closes. The Drawer unmounts with its Radix Dialog, so Radix has no trigger121 * to hand focus back to.122 */123const useReturnFocus = (isOpen: boolean) => {124 const menuButtonRef = useRef<HTMLElement | null>(null);125126 useEffect(() => {127 if (!isOpen) {128 menuButtonRef.current?.focus();129 }130 }, [isOpen]);131132 return menuButtonRef;133};134135/** Menu button and the Drawer it opens; shown only while the header is narrow. */136const HeaderMenu = ({ links, linkComponent, menuLabel }: HeaderMenuProps) => {137 const [isMenuOpen, setIsMenuOpen] = useState(false);138 const [, getChildClass] = getClassName({139 rootClass: "BHT__SiteHeader",140 styles,141 });142 const menuButtonRef = useReturnFocus(isMenuOpen);143144 const closeMenu = () => setIsMenuOpen(false);145146 return (147 <div className={getChildClass("menu")}>148 <Button149 variant="ghost"150 size="lg"151 iconOnly152 aria-label={menuLabel}153 aria-haspopup="dialog"154 aria-expanded={isMenuOpen}155 onClick={(event) => {156 menuButtonRef.current = event.currentTarget;157 setIsMenuOpen(true);158 }}159 >160 <MenuIcon />161 </Button>162 {isMenuOpen ? (163 <MenuDrawer164 links={links}165 linkComponent={linkComponent}166 menuLabel={menuLabel}167 navClassName={getChildClass("drawer-nav")}168 onClose={closeMenu}169 />170 ) : null}171 </div>172 );173};174175/**176 * Site header with a brand slot, navigation links and optional actions. While177 * the header is at least `64rem` wide the links sit inline; narrower, a menu178 * button opens them in a right-hand `Drawer`, which keeps the links in reach of179 * one thumb on a phone. The switch follows the header's own width (a container180 * query), not the viewport.181 */182export const SiteHeader = ({183 className,184 brand,185 links,186 actions,187 linkComponent = "a",188 menuLabel,189 ...rest190}: SiteHeaderProps) => {191 const [rootClass, getChildClass] = getClassName({192 className,193 rootClass: "BHT__SiteHeader",194 styles,195 });196197 return (198 <header {...rest} className={rootClass}>199 <div className={getChildClass("bar")}>200 <div className={getChildClass("brand")}>{brand}</div>201 <div className={getChildClass("end")}>202 <nav aria-label={menuLabel} className={getChildClass("nav")}>203 <HeaderLinks links={links} linkComponent={linkComponent} />204 </nav>205 {actions ? (206 <div className={getChildClass("actions")}>{actions}</div>207 ) : null}208 <HeaderMenu209 links={links}210 linkComponent={linkComponent}211 menuLabel={menuLabel}212 />213 </div>214 </div>215 </header>216 );217};
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.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 = screen.getByRole("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 = screen.getByRole("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 userEvent.keyboard("{Escape}");81 expect(screen.queryByRole("dialog")).toBeNull();82 expect(menuButton).toHaveFocus();83 });8485 it("closes the Drawer from its Close button", async () => {86 renderHeader();87 await userEvent.click(screen.getByRole("button", { name: "Menu" }));88 await userEvent.click(screen.getByRole("button", { name: "Close" }));89 expect(screen.queryByRole("dialog")).toBeNull();90 });9192 it("closes the Drawer when a link in it is followed", async () => {93 // Keeps jsdom from attempting a real navigation; the header's own onClick94 // still runs first, which is what this test asserts.95 const NonNavigatingLink = forwardRef<96 HTMLAnchorElement,97 SiteHeaderLinkProps98 >(({ onClick, ...props }, ref) => (99 <a100 {...props}101 ref={ref}102 onClick={(event) => {103 onClick?.(event);104 event.preventDefault();105 }}106 />107 ));108 NonNavigatingLink.displayName = "NonNavigatingLink";109 renderHeader({ linkComponent: NonNavigatingLink });110 await userEvent.click(screen.getByRole("button", { name: "Menu" }));111 const dialog = screen.getByRole("dialog", { name: "Menu" });112 await userEvent.click(113 within(dialog).getByRole("link", { name: "Gallery" }),114 );115 expect(screen.queryByRole("dialog")).toBeNull();116 });117118 it("renders every link through linkComponent, with aria-current on the current one", async () => {119 const RouterLink = forwardRef<HTMLAnchorElement, SiteHeaderLinkProps>(120 (props, ref) => <a {...props} ref={ref} data-router-link="true" />,121 );122 RouterLink.displayName = "RouterLink";123 renderHeader({ linkComponent: RouterLink });124125 const guide = screen.getByRole("link", { name: "Guide" });126 expect(guide).toHaveAttribute("data-router-link", "true");127 expect(guide).toHaveAttribute("aria-current", "page");128 expect(screen.getByRole("link", { name: "Gallery" })).toHaveAttribute(129 "data-router-link",130 "true",131 );132133 await userEvent.click(screen.getByRole("button", { name: "Menu" }));134 const dialog = screen.getByRole("dialog", { name: "Menu" });135 expect(within(dialog).getByRole("link", { name: "Guide" })).toHaveAttribute(136 "data-router-link",137 "true",138 );139 });140});