Hero Banner
v0.8.3StableA full-width hero section: a background image behind a headline, subtext and call-to-action links, aligned left, centre or right.
registry/behivetech/organisms/hero-banner
hero-banner.meta.ts
1import type { ComponentMeta } from "@behivetech/cms.types";23// The CMS admin never imports this package at runtime — it reads `meta` and4// `schema` at catalog-seed time and stores a snapshot. So this is the single5// source of truth for how the component presents itself in the block picker;6// anything restated in the admin would drift from what the package ships.7//8// `name` is the registry key: it is what lands in `Block.component`, and what9// a consuming site must use as its key in `src/cms/registry.ts`.10export const heroBannerMeta: ComponentMeta = {11 name: "HeroBanner",12 displayName: "Hero Banner",13 description:14 "Full-width hero section with headline, subtext, and call-to-action",15 icon: "🎯",16 tags: ["marketing", "layout"],17 acceptsChildren: false,18};
hero-banner.migrations.spec.ts
1import { heroBannerMigrations } from "./hero-banner.migrations.js";23const to040 = heroBannerMigrations.find((m) => m.toVersion === "0.4.0")!;45describe("hero-banner migrations", () => {6 it("links 0.3.1 to 0.4.0", () => {7 expect(to040.fromVersion).toBe("0.3.1");8 });910 it("converts the old CTA pair into a button", () => {11 const result = to040.migrate({12 headline: "Welcome",13 ctaLabel: "Get started",14 ctaHref: "https://example.com",15 });1617 expect(result).toEqual({18 headline: "Welcome",19 ctaButtons: [20 {21 label: "Get started",22 href: "https://example.com",23 variant: "primary",24 },25 ],26 });27 });2829 it("drops the old keys so the fallback path stops being reachable", () => {30 // The whole point of migrating: the component's ctaLabel/ctaHref fallback31 // is a permanent second code path, and it can only be removed once no32 // stored content still relies on it.33 const result = to040.migrate({34 ctaLabel: "Go",35 ctaHref: "https://example.com",36 });37 expect("ctaLabel" in result).toBe(false);38 expect("ctaHref" in result).toBe(false);39 });4041 it("does not invent a button when there was never a CTA", () => {42 // Otherwise every hero banner without a call to action gains an empty row43 // the author has to notice and delete.44 const result = to040.migrate({ headline: "Welcome" });45 expect(result).toEqual({ headline: "Welcome" });46 expect("ctaButtons" in result).toBe(false);47 });4849 it("does not invent a button from half a CTA", () => {50 expect(to040.migrate({ ctaLabel: "Go" })).toEqual({});51 expect(to040.migrate({ ctaHref: "https://example.com" })).toEqual({});52 });5354 it("keeps buttons the author already added and discards the stale pair", () => {55 // A block edited after the upgrade: the new field is intent, the old pair56 // is leftovers.57 const result = to040.migrate({58 ctaLabel: "Old",59 ctaHref: "https://old.example.com",60 ctaButtons: [{ label: "New", href: "https://new.example.com" }],61 });6263 expect(result.ctaButtons).toEqual([64 { label: "New", href: "https://new.example.com" },65 ]);66 expect("ctaLabel" in result).toBe(false);67 });6869 it("is idempotent", () => {70 const once = to040.migrate({71 ctaLabel: "Go",72 ctaHref: "https://example.com",73 });74 expect(to040.migrate(once)).toEqual(once);75 });7677 it("does not mutate the props it is given", () => {78 const props = { ctaLabel: "Go", ctaHref: "https://example.com" };79 to040.migrate(props);80 expect(props).toEqual({ ctaLabel: "Go", ctaHref: "https://example.com" });81 });82});
hero-banner.migrations.ts
1import type { Migration } from "@behivetech/cms.types";23/**4 * Prop migrations for stored hero-banner blocks.5 *6 * This is a real migration, not a demonstration. 0.4.0 replaced the single7 * `ctaLabel`/`ctaHref` pair with `ctaButtons`, an array. The component still8 * reads the old pair so nothing broke on release, but that fallback is a9 * permanent tax on the component: two code paths for one feature, forever.10 * Migrating the stored content is what eventually lets it go.11 *12 * Chain order is expressed by `fromVersion`/`toVersion`, not array position —13 * the runner follows the links.14 */15export const heroBannerMigrations: Migration[] = [16 {17 fromVersion: "0.3.1",18 toVersion: "0.4.0",19 migrate(props) {20 const { ctaLabel, ctaHref, ...rest } = props as {21 ctaLabel?: unknown;22 ctaHref?: unknown;23 } & Record<string, unknown>;2425 // Only build a button when there is actually one to build. A block that26 // never had a CTA must not gain an empty row that shows up in the editor27 // as a half-filled button the author never created.28 const hasCta =29 typeof ctaLabel === "string" &&30 ctaLabel !== "" &&31 typeof ctaHref === "string" &&32 ctaHref !== "";3334 if (!hasCta) return rest;3536 // Not overwritten if `ctaButtons` is already populated: a block could have37 // been edited after the upgrade, in which case the new field is the38 // author's intent and the stale pair is the thing to drop.39 const existing = rest.ctaButtons;40 if (Array.isArray(existing) && existing.length > 0) return rest;4142 return {43 ...rest,44 ctaButtons: [{ label: ctaLabel, href: ctaHref, variant: "primary" }],45 };46 },47 },48];
hero-banner.schema.ts
1import type { ComponentSchema } from "@behivetech/cms.types";23// Field order here is the order the CMS prop panel renders inputs in, so it is4// deliberate rather than incidental: headline first, then supporting content,5// then the call to action, then presentation.6//7// `ctaLabel` / `ctaHref` are deliberately absent: `ctaButtons` replaces them for8// authoring. The component still reads the old pair, so pages written before9// this change keep rendering — but the panel should not offer two ways to set10// the same thing, and it is the schema, not the props interface, that decides11// what an author sees.12export const heroBannerSchema: ComponentSchema = {13 fields: [14 {15 name: "headline",16 type: "string",17 label: "Headline",18 placeholder: "Enter headline…",19 required: true,20 defaultValue: "Welcome",21 },22 {23 name: "subtext",24 type: "richtext",25 label: "Subtext",26 placeholder: "Supporting text…",27 defaultValue: "",28 },29 {30 name: "imageUrl",31 type: "image",32 label: "Background Image",33 defaultValue: "",34 },35 {36 name: "ctaButtons",37 type: "array",38 label: "Buttons",39 hint: "The first button is the main action. Leave empty for a banner with no call to action.",40 defaultValue: [],41 itemSchema: [42 {43 name: "label",44 type: "string",45 label: "Label",46 placeholder: "Get started",47 required: true,48 },49 {50 name: "href",51 type: "url",52 label: "Link",53 placeholder: "https://…",54 required: true,55 },56 {57 name: "variant",58 type: "select",59 label: "Style",60 defaultValue: "primary",61 options: [62 { label: "Primary", value: "primary" },63 { label: "Secondary", value: "secondary" },64 ],65 },66 ],67 },68 {69 name: "align",70 type: "select",71 label: "Text Alignment",72 defaultValue: "center",73 options: [74 { label: "Left", value: "left" },75 { label: "Center", value: "center" },76 { label: "Right", value: "right" },77 ],78 },79 ],80};
hero-banner.tsx
1import { type ReactNode } from "react";2import { getClassName } from "@bht-component-registry/class-names";3import styles from "./hero-banner.module.scss";45/** One call-to-action link in `ctaButtons`. */6export interface HeroBannerCta {7 /** Link text; a button with no label is skipped */8 label?: string;9 /** Destination URL; a button with no href is skipped */10 href?: string;11 /** Visual weight — the first button usually leads, the rest support */12 variant?: "primary" | "secondary";13}1415export interface HeroBannerProps {16 /** Main heading text */17 headline?: string;18 /** Supporting text shown below the headline */19 subtext?: string;20 /** Background image URL rendered behind the content */21 imageUrl?: string;22 /** Label for the call-to-action link; requires `ctaHref` to render. Superseded by `ctaButtons`, kept so existing content keeps rendering */23 ctaLabel?: string;24 /** Destination URL for the call-to-action link; requires `ctaLabel` to render. Superseded by `ctaButtons` */25 ctaHref?: string;26 /** Call-to-action links. When present these render instead of `ctaLabel`/`ctaHref` */27 ctaButtons?: HeroBannerCta[];28 /** Horizontal alignment of the content; defaults to center */29 align?: "left" | "center" | "right";30 /** Additional class names to merge with the component root element */31 className?: string;32 /** Additional content rendered after the CTA */33 children?: ReactNode;34}3536/**37 * A full-width hero: a background image under a scrim, with a headline,38 * subtext and call-to-action links on top.39 *40 * **Styling hook:** the scrim's darkness is read from the41 * `--hero-banner-scrim-opacity` custom property (default `0.4`). A site whose42 * editors pick any photo sets it on its own class, e.g. `0.6` to keep the white43 * text above 4.5:1 over a pale photo.44 */45export const HeroBanner = ({46 headline,47 subtext,48 imageUrl,49 ctaLabel,50 ctaHref,51 ctaButtons,52 align = "center",53 className,54 children,55}: HeroBannerProps) => {56 const [rootClass, getChildClass] = getClassName({57 className,58 rootClass: "BHT__HeroBanner",59 modifiers: { [`align-${align}`]: true },60 styles,61 });6263 // `ctaButtons` arrives from a jsonb column an author edits through the CMS, so64 // it can legitimately hold half-filled rows — someone adds a button, types the65 // label, and saves before pasting the URL. Those are skipped rather than66 // rendered as a dead link. Falling back to ctaLabel/ctaHref keeps pages67 // authored before this prop existed rendering unchanged.68 const ctas: HeroBannerCta[] = (69 Array.isArray(ctaButtons) && ctaButtons.length > 070 ? ctaButtons71 : [{ label: ctaLabel, href: ctaHref, variant: "primary" as const }]72 ).filter(73 (cta): cta is HeroBannerCta =>74 Boolean(cta) && Boolean(cta.label) && Boolean(cta.href),75 );7677 return (78 <div79 className={rootClass}80 style={imageUrl ? { backgroundImage: `url(${imageUrl})` } : undefined}81 >82 <div className={getChildClass("content")}>83 {headline && <h1 className={getChildClass("headline")}>{headline}</h1>}84 {subtext && <p className={getChildClass("subtext")}>{subtext}</p>}85 {ctas.length > 0 && (86 <div className={getChildClass("ctas")}>87 {ctas.map((cta, index) => (88 <a89 key={`${cta.href}-${index}`}90 className={`${getChildClass("cta")} ${91 cta.variant === "secondary"92 ? getChildClass("cta-secondary")93 : ""94 }`.trim()}95 href={cta.href}96 >97 {cta.label}98 </a>99 ))}100 </div>101 )}102 {children}103 </div>104 </div>105 );106};
hero-banner.module.scss
1// Hero banner: full-bleed image under a scrim, with content on top.2//3// The text sits on a darkened photograph, not on a theme surface, so it must4// stay light in both color schemes. MD3 has no "on-scrim" role, and every5// `on-*` role flips with the theme, so the one light literal lives in6// `--hero-banner-on-image` below rather than being repeated per rule. A site7// can override it on the root if its imagery calls for something else.8//9// Styling hook: `--hero-banner-scrim-opacity` (default 0.4) sets how far the10// scrim darkens the image. A site whose editors choose any photo raises it on11// its own class (0.6 keeps white text above 4.5:1 over a white photo); the12// default leaves every existing use unchanged.1314.BHT__HeroBanner {15 --hero-banner-on-image: #ffffff;1617 position: relative;18 min-height: 30rem;19 display: flex;20 align-items: center;21 background-size: cover;22 background-position: center;23 background-color: var(--md-sys-color-surface, var(--color-surface));24 padding: var(--spacing-16, 4rem) var(--spacing-8, 2rem);2526 // Scrim over the image. Opacity rather than a translucent color so it stays27 // on the `scrim` role; the fallback matches the previous 40% darkening exactly.28 &::before {29 content: "";30 position: absolute;31 inset: 0;32 background: var(--md-sys-color-scrim, var(--color-overlay));33 opacity: var(--hero-banner-scrim-opacity, 0.4);34 }3536 &__content {37 position: relative;38 z-index: 1;39 max-width: 45rem;40 width: 100%;41 margin: 0 auto;42 text-align: center;43 }4445 &__headline {46 font-family: var(47 --md-sys-typescale-display-medium-font,48 var(--font-heading)49 );50 font-size: var(--md-sys-typescale-display-medium-size, 2.8125rem);51 font-weight: var(--md-sys-typescale-display-medium-weight, 400);52 line-height: var(--md-sys-typescale-display-medium-line-height, 3.25rem);53 letter-spacing: var(--md-sys-typescale-display-medium-tracking, 0em);54 color: var(--hero-banner-on-image);55 margin: 0 0 var(--spacing-4, 1rem);56 }5758 &__subtext {59 font-family: var(--md-sys-typescale-title-large-font, var(--font-body));60 font-size: var(--md-sys-typescale-title-large-size, 1.375rem);61 font-weight: var(--md-sys-typescale-title-large-weight, 400);62 line-height: var(--md-sys-typescale-title-large-line-height, 1.75rem);63 letter-spacing: var(--md-sys-typescale-title-large-tracking, 0em);64 color: color-mix(in srgb, var(--hero-banner-on-image) 85%, transparent);65 margin: 0 0 var(--spacing-6, 1.5rem);66 }6768 &__ctas {69 display: flex;70 flex-wrap: wrap;71 gap: var(--spacing-3, 0.75rem);72 justify-content: center;73 }7475 // CTAs follow Button's large filled / outlined treatment: pill shape,76 // label-large type at title-medium size, and a currentColor state layer.77 &__cta {78 position: relative;79 isolation: isolate;80 display: inline-block;81 padding: var(--spacing-3, 0.75rem) var(--spacing-8, 2rem);82 background: var(--md-sys-color-primary, var(--color-primary));83 color: var(--md-sys-color-on-primary, var(--color-on-primary));84 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));85 font-size: var(--md-sys-typescale-title-medium-size, 1rem);86 font-weight: var(--md-sys-typescale-label-large-weight, 500);87 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);88 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);89 border-radius: var(--md-sys-shape-corner-full, 9999px);90 text-decoration: none;9192 &::after {93 content: "";94 position: absolute;95 inset: 0;96 border-radius: inherit;97 background: currentColor;98 opacity: 0;99 pointer-events: none;100 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)101 var(--md-sys-motion-easing-standard, ease);102 }103104 &:hover::after {105 opacity: var(--md-sys-state-hover-opacity, 0.08);106 }107108 &:focus-visible::after {109 opacity: var(--md-sys-state-focus-opacity, 0.1);110 }111112 &:active::after {113 opacity: var(--md-sys-state-pressed-opacity, 0.1);114 }115116 // Touch target, as in Button: extends the hit area to 48px without117 // changing the drawn size. px because it is sized to the finger.118 &::before {119 content: "";120 position: absolute;121 left: 0;122 right: 0;123 top: 50%;124 translate: 0 -50%;125 height: 100%;126 min-height: 48px;127 }128129 &:focus-visible {130 outline: 3px solid131 var(--md-sys-color-secondary, var(--color-border-focus));132 outline-offset: 2px;133 }134 }135136 &__cta-secondary {137 background: transparent;138 color: var(--hero-banner-on-image);139 box-shadow: inset 0 0 0 1px140 color-mix(in srgb, var(--hero-banner-on-image) 70%, transparent);141 }142143 &--align-left {144 .BHT__HeroBanner {145 &__content {146 text-align: left;147 margin-left: 0;148 }149150 &__ctas {151 justify-content: flex-start;152 }153 }154 }155156 &--align-right {157 .BHT__HeroBanner {158 &__content {159 text-align: right;160 margin-right: 0;161 }162163 &__ctas {164 justify-content: flex-end;165 }166 }167 }168}
index.ts
1export { HeroBanner } from "./hero-banner.js";2export type { HeroBannerProps, HeroBannerCta } from "./hero-banner.js";3// `schema` and `meta` aliases are the stable contract the CMS catalog seed4// reads by; the prefixed names stay for direct consumers.5export {6 heroBannerSchema,7 heroBannerSchema as schema,8} from "./hero-banner.schema.js";9export { heroBannerMeta, heroBannerMeta as meta } from "./hero-banner.meta.js";10export {11 heroBannerMigrations,12 heroBannerMigrations as migrations,13} from "./hero-banner.migrations.js";
hero-banner.example.tsx
1import { HeroBanner } from "./hero-banner.js";23export const BasicHeroBanner = () => (4 <HeroBanner>HeroBanner content</HeroBanner>5);67export const HeroBannerWithButtons = () => (8 <HeroBanner9 headline="Build it once"10 subtext="Ship the same components to every site you run."11 ctaButtons={[12 { label: "Get started", href: "https://example.com" },13 {14 label: "Read the docs",15 href: "https://docs.example.com",16 variant: "secondary",17 },18 ]}19 />20);
hero-banner.spec.tsx
1import { readFileSync } from "node:fs";2import { join } from "node:path";3import { render, screen } from "@testing-library/react";4import { HeroBanner } from "./hero-banner.js";56describe("HeroBanner", () => {7 it("renders headline", () => {8 render(<HeroBanner headline="Welcome" />);9 expect(screen.getByRole("heading", { name: "Welcome" })).toBeTruthy();10 });1112 it("renders subtext", () => {13 render(<HeroBanner subtext="Supporting text" />);14 expect(screen.getByText("Supporting text")).toBeTruthy();15 });1617 it("renders CTA link when both ctaLabel and ctaHref are provided", () => {18 render(<HeroBanner ctaLabel="Get started" ctaHref="https://example.com" />);19 const link = screen.getByRole("link", { name: "Get started" });20 expect(link.getAttribute("href")).toBe("https://example.com");21 });2223 it("renders every entry in ctaButtons", () => {24 render(25 <HeroBanner26 ctaButtons={[27 { label: "Get started", href: "https://example.com" },28 {29 label: "Read the docs",30 href: "https://docs.example.com",31 variant: "secondary",32 },33 ]}34 />,35 );36 expect(37 screen.getByRole("link", { name: "Get started" }).getAttribute("href"),38 ).toBe("https://example.com");39 expect(40 screen.getByRole("link", { name: "Read the docs" }).getAttribute("href"),41 ).toBe("https://docs.example.com");42 });4344 it("skips a half-filled button rather than rendering a dead link", () => {45 // An author adds a button, types the label, and saves before pasting the46 // URL. That row is stored as-is, so the component has to cope with it.47 render(48 <HeroBanner49 ctaButtons={[50 { label: "Get started", href: "https://example.com" },51 { label: "Not finished yet" },52 ]}53 />,54 );55 expect(screen.getAllByRole("link")).toHaveLength(1);56 expect(screen.queryByText("Not finished yet")).toBeNull();57 });5859 it("still renders ctaLabel/ctaHref content written before ctaButtons existed", () => {60 // The schema no longer offers these fields, but stored pages still carry61 // them. Dropping the fallback would silently blank the CTA on every hero62 // banner already published.63 render(64 <HeroBanner65 ctaLabel="Legacy button"66 ctaHref="https://legacy.example.com"67 ctaButtons={[]}68 />,69 );70 expect(71 screen.getByRole("link", { name: "Legacy button" }).getAttribute("href"),72 ).toBe("https://legacy.example.com");73 });7475 it("prefers ctaButtons over the legacy pair when both are present", () => {76 render(77 <HeroBanner78 ctaLabel="Legacy button"79 ctaHref="https://legacy.example.com"80 ctaButtons={[{ label: "New button", href: "https://example.com" }]}81 />,82 );83 expect(screen.getByRole("link", { name: "New button" })).toBeTruthy();84 expect(screen.queryByText("Legacy button")).toBeNull();85 });8687 it("renders children", () => {88 render(<HeroBanner>Hello</HeroBanner>);89 expect(screen.getByText("Hello")).toBeTruthy();90 });9192 it("applies additional className to root element", () => {93 const { container } = render(<HeroBanner className="custom" />);94 expect(container.firstElementChild?.className).toContain("custom");95 });9697 it("applies the align modifier to the root element", () => {98 const { container } = render(<HeroBanner align="left" />);99 expect(container.firstElementChild?.className).toContain(100 "BHT__HeroBanner--align-left",101 );102 });103104 /**105 * `getChildClass("x")` looks up `BHT__HeroBanner__x` and silently falls back106 * to that literal string when the stylesheet has no such rule. This107 * stylesheet used to declare flat `.content` / `.headline` / `.cta`, so every108 * child class resolved to a selector that did not exist and none of the child109 * styling applied. Nothing at runtime catches that — vitest stubs CSS110 * modules, so the fallback and a real hit are indistinguishable — which is111 * why this asserts against the source instead.112 */113 it("declares a rule for every child class the component renders", () => {114 const dir = import.meta.dirname;115 const tsx = readFileSync(join(dir, "hero-banner.tsx"), "utf-8");116 const scss = readFileSync(join(dir, "hero-banner.module.scss"), "utf-8");117118 const used = [...tsx.matchAll(/getChildClass\("([a-z-]+)"\)/g)].map(119 (m) => m[1],120 );121 expect(used.length).toBeGreaterThan(0);122123 const declared = new Set(124 [...scss.matchAll(/&__([a-z-]+)\s*\{/g)].map((m) => m[1]),125 );126 expect([...new Set(used)].filter((n) => !declared.has(n))).toEqual([]);127 });128});