Site Footer
v0.1.2DraftSite 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 "@bht-component-registry/class-names";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 */57export const SiteFooter = ({58 className,59 brand,60 groups = [],61 legal,62 linkComponent: LinkComponent = "a",63 children,64 ...rest65}: 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);7374 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 <section89 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;23// Material Design 3 footer band. Mobile first: everything stacks in one column4// 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 sit6// 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);1415 &__body {16 display: flex;17 flex-direction: column;18 gap: var(--spacing-6, 1.5rem);19 }2021 &__lead {22 display: flex;23 flex-direction: column;24 gap: var(--spacing-3, 0.75rem);25 }2627 &__group {28 display: flex;29 flex-direction: column;30 gap: var(--spacing-2, 0.5rem);31 }3233 &__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 }4243 // The list bleeds one spacing step into the gutter so NavItem labels line up44 // 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 }5354 &__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 }6364 @include breakpoints.md {65 padding-inline: var(--spacing-8, 2rem);6667 &__body {68 flex-direction: row;69 align-items: flex-start;70 gap: var(--spacing-8, 2rem);71 }7273 &__lead {74 flex: 0 0 20rem;75 }7677 &__group {78 flex: 1 1 0;79 }8081 &__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";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});