XGitHub

How It Works

Numora wraps 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 code.

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.

beforeinput

beforeinput is the primary formatting hook. It fires after the browser has resolved what the input will be, but before the DOM is mutated - making it the correct place to intercept and reformat. Numora calls e.preventDefault() to suppress the native mutation, computes the intended value, runs the full sanitize → format pipeline, then writes the result via setRangeText (which preserves the browser's undo/redo stack).

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 against its own undo stack.
  • Paste/drop - deferred to the dedicated paste handler.

input

handleChange is the single place onChange is emitted - it always runs the full sanitize → format pipeline via handleOnChangeNumoraInput. Three paths lead here:

  • After beforeinput - setRangeText fires a synchronous input event in real browsers (jsdom skips this; tests dispatch it manually). formatInputValue is idempotent on the already-formatted value so the pipeline is a no-op and onChange is emitted.
  • After paste - handlePaste sets the value directly then dispatches a synthetic input event. Same idempotent run.
  • Undo/redo and programmatic changes - the browser (or external code) sets the value and fires input; the pipeline formats the new value and emits onChange.

paste

Numora prevents the default browser paste, reads the clipboard text, splices it into the current value at the active selection range (replacing any selected characters), then passes the result through the same sanitize → format pipeline. The new cursor position is calculated based on how many characters formatting added or removed relative to the paste point.

focus / blur

When formatOn is set to "blur", thousand separators are stripped on focus so the user edits a clean number (e.g. 1234.56), then blur calls formatValueForDisplay to re-apply thousand grouping for display (e.g. 1,234.56). In "change" mode (the default) formatting is applied live on every input event; blur does not alter the value in this mode.

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: true.
  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: true.

The formatting step

After sanitization, two length adjustments run before visual formatting:

  • trimToDecimalMaxLength - truncates the decimal portion to decimalMaxLength digits.
  • ensureMinDecimals - zero-pads the decimal portion up to decimalMinLength digits.

formatNumoraInput then applies thousand grouping in the configured style (only when formatOn='change'; in FormatOn.Blur mode this step is deferred to the blur handler). Finally, updateCursorPosition restores the caret to the correct position after the formatted value is written back to input.value.

typescript
import { NumoraInput, ThousandStyle } from 'numora'

// ThousandStyle.Thousand  →  1,234,567  (Western)
// ThousandStyle.Lakh      →  12,34,567  (South Asian)
// ThousandStyle.Wan       →  123,4567   (East Asian)

const numoraInput = new NumoraInput(container, {
  thousandSeparator: ',',
  thousandStyle: ThousandStyle.Thousand,
  decimalMaxLength: 2,
})

Value output

The pipeline produces two representations of the value:

  • formatted - the display string including thousand separators. This is always written to input.value.
  • raw - the value after sanitization but before thousand grouping (e.g. "1234.56"). Emitted via onChange when rawValueMode: true; otherwise onChange receives the formatted value.
typescript
import { NumoraInput } from 'numora'

const numoraInput = new NumoraInput(container, {
  decimalMaxLength: 2,
  thousandSeparator: ',',
  rawValueMode: true, // onChange receives "1234.56" instead of "1,234.56"
  onChange: (value) => {
    console.log(value)             // "1234.56"
    console.log(numoraInput.value) // "1,234.56" (always formatted)
  },
})

Full pipeline diagram

text
user action
    ├─ keydown ──────────── skip cursor over thousand separator (Delete/Backspace only)
    ├─ beforeinput ─────── decimal separator handling          (primary path)
    │                      character insertion / deletion
    │                      e.preventDefault() + setRangeText
    │                      ↓ fires synchronous 'input' (real browsers)
    │                        or dispatched manually in tests (jsdom gap)
    ├─ paste ────────────── splice clipboard into selection    (dedicated handler)
    │                      sanitize + format + cursor reposition
    │                      ↓ dispatches synthetic 'input'
    ├─ input ────────────── always runs full pipeline          (single onChange emitter)
    │   (handleChange)     covers all paths above + undo/redo + programmatic changes
    │                      formatInputValue is idempotent: no-op when already formatted
    │                      ▼
    │             trimToDecimalMaxLength
    │                      │
    │                      ▼
    │                ensureMinDecimals
    │                      │
    │                      ▼
    │            formatNumoraInput (thousand grouping)
    │            [FormatOn.Change only; skipped in FormatOn.Blur]
    │                      │
    │                ┌─────┴──────┐
    │                ▼            ▼
    │           formatted        raw
    │         → input.value   → onChange (if rawValueMode)
    └─ blur ─────────────── formatValueForDisplay (FormatOn.Blur only)
                            → input.value