Behive Tech Component Registry

Site Footer

v0.1.2Draft

Site footer with a brand, grouped navigation links, free content and a legal line; the groups stack on small screens and sit in columns from the medium breakpoint.

pnpm add @behivetech/organisms.site-footer

registry/behivetech/organisms/site-footer · depends on @behivetech/atoms.nav-item

site-footer.tsx
1import {
2 type AnchorHTMLAttributes,
3 type ElementType,
4 type HTMLAttributes,
5 type ReactNode,
6} from "react";
7import { getClassName } from "@bht-component-registry/class-names";
8import { NavItem } from "@behivetech/atoms.nav-item";
9import styles from "./site-footer.module.scss";
10
11/** One link in a footer group. */
12export interface SiteFooterLink {
13 /** Link text */
14 label: string;
15 /** Destination URL */
16 href: string;
17}
18
19/** Props the footer passes to `linkComponent` for every link. */
20export type SiteFooterLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
21 href: string;
22};
23
24/** A titled list of links. */
25export interface SiteFooterGroup {
26 /** Group heading; rendered as an h2 */
27 title: string;
28 /** Links in the group; a group with none renders nothing */
29 links: SiteFooterLink[];
30}
31
32/** Props of the `SiteFooter` component. */
33export interface SiteFooterProps extends HTMLAttributes<HTMLElement> {
34 /** Additional class names to merge with the component root element */
35 className?: string;
36 /** Brand mark, such as a wordmark or logo, shown in the lead column */
37 brand?: ReactNode;
38 /** Link groups. Groups without links, and an empty list, render no heading */
39 groups?: SiteFooterGroup[];
40 /** Legal line shown in its own row under the groups, such as a copyright */
41 legal?: ReactNode;
42 /**
43 * Component that renders each link, so a router link such as Next's `Link`
44 * can be injected. Receives `href`, `children` and NavItem's props (merged
45 * through `asChild`); pass them all through to the anchor, or the link loses
46 * NavItem's styling and hit area. Defaults to `a`.
47 */
48 linkComponent?: ElementType<SiteFooterLinkProps>;
49 /** Free content rendered under the brand, such as contact details */
50 children?: ReactNode;
51}
52
53/**
54 * Site footer with a brand, grouped links, free content and a legal line.
55 * Groups stack on small screens and sit in columns from the medium breakpoint.
56 */
57export const SiteFooter = ({
58 className,
59 brand,
60 groups = [],
61 legal,
62 linkComponent: LinkComponent = "a",
63 children,
64 ...rest
65}: SiteFooterProps) => {
66 const [rootClass, getChildClass] = getClassName({
67 className,
68 rootClass: "BHT__SiteFooter",
69 styles,
70 });
71 const visibleGroups = groups.filter((group) => group.links.length > 0);
72 const hasLead = Boolean(brand) || Boolean(children);
73
74 return (
75 <footer {...rest} className={rootClass}>
76 <div className={getChildClass("body")}>
77 {hasLead ? (
78 <div className={getChildClass("lead")}>
79 {brand ? (
80 <div className={getChildClass("brand")}>{brand}</div>
81 ) : null}
82 {children ? (
83 <div className={getChildClass("content")}>{children}</div>
84 ) : null}
85 </div>
86 ) : null}
87 {visibleGroups.map((group, index) => (
88 <section
89 key={`${index}-${group.title}`}
90 className={getChildClass("group")}
91 >
92 <h2 className={getChildClass("heading")}>{group.title}</h2>
93 <ul className={getChildClass("links")}>
94 {group.links.map((link) => (
95 <li key={`${link.href}:${link.label}`}>
96 <NavItem asChild>
97 <LinkComponent href={link.href}>{link.label}</LinkComponent>
98 </NavItem>
99 </li>
100 ))}
101 </ul>
102 </section>
103 ))}
104 </div>
105 {legal ? <div className={getChildClass("legal")}>{legal}</div> : null}
106 </footer>
107 );
108};
site-footer.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;
2
3// Material Design 3 footer band. Mobile first: everything stacks in one column
4// and each link keeps NavItem's 48px hit area at a 48px pitch. From `md`
5// (`breakpoints.md`, the registry medium breakpoint) the lead column and the groups sit
6// side by side.
7.BHT__SiteFooter {
8 display: flex;
9 flex-direction: column;
10 gap: var(--spacing-6, 1.5rem);
11 padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);
12 background: var(--md-sys-color-surface-container);
13 color: var(--md-sys-color-on-surface-variant);
14
15 &__body {
16 display: flex;
17 flex-direction: column;
18 gap: var(--spacing-6, 1.5rem);
19 }
20
21 &__lead {
22 display: flex;
23 flex-direction: column;
24 gap: var(--spacing-3, 0.75rem);
25 }
26
27 &__group {
28 display: flex;
29 flex-direction: column;
30 gap: var(--spacing-2, 0.5rem);
31 }
32
33 &__heading {
34 margin: 0;
35 color: var(--md-sys-color-secondary);
36 font-family: var(--md-sys-typescale-title-small-font);
37 font-size: var(--md-sys-typescale-title-small-size);
38 font-weight: var(--md-sys-typescale-title-small-weight);
39 line-height: var(--md-sys-typescale-title-small-line-height);
40 letter-spacing: var(--md-sys-typescale-title-small-tracking);
41 }
42
43 // The list bleeds one spacing step into the gutter so NavItem labels line up
44 // with the brand, headings and legal line on a single text edge.
45 &__links {
46 display: flex;
47 flex-direction: column;
48 gap: var(--spacing-3, 0.75rem);
49 margin: 0 calc(-1 * var(--spacing-3, 0.75rem));
50 padding: 0;
51 list-style: none;
52 }
53
54 &__legal {
55 padding-top: var(--spacing-4, 1rem);
56 border-top: 1px solid var(--md-sys-color-outline-variant);
57 font-family: var(--md-sys-typescale-body-small-font);
58 font-size: var(--md-sys-typescale-body-small-size);
59 font-weight: var(--md-sys-typescale-body-small-weight);
60 line-height: var(--md-sys-typescale-body-small-line-height);
61 letter-spacing: var(--md-sys-typescale-body-small-tracking);
62 }
63
64 @include breakpoints.md {
65 padding-inline: var(--spacing-8, 2rem);
66
67 &__body {
68 flex-direction: row;
69 align-items: flex-start;
70 gap: var(--spacing-8, 2rem);
71 }
72
73 &__lead {
74 flex: 0 0 20rem;
75 }
76
77 &__group {
78 flex: 1 1 0;
79 }
80
81 &__links {
82 align-items: flex-start;
83 }
84 }
85}
index.ts
1export { SiteFooter } from "./site-footer.js";
2export type {
3 SiteFooterGroup,
4 SiteFooterLink,
5 SiteFooterLinkProps,
6 SiteFooterProps,
7} from "./site-footer.js";
site-footer.example.tsx
1import { SiteFooter } from "./site-footer.js";
2
3/** A footer with a brand, three link groups, contact content and a legal line. */
4export const BasicSiteFooter = () => (
5 <SiteFooter
6 brand="The BeHive"
7 legal="© 2026 The BeHive"
8 groups={[
9 {
10 title: "Your stay",
11 links: [
12 { label: "House guide", href: "#guide" },
13 { label: "Wifi", href: "#wifi" },
14 ],
15 },
16 { title: "Art", links: [{ label: "The artists", href: "#artists" }] },
17 ]}
18 >
19 <a href="mailto:hello@example.com">hello@example.com</a>
20 </SiteFooter>
21);
22
23/** Without groups the footer shows only the brand and the legal line. */
24export const BrandOnlySiteFooter = () => (
25 <SiteFooter brand="The BeHive" legal="© 2026 The BeHive" />
26);
site-footer.spec.tsx
1import { type ReactNode } from "react";
2import { render, screen } from "@testing-library/react";
3import { SiteFooter } from "./site-footer.js";
4
5const GROUPS = [
6 {
7 title: "Your stay",
8 links: [
9 { label: "House guide", href: "/guide" },
10 { label: "Wifi", href: "/wifi" },
11 ],
12 },
13 { title: "Art", links: [{ label: "The artists", href: "/artists" }] },
14];
15
16describe("SiteFooter", () => {
17 it("exposes the contentinfo landmark", () => {
18 render(<SiteFooter groups={GROUPS} />);
19 expect(screen.getByRole("contentinfo")).toBeTruthy();
20 });
21
22 it("renders a heading and links for each group", () => {
23 render(<SiteFooter groups={GROUPS} />);
24 expect(screen.getByRole("heading", { name: "Your stay" })).toBeTruthy();
25 expect(screen.getByRole("heading", { name: "Art" })).toBeTruthy();
26 expect(
27 screen.getByRole("link", { name: "Wifi" }).getAttribute("href"),
28 ).toBe("/wifi");
29 });
30
31 it("renders brand, children and legal", () => {
32 render(
33 <SiteFooter brand="BeHive" legal="© 2026">
34 hello@example.com
35 </SiteFooter>,
36 );
37 expect(screen.getByText("BeHive")).toBeTruthy();
38 expect(screen.getByText("hello@example.com")).toBeTruthy();
39 expect(screen.getByText("© 2026")).toBeTruthy();
40 });
41
42 it("renders no heading when groups is empty", () => {
43 render(<SiteFooter groups={[]} brand="BeHive" />);
44 expect(screen.queryByRole("heading")).toBeNull();
45 });
46
47 it("renders no heading for a group with no links", () => {
48 render(<SiteFooter groups={[{ title: "Empty", links: [] }, ...GROUPS]} />);
49 expect(screen.queryByRole("heading", { name: "Empty" })).toBeNull();
50 expect(screen.getAllByRole("heading")).toHaveLength(2);
51 });
52
53 it("applies additional className", () => {
54 render(<SiteFooter className="custom" />);
55 expect(screen.getByRole("contentinfo").className).toContain("custom");
56 });
57
58 it("forwards linkComponent to every link", () => {
59 const CustomLink = ({
60 href,
61 className,
62 children,
63 }: {
64 href?: string;
65 className?: string;
66 children?: ReactNode;
67 }) => (
68 <a data-custom="true" href={href} className={className}>
69 {children}
70 </a>
71 );
72 render(<SiteFooter groups={GROUPS} linkComponent={CustomLink} />);
73 const links = screen.getAllByRole("link");
74 expect(links).toHaveLength(3);
75 links.forEach((link) => {
76 expect(link).toHaveAttribute("data-custom", "true");
77 expect(link.className).toContain("BHT__NavItem");
78 });
79 });
80});