Skip to content

feat: native Intl currency and percent formatting via a format prop - #4

Closed
Amanfromearth wants to merge 5 commits into
AmatoGiulio:mainfrom
Amanfromearth:upstream/intl-formatting
Closed

feat: native Intl currency and percent formatting via a format prop#4
Amanfromearth wants to merge 5 commits into
AmatoGiulio:mainfrom
Amanfromearth:upstream/intl-formatting

Conversation

@Amanfromearth

Copy link
Copy Markdown

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-shaped format prop, resolved by each platform's own formatter:
NumberFormatter on iOS, android.icu on Android, Intl on web. The string is 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%
<NumericText value={-1234.5} format={{ style: 'currency', currency: 'USD', currencySign: 'accounting' }} />
// ($1,234.50)

API

format carries 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 for the common
case. The existing useGrouping, minimumFractionDigits and maximumFractionDigits props stay as
shorthands, so nothing breaks; this is additive.

The shape is borrowed from number-flow by Maxwell
Barvian, 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:

Option Reason
notation: 'compact' The two platforms carry different CLDR vintages and would disagree on the string for the same input.
signDisplay, narrowSymbol, unit, roundingIncrement Below one platform's floor (Android's NumberFormatter is 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, 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 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.

  1. Affix keying (fix(android): key affixes by distance from the digits). A token outside the
    number was keyed by its string offset (O$i), so $999$1,000 killed the $ at offset 0
    and 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, P0 before the first digit and X0 after the last.
    6 new cases in TransitionLogicTest.

  2. Font coverage (chore(android): add currency and latin glyphs...). The bundled subset had
    no $, no and no letters at all, so a currency format would have driven canRender into
    falling 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 the
    calibration constants in src/measureBox.ts still hold. This is the other call that is yours:
    dropping A-Z a-z would roughly halve the increase at the cost of currencyDisplay: '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:

  • 39 JS tests (src/__tests__/numberFormat.test.ts is new), yarn typecheck, yarn lint
  • 25 Kotlin tests, compileDebugKotlin (codegen accepted the new prop spec)
  • swiftc -typecheck in both configurations; a full xcodebuild of the example, 0 errors
  • Both renderers driven by hand on a simulator through every format: currency symbol, ISO code,
    currency name, accounting brackets, percent, de-DE trailing symbol, integer padding, significant
    digits
  • yarn check && yarn prepare && npm pack --dry-run

Not verified against a ground-truth recording. No .agent run has been taken with a currency
format, so the affix's motion during a carry is reasoned-about rather than measured. I know that is
not the bar .agent/AGENTS.md sets ("Measure, then claim"), so I have said so in .agent/NEXT.md
rather 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

  • No version bump. release-it owns versioning per CONTRIBUTING.md.
  • No unrelated tooling changes. I have a one-line eslint fix (**/build/ in the ignores, so a
    local gradlew run does not make yarn lint read Gradle's own test report as source) but left it
    out to keep this focused on one change. Happy to send it separately if useful.

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.
@Amanfromearth

Copy link
Copy Markdown
Author

@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.
@Amanfromearth

Copy link
Copy Markdown
Author

Pushed one more commit, from a real amount field hitting the case.

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. The workaround people reach for 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.

trailingDecimalSeparator makes it a real DEC column instead, so the same state renders as one view whose accessibility label reads $0. rather than $0. It goes after the last digit rather than at the end of the string, so de-DE gives 1.234, €, and it is a no-op once a fraction digit arrives.

Verified before/after on a device, plus 7 new JS cases (46 JS + 25 Kotlin tests green, compileDebugKotlin and swiftc -typecheck clean). Say the word if you would rather this were a separate PR.

@AmatoGiulio

Copy link
Copy Markdown
Owner

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.

Copy link
Copy Markdown
Owner

Thanks @Amanfromearth — the format API direction from this PR was kept and then validated/reworked against the SwiftUI ground truth on Android. The integration-ready successor is #5.

#5 explicitly credits this contribution and keeps the original upstream/intl-formatting branch untouched. I’m closing this PR as superseded so there is a single merge target, while retaining this discussion and branch as the origin of the feature.

AmatoGiulio added a commit that referenced this pull request Aug 17, 2026
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.
AmatoGiulio added a commit that referenced this pull request Aug 17, 2026
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.

Copy link
Copy Markdown
Owner

One final update now that the validation work is complete: #5 has been merged into main.

Thanks again @Amanfromearth — your contribution is what started the currency/percent formatting work in this library. The core API direction from #4 (format plus the currency shorthand, resolved natively per platform) is now part of the shipped implementation, after the additional ground-truth work on affix motion, bidi layout, retargeting/reversals, clipping, accessibility and the final contract cleanup.

A couple of pieces from the original proposal — currencyDisplay: 'name' and trailingDecimalSeparator — were deliberately deferred after validation rather than merged as partially specified behavior.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants