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
pastehandler.
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-setRangeTextfires a synchronousinputevent in real browsers (jsdom skips this; tests dispatch it manually).formatInputValueis idempotent on the already-formatted value so the pipeline is a no-op andonChangeis emitted. - After paste -
handlePastesets the value directly then dispatches a syntheticinputevent. 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 emitsonChange.
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:
- 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: true. - 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: true.
The formatting step
After sanitization, two length adjustments run before visual formatting:
- trimToDecimalMaxLength - truncates the decimal portion to
decimalMaxLengthdigits. - ensureMinDecimals - zero-pads the decimal portion up to
decimalMinLengthdigits.
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.
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 viaonChangewhenrawValueMode: true; otherwiseonChangereceives the formatted value.
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
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