How It Works
NumoraInput renders a type="text" input and intercepts all three input vectors - keyboard, paste, and focus/blur - to run every value through a consistent sanitize → format → emit pipeline before any change reaches your React state.
React integration
numora-react is a functional React component that calls the same core handler functions as the vanilla library directly - no class is instantiated or destroyed. Props map to core options, and the React onChange event fires after every pipeline cycle with the same event object shape you expect from a regular input.
import { NumoraInput } from 'numora-react'
function App() {
return (
<NumoraInput
maxDecimals={2}
thousandSeparator=","
onChange={(e) => {
console.log(e.target.value) // always "1,234.56" - the formatted display string
}}
/>
)
}Event lifecycle
keydown
keydown handles one special case before any DOM mutation occurs:
- Skips the cursor over thousand separators on Delete / Backspace so the user never has to manually navigate past a formatting character.
// thousand separator skip - handled transparently
// user presses Backspace on "1,234" → cursor jumps over the comma
// result: "1234" after the next input eventbeforeinput
beforeinput is the primary formatting hook, registered via native addEventListener directly on the element - not through React's synthetic event system. React delegates events from the root, which means preventDefault() would be a no-op by the time it runs; a native listener fires synchronously at the element, before the browser commits the mutation.
The handler calls e.preventDefault(), computes the intended value, runs the full sanitize → format pipeline, and writes the result via setRangeText - which preserves the browser's undo/redo stack. This fires a synchronous input event, which React catches as onChange.
The handler covers:
- Decimal separator key - converts
,or.to the configured separator; blocks a second decimal if one already exists. - Character insertion (
insertText) - inserts at cursor, formats the result. - Deletions (
deleteContentBackward,deleteContentForward,deleteByCut,deleteByDrag) - removes the correct range, formats the result. - Undo/redo (
historyUndo,historyRedo) - not intercepted; the browser handles these natively. - Paste/drop - deferred to the dedicated
pastehandler.
input / onChange
The synchronous input event fired by setRangeText inside beforeinput is caught by React as onChange. handleChange is a pure emitter - it reads the already-formatted value from the DOM, strips thousand separators to compute the raw value, and fires the onChange callback. It does not reformat. The same handler fires for paste (via synthetic event) and undo/redo (via native browser event).
<NumoraInput
maxDecimals={2}
thousandSeparator=","
onChange={(e) => {
// fires after every sanitize + format cycle
console.log(e.target.value) // e.g. "1234.56" - raw, separators stripped
}}
/>paste
The default browser paste is prevented. The clipboard text is spliced into the current value at the active selection range (replacing any selected text), then the combined string passes through the full sanitize → format pipeline. The new cursor position is calculated from how many characters formatting added or removed relative to the paste point.
// pasting " 1,234.56.78abc" into an empty input
// → after splice + sanitize + format → "1,234.5678" (if maxDecimals allows)
// invalid characters, extra separators, and whitespace are stripped automaticallyfocus / blur
The default formatOn is "blur": thousand separators are stripped on focus so the user edits a clean number (e.g. 1234.56), then re-applied on blur for display (e.g. 1,234.56). In "change" mode formatting is applied live on every keystroke and focus/blur do not alter the value.
// default: formatOn="blur"
<NumoraInput
maxDecimals={2}
thousandSeparator=","
formatOn="blur" // strip separators on focus, re-apply on blur
/>
// live formatting on every keystroke:
<NumoraInput formatOn="change" thousandSeparator="," maxDecimals={2} />The sanitization pipeline
sanitizeNumoraInput runs seven steps in order on every value before formatting is applied:
- Mobile keyboard artifact filtering - removes non-breaking spaces (U+00A0) and other Unicode whitespace variants inserted by mobile keyboards.
- Thousand separator removal - strips any existing thousand separators so the pipeline always works on a plain numeric string.
- Compact notation expansion - expands shorthand like
"1k"→"1000"or"2.5M"→"2500000". Opt-in viaenableCompactNotation. - Scientific notation expansion - always active; expands
"1.5e-7"→"0.00000015". - Non-numeric character removal - keeps only digits, the configured decimal separator, and (if
enableNegativeis true) a leading-. Everything else is discarded. - Extra decimal separator removal - if more than one decimal separator survives the previous step, only the first one is kept.
- Leading zero normalization -
"007"becomes"7". Disable withenableLeadingZeros.
The formatting step
After sanitization, two length adjustments run before visual formatting:
- trimToDecimalMaxLength - truncates the decimal portion to
maxDecimalsdigits. - ensureMinDecimals - zero-pads the decimal portion up to
minDecimalsdigits.
formatNumoraInput then applies thousand grouping in the configured style:
// thousandStyle="thousand" → 1,234,567 (Western, default)
// thousandStyle="lakh" → 12,34,567 (South Asian)
// thousandStyle="wan" → 123,4567 (East Asian)
<NumoraInput
thousandSeparator=","
thousandStyle="thousand"
maxDecimals={2}
/>Value output
The pipeline produces two representations of the value:
- raw - the value after sanitization but before thousand grouping (e.g.
"1234.56"). Always returned ase.target.valueinonChange- safe to pass directly to form libraries. - formatted - the display string including thousand separators (e.g.
"1,234.56"). Written to the DOM and available ase.target.formattedValueviaNumoraHTMLInputElement.
<NumoraInput
maxDecimals={2}
thousandSeparator=","
onChange={(e) => {
console.log(e.target.value) // "1234.56" - raw, no thousand separators
console.log(e.target.formattedValue) // "1,234.56" - formatted display string
}}
/>Full pipeline diagram
user action
│
├─ keydown ──────────── skip cursor over thousand separator (Delete/Backspace only)
│
├─ beforeinput ─────── decimal separator handling [native addEventListener]
│ character insertion / deletion (primary path)
│ e.preventDefault() + setRangeText
│ ↓ fires synchronous 'input' → React onChange (handleChange)
│
├─ paste ────────────── splice clipboard into selection (dedicated handler)
│ sanitize + format + cursor reposition
│ ↓ synthetic ChangeEvent → onChange directly
│
├─ input / onChange ─── pure emitter (handleChange)
│ reads already-formatted value from DOM
│ strips separators → raw value
│ fires onChange (e.target.value = raw)
│ (also fires for undo/redo via native browser input event)
│
│ (beforeinput pipeline produces:)
│ ▼
│ trimToDecimalMaxLength
│ │
│ ▼
│ ensureMinDecimals
│ │
│ ▼
│ formatNumoraInput (thousand grouping)
│ [FormatOn.Change only; skipped in FormatOn.Blur]
│ │
│ ┌─────┴──────────────────────┐
│ ▼ ▼
│ formatted raw
│ → input.value → e.target.value (onChange)
│ → e.target.formattedValue
│
└─ blur ─────────────── formatValueForDisplay (FormatOn.Blur only)
→ input.value