Behive Tech Component Registry

Hero Banner

v0.8.3Stable

A full-width hero section: a background image behind a headline, subtext and call-to-action links, aligned left, centre or right.

pnpm add @behivetech/organisms.hero-banner

registry/behivetech/organisms/hero-banner

hero-banner.meta.ts
1import type { ComponentMeta } from "@behivetech/cms.types";
2
3// The CMS admin never imports this package at runtime — it reads `meta` and
4// `schema` at catalog-seed time and stores a snapshot. So this is the single
5// 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 what
9// 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";
2
3const to040 = heroBannerMigrations.find((m) => m.toVersion === "0.4.0")!;
4
5describe("hero-banner migrations", () => {
6 it("links 0.3.1 to 0.4.0", () => {
7 expect(to040.fromVersion).toBe("0.3.1");
8 });
9
10 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 });
16
17 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 });
28
29 it("drops the old keys so the fallback path stops being reachable", () => {
30 // The whole point of migrating: the component's ctaLabel/ctaHref fallback
31 // is a permanent second code path, and it can only be removed once no
32 // 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 });
40
41 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 row
43 // 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 });
48
49 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 });
53
54 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 pair
56 // 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 });
62
63 expect(result.ctaButtons).toEqual([
64 { label: "New", href: "https://new.example.com" },
65 ]);
66 expect("ctaLabel" in result).toBe(false);
67 });
68
69 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 });
76
77 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";
2
3/**
4 * Prop migrations for stored hero-banner blocks.
5 *
6 * This is a real migration, not a demonstration. 0.4.0 replaced the single
7 * `ctaLabel`/`ctaHref` pair with `ctaButtons`, an array. The component still
8 * reads the old pair so nothing broke on release, but that fallback is a
9 * 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>;
24
25 // Only build a button when there is actually one to build. A block that
26 // never had a CTA must not gain an empty row that shows up in the editor
27 // as a half-filled button the author never created.
28 const hasCta =
29 typeof ctaLabel === "string" &&
30 ctaLabel !== "" &&
31 typeof ctaHref === "string" &&
32 ctaHref !== "";
33
34 if (!hasCta) return rest;
35
36 // Not overwritten if `ctaButtons` is already populated: a block could have
37 // been edited after the upgrade, in which case the new field is the
38 // 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;
41
42 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";
2
3// Field order here is the order the CMS prop panel renders inputs in, so it is
4// 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 for
8// authoring. The component still reads the old pair, so pages written before
9// this change keep rendering — but the panel should not offer two ways to set
10// the same thing, and it is the schema, not the props interface, that decides
11// 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";
4
5/** 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}
14
15export 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}
35
36/**
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 the
41 * `--hero-banner-scrim-opacity` custom property (default `0.4`). A site whose
42 * editors pick any photo sets it on its own class, e.g. `0.6` to keep the white
43 * 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 });
62
63 // `ctaButtons` arrives from a jsonb column an author edits through the CMS, so
64 // it can legitimately hold half-filled rows — someone adds a button, types the
65 // label, and saves before pasting the URL. Those are skipped rather than
66 // rendered as a dead link. Falling back to ctaLabel/ctaHref keeps pages
67 // authored before this prop existed rendering unchanged.
68 const ctas: HeroBannerCta[] = (
69 Array.isArray(ctaButtons) && ctaButtons.length > 0
70 ? ctaButtons
71 : [{ 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 );
76
77 return (
78 <div
79 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 <a
89 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 must
4// stay light in both color schemes. MD3 has no "on-scrim" role, and every
5// `on-*` role flips with the theme, so the one light literal lives in
6// `--hero-banner-on-image` below rather than being repeated per rule. A site
7// 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 the
10// scrim darkens the image. A site whose editors choose any photo raises it on
11// its own class (0.6 keeps white text above 4.5:1 over a white photo); the
12// default leaves every existing use unchanged.
13
14.BHT__HeroBanner {
15 --hero-banner-on-image: #ffffff;
16
17 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);
25
26 // Scrim over the image. Opacity rather than a translucent color so it stays
27 // 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 }
35
36 &__content {
37 position: relative;
38 z-index: 1;
39 max-width: 45rem;
40 width: 100%;
41 margin: 0 auto;
42 text-align: center;
43 }
44
45 &__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 }
57
58 &__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 }
67
68 &__ctas {
69 display: flex;
70 flex-wrap: wrap;
71 gap: var(--spacing-3, 0.75rem);
72 justify-content: center;
73 }
74
75 // 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;
91
92 &::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 }
103
104 &:hover::after {
105 opacity: var(--md-sys-state-hover-opacity, 0.08);
106 }
107
108 &:focus-visible::after {
109 opacity: var(--md-sys-state-focus-opacity, 0.1);
110 }
111
112 &:active::after {
113 opacity: var(--md-sys-state-pressed-opacity, 0.1);
114 }
115
116 // Touch target, as in Button: extends the hit area to 48px without
117 // 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 }
128
129 &:focus-visible {
130 outline: 3px solid
131 var(--md-sys-color-secondary, var(--color-border-focus));
132 outline-offset: 2px;
133 }
134 }
135
136 &__cta-secondary {
137 background: transparent;
138 color: var(--hero-banner-on-image);
139 box-shadow: inset 0 0 0 1px
140 color-mix(in srgb, var(--hero-banner-on-image) 70%, transparent);
141 }
142
143 &--align-left {
144 .BHT__HeroBanner {
145 &__content {
146 text-align: left;
147 margin-left: 0;
148 }
149
150 &__ctas {
151 justify-content: flex-start;
152 }
153 }
154 }
155
156 &--align-right {
157 .BHT__HeroBanner {
158 &__content {
159 text-align: right;
160 margin-right: 0;
161 }
162
163 &__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 seed
4// 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";
2
3export const BasicHeroBanner = () => (
4 <HeroBanner>HeroBanner content</HeroBanner>
5);
6
7export const HeroBannerWithButtons = () => (
8 <HeroBanner
9 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";
5
6describe("HeroBanner", () => {
7 it("renders headline", () => {
8 render(<HeroBanner headline="Welcome" />);
9 expect(screen.getByRole("heading", { name: "Welcome" })).toBeTruthy();
10 });
11
12 it("renders subtext", () => {
13 render(<HeroBanner subtext="Supporting text" />);
14 expect(screen.getByText("Supporting text")).toBeTruthy();
15 });
16
17 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 });
22
23 it("renders every entry in ctaButtons", () => {
24 render(
25 <HeroBanner
26 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 });
43
44 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 the
46 // URL. That row is stored as-is, so the component has to cope with it.
47 render(
48 <HeroBanner
49 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 });
58
59 it("still renders ctaLabel/ctaHref content written before ctaButtons existed", () => {
60 // The schema no longer offers these fields, but stored pages still carry
61 // them. Dropping the fallback would silently blank the CTA on every hero
62 // banner already published.
63 render(
64 <HeroBanner
65 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 });
74
75 it("prefers ctaButtons over the legacy pair when both are present", () => {
76 render(
77 <HeroBanner
78 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 });
86
87 it("renders children", () => {
88 render(<HeroBanner>Hello</HeroBanner>);
89 expect(screen.getByText("Hello")).toBeTruthy();
90 });
91
92 it("applies additional className to root element", () => {
93 const { container } = render(<HeroBanner className="custom" />);
94 expect(container.firstElementChild?.className).toContain("custom");
95 });
96
97 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 });
103
104 /**
105 * `getChildClass("x")` looks up `BHT__HeroBanner__x` and silently falls back
106 * to that literal string when the stylesheet has no such rule. This
107 * stylesheet used to declare flat `.content` / `.headline` / `.cta`, so every
108 * child class resolved to a selector that did not exist and none of the child
109 * styling applied. Nothing at runtime catches that — vitest stubs CSS
110 * modules, so the fallback and a real hit are indistinguishable — which is
111 * 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");
117
118 const used = [...tsx.matchAll(/getChildClass\("([a-z-]+)"\)/g)].map(
119 (m) => m[1],
120 );
121 expect(used.length).toBeGreaterThan(0);
122
123 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});