Behive Tech Component Registry

QR Card

v0.1.1Draft

A printable card with a title, blurb, QR code, the printed address and a footer: it fills its container on screen and prints at exactly 4 x 6 in.

pnpm add @behivetech/molecules.qr-card

registry/behivetech/molecules/qr-card · depends on @behivetech/atoms.card, @behivetech/atoms.qr-code

qr-card.tsx
1import { type ElementType, type HTMLAttributes, type ReactNode } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import { Card, type CardHeadingLevel } from "@behivetech/atoms.card";
4import { canEncodeQr, QrCode } from "@behivetech/atoms.qr-code";
5import styles from "./qr-card.module.scss";
6
7export interface QrCardProps extends Omit<
8 HTMLAttributes<HTMLDivElement>,
9 "title" | "onSelect"
10> {
11 /** Card title, clamped at two lines. It is the card's heading. */
12 title: ReactNode;
13 /** Optional blurb under the title, clamped at three lines */
14 description?: ReactNode;
15 /**
16 * The address the QR code encodes, in full. It is also the code's accessible
17 * name, and it is printed under the code without its `https://`.
18 */
19 url: string;
20 /**
21 * The code's accessible name. Defaults to `url`, then a plain-text `title`,
22 * then "QR code". Pass your translated text when `url` can be empty or
23 * unencodable, so the invalid-value box has a name in your language.
24 */
25 label?: string;
26 /** Optional line at the card's foot over a rule, such as the house name or a Wi-Fi hint */
27 footer?: ReactNode;
28 /** Width and height of the QR code in rem, capped at the card's body width. Defaults to `15` (2.5 in). */
29 size?: number;
30 /** Which heading tag (`h1`-`h6`) wraps the title. Defaults to `3`. */
31 headingLevel?: CardHeadingLevel;
32 /** Additional class names to merge with the component root element */
33 className?: string;
34}
35
36const DEFAULT_SIZE = 15;
37const DEFAULT_LABEL = "QR code";
38const PROTOCOL_PATTERN = /^https?:\/\//i;
39
40/**
41 * A card with a title, blurb, QR code, the printed address and a footer. On
42 * screen it follows the theme and fills its container up to 24rem; printed it
43 * is exactly 4 x 6 in on white with no shadow or background, and the page
44 * never splits it. The code itself is always black on white.
45 */
46export const QrCard = ({
47 title,
48 description,
49 url,
50 label,
51 footer,
52 size = DEFAULT_SIZE,
53 headingLevel = 3,
54 className,
55 ...rest
56}: QrCardProps) => {
57 // Never an empty name: fall back to the address, then a plain-text title, then a generic name.
58 const codeName =
59 label || url || (typeof title === "string" && title) || DEFAULT_LABEL;
60 const HeadingTag = `h${headingLevel}` as ElementType;
61 const [rootClass, getChildClass] = getClassName({
62 className,
63 rootClass: "BHT__QrCard",
64 styles,
65 });
66
67 return (
68 <Card {...rest} className={rootClass} padding="lg">
69 <div className={getChildClass("content")}>
70 <HeadingTag className={getChildClass("title")}>{title}</HeadingTag>
71 {description !== undefined && (
72 <p className={getChildClass("description")}>{description}</p>
73 )}
74 <div className={getChildClass("code")}>
75 <QrCode
76 className={getChildClass("qr")}
77 value={url}
78 label={codeName}
79 size={size}
80 />
81 {url !== "" && canEncodeQr(url) && (
82 <p className={getChildClass("url")}>
83 {url.replace(PROTOCOL_PATTERN, "")}
84 </p>
85 )}
86 </div>
87 {footer !== undefined && (
88 <div className={getChildClass("footer")}>{footer}</div>
89 )}
90 </div>
91 </Card>
92 );
93};
qr-card.module.scss
1// Composes atoms.card (padding lg), which owns fill, border and corners. This
2// root class is merged onto Card's root, so the rules below style the card
3// itself; Card's public hooks (--card-background, --card-border, --card-radius)
4// carry the print overrides.
5
6$print-width: 4in;
7$print-height: 6in;
8
9.BHT__QrCard {
10 // Fills the width of its container up to a phone-friendly 24rem (4 in), at
11 // the 4 x 6 ratio. Card's body is the only child; a one-track grid stretches
12 // it to the card's height.
13 display: grid;
14 grid-template-rows: minmax(0, 1fr);
15 width: 100%;
16 max-width: 24rem;
17 aspect-ratio: 2 / 3;
18 break-inside: avoid;
19
20 &__content {
21 display: flex;
22 flex-direction: column;
23 gap: var(--spacing-3);
24 height: 100%;
25 min-height: 0;
26 text-align: center;
27 }
28
29 &__title {
30 margin: 0;
31 font-family: var(
32 --md-sys-typescale-headline-small-font,
33 var(--font-heading)
34 );
35 font-size: var(--md-sys-typescale-headline-small-size, 1.5rem);
36 font-weight: var(--md-sys-typescale-headline-small-weight, 400);
37 line-height: var(--md-sys-typescale-headline-small-line-height, 2rem);
38 letter-spacing: var(--md-sys-typescale-headline-small-tracking, 0);
39 color: var(--md-sys-color-on-surface);
40 overflow-wrap: anywhere;
41 display: -webkit-box;
42 -webkit-box-orient: vertical;
43 -webkit-line-clamp: 2;
44 line-clamp: 2;
45 overflow: hidden;
46 }
47
48 &__description {
49 margin: 0;
50 font-family: var(--md-sys-typescale-body-large-font, var(--font-body));
51 font-size: var(--md-sys-typescale-body-large-size, 1rem);
52 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);
53 letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.031em);
54 color: var(--md-sys-color-on-surface-variant);
55 overflow-wrap: anywhere;
56 display: -webkit-box;
57 -webkit-box-orient: vertical;
58 -webkit-line-clamp: 3;
59 line-clamp: 3;
60 overflow: hidden;
61 }
62
63 // Takes the space the text leaves and centres the code in it.
64 &__code {
65 display: flex;
66 flex: 1;
67 flex-direction: column;
68 align-items: center;
69 justify-content: center;
70 gap: var(--spacing-3);
71 min-height: 0;
72 }
73
74 &__qr {
75 max-width: 100%;
76 }
77
78 // The address a person types if the scan fails. Clamped at two lines so a
79 // long value cannot push past the fixed 4 x 6 in card; QrCard hides it when
80 // the code is the invalid box.
81 &__url {
82 margin: 0;
83 font-family: var(--md-sys-typescale-title-medium-font, var(--font-body));
84 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
85 font-weight: var(--md-sys-typescale-title-medium-weight, 500);
86 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);
87 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.009em);
88 color: var(--md-sys-color-on-surface);
89 overflow-wrap: anywhere;
90 display: -webkit-box;
91 -webkit-box-orient: vertical;
92 -webkit-line-clamp: 2;
93 line-clamp: 2;
94 overflow: hidden;
95 }
96
97 &__footer {
98 padding-top: var(--spacing-3);
99 border-top: 1px solid var(--md-sys-color-outline-variant);
100 font-family: var(--md-sys-typescale-body-small-font, var(--font-body));
101 font-size: var(--md-sys-typescale-body-small-size, 0.75rem);
102 line-height: var(--md-sys-typescale-body-small-line-height, 1rem);
103 letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.033em);
104 color: var(--md-sys-color-on-surface-variant);
105 }
106}
107
108// Paper is light whatever the theme. The token sheet has no theme-fixed
109// roles yet, so print pins the light scheme and reads the system colours, as
110// rich-text.module.scss does: Canvas is white and CanvasText black under
111// color-scheme: light, even on a Dark page printed with backgrounds on.
112@media print {
113 .BHT__QrCard {
114 color-scheme: light;
115 --card-background: Canvas;
116 // The cut line is the frame's light grey. The outline-variant token
117 // follows the page theme, so on a Dark page it would print dark; this grey
118 // is derived from Canvas/CanvasText instead, which color-scheme: light pins
119 // to white and black. 25% keeps it visible on paper without competing with
120 // the card's text.
121 --card-border: 1px solid color-mix(in srgb, CanvasText 25%, Canvas);
122 --card-radius: 0;
123 width: $print-width;
124 max-width: none;
125 height: $print-height;
126 aspect-ratio: auto;
127 box-shadow: none;
128 color: CanvasText;
129
130 &__title,
131 &__description,
132 &__url,
133 &__footer {
134 color: CanvasText;
135 }
136
137 &__footer {
138 border-top-color: color-mix(in srgb, CanvasText 25%, Canvas);
139 }
140 }
141}
index.ts
1export { QrCard } from "./qr-card.js";
2export type { QrCardProps } from "./qr-card.js";
qr-card.example.tsx
1import { QrCard } from "./qr-card.js";
2
3/** An instruction page card: title, blurb, code, printed address and a footer. */
4export const BasicQrCard = () => (
5 <QrCard
6 title="Wi-Fi and the router"
7 description="Join the network, then scan to see the full steps and the password."
8 url="https://the-behive.com/q/K7M2QX9P"
9 footer="The Behive"
10 />
11);
12
13/** An artwork card with no footer. */
14export const ArtworkQrCard = () => (
15 <QrCard
16 title="Blue hour on the porch"
17 description="Oil on canvas by Lena Park. Scan to read about the piece or to buy it."
18 url="https://the-behive.com/q/R4TW8NHC"
19 />
20);
21
22/** With neither description nor footer the code and its address sit in the middle. */
23export const MinimalQrCard = () => (
24 <QrCard title="Check-out" url="https://the-behive.com/q/R4TW8NHC" />
25);
26
27/** A long title and blurb clamp at two and three lines; the address is never cut. */
28export const ClampedQrCard = () => (
29 <QrCard
30 title="Hive study no. 3, the garden at dusk, with an unusually long title that wraps"
31 description="Oil on canvas by Lena Park, painted over a single week in the autumn of the year the porch was rebuilt. Scan to read about the piece, its materials and how to buy it."
32 url="https://the-behive.com/q/R4TW8NHC"
33 footer="The Behive"
34 />
35);
36
37/** A value that cannot be encoded shows the dashed invalid box, still named for screen readers. */
38export const InvalidQrCard = () => (
39 <QrCard title="Check-out" url="" label="QR code unavailable for this card" />
40);
qr-card.spec.tsx
1import { render, screen } from "@testing-library/react";
2import { describe, expect, it } from "vitest";
3import { QrCard } from "./qr-card.js";
4
5const CARD_URL = "https://the-behive.com/q/K7M2QX9P";
6
7const renderCard = (props: Partial<Parameters<typeof QrCard>[0]> = {}) =>
8 render(
9 <QrCard
10 title="Wi-Fi and the router"
11 description="Join the network first."
12 url={CARD_URL}
13 footer="The Behive"
14 {...props}
15 />,
16 );
17
18describe("QrCard", () => {
19 it("renders the title as a level 3 heading by default", () => {
20 renderCard();
21 expect(
22 screen.getByRole("heading", { level: 3, name: "Wi-Fi and the router" }),
23 ).toBeTruthy();
24 });
25
26 it("moves the title to the heading level asked for", () => {
27 renderCard({ headingLevel: 2 });
28 expect(screen.getByRole("heading", { level: 2 })).toBeTruthy();
29 });
30
31 it("names the code image with the full url", () => {
32 renderCard();
33 expect(screen.getByRole("img", { name: CARD_URL })).toBeTruthy();
34 });
35
36 it("prints the url without its protocol", () => {
37 renderCard();
38 expect(screen.getByText("the-behive.com/q/K7M2QX9P")).toBeTruthy();
39 });
40
41 it("shows the description and footer when given", () => {
42 renderCard();
43 expect(screen.getByText("Join the network first.")).toBeTruthy();
44 expect(screen.getByText("The Behive")).toBeTruthy();
45 });
46
47 it("omits the description and footer when not given", () => {
48 renderCard({ description: undefined, footer: undefined });
49 expect(screen.queryByText("Join the network first.")).toBeNull();
50 expect(screen.queryByText("The Behive")).toBeNull();
51 });
52
53 it("sizes the code in rem and defaults to 15", () => {
54 renderCard();
55 expect(screen.getByRole("img").getAttribute("width")).toBe("15rem");
56 });
57
58 it("passes size through to the code", () => {
59 renderCard({ size: 10 });
60 expect(screen.getByRole("img").getAttribute("width")).toBe("10rem");
61 });
62
63 it("is not interactive", () => {
64 renderCard();
65 expect(screen.queryByRole("link")).toBeNull();
66 expect(screen.queryByRole("button")).toBeNull();
67 });
68
69 it("merges a custom className onto the root", () => {
70 const { container } = renderCard({ className: "custom" });
71 expect((container.firstElementChild as HTMLElement).className).toContain(
72 "custom",
73 );
74 });
75
76 it("names the invalid-value box with the label when the url is empty", () => {
77 const { container } = renderCard({
78 url: "",
79 label: "QR code unavailable",
80 });
81 expect(
82 screen.getByRole("img", { name: "QR code unavailable" }),
83 ).toBeTruthy();
84 expect(container.querySelector('[class*="__url"]')).toBeNull();
85 });
86
87 it("hides the printed address when the value is too long to encode", () => {
88 const { container } = renderCard({
89 url: `https://the-behive.com/${"a".repeat(4000)}`,
90 label: "QR code unavailable",
91 });
92 expect(container.querySelector('[class*="__url"]')).toBeNull();
93 expect(
94 screen.getByRole("img", { name: "QR code unavailable" }),
95 ).toBeTruthy();
96 });
97
98 it("never leaves the code unnamed when url and label are empty", () => {
99 renderCard({ url: "" });
100 expect(
101 screen.getByRole("img", { name: "Wi-Fi and the router" }),
102 ).toBeTruthy();
103 });
104
105 it("prefers a label over the url for the code's name", () => {
106 renderCard({ label: "Scan for the Wi-Fi steps" });
107 expect(
108 screen.getByRole("img", { name: "Scan for the Wi-Fi steps" }),
109 ).toBeTruthy();
110 });
111});