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.
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 "@behivetech/get-class-name";8import { NavItem } from "@behivetech/atoms.nav-item";9import styles from "./site-footer.module.scss";1011/** One link in a footer group. */12export interface SiteFooterLink {13 /** Link text */14 label: string;15 /** Destination URL */16 href: string;17}1819/** Props the footer passes to `linkComponent` for every link. */20export type SiteFooterLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {21 href: string;22};2324/** 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}3132/** 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 (merged45 * through `asChild`); pass them all through to the anchor, or the link loses46 * 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}5253/**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 * the links of a group wrap in a row, each in a 44px-tall tap target.57 */58export const SiteFooter = ({59 className,60 brand,61 groups = [],62 legal,63 linkComponent: LinkComponent = "a",64 children,65 ...rest66}: SiteFooterProps) => {67 const [rootClass, getChildClass] = getClassName({68 className,69 rootClass: "BHT__SiteFooter",70 styles,71 });72 const visibleGroups = groups.filter((group) => group.links.length > 0);73 const hasLead = Boolean(brand) || Boolean(children);7475 return (76 <footer {...rest} className={rootClass}>77 <div className={getChildClass("body")}>78 {hasLead ? (79 <div className={getChildClass("lead")}>80 {brand ? (81 <div className={getChildClass("brand")}>{brand}</div>82 ) : null}83 {children ? (84 <div className={getChildClass("content")}>{children}</div>85 ) : null}86 </div>87 ) : null}88 {visibleGroups.map((group, index) => (89 <section90 key={`${index}-${group.title}`}91 className={getChildClass("group")}92 >93 <h2 className={getChildClass("heading")}>{group.title}</h2>94 <ul className={getChildClass("links")}>95 {group.links.map((link) => (96 <li97 key={`${link.href}:${link.label}`}98 className={getChildClass("item")}99 >100 <NavItem asChild>101 <LinkComponent href={link.href}>{link.label}</LinkComponent>102 </NavItem>103 </li>104 ))}105 </ul>106 </section>107 ))}108 </div>109 {legal ? <div className={getChildClass("legal")}>{legal}</div> : null}110 </footer>111 );112};
site-footer.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;23// Material Design 3 footer band. Mobile first: the lead and the groups stack in4// one column, and the links of a group flow in a wrapping row, so a short list5// sits on one line instead of a tall stack. Every link keeps a 2.75rem row6// (44px at the default size) as its tap target; the spacing between links is7// small, because the row height already separates the targets. From `md`8// (`breakpoints.md`, the registry medium breakpoint) the lead and the groups sit9// side by side, and wrap when there are too many for one line; the links of a10// group go back to a column.11.BHT__SiteFooter {12 display: flex;13 flex-direction: column;14 gap: var(--spacing-6, 1.5rem);15 padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);16 background: var(--md-sys-color-surface-container);17 color: var(--md-sys-color-on-surface-variant);1819 &__body {20 display: flex;21 flex-direction: column;22 gap: var(--spacing-4, 1rem);23 }2425 &__lead {26 display: flex;27 flex-direction: column;28 gap: var(--spacing-3, 0.75rem);29 }3031 &__group {32 display: flex;33 flex-direction: column;34 gap: var(--spacing-1, 0.25rem);35 min-width: 0;36 }3738 &__heading {39 margin: 0;40 color: var(--md-sys-color-secondary);41 font-family: var(--md-sys-typescale-title-small-font);42 font-size: var(--md-sys-typescale-title-small-size);43 font-weight: var(--md-sys-typescale-title-small-weight);44 line-height: var(--md-sys-typescale-title-small-line-height);45 letter-spacing: var(--md-sys-typescale-title-small-tracking);46 }4748 // The list bleeds one spacing step into the gutter so NavItem labels line up49 // with the brand, headings and legal line on a single text edge.50 &__links {51 display: flex;52 flex-flow: row wrap;53 gap: 0 var(--spacing-1, 0.25rem);54 margin: 0 calc(-1 * var(--spacing-3, 0.75rem));55 padding: 0;56 list-style: none;57 }5859 // The row is the tap target: NavItem draws 2.25rem, and this centres it in a60 // 2.75rem row, so the gap between links is the row's own padding.61 &__item {62 display: flex;63 align-items: center;64 min-height: 2.75rem;65 min-width: 0;66 }6768 &__legal {69 padding-top: var(--spacing-4, 1rem);70 border-top: 1px solid var(--md-sys-color-outline-variant);71 font-family: var(--md-sys-typescale-body-small-font);72 font-size: var(--md-sys-typescale-body-small-size);73 font-weight: var(--md-sys-typescale-body-small-weight);74 line-height: var(--md-sys-typescale-body-small-line-height);75 letter-spacing: var(--md-sys-typescale-body-small-tracking);76 }7778 @include breakpoints.md {79 padding-inline: var(--spacing-8, 2rem);8081 &__body {82 flex-flow: row wrap;83 align-items: flex-start;84 gap: var(--spacing-6, 1.5rem) var(--spacing-8, 2rem);85 }8687 // Shares the row with the groups but gives way first, and never grows past88 // a readable measure.89 &__lead {90 flex: 1 1 14rem;91 max-width: 20rem;92 }9394 &__group {95 flex: 1 1 10rem;96 }9798 // From `md` the links of a group stack again, each in its 2.75rem row.99 &__links {100 flex-direction: column;101 align-items: flex-start;102 }103 }104}
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";23/** A footer with a brand, three link groups, contact content and a legal line. */4export const BasicSiteFooter = () => (5 <SiteFooter6 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);2223/** 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";45const 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];1516describe("SiteFooter", () => {17 it("exposes the contentinfo landmark", () => {18 render(<SiteFooter groups={GROUPS} />);19 expect(screen.getByRole("contentinfo")).toBeTruthy();20 });2122 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 });3031 it("renders brand, children and legal", () => {32 render(33 <SiteFooter brand="BEhive" legal="© 2026">34 hello@example.com35 </SiteFooter>,36 );37 expect(screen.getByText("BEhive")).toBeTruthy();38 expect(screen.getByText("hello@example.com")).toBeTruthy();39 expect(screen.getByText("© 2026")).toBeTruthy();40 });4142 it("renders no heading when groups is empty", () => {43 render(<SiteFooter groups={[]} brand="BEhive" />);44 expect(screen.queryByRole("heading")).toBeNull();45 });4647 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 });5253 it("applies additional className", () => {54 render(<SiteFooter className="custom" />);55 expect(screen.getByRole("contentinfo").className).toContain("custom");56 });5758 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});