Price
v0.1.0DraftFormatted US dollars from an integer amount of cents, rendered in a data element with a plain decimal value, in three sizes.
registry/behivetech/atoms/price
price.tsx
1import { type HTMLAttributes } from "react";2import { getClassName } from "@bht-component-registry/class-names";3import styles from "./price.module.scss";45/** The type sizes a Price can render at. */6export type PriceSize = "sm" | "md" | "lg";78const DEFAULT_LOCALE = "en-US";9const CURRENCY = "USD";10const FRACTION_DIGITS = 2;1112const formatters = new Map<string, Intl.NumberFormat>();1314/** Intl.NumberFormat v3 formats a decimal string exactly; this TypeScript lib only types number | bigint. */15type DecimalFormatter = { format: (value: string) => string };1617/** Returns the cached USD formatter for a locale, so long lists build each one once. */18const getFormatter = (locale: string) => {19 const formatter =20 formatters.get(locale) ??21 new Intl.NumberFormat(locale, { style: "currency", currency: CURRENCY });22 formatters.set(locale, formatter);23 return formatter;24};2526/**27 * Builds the exact decimal string for an integer amount of cents without28 * floating-point division, so large amounts keep every cent and -0 becomes 0.29 */30const toDecimal = (amountCents: number) => {31 const rounded = Math.round(amountCents) + 0;32 const digits = String(Math.abs(rounded)).padStart(FRACTION_DIGITS + 1, "0");33 const whole = digits.slice(0, -FRACTION_DIGITS);34 const fraction = digits.slice(-FRACTION_DIGITS);35 const sign = rounded < 0 ? "-" : "";36 return `${sign}${whole}.${fraction}`;37};3839/** Props for Price: an amount of cents, a locale and a size. */40export interface PriceProps extends HTMLAttributes<HTMLDataElement> {41 /** The amount in cents, as an integer; other values are rounded */42 amountCents: number;43 /** The currency code; only USD is supported, so it defaults to USD until a second currency is added */44 currency?: typeof CURRENCY;45 /** BCP 47 locale the amount is formatted in; defaults to en-US so the server render and hydration match */46 locale?: string;47 /** Controls the type size; defaults to md */48 size?: PriceSize;49 /** Additional class names to merge with the component root element */50 className?: string;51}5253/**54 * Formats an amount of minor units as money for a locale and renders it in a55 * `<data>` element whose `value` is the plain decimal, so it stays readable by56 * machines whatever the locale.57 */58export const Price = ({59 amountCents,60 currency = CURRENCY,61 locale = DEFAULT_LOCALE,62 size = "md",63 className,64 ...rest65}: PriceProps) => {66 const [rootClass] = getClassName({67 className,68 rootClass: "BHT__Price",69 modifiers: { [size]: true },70 styles,71 });7273 const isValid = Number.isFinite(amountCents) && currency === CURRENCY;74 const decimal = isValid ? toDecimal(amountCents) : "";75 const price = isValid ? (76 <data {...rest} className={rootClass} value={decimal}>77 {(getFormatter(locale) as unknown as DecimalFormatter).format(decimal)}78 </data>79 ) : null;8081 return price;82};
price.module.scss
1// Material Design 3 type roles per size: sm = title-small, md = title-medium,2// lg = headline-small. The colour is always on-surface.34.BHT__Price {5 display: inline-block;6 color: var(--md-sys-color-on-surface, var(--color-text-heading));7 white-space: nowrap;8 font-variant-numeric: tabular-nums;910 &--sm {11 font-family: var(--md-sys-typescale-title-small-font, var(--font-body));12 font-size: var(--md-sys-typescale-title-small-size, 0.875rem);13 font-weight: var(--md-sys-typescale-title-small-weight, 600);14 line-height: var(--md-sys-typescale-title-small-line-height, 1.25rem);15 letter-spacing: var(--md-sys-typescale-title-small-tracking, 0.0071em);16 }1718 &--md {19 font-family: var(--md-sys-typescale-title-medium-font, var(--font-body));20 font-size: var(--md-sys-typescale-title-medium-size, 1rem);21 font-weight: var(--md-sys-typescale-title-medium-weight, 600);22 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);23 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.0094em);24 }2526 &--lg {27 font-family: var(28 --md-sys-typescale-headline-small-font,29 var(--font-heading)30 );31 font-size: var(--md-sys-typescale-headline-small-size, 1.5rem);32 font-weight: var(--md-sys-typescale-headline-small-weight, 700);33 line-height: var(--md-sys-typescale-headline-small-line-height, 2rem);34 letter-spacing: var(--md-sys-typescale-headline-small-tracking, 0);35 }36}
index.ts
1export { Price } from "./price.js";2export type { PriceProps, PriceSize } from "./price.js";
price.example.tsx
1import { Price } from "./price.js";23/** The default md size, formatted from integer cents. */4export const BasicPrice = () => <Price amountCents={125000} currency="USD" />;56/** Every size: sm for list meta, md for checkout lines, lg for detail pages and totals. */7export const PriceSizes = () => (8 <>9 <Price amountCents={125000} currency="USD" size="sm" />{" "}10 <Price amountCents={125000} currency="USD" size="md" />{" "}11 <Price amountCents={125000} currency="USD" size="lg" />12 </>13);1415/** The amount is formatted in the given locale; the value attribute stays a plain decimal. */16export const PriceLocale = () => (17 <Price amountCents={125000} currency="USD" locale="de-DE" />18);
price.spec.tsx
1import { render, screen } from "@testing-library/react";2import { Price } from "./price.js";34describe("Price", () => {5 it("formats 125000 USD in en-US with a plain decimal value", () => {6 render(<Price amountCents={125000} currency="USD" locale="en-US" />);7 const price = screen.getByText("$1,250.00");8 expect(price.tagName).toBe("DATA");9 expect(price.getAttribute("value")).toBe("1250.00");10 });1112 it("renders zero as $0.00 when no locale is given", () => {13 render(<Price amountCents={0} currency="USD" />);14 expect(screen.getByText("$0.00").getAttribute("value")).toBe("0.00");15 });1617 it("keeps a leading minus in the value for negative amounts", () => {18 render(<Price amountCents={-1250} currency="USD" />);19 expect(screen.getByText("-$12.50").getAttribute("value")).toBe("-12.50");20 });2122 it.each([[-0], [-0.4]])(23 "renders %s cents as $0.00 with no minus",24 (cents) => {25 render(<Price amountCents={cents} currency="USD" />);26 expect(screen.getByText("$0.00").getAttribute("value")).toBe("0.00");27 },28 );2930 it("keeps every cent of a large amount", () => {31 render(<Price amountCents={7_100_000_000_000_001} />);32 expect(33 screen.getByText("$71,000,000,000,000.01").getAttribute("value"),34 ).toBe("71000000000000.01");35 });3637 it("renders nothing for NaN", () => {38 const { container } = render(<Price amountCents={Number.NaN} />);39 expect(container.firstChild).toBeNull();40 });4142 it("formats in the given locale", () => {43 render(<Price amountCents={125000} currency="USD" locale="de-DE" />);44 expect(screen.getByText(/1\.250,00/).getAttribute("value")).toBe("1250.00");45 });4647 it.each([48 ["sm", "BHT__Price--sm"],49 ["md", "BHT__Price--md"],50 ["lg", "BHT__Price--lg"],51 ] as const)("applies the %s size modifier", (size, modifier) => {52 render(<Price amountCents={100} currency="USD" size={size} />);53 expect(screen.getByText("$1.00").className).toContain(modifier);54 });5556 it("defaults to the md size", () => {57 render(<Price amountCents={100} currency="USD" />);58 expect(screen.getByText("$1.00").className).toContain("BHT__Price--md");59 });6061 it("applies additional className", () => {62 render(<Price amountCents={100} currency="USD" className="custom" />);63 expect(screen.getByText("$1.00").className).toContain("custom");64 });65});