Price¶
Price is one style of the number run, the numeric variant of
Text Foundation's content item union. One run type, one formatter, one set
of role names, covering every number a retail template renders:
{ "number": { "value": 200, "style": "currency", "currency": "AUD", "locale": "en-AU" } }
{ "number": { "value": 50, "style": "percent" } }
{ "number": { "value": 4.5, "style": "unit", "unit": "stars" } }
{ "number": { "value": 1299, "style": "decimal" } }
The reason it's one variant rather than a price run plus a percent run plus a number run: all
four are the same call. Intl.NumberFormat with different options, formatToParts to split the
result, role typography over the parts. Giving them separate schema shapes would be three
mechanisms computing one class of fact, which is the thing this brief keeps refusing to do. The
feature is still called Price because that's the retail concept it exists for; style: "currency"
is what the rest of this page describes.
A number is always a number, never a typed string. That is the whole point: 50 is bindable,
formattable, and styleable part by part, where "50%" typed into a text field is none of those and
lets a campaign editor write "50 %" or "fifty".
percent takes 50, not 0.5
Intl's own percent style expects a fraction, so 0.5 renders as "50%". A retail author types
50, a campaign operator filling in "Discount %" types 50, and a product feed carries 50. So the
authored value is the percentage itself and the formatter divides by 100 before handing it to
Intl. One documented conversion at one boundary, in exchange for never having a field whose
value means something different from what it's labelled. Intl still decides where the sign
sits and whether a space precedes it, which is the part that genuinely varies by locale.
The formatter¶
One Intl.NumberFormat(locale, options) instance, cached per (locale, style, currency, unit,
grouping). For style: "currency" the options are
{ style: 'currency', currency, trailingZeroDisplay: 'stripIfInteger', useGrouping } —
grouping: false on the run turns off the thousands separator, since a retail price is as often
$1088 as $1,088 and that's a brand choice, not a locale one.
formatToParts(amount) maps through a fixed
six-case switch (currency→currencySymbol, integer→dollars, group→thousandsSeparator,
decimal→decimalSeparator, fraction→cents, everything else→literal) into NumberPart[] carrying
only {role, value} — zero currency-specific and zero style-specific branches. The roles are
formatToParts' own types, so widening to a new style adds no role vocabulary: currencySymbol,
integer, group, decimal, fraction, unit, percentSign, literal.
trailingZeroDisplay:'stripIfInteger' natively handles "decimals only when present" (verified:
integer amounts omit the decimal/fraction parts entirely); non-standard fraction-digit
currencies (JPY = 0, BHD = 3) fall out for free. style:'unit' cannot express an arbitrary retail
unit string (fixed ~50–60 CLDR vocabulary, throws on 'each') — a free-text unit is appended as a
plain literal part outside Intl's control, with placement/separator from an explicit
locale → { position, separator } config table. Styling is a separate renderer-side
typography[role] lookup (Text Foundation's typography resolver), not
threaded through the formatter. "Decimals: always/never" needs explicit override logic on top of
the native auto default. Price parts sit inline with literal runs — the discount lockup's suffix is an ordinary text run
in the same content array (Text Foundation), not a pinned layer.
Migration target is the four bespoke components that actually call the shared currency
formatter: betta-price-box, freshmart-price-box, freshmart-save-box, jb-price-box (and
their test files) — retire their hand-rolled currency logic in favour of this formatter.
betta-discount-box and jb-price-frenzy share the naming pattern but carry no currency logic at
all — out of scope (the former is entirely Text Foundation's padding work,
the latter renders a freeform headline string).
formatToParts returns parts in the locale's own logical (RTL-correct) order — preserve that array
order when re-concatenating by role, so RTL isn't a rework later even though it's out of scope this
round (non-goal).
Done when¶
The price-with-levers and inline-price hard cases render, and a bound value change restyles correctly across currencies.