Behive Tech Component Registry

Price

v0.1.0Draft

Formatted US dollars from an integer amount of cents, rendered in a data element with a plain decimal value, in three sizes.

pnpm add @behivetech/atoms.price

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";
4
5/** The type sizes a Price can render at. */
6export type PriceSize = "sm" | "md" | "lg";
7
8const DEFAULT_LOCALE = "en-US";
9const CURRENCY = "USD";
10const FRACTION_DIGITS = 2;
11
12const formatters = new Map<string, Intl.NumberFormat>();
13
14/** Intl.NumberFormat v3 formats a decimal string exactly; this TypeScript lib only types number | bigint. */
15type DecimalFormatter = { format: (value: string) => string };
16
17/** 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};
25
26/**
27 * Builds the exact decimal string for an integer amount of cents without
28 * 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};
38
39/** 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}
52
53/**
54 * Formats an amount of minor units as money for a locale and renders it in a
55 * `<data>` element whose `value` is the plain decimal, so it stays readable by
56 * machines whatever the locale.
57 */
58export const Price = ({
59 amountCents,
60 currency = CURRENCY,
61 locale = DEFAULT_LOCALE,
62 size = "md",
63 className,
64 ...rest
65}: PriceProps) => {
66 const [rootClass] = getClassName({
67 className,
68 rootClass: "BHT__Price",
69 modifiers: { [size]: true },
70 styles,
71 });
72
73 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;
80
81 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.
3
4.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;
9
10 &--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 }
17
18 &--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 }
25
26 &--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";
2
3/** The default md size, formatted from integer cents. */
4export const BasicPrice = () => <Price amountCents={125000} currency="USD" />;
5
6/** 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);
14
15/** 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";
3
4describe("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 });
11
12 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 });
16
17 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 });
21
22 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 );
29
30 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 });
36
37 it("renders nothing for NaN", () => {
38 const { container } = render(<Price amountCents={Number.NaN} />);
39 expect(container.firstChild).toBeNull();
40 });
41
42 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 });
46
47 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 });
55
56 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 });
60
61 it("applies additional className", () => {
62 render(<Price amountCents={100} currency="USD" className="custom" />);
63 expect(screen.getByText("$1.00").className).toContain("custom");
64 });
65});