Behive Tech Component Registry

Site Header

v0.1.4Draft

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/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";
16
17/** 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}
26
27/** Props the header passes to `linkComponent` for every link. */
28export type SiteHeaderLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
29 href: string;
30};
31
32/** 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}
53
54interface HeaderLinksProps {
55 links: SiteHeaderLink[];
56 linkComponent: ElementType<SiteHeaderLinkProps>;
57 onNavigate?: () => void;
58}
59
60const HeaderLinks = ({
61 links,
62 linkComponent: LinkComponent,
63 onNavigate,
64}: HeaderLinksProps) => (
65 <>
66 {links.map(({ href, label, current }) => (
67 <NavItem
68 asChild
69 key={`${href}:${label}`}
70 active={current}
71 onClick={onNavigate}
72 >
73 <LinkComponent href={href}>{label}</LinkComponent>
74 </NavItem>
75 ))}
76 </>
77);
78
79interface MenuDrawerProps {
80 links: SiteHeaderLink[];
81 linkComponent: ElementType<SiteHeaderLinkProps>;
82 menuLabel: string;
83 navClassName: string;
84 onClose: () => void;
85}
86
87/** 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 <Drawer
96 placement="right"
97 title={menuLabel}
98 headingLevel={2}
99 padding="sm"
100 onClose={onClose}
101 >
102 <nav aria-label={menuLabel} className={navClassName}>
103 <HeaderLinks
104 links={links}
105 linkComponent={linkComponent}
106 onNavigate={onClose}
107 />
108 </nav>
109 </Drawer>
110);
111
112interface HeaderMenuProps {
113 links: SiteHeaderLink[];
114 linkComponent: ElementType<SiteHeaderLinkProps>;
115 menuLabel: string;
116}
117
118/**
119 * Returns a ref for the menu button and puts focus back on it once the Drawer
120 * closes. The Drawer unmounts with its Radix Dialog, so Radix has no trigger
121 * to hand focus back to.
122 */
123const useReturnFocus = (isOpen: boolean) => {
124 const menuButtonRef = useRef<HTMLElement | null>(null);
125
126 useEffect(() => {
127 if (!isOpen) {
128 menuButtonRef.current?.focus();
129 }
130 }, [isOpen]);
131
132 return menuButtonRef;
133};
134
135/** 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);
143
144 const closeMenu = () => setIsMenuOpen(false);
145
146 return (
147 <div className={getChildClass("menu")}>
148 <Button
149 variant="ghost"
150 size="lg"
151 iconOnly
152 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 <MenuDrawer
164 links={links}
165 linkComponent={linkComponent}
166 menuLabel={menuLabel}
167 navClassName={getChildClass("drawer-nav")}
168 onClose={closeMenu}
169 />
170 ) : null}
171 </div>
172 );
173};
174
175/**
176 * Site header with a brand slot, navigation links and optional actions. While
177 * the header is at least `64rem` wide the links sit inline; narrower, a menu
178 * button opens them in a right-hand `Drawer`, which keeps the links in reach of
179 * one thumb on a phone. The switch follows the header's own width (a container
180 * query), not the viewport.
181 */
182export const SiteHeader = ({
183 className,
184 brand,
185 links,
186 actions,
187 linkComponent = "a",
188 menuLabel,
189 ...rest
190}: SiteHeaderProps) => {
191 const [rootClass, getChildClass] = getClassName({
192 className,
193 rootClass: "BHT__SiteHeader",
194 styles,
195 });
196
197 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 <HeaderMenu
209 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;
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.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 = 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 });
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 = screen.getByRole("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 userEvent.keyboard("{Escape}");
81 expect(screen.queryByRole("dialog")).toBeNull();
82 expect(menuButton).toHaveFocus();
83 });
84
85 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 });
91
92 it("closes the Drawer when a link in it is followed", async () => {
93 // Keeps jsdom from attempting a real navigation; the header's own onClick
94 // still runs first, which is what this test asserts.
95 const NonNavigatingLink = forwardRef<
96 HTMLAnchorElement,
97 SiteHeaderLinkProps
98 >(({ onClick, ...props }, ref) => (
99 <a
100 {...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 });
117
118 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 });
124
125 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 );
132
133 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});