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.

pnpm add @behivetech/organisms.site-header

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";
17
18/** Loads the Drawer module; repeat calls share the one request. */
19const loadDrawer = () =>
20 import("@behivetech/atoms.drawer").then((module) => ({
21 default: module.Drawer,
22 }));
23
24// The Drawer (Radix Dialog and its focus handling) is about 10 KB gzipped and
25// opens only after a tap at phone width, so it is fetched on demand: ahead of
26// the tap when the reader's pointer or focus reaches the menu button, and at
27// the latest when the button is pressed.
28const Drawer = lazy(loadDrawer);
29
30/** Starts the request ahead of the tap; a failure here is retried by the tap itself. */
31const prefetchDrawer = () => {
32 loadDrawer().catch(() => undefined);
33};
34
35/** 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}
44
45/** Props the header passes to `linkComponent` for every link. */
46export type SiteHeaderLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
47 href: string;
48};
49
50/** 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}
71
72interface HeaderLinksProps {
73 links: SiteHeaderLink[];
74 linkComponent: ElementType<SiteHeaderLinkProps>;
75 onNavigate?: () => void;
76}
77
78const HeaderLinks = ({
79 links,
80 linkComponent: LinkComponent,
81 onNavigate,
82}: HeaderLinksProps) => (
83 <>
84 {links.map(({ href, label, current }) => (
85 <NavItem
86 asChild
87 key={`${href}:${label}`}
88 active={current}
89 onClick={onNavigate}
90 >
91 <LinkComponent href={href}>{label}</LinkComponent>
92 </NavItem>
93 ))}
94 </>
95);
96
97interface MenuDrawerProps {
98 links: SiteHeaderLink[];
99 linkComponent: ElementType<SiteHeaderLinkProps>;
100 menuLabel: string;
101 navClassName: string;
102 onClose: () => void;
103}
104
105/** 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 <Drawer
115 placement="right"
116 title={menuLabel}
117 headingLevel={2}
118 padding="sm"
119 onClose={onClose}
120 >
121 <nav aria-label={menuLabel} className={navClassName}>
122 <HeaderLinks
123 links={links}
124 linkComponent={linkComponent}
125 onNavigate={onClose}
126 />
127 </nav>
128 </Drawer>
129 </Suspense>
130);
131
132interface HeaderMenuProps {
133 links: SiteHeaderLink[];
134 linkComponent: ElementType<SiteHeaderLinkProps>;
135 menuLabel: string;
136}
137
138/**
139 * Returns a ref for the menu button and puts focus back on it once the Drawer
140 * closes. The Drawer unmounts with its Radix Dialog, so Radix has no trigger
141 * to hand focus back to.
142 */
143const useReturnFocus = (isOpen: boolean) => {
144 const menuButtonRef = useRef<HTMLElement | null>(null);
145
146 useEffect(() => {
147 if (!isOpen) {
148 menuButtonRef.current?.focus();
149 }
150 }, [isOpen]);
151
152 return menuButtonRef;
153};
154
155/** 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);
163
164 const closeMenu = () => setIsMenuOpen(false);
165
166 return (
167 <div className={getChildClass("menu")}>
168 <Button
169 variant="ghost"
170 size="lg"
171 iconOnly
172 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;
179
180 // The menu counts as open (and `aria-expanded` turns true) only once
181 // the Drawer's code is here, so assistive technology is never told
182 // about a dialog that is not there yet. A failed request leaves the
183 // 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 <MenuDrawer
196 links={links}
197 linkComponent={linkComponent}
198 menuLabel={menuLabel}
199 navClassName={getChildClass("drawer-nav")}
200 onClose={closeMenu}
201 />
202 ) : null}
203 </div>
204 );
205};
206
207/**
208 * Site header with a brand slot, navigation links and optional actions. While
209 * the header is at least `64rem` wide the links sit inline; narrower, a menu
210 * button opens them in a right-hand `Drawer`, which keeps the links in reach of
211 * one thumb on a phone. The switch follows the header's own width (a container
212 * query), not the viewport.
213 */
214export const SiteHeader = ({
215 className,
216 brand,
217 links,
218 actions,
219 linkComponent = "a",
220 menuLabel,
221 ...rest
222}: SiteHeaderProps) => {
223 const [rootClass, getChildClass] = getClassName({
224 className,
225 rootClass: "BHT__SiteHeader",
226 styles,
227 });
228
229 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 <HeaderMenu
241 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;
2
3// Surface bar with a hairline divider. The root is a named inline-size
4// container, so the links go inline once the header itself is 64rem wide and
5// the menu button opens them in a Drawer below that. A container cannot style
6// 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);
12
13 &__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 }
21
22 &__brand {
23 display: flex;
24 align-items: center;
25 min-width: 0;
26 }
27
28 &__end {
29 display: flex;
30 align-items: center;
31 gap: var(--spacing-2, 0.5rem);
32 }
33
34 &__nav {
35 display: none;
36 }
37
38 &__actions {
39 display: flex;
40 align-items: center;
41 }
42
43 &__menu {
44 display: flex;
45 }
46
47 // 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 }
53
54 // 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. The
56 // threshold is the shared `$breakpoint-lg`. It sits last so it wins over the
57 // 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 }
62
63 &__end {
64 gap: var(--spacing-6, 1.5rem);
65 }
66
67 &__nav {
68 display: flex;
69 align-items: center;
70 gap: var(--spacing-1, 0.25rem);
71 }
72
73 &__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";
3
4const LINKS = [
5 { href: "/guide", label: "Guide", current: true },
6 { href: "/gallery", label: "Gallery" },
7 { href: "/art", label: "Art" },
8 { href: "/artists", label: "Artists" },
9];
10
11/** Brand, four links and no actions. Narrow the header below 64rem to get the menu button and Drawer. */
12export const BasicSiteHeader = () => (
13 <SiteHeader
14 brand={<a href="/">The BEhive</a>}
15 links={LINKS}
16 menuLabel="Menu"
17 />
18);
19
20/** An action stays in the bar at every breakpoint. */
21export const SiteHeaderWithActions = () => (
22 <SiteHeader
23 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";
4
5const DRAWER = "@behivetech/atoms.drawer";
6const LINKS = [{ href: "/guide", label: "Guide" }];
7
8/** 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");
12
13 return () =>
14 render(
15 <SiteHeader
16 brand={<a href="/">Home</a>}
17 links={LINKS}
18 menuLabel="Menu"
19 />,
20 );
21};
22
23afterEach(() => {
24 vi.doUnmock(DRAWER);
25});
26
27describe("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" });
44
45 await userEvent.click(button);
46 expect(button).toHaveAttribute("aria-expanded", "false");
47 expect(screen.queryByRole("dialog")).toBeNull();
48
49 await act(async () => {
50 arrive();
51 await pending;
52 });
53
54 expect(await screen.findByRole("dialog", { name: "Menu" })).toBeTruthy();
55 expect(button).toHaveAttribute("aria-expanded", "true");
56 });
57
58 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" });
65
66 fireEvent.click(button);
67 await act(async () => {
68 await Promise.resolve();
69 });
70 expect(button).toHaveAttribute("aria-expanded", "false");
71
72 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);
79
80 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";
5
6const LINKS = [
7 { href: "/guide", label: "Guide", current: true },
8 { href: "/gallery", label: "Gallery" },
9];
10
11const renderHeader = (props: Partial<Parameters<typeof SiteHeader>[0]> = {}) =>
12 render(
13 <SiteHeader
14 brand={<a href="/">The BEhive</a>}
15 links={LINKS}
16 menuLabel="Menu"
17 {...props}
18 />,
19 );
20
21describe("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 });
32
33 it("renders the inline navigation landmark named by menuLabel", () => {
34 renderHeader({ menuLabel: "Main menu" });
35 expect(screen.getByRole("navigation", { name: "Main menu" })).toBeTruthy();
36 });
37
38 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 });
48
49 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();
54
55 await userEvent.click(menuButton);
56
57 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 });
68
69 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 });
75
76 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 });
85
86 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 });
92
93 it("closes the Drawer when a link in it is followed", async () => {
94 // Keeps jsdom from attempting a real navigation; the header's own onClick
95 // still runs first, which is what this test asserts.
96 const NonNavigatingLink = forwardRef<
97 HTMLAnchorElement,
98 SiteHeaderLinkProps
99 >(({ onClick, ...props }, ref) => (
100 <a
101 {...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 });
118
119 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 });
125
126 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 );
133
134 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});