Repository navigation
NumberForm foundation 1: root API and NumberForm.Input #98169
Description
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 primitivessrc/components/NumberForm/types.tssrc/components/NumberForm/context/— shared state for the primitivessrc/components/NumberForm/primitives/NumberFormInput.tsxconfig/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 variantssrc/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) servesdisplayAsTextInputonly, is not memoised, readsselectiondirectly, and additionally appliesaddLeadingZero. Collapse them into one setter withaddLeadingZeroas an explicit option. NumberWithSymbolFormRefis the seam to preserve. It is{clearSelection, updateNumber, getNumber}, held by 8 files. Keep the same three methods on theNumberFormref 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
-
NumberFormroot acceptsvalue,onInputChange,negativeMode,children -
NumberFormroot works as aFormInputComponent(ref,onBlur,errorText) -
NumberForm.Inputrenders and handles parsing, formatting, validation, selection, keyboard and locale - The ref exposes
clearSelection,updateNumberandgetNumberwith unchanged semantics - One value setter, with
addLeadingZeroas an explicit option - Dedicated tests for
NumberFormandNumberForm.Input - Lint passes with no new seatbelt rows, or with the three violations fixed
-
NumberWithSymbolFormbehaviour is unchanged, and the tests from #98168 still pass - Verify that no errors appear in the JS console
Activity
jmgraa commentedon Aug 19, 2026
Implementation update
Following the investigation of the existing
NumberWithSymbolFormimplementation, this update summarizes the proposedNumberFormAPI and the behavior changes currently under review.Public API
The proposed API introduces separate primitives for the two input types:
This replaces the planned
NumberForm.InputAPI with explicitSymbolInputandTextInputcomponents 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:
maxLengthhandling to both input types.TextInputWithSymbolnormalization forNumberForm.SymbolInput.maxLengthas numeric validation rather than forwarding it to the native input.Dedicated tests cover the root state behavior, both input variants, formatting, validation, external updates, and selection handling.
jmgraa commentedon Aug 28, 2026
Implementation update 2
After further investigation of the existing
NumberWithSymbolFormand rethinking singleNumberFormcomponent architecture, we decided to move forward with two separate primitives:NumericField.TextInputNumericInput.SymbolInputNumericFieldis intended to be a standard, single input field for entering an amount.NumericInputis 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
NumericInput.SymbolInput
This separation comes from the different behaviors currently combined in the legacy
src/components/NumberWithSymbolForm.tsximplementation. 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:
NumericInputControllerlogic and introducingNumericField.NumericInputand itsSymbolInputimplementation.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. InNumericInput.SymbolInput, the input displays only the amount, while the minus sign is rendered separately.This means that
NumericInputneeds 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 currentNumberWithSymbolForm, this logic is closely tied to several other input behaviors, which has led to unexpected behavior that can affect the user experience: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.
NumericFieldandNumericInput, while keeping currency selection separate from amount entry.Recommended approach
recommended.mov
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
NumericFieldfirst andNumericInputafterward. However, I am waiting for feedback on these redesign options and their feasibility @mountinyjmgraa commentedon Aug 28, 2026
@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:
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?
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.
mountiny commentedon Aug 30, 2026
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
dubielzyk-expensify commentedon Aug 30, 2026
My take:
Yes
Yes
At the start, no. Ad the end, yes.
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:
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
shawnborton commentedon Aug 31, 2026
I am aligned with all of your thoughts there. Except:
I don't feel strongly here - if someone wanted to enter
00.15for whatever reason, I think we'd just convert it to be0.15in the next screen.dannymcclain commentedon Aug 31, 2026
I am also aligned with all those thoughts. 🤝
I feel the same as you—I could go either way really. Is the implementation easier one way or the other?
10 remaining items
melvin-bot commentedon Sep 14, 2026
@bernhardoj Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
melvin-bot commentedon Sep 15, 2026
@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:
melvin-bot commentedon Sep 16, 2026
@bernhardoj Eep! 4 days overdue now. Issues have feelings too...
melvin-bot commentedon Sep 18, 2026
@bernhardoj 6 days overdue. This is scarier than being forced to listen to Vogon poetry!
melvin-bot commentedon Sep 22, 2026
@bernhardoj 10 days overdue. Is anyone even seeing these? Hello?
melvin-bot commentedon Sep 24, 2026
@bernhardoj 12 days overdue. Walking. Toward. The. Light...
melvin-bot commentedon Sep 28, 2026
This issue has not been updated in over 14 days. @mountiny, @bernhardoj, @jmgraa eroding to Weekly issue.
mountiny commentedon Oct 1, 2026
@jmgraa This is done right? We can close since payment is handled in bulk
jmgraa commentedon Oct 1, 2026
Yes, it can be closed. I will probably comment there at the end of the refactor for documentation purposes