feat: native Intl currency and percent formatting via a format prop - #4
feat: native Intl currency and percent formatting via a format prop#4Amanfromearth wants to merge 5 commits into
format prop#4Conversation
A token outside the number was keyed by its offset in the string (`O$i`), so any glyph before or after the digits lost its identity the moment the number gained or lost one: $999 -> $1,000 killed the $ at offset 0 and created a new one at offset 0, a full born/died cycle instead of a slide. Keys are now counted outward from the nearest end of the number: P0 is the character immediately before the first digit, X0 the one immediately after the last. $1.00 and ($1.00) therefore agree that $ is P0 and differ only in the bracket that one of them also carries. The old scheme was unreachable in practice, since a plain grouped decimal only ever produces digits, group and decimal marks, and a sign. It becomes reachable with currency and percent. 6 new cases in TransitionLogicTest; 25 Kotlin tests pass.
The subset carried digits, separators and signs only: no $, no €, no letters. A currency format would have driven the coverage check in NumericTextView to fall the whole line back to the platform font, losing the rounded face the library exists to provide. Adds the currency symbols, the whole U+20A0-20C0 currency-signs block, the accounting brackets, and A-Z a-z for `currencyDisplay: 'code'` and `'name'`. Cost: 46 -> 144 glyphs, ~11 KB -> ~33 KB a weight, ~97 KB -> ~300 KB for the nine. The packed tarball goes to 263.7 kB. Digit and separator advances are unchanged to four decimals (Regular: 0 = 0.5953, 8 = 0.6057, . = 0.2541), so the DIGIT_EM and SEPARATOR_EM constants in src/measureBox.ts still hold.
Adds a `format` prop shaped like `Intl.NumberFormatOptions`, resolved by each platform's own
formatter: `NumberFormatter` on iOS, `android.icu` on Android, `Intl` on web. The string has to be
produced where it is drawn, because the renderers animate the structure of a formatted number
rather than a string handed to them ready-made.
<NumericText value={total} currency="USD" /> // $1,234.50
<NumericText value={total} locale="de-DE" currency="EUR" /> // 1.234,50 €
<NumericText value={rate} format={{ style: 'percent' }} /> // 42%
Supported: style decimal/currency/percent, currency, currencyDisplay symbol/code/name,
currencySign standard/accounting, useGrouping, minimumIntegerDigits, and the fraction and
significant digit bounds. `currency` is a top-level shorthand; `useGrouping` and the fraction
bounds stay as shorthands, so nothing breaks.
Left out on purpose, each for a stated reason in NumericTextFormat's doc comment:
notation compact (the two platforms carry different CLDR vintages and would disagree on the
string), signDisplay, narrowSymbol, unit, roundingIncrement (below one platform's floor).
Two behaviour changes worth knowing:
- Digit bounds now follow ECMA-402 exactly, implemented identically in all three places. Android
previously clamped `minimumFractionDigits={4}` down to 3 while iOS honoured it, because
java.text.NumberFormat pulls the minimum down to meet a lower maximum.
- Rounding is pinned to half-away-from-zero, which is Intl's default and neither platform's.
`2.5` at zero decimals previously read 3 on web, 2 on iOS and 2 on Android; it now reads 3
everywhere. Not exposed as an option: two renderers disagreeing about the number they draw is
a bug, not a preference.
Android also reads the locale's *monetary* decimal mark for a currency format, so the fraction
columns stay keyed where a locale distinguishes the two, and re-checks the bundled typeface
against the characters the current format will actually draw rather than against the locale's
digits alone.
measureBox charges currency symbols and letters as glyphs (0.62 em) rather than as punctuation
(0.2541 em); `$` measures 0.6064 em in the bundled Regular, so the old estimate under-reserved the
box by most of a digit per symbol.
Verified: 39 JS tests, 25 Kotlin tests, compileDebugKotlin, swiftc -typecheck in both configs, a
full xcodebuild of the example, and both renderers driven by hand on a simulator through every
format. Not verified against a ground-truth recording; see .agent/NEXT.md.
The `format` prop takes its shape from number-flow (https://github.com/barvian/number-flow) by Maxwell Barvian: one object shaped like `Intl.NumberFormatOptions` rather than a growing row of flat props, and a bound passed through untouched so `Intl`'s own defaulting rule decides the rest. Credited in the Origin section, beside the prop in the formatting section, and in the doc comment on `NumericTextFormat` itself.
|
@AmatoGiulio pls check this |
`value` is a number and a number cannot hold `7.`, so someone typing 7 . 5 produces the values
7, 7, 7.5 and the mark they typed has nowhere to live between the second and third keystroke.
`trailingDecimalSeparator` gives it one. The mark becomes a real column: the locale's own
character, drawn by the same typesetter, keyed as `DEC`, and already in place when the first
fraction digit is born beside it.
<NumericText value={Number(raw) || 0} currency="USD"
trailingDecimalSeparator={raw.endsWith('.')} />
It goes after the last digit rather than at the end of the string, so `de-DE` gives `1.234, €`
rather than `1.234 €,`. It is a no-op once a fraction digit arrives and when the format already
prints a mark, so pairing it with `minimumFractionDigits` is safe.
The workaround it replaces is a sibling `<Text>` holding a `.`, which cannot be made to line up:
the view reserves half an em of headroom for the transition's overspill and centres the number
inside it, so the distance from the last digit to the right edge is not fixed and moves with both
the value and the font. Measured on an emulator at fontSize 44: the number occupied x 0.391-0.578
of the screen and the sibling dot sat at 0.577-0.608 on a different baseline. With the prop the
same state renders as one view whose accessibility label reads `$0.` rather than `$0`.
7 new JS cases; 46 JS and 25 Kotlin tests pass, compileDebugKotlin and swiftc -typecheck clean,
and the before/after was compared on a device.
|
Pushed one more commit, from a real amount field hitting the case.
Measured on an emulator at fontSize 44: the number occupied x 0.391-0.578 of the screen and the sibling dot sat at 0.577-0.608, on a different baseline.
Verified before/after on a device, plus 7 new JS cases (46 JS + 25 Kotlin tests green, |
|
Thanks for putting this together, this is definitely a direction I wanted the library to cover, and I’m glad you took the time to work through it properly. The API direction makes sense to me, especially keeping the current props as shorthands and using a native formatter on each platform. Before merging though, I want to run the full validation pass on my side: visual behavior, interruption/retargeting, locale/currency edge cases, accessibility, and especially the affix motion against a recorded ground truth on iOS/Android. I also want to look carefully at the font subset size increase and the rounding behavior, since those become part of the library’s long-term contract. "trailingDecimalSeparator" is interesting too, I’d like to test it separately because it expands the use case a bit toward amount/input flows. Really appreciate the level of detail here. I’ll start going through the PR properly and report back with anything I find. |
|
Thanks @Amanfromearth — the #5 explicitly credits this contribution and keeps the original |
Validated successor to #4, originally contributed by @Amanfromearth. Adds the Intl-shaped format contract, native iOS/Android formatting, currency/percent transitions, format retarget handling, regression coverage, and the FormatLab validation harness.
Validated successor to #4, originally contributed by @Amanfromearth. Adds the Intl-shaped format contract, native iOS/Android formatting, currency/percent transitions, format retarget handling, regression coverage, and the FormatLab validation harness.
|
One final update now that the validation work is complete: #5 has been merged into Thanks again @Amanfromearth — your contribution is what started the currency/percent formatting work in this library. The core API direction from #4 ( A couple of pieces from the original proposal — I’m also adding a permanent acknowledgement in the README so the origin of the feature remains visible beyond the PR history. Really appreciate the work and the level of detail you brought to #4. |
Discussed first in #3, as CONTRIBUTING.md asks for API changes. Happy to close this if the
direction is wrong for the library.
Adds an
Intl.NumberFormatOptions-shapedformatprop, resolved by each platform's own formatter:NumberFormatteron iOS,android.icuon Android,Intlon web. The string is produced where itis drawn, because the renderers animate the structure of a formatted number rather than a string
handed to them ready-made.
API
formatcarriesstyle(decimal/currency/percent),currency,currencyDisplay(symbol/code/name),
currencySign(standard/accounting),useGrouping,minimumIntegerDigits,and the fraction and significant digit bounds.
currencyis a top-level shorthand for the commoncase. The existing
useGrouping,minimumFractionDigitsandmaximumFractionDigitsprops stay asshorthands, so nothing breaks; this is additive.
The shape is borrowed from
number-flowby MaxwellBarvian, credited in the README's Origin section, beside the prop, and in the type's doc comment.
Deliberately not supported, each with a stated reason in the type's doc comment:
notation: 'compact'signDisplay,narrowSymbol,unit,roundingIncrementNumberFormatteris API 30; minSdk here is 24).Two behaviour changes worth reviewing
Digit bounds now follow ECMA-402 exactly, implemented identically in JS, Kotlin and Swift.
Android previously clamped
minimumFractionDigits={4}down to 3 while iOS honoured it, becausejava.text.NumberFormatpulls the minimum down to meet a lower maximum.Rounding is pinned to half-away-from-zero, which is
Intl's default and neither platform's.2.5at zero decimals previously read3on web,2on iOS and2on Android; it now reads3everywhere. Not exposed as an option: two renderers disagreeing about the number they draw seemed
like a bug rather than a preference, but this is the change most worth your veto.
Two latent defects this had to close first
Neither is reachable today, because a plain grouped decimal only ever produces digits, group and
decimal marks, and a sign. Both become reachable with currency.
Affix keying (
fix(android): key affixes by distance from the digits). A token outside thenumber was keyed by its string offset (
O$i), so$999→$1,000killed the$at offset 0and created a new one at offset 0: a born/died cycle instead of a slide. Keys are now counted
outward from the nearest end of the number,
P0before the first digit andX0after the last.6 new cases in
TransitionLogicTest.Font coverage (
chore(android): add currency and latin glyphs...). The bundled subset hadno
$, no€and no letters at all, so a currency format would have drivencanRenderintofalling the whole line back to the platform font. Regenerated with
.agent/tools/subset_font.sh.Cost: 46 → 144 glyphs, ~97 KB → ~300 KB for the nine weights. Digit and separator advances
are unchanged to four decimals (Regular:
0= 0.5953,8= 0.6057,.= 0.2541), so thecalibration constants in
src/measureBox.tsstill hold. This is the other call that is yours:dropping
A-Z a-zwould roughly halve the increase at the cost ofcurrencyDisplay: 'code'and'name'losing the rounded face.Android additionally reads the locale's monetary decimal mark for a currency format, and
re-checks the bundled typeface against the characters the current format will actually draw rather
than against the locale's digits alone.
Verification
Per CONTRIBUTING.md, linters and tests pass on this branch:
src/__tests__/numberFormat.test.tsis new),yarn typecheck,yarn lintcompileDebugKotlin(codegen accepted the new prop spec)swiftc -typecheckin both configurations; a fullxcodebuildof the example, 0 errorscurrency name, accounting brackets, percent, de-DE trailing symbol, integer padding, significant
digits
yarn check && yarn prepare && npm pack --dry-runNot verified against a ground-truth recording. No
.agentrun has been taken with a currencyformat, so the affix's motion during a carry is reasoned-about rather than measured. I know that is
not the bar
.agent/AGENTS.mdsets ("Measure, then claim"), so I have said so in.agent/NEXT.mdrather than letting it pass silently. Tell me if you would rather that file were left alone
entirely; it is your research log and I am happy to drop that hunk.
Deliberately not included
release-itowns versioning per CONTRIBUTING.md.**/build/in the ignores, so alocal
gradlewrun does not makeyarn lintread Gradle's own test report as source) but left itout to keep this focused on one change. Happy to send it separately if useful.