Price Tag
v0.0.0A formatted price with an optional "was" price and the percentage saved. Formatting goes through `Intl.NumberFormat`, so symbols, decimals and grouping follow the locale and currency instead of being hand-rolled.
registry/acme/atoms/price-tag · depends on @behivetech/get-class-name
price-tag.tsx
1import { type HTMLAttributes } from "react";2import { getClassName } from "@behivetech/get-class-name";3import styles from "./price-tag.module.scss";45export type PriceTagSize = "sm" | "md" | "lg";67export interface PriceTagProps extends HTMLAttributes<HTMLSpanElement> {8 /** The price to show, in major units (e.g. 19.99) */9 amount: number;10 /** ISO 4217 currency code; defaults to USD */11 currency?: string;12 /** BCP 47 locale used for formatting; defaults to the browser's, then en-US */13 locale?: string;14 /** The previous price, when `amount` is a discount — rendered struck through with the saving */15 compareAt?: number;16 /** Controls the type size; defaults to md */17 size?: PriceTagSize;18 /** Additional class names to merge with the component root element */19 className?: string;20}2122/**23 * A formatted price, with an optional "was" price and the percentage saved.24 *25 * Formatting goes through `Intl.NumberFormat`, so currency symbols, decimal26 * rules and grouping follow the locale rather than being hand-rolled — 1 999 €27 * in French, $1,999.00 in US English, ¥1,999 with no decimals in Japanese.28 */29export const PriceTag = ({30 amount,31 currency = "USD",32 locale,33 compareAt,34 size = "md",35 className,36 ...rest37}: PriceTagProps) => {38 const [rootClass, getChildClass] = getClassName({39 className,40 rootClass: "ACME__PriceTag",41 modifiers: { [size]: true, discounted: compareAt !== undefined && compareAt > amount },42 styles,43 });4445 const format = new Intl.NumberFormat(locale, { style: "currency", currency });46 const discounted = compareAt !== undefined && compareAt > amount;47 const saving = discounted ? Math.round(((compareAt - amount) / compareAt) * 100) : 0;4849 return (50 <span {...rest} className={rootClass}>51 <span className={getChildClass("amount")}>{format.format(amount)}</span>52 {discounted && (53 <>54 <s className={getChildClass("compare-at")}>{format.format(compareAt)}</s>55 <span className={getChildClass("saving")}>−{saving}%</span>56 </>57 )}58 </span>59 );60};
price-tag.module.scss
1.ACME__PriceTag {2 display: inline-flex;3 align-items: baseline;4 gap: 0.5em;5 font-family: var(--md-sys-typescale-body-font);6 font-variant-numeric: tabular-nums;7 color: var(--md-sys-color-on-surface);89 &--sm {10 font-size: var(--md-sys-typescale-body-small-size);11 }1213 &--md {14 font-size: var(--md-sys-typescale-title-medium-size);15 }1617 &--lg {18 font-size: var(--md-sys-typescale-headline-small-size);19 }2021 &__amount {22 font-weight: 600;23 }2425 &--discounted &__amount {26 color: var(--md-sys-color-primary);27 }2829 &__compare-at {30 font-size: 0.8em;31 color: var(--md-sys-color-on-surface-variant);32 }3334 &__saving {35 padding: 0.1em 0.5em;36 border-radius: var(--md-sys-shape-corner-full);37 font-size: 0.7em;38 font-weight: 600;39 background: var(--md-sys-color-tertiary-container);40 color: var(--md-sys-color-on-tertiary-container);41 }42}
index.ts
1export { PriceTag } from './price-tag.js';2export type { PriceTagProps } from './price-tag.js';
price-tag.composition.tsx
1import { PriceTag } from "./price-tag.js";23export const BasicPriceTag = () => <PriceTag amount={19.99} />;45export const DiscountedPriceTag = () => <PriceTag amount={14.99} compareAt={19.99} />;67export const EuroPriceTag = () => <PriceTag amount={1999} currency="EUR" locale="fr-FR" />;89export const YenPriceTag = () => <PriceTag amount={1999} currency="JPY" locale="ja-JP" />;1011/** All three sizes side by side, so the scale can be judged at once. */12export const PriceTagSizes = () => (13 <div style={{ display: "flex", gap: "1.5rem", alignItems: "baseline" }}>14 <PriceTag size="sm" amount={9.5} compareAt={12} />15 <PriceTag size="md" amount={9.5} compareAt={12} />16 <PriceTag size="lg" amount={9.5} compareAt={12} />17 </div>18);
price-tag.spec.tsx
1import { render, screen } from "@testing-library/react";2import { PriceTag } from "./price-tag.js";34describe("PriceTag", () => {5 it("formats the amount as currency for the locale", () => {6 render(<PriceTag amount={1999.5} locale="en-US" />);7 expect(screen.getByText("$1,999.50")).toBeTruthy();8 });910 it("respects the currency's own decimal rules", () => {11 render(<PriceTag amount={1999} currency="JPY" locale="ja-JP" />);12 expect(screen.getByText("¥1,999")).toBeTruthy();13 });1415 it("shows the previous price and the saving when discounted", () => {16 render(<PriceTag amount={15} compareAt={20} locale="en-US" />);17 expect(screen.getByText("$20.00").tagName).toBe("S");18 expect(screen.getByText("−25%")).toBeTruthy();19 });2021 it("ignores a compareAt that is not actually higher", () => {22 render(<PriceTag amount={20} compareAt={20} locale="en-US" />);23 expect(screen.queryByText(/%/)).toBeNull();24 });2526 it("applies additional className", () => {27 const { container } = render(<PriceTag amount={1} className="custom" />);28 expect(container.firstElementChild?.className).toContain("custom");29 });30});