Skip to content

NumberForm foundation 1: root API and NumberForm.Input #98169

Closed

Description

@mountiny

Sub-issue of #97970.
Migration plan: jmgraa comment.

This is the foundation of the whole decomposition. It defines the root NumberForm contract and ships NumberForm.Input in the same PR, so the public types do not churn in a follow up. Nothing else in the decomposition can start until this lands.

NumberWithSymbolForm is untouched by this issue and keeps working.

Scope

  • Step: 1 of the migration plan
  • Scope: new src/components/NumberForm/**, plus 2 lines moved in the lint baseline

Root API

<NumberForm value={amount} onInputChange={handleAmount}>
    <NumberForm.Input symbol="$" position="prefix" />
</NumberForm>

Root props: value, onInputChange, negativeMode, children.

The root must also satisfy the InputWrapper contract, because src/pages/workspace/taxes/ValuePage.tsx:95 registers the component as a Form InputComponent and src/components/Form/types.ts:61 lists it in the ValidInputs union. That means accepting ref, onBlur, errorText and value as Form injects them. Design this in now rather than discovering it during the migration batch.

Files

Mirror the folder shape of src/components/ButtonComposed/, which is the accepted precedent in this codebase:

  • src/components/NumberForm/index.tsx — root, attaches the primitives
  • src/components/NumberForm/types.ts
  • src/components/NumberForm/context/ — shared state for the primitives
  • src/components/NumberForm/primitives/NumberFormInput.tsx
  • config/eslint/eslint.seatbelt.tsv:275-276 — see below

What moves into NumberForm.Input

Everything the numeric input owns today, lifted out of src/components/NumberWithSymbolForm.tsx: parsing, formatting, validation, selection, keyboard handling, locale and ref behaviour.

Reuse the existing primitives instead of reimplementing them:

  • src/components/TextInputWithSymbol/ — symbol display and platform variants
  • src/components/AmountTextInput.tsx

Two things to reconcile while moving:

  • One value setter, not two. setNewNumber (line 283) serves the pad and the symbol input. setFormattedNumber (line 323) serves displayAsTextInput only, is not memoised, reads selection directly, and additionally applies addLeadingZero. Collapse them into one setter with addLeadingZero as an explicit option.
  • NumberWithSymbolFormRef is the seam to preserve. It is {clearSelection, updateNumber, getNumber}, held by 8 files. Keep the same three methods on the NumberForm ref so migration batches are a rename, not a rewrite.

Lint baseline

config/eslint/eslint.seatbelt.tsv:275-276 holds grandfathered violations keyed to the NumberWithSymbolForm.tsx path:

"../../src/components/NumberWithSymbolForm.tsx"	"@typescript-eslint/no-unsafe-type-assertion"	1
"../../src/components/NumberWithSymbolForm.tsx"	"react-hooks/set-state-in-effect"	2

New files under src/components/NumberForm/ inherit no such grandfathering, so either fix these three violations while moving the code, or add matching baseline rows for the new paths. Prefer fixing them.

Out of scope

  • The flip and currency buttons — #98170
  • The number pad and layout composition — #98171
  • Deprecating NumberWithSymbolForm — #98172
  • Any call site change

Acceptance criteria

  • NumberForm root accepts value, onInputChange, negativeMode, children
  • NumberForm root works as a Form InputComponent (ref, onBlur, errorText)
  • NumberForm.Input renders and handles parsing, formatting, validation, selection, keyboard and locale
  • The ref exposes clearSelection, updateNumber and getNumber with unchanged semantics
  • One value setter, with addLeadingZero as an explicit option
  • Dedicated tests for NumberForm and NumberForm.Input
  • Lint passes with no new seatbelt rows, or with the three violations fixed
  • NumberWithSymbolForm behaviour is unchanged, and the tests from #98168 still pass
  • Verify that no errors appear in the JS console

Dependencies

Issue OwnerCurrent Issue Owner: @bernhardoj

Activity

  1. added
    Skip PaymentSkips all the payment process steps for an issue. Use it for fixed-price projects.
    on Aug 10, 2026
  2. jmgraa commented on Aug 19, 2026

    @jmgraa
    Contributor

    Implementation update

    Following the investigation of the existing NumberWithSymbolForm implementation, this update summarizes the proposed NumberForm API and the behavior changes currently under review.

    Public API

    The proposed API introduces separate primitives for the two input types:

    <NumberForm value={amount} onInputChange={handleAmount}>
        <NumberForm.SymbolInput symbol="$" position="prefix" />
        <NumberForm.TextInput symbol="$" position="prefix" />
    </NumberForm>

    This replaces the planned NumberForm.Input API with explicit SymbolInput and TextInput components while keeping their numeric behavior shared.

    Shared behavior changes

    The legacy component had separate update paths for the symbol input and standard text-input mode. The proposed implementation consolidates them into one shared logic path, which:

    • Applies the same locale, separator, space, leading-zero, negative-value, decimal, and maxLength handling to both input types.
    • Prevents normalization from being performed twice by disabling the default TextInputWithSymbol normalization for NumberForm.SymbolInput.
    • Keeps maxLength as numeric validation rather than forwarding it to the native input.
    • Tracks the latest committed value so caret calculations remain correct across multiple updates in the same render batch.
    • Handles stale native selection events during manual updates.
    • Clamps the caret when the externally controlled value becomes shorter.
    • Resets stale selections after focus is regained.
    • Handles decimal-precision changes without modifying intentionally empty fields.

    Dedicated tests cover the root state behavior, both input variants, formatting, validation, external updates, and selection handling.

  3. jmgraa commented on Aug 28, 2026

    @jmgraa
    Contributor

    Implementation update 2

    After further investigation of the existing NumberWithSymbolForm and rethinking single NumberForm component architecture, we decided to move forward with two separate primitives:

    • NumericField.TextInput
    • NumericInput.SymbolInput
    <NumericField value={amount} onInputChange={setAmount}>
        <NumericField.TextInput symbol="$" position="prefix" />
    </NumericField>
    <NumericInput value={amount} onInputChange={setAmount} allowNegative>
        <NumericInput.SymbolInput symbol="$" position="prefix" />
        <NumericInput.Error />
    </NumericInput>

    NumericField is intended to be a standard, single input field for entering an amount. NumericInput is intended to provide a full-screen currency-entry experience, which can include the amount input, a number pad, and other controls. Because of this, it does not behave exactly like a regular standalone text input and has different requirements for managing the value, selection, and minus sign.

    NumericField.TextInput Image
    NumericInput.SymbolInput Image

    This separation comes from the different behaviors currently combined in the legacy src/components/NumberWithSymbolForm.tsx implementation. That component supports both a regular text-input flow and a more complex currency-entry flow, but the logic for these two cases is closely connected and difficult to maintain.

    I am splitting this work into two PRs:

    1. Adding the shared NumericInputController logic and introducing NumericField.
    2. Adding NumericInput and its SymbolInput implementation.

    The two components can share most of their number-related behavior, including parsing, formatting, validation, locale handling, decimal precision, and updates from external values. However, their negative-value handling is different. In NumericField.TextInput, the minus sign is part of the value inside the input. In NumericInput.SymbolInput, the input displays only the amount, while the minus sign is rendered separately.

    This means that NumericInput needs separate logic to keep the displayed minus sign synchronized with the actual value, add or remove the sign without changing the amount text, and maintain the correct cursor position. In the current NumberWithSymbolForm, this logic is closely tied to several other input behaviors, which has led to unexpected behavior that can affect the user experience:

    • Selecting the entire negative amount and clearing it does not remove the minus sign.
    • Pasting a negative number can cause the minus sign to switch back and forth.
    • Multiple zeros can be inserted into the amount.
    Unexpected behavior
    unexpected_behavior.mov

    Because these two use cases are currently mixed together and the minus-sign handling has become complicated, we are also considering two possible ways to redesign the component.

    1. Our recommended approach is to use a separate currency selector together with one input that handles both the minus sign and the amount. This would allow us to reuse the same input component in both NumericField and NumericInput, while keeping currency selection separate from amount entry.
    Recommended approach
    recommended.mov
    1. The alternative approach would be to place the currency code and amount inside one input field. The user would be able to type or delete the currency code directly. If the currency code were removed, the currency currently displayed on the button would be used as a fallback.
    Alternative approach
    alternative.mov

    The first approach is strongly preferred because it would allow us to use one input component in both cases and make the responsibilities clearer. It should also reduce the special handling required for the externally rendered minus sign and help prevent inconsistencies between the displayed value, the actual value, and the cursor position.

    I am currently proceeding with NumericField first and NumericInput afterward. However, I am waiting for feedback on these redesign options and their feasibility @mountiny

  4. jmgraa commented on Aug 28, 2026

    @jmgraa
    Contributor

    @Expensify/design I’m following up on the input behavior and redesign options described in my previous update. The videos show the current unexpected behavior and the two possible redesigns:

    Before deciding on the component structure, could you first review the behaviors shown in the first video and confirm the intended experience? In particular:

    • Should clearing a negative amount also remove the minus sign?
    • Should pasting a negative amount preserve the minus sign consistently?
    • Should entering multiple zeros be allowed?
    • Are there any other behaviors in the video that should work differently?

    Based on that review, could you also confirm whether the current design needs to be changed at all? From the maintenance perspective, it would be simpler if the input owned the complete amount - including the minus sign - and the currency selector or symbol were handled separately. This would avoid the currency code getting in the way while the user edits the amount.

    This is also consistent with the approach documented by GOV.UK, USWDS, and Material UI, where currency prefixes or adornments are presented separately from the editable text.

    If a redesign is appropriate, could you then help choose between these two options?

    1. A separate currency selector with one input containing the minus sign and amount.
    2. A currency code and amount together inside the editable input, allowing the user to modify or remove the currency code.

    The first option is currently preferred from the implementation and maintenance perspective, but I’d like to confirm that it also matches the intended user experience before continuing the refactor.

  5. mountiny commented on Aug 30, 2026

    @mountiny
    ContributorAuthor

    Should entering multiple zeros be allowed?

    Leading zeros? I would say no

    I like the recommended video

    @jmgraa however, can you please bring this to expert-contributors? I think it will be much easier to discuss in slack than here

  6. dubielzyk-expensify commented on Aug 30, 2026

    @dubielzyk-expensify
    Contributor

    My take:

    Should clearing a negative amount also remove the minus sign?

    Yes

    Should pasting a negative amount preserve the minus sign consistently?

    Yes

    Should entering multiple zeros be allowed?

    At the start, no. Ad the end, yes.

    If a redesign is appropriate, could you then help choose between these two options?

    I don't think we should allow for editable currency outside clicking the currency button (i.e. you shouldn't be able to type AUD or A$ to swap it).

    As for the videos, the styling of the recommended video is wrong. The font size should be the same between the currency and the amount, which it is not at the moment:

    Image

    We moved to the Alt styling (with the button) for accessibility and other reasons so I'd expect that to probably be the route we'd wanna take. I also expect the negative symbol to be at the start of the amount (like alternative) and not in the middle (like recommended).

    Curious for @Expensify/design thoughts here too

  7. shawnborton commented on Aug 31, 2026

    @shawnborton
    Contributor

    I am aligned with all of your thoughts there. Except:

    Should entering multiple zeros be allowed?

    I don't feel strongly here - if someone wanted to enter 00.15 for whatever reason, I think we'd just convert it to be 0.15 in the next screen.

  8. dannymcclain commented on Aug 31, 2026

    @dannymcclain
    Contributor

    I am also aligned with all those thoughts. 🤝

    Should entering multiple zeros be allowed?

    I don't feel strongly here - if someone wanted to enter 00.15 for whatever reason, I think we'd just convert it to be 0.15 in the next screen.

    I feel the same as you—I could go either way really. Is the implementation easier one way or the other?

  9. 10 remaining items

  10. melvin-bot commented on Sep 14, 2026

    @melvin-bot

    @bernhardoj Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  11. melvin-bot commented on Sep 15, 2026

    @melvin-bot

    @bernhardoj

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.4.78-3 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

  12. melvin-bot commented on Sep 16, 2026

    @melvin-bot

    @bernhardoj Eep! 4 days overdue now. Issues have feelings too...

  13. melvin-bot commented on Sep 18, 2026

    @melvin-bot

    @bernhardoj 6 days overdue. This is scarier than being forced to listen to Vogon poetry!

  14. melvin-bot commented on Sep 22, 2026

    @melvin-bot

    @bernhardoj 10 days overdue. Is anyone even seeing these? Hello?

  15. melvin-bot commented on Sep 24, 2026

    @melvin-bot

    @bernhardoj 12 days overdue. Walking. Toward. The. Light...

  16. melvin-bot commented on Sep 28, 2026

    @melvin-bot

    This issue has not been updated in over 14 days. @mountiny, @bernhardoj, @jmgraa eroding to Weekly issue.

  17. mountiny commented on Oct 1, 2026

    @mountiny
    ContributorAuthor

    @jmgraa This is done right? We can close since payment is handled in bulk

  18. jmgraa commented on Oct 1, 2026

    @jmgraa
    Contributor

    Yes, it can be closed. I will probably comment there at the end of the refactor for documentation purposes

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

Metadata

Metadata

Labels

Skip PaymentSkips all the payment process steps for an issue. Use it for fixed-price projects.TaskWeeklyKSv2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions