XGitHub

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.

tsx
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.
typescript
// thousand separator skip - handled transparently
// user presses Backspace on "1,234" → cursor jumps over the comma
// result: "1234" after the next input event

beforeinput

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 paste handler.

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).

tsx
<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.

typescript
// 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 automatically

focus / 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.

tsx
// 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:

  1. Mobile keyboard artifact filtering - removes non-breaking spaces (U+00A0) and other Unicode whitespace variants inserted by mobile keyboards.
  2. Thousand separator removal - strips any existing thousand separators so the pipeline always works on a plain numeric string.
  3. Compact notation expansion - expands shorthand like "1k""1000" or "2.5M""2500000". Opt-in via enableCompactNotation.
  4. Scientific notation expansion - always active; expands "1.5e-7""0.00000015".
  5. Non-numeric character removal - keeps only digits, the configured decimal separator, and (if enableNegative is true) a leading -. Everything else is discarded.
  6. Extra decimal separator removal - if more than one decimal separator survives the previous step, only the first one is kept.
  7. Leading zero normalization - "007" becomes "7". Disable with enableLeadingZeros.

The formatting step

After sanitization, two length adjustments run before visual formatting:

  • trimToDecimalMaxLength - truncates the decimal portion to maxDecimals digits.
  • ensureMinDecimals - zero-pads the decimal portion up to minDecimals digits.

formatNumoraInput then applies thousand grouping in the configured style:

tsx
// 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 as e.target.value in onChange - 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 as e.target.formattedValue via NumoraHTMLInputElement.
tsx
<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

text
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