QR Card
v0.1.1DraftA 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.
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";67export 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 accessible17 * 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 or23 * 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}3536const DEFAULT_SIZE = 15;37const DEFAULT_LABEL = "QR code";38const PROTOCOL_PATTERN = /^https?:\/\//i;3940/**41 * A card with a title, blurb, QR code, the printed address and a footer. On42 * screen it follows the theme and fills its container up to 24rem; printed it43 * is exactly 4 x 6 in on white with no shadow or background, and the page44 * 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 ...rest56}: 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 });6667 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 <QrCode76 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. This2// root class is merged onto Card's root, so the rules below style the card3// itself; Card's public hooks (--card-background, --card-border, --card-radius)4// carry the print overrides.56$print-width: 4in;7$print-height: 6in;89.BHT__QrCard {10 // Fills the width of its container up to a phone-friendly 24rem (4 in), at11 // the 4 x 6 ratio. Card's body is the only child; a one-track grid stretches12 // 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;1920 &__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 }2829 &__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 }4748 &__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 }6263 // 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 }7374 &__qr {75 max-width: 100%;76 }7778 // The address a person types if the scan fails. Clamped at two lines so a79 // long value cannot push past the fixed 4 x 6 in card; QrCard hides it when80 // 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 }9697 &__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}107108// Paper is light whatever the theme. The token sheet has no theme-fixed109// roles yet, so print pins the light scheme and reads the system colours, as110// rich-text.module.scss does: Canvas is white and CanvasText black under111// 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 token117 // follows the page theme, so on a Dark page it would print dark; this grey118 // is derived from Canvas/CanvasText instead, which color-scheme: light pins119 // to white and black. 25% keeps it visible on paper without competing with120 // 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;129130 &__title,131 &__description,132 &__url,133 &__footer {134 color: CanvasText;135 }136137 &__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";23/** An instruction page card: title, blurb, code, printed address and a footer. */4export const BasicQrCard = () => (5 <QrCard6 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);1213/** An artwork card with no footer. */14export const ArtworkQrCard = () => (15 <QrCard16 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);2122/** 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);2627/** A long title and blurb clamp at two and three lines; the address is never cut. */28export const ClampedQrCard = () => (29 <QrCard30 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);3637/** 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";45const CARD_URL = "https://the-behive.com/q/K7M2QX9P";67const renderCard = (props: Partial<Parameters<typeof QrCard>[0]> = {}) =>8 render(9 <QrCard10 title="Wi-Fi and the router"11 description="Join the network first."12 url={CARD_URL}13 footer="The Behive"14 {...props}15 />,16 );1718describe("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 });2526 it("moves the title to the heading level asked for", () => {27 renderCard({ headingLevel: 2 });28 expect(screen.getByRole("heading", { level: 2 })).toBeTruthy();29 });3031 it("names the code image with the full url", () => {32 renderCard();33 expect(screen.getByRole("img", { name: CARD_URL })).toBeTruthy();34 });3536 it("prints the url without its protocol", () => {37 renderCard();38 expect(screen.getByText("the-behive.com/q/K7M2QX9P")).toBeTruthy();39 });4041 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 });4647 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 });5253 it("sizes the code in rem and defaults to 15", () => {54 renderCard();55 expect(screen.getByRole("img").getAttribute("width")).toBe("15rem");56 });5758 it("passes size through to the code", () => {59 renderCard({ size: 10 });60 expect(screen.getByRole("img").getAttribute("width")).toBe("10rem");61 });6263 it("is not interactive", () => {64 renderCard();65 expect(screen.queryByRole("link")).toBeNull();66 expect(screen.queryByRole("button")).toBeNull();67 });6869 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 });7576 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 });8687 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 });9798 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 });104105 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});