Drive an input per keystroke
Composes: ensureEngineReady + createNationalInputController + InputController + ValidationError
This guide wires the input controller to any input surface without @telixon/web-sdk: a custom renderer, a React Native TextInput, a terminal prompt. parsePhoneNumber and the input controller are the same shared resolve layer, queried in batch versus per keystroke, so what the user sees while typing and what you validate on submit are one resolution. If your surface is a DOM <input>, @telixon/web-sdk ships this loop as a ready-made binding.
import { ensureEngineReady, createNationalInputController } from '@telixon/core';import type { InputState } from '@telixon/core';
await ensureEngineReady();
// Any input surface reduces to three fields the loop reads and writes.const widget = { value: '', selectionStart: 0, selectionEnd: 0 };
function applyToWidget(state: InputState): void { widget.value = state.value; widget.selectionStart = state.selectionStart; widget.selectionEnd = state.selectionEnd;}
const controller = createNationalInputController({ defaultRegion: 'AR' });
// One keystroke -> one controller call -> one InputState written back.applyToWidget(controller.setValue('011 15-2345-678'));console.log(widget);// -> { value: '011 15-2345-678', selectionStart: 15, selectionEnd: 15 }
applyToWidget(controller.insert(widget.value, '9', widget.selectionStart, widget.selectionEnd));console.log(widget);// -> { value: '011 15-2345-6789', selectionStart: 16, selectionEnd: 16 }
const phone = controller.getPhoneNumber();console.log(phone.isValid(), phone.getNumberType(), phone.formatE164());// -> true MOBILE +5491123456789Running this prints exactly the comments: the typed 9 lands at the caret, the caret advances to 16, and the resolved number is a valid Argentine mobile. Every keystroke repeats the same cycle:
keystroke -> insert / deleteBackward / deleteForward (value, selectionStart, selectionEnd) -> InputState { value, region, selectionStart, selectionEnd } -> write value and caret back to the widget -> controller.getPhoneNumber() for live validity1. Initialize the engine once
Section titled “1. Initialize the engine once”Call ensureEngineReady before creating any controller; createNationalInputController throws EngineNotReadyError if the engine is not initialized. Loading strategies per environment are covered in Engine loading.
2. Create one controller per input
Section titled “2. Create one controller per input”createNationalInputController requires a defaultRegion and holds per-input state, including the edit history, so give each field its own instance. Initial value, strict mode, and history depth are set in the same config.
3. Route every edit through the three operations
Section titled “3. Route every edit through the three operations”The controller does not read your widget. Each call is pure per call: you pass the current value and selection, and it returns the next state. That is the entire portability contract; anything that can report a string and two selection indices can host the controller.
insert(value: string, text: string, selectionStart: number, selectionEnd: number): InputState;deleteBackward(value: string, selectionStart: number, selectionEnd: number): InputState;deleteForward(value: string, selectionStart: number, selectionEnd: number): InputState;A minimal keystroke router:
function onKeystroke(key: string): void { const { value, selectionStart, selectionEnd } = widget; if (key === 'Backspace') { applyToWidget(controller.deleteBackward(value, selectionStart, selectionEnd)); } else if (key === 'Delete') { applyToWidget(controller.deleteForward(value, selectionStart, selectionEnd)); } else { applyToWidget(controller.insert(value, key, selectionStart, selectionEnd)); }}Paste is the same operation with a longer text:
console.log(controller.insert('', '011 15-2345-6789', 0, 0));// -> { value: '011 15-2345-6789', region: 'AR', selectionStart: 16, selectionEnd: 16 }The full operation contract, including region and number-type filters, is on InputController.
4. Write the state back exactly as returned
Section titled “4. Write the state back exactly as returned”Write value, selectionStart, and selectionEnd back to the widget verbatim. The caret is managed for you: when live formatting inserts or removes separators, the returned selection already accounts for them, so never re-derive the caret from the previous position.
applyToWidget(controller.deleteBackward(widget.value, widget.selectionStart, widget.selectionEnd));console.log(widget);// -> { value: '011 15-2345-678', selectionStart: 15, selectionEnd: 15 }5. Query live validity after each write-back
Section titled “5. Query live validity after each write-back”controller.getPhoneNumber() resolves the current state through the automaton. Validity is an accepting state, and every ValidationError variant is a named way of not being in one, so a mid-typing value reports a structured reason rather than a bare false:
const phone = controller.getPhoneNumber();console.log(phone.isValid());// -> falseconsole.log(phone.getValidationError());// -> { kind: 'PATTERN_MISMATCH' }Mapping each kind to user-facing copy is covered in Show form errors.
6. Wire undo and redo
Section titled “6. Wire undo and redo”undo() and redo() return an InputState exactly like the edit operations, and history entries store value and selection together, so the caret is restored too. Guard the calls with canUndo and canRedo. History keeps the last 150 states by default; tune it with maxHistorySize in the controller config, and drop it with clearHistory() after a programmatic reset.
applyToWidget(controller.undo());console.log(widget);// -> { value: '011 15-2345-6789', selectionStart: 16, selectionEnd: 16 }console.log(controller.canUndo, controller.canRedo);// -> true true
applyToWidget(controller.redo());console.log(widget);// -> { value: '011 15-2345-678', selectionStart: 15, selectionEnd: 15 }The same loop, international
Section titled “The same loop, international”When the input accepts full international numbers, swap the factory for createInternationalInputController. The loop is unchanged; the controller additionally infers the region from the typed calling code and reports it on every InputState.
import { createInternationalInputController } from '@telixon/core';
const international = createInternationalInputController({ display: { callingCodeInInput: true, plusPrefix: 'fixed' },});
let state = international.currentState;console.log(state.value);// -> +
for (const digit of ['4', '4', '2', '0', '7', '9', '4', '6', '0', '9']) { state = international.insert(state.value, digit, state.selectionStart, state.selectionEnd);}// value after each insert:// '+4' -> '+44 ' -> '+44 2' -> '+44 20 ' -> '+44 20 7' -> '+44 20 79'// -> '+44 20 794' -> '+44 20 7946 ' -> '+44 20 7946 0' -> '+44 20 7946 09'
console.log(state.value, state.region);// -> +44 20 7946 09 GBThe second 4 returns '+44 ' with the caret after the separator: formatting is applied live inside the same pure call, which is why step 4 writes the returned selection back verbatim.
Next steps
Section titled “Next steps”- Map every
ValidationErrorkind to user-facing copy: Show form errors - Gate submission on the same resolve layer: Validate on submit
- The full controller contract, including filters and
setRegion:InputController