Skip to content

InputController

InputController is the contract shared by every Telixon input controller: a state machine that owns an input widget’s value, caret, and history, and steps them one edit at a time. It is the per-keystroke entry into the resolve layer: parsePhoneNumber and the input controller are the same shared resolve layer, queried in batch versus per keystroke, and history and caret are controller state. Both factories, createNationalInputController and createInternationalInputController, return exactly this contract; their pages document only their config and behavioral delta.

import type { InputController, InputState } from '@telixon/core';

Instances come from the factories:

import { createNationalInputController, createInternationalInputController } from '@telixon/core';
export interface InputController {
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;
setValue(value: string): InputState;
setRegion(region: RegionCode): InputState;
undo(): InputState;
redo(): InputState;
setRegionFilter(regions: readonly RegionCode[] | null): InputState;
setNumberTypeFilter(numberTypes: readonly NumberType[] | null): InputState;
clearHistory(): void;
getPhoneNumber(): PhoneNumber;
readonly canUndo: boolean;
readonly canRedo: boolean;
readonly currentState: InputState;
}
export interface InputState {
readonly value: string;
readonly region: RegionCode | null;
readonly selectionStart: number;
readonly selectionEnd: number;
}
export interface InputChange {
readonly insertText: string;
readonly selectionStart: number;
readonly selectionEnd: number;
}
export type CaretIndex = number;

InputController and InputState are exported by name from @telixon/core. InputChange and CaretIndex name the shapes inside the contract, the text-plus-selection of one edit and a caret position in value; they are not exported.

Every InputState field:

Field Type Meaning
value string The formatted text the widget should display after the step.
region RegionCode | null The region the digits currently resolve to; null while it is undetermined.
selectionStart number Authoritative caret start; write it back to the widget along with value.
selectionEnd number Authoritative caret end; equals selectionStart when the caret is collapsed.

The editing methods take the widget’s current value and selection as arguments instead of trusting internal state, so an external change to the widget (autofill, another script) can never desynchronize the machine. Each call is pure per invocation: you own the widget, it owns the machine. You pass in what the widget shows; it returns what the widget should show next.

Method Returns Purpose
insert InputState Apply typed or pasted text at a selection
deleteBackward InputState Apply Backspace at a selection
deleteForward InputState Apply Delete at a selection
setValue InputState Replace the whole value in one batch resolve
setRegion InputState Re-resolve the same digits under another region
undo / redo InputState Step through bounded edit history
setRegionFilter InputState Constrain resolvable regions; null clears
setNumberTypeFilter InputState Constrain number types; null clears
clearHistory void Drop the undo and redo stacks
getPhoneNumber PhoneNumber Snapshot the current digits as a PhoneNumber

Applies text at the selection [selectionStart, selectionEnd) of value. A collapsed selection is a caret; a multi-character text is a paste. Typing and pasting are the same operation.

import { ensureEngineReady, createNationalInputController } from '@telixon/core';
await ensureEngineReady();
const controller = createNationalInputController({ defaultRegion: 'US' });
// One keystroke: the widget shows '(415) 555-013' with the caret at 13.
controller.insert('(415) 555-013', '2', 13, 13);
// { value: '(415) 555-0132', region: 'US', selectionStart: 14, selectionEnd: 14 }
// A paste into an empty widget.
controller.insert('', '415 555 0132', 0, 0);
// { value: '(415) 555-0132', region: 'US', selectionStart: 14, selectionEnd: 14 }
// Typing over a selection: the second '555' group is selected.
controller.insert('(415) 555-0132', '9', 6, 9);
// { value: '(415) 901-32', region: 'US', selectionStart: 7, selectionEnd: 7 }

Characters in text that cannot participate in the number are discarded; see Non-significant input.

Applies Backspace: with a collapsed caret it targets the character before selectionStart, with a selection it removes the selection.

// Backspace with the caret at the end.
controller.deleteBackward('(415) 555-0132', 14, 14);
// { value: '(415) 555-013', region: 'US', selectionStart: 13, selectionEnd: 13 }
// Backspace with the second '555' group selected.
controller.deleteBackward('(415) 555-0132', 6, 9);
// { value: '(415) 013-2', region: 'US', selectionStart: 6, selectionEnd: 6 }

When the targeted character is a formatting character, the caret moves without removing a digit; see Deleting across formatting.

Applies Delete, the forward mirror of deleteBackward: a collapsed caret targets the character after selectionStart, a selection is removed.

// Delete with the caret before the final '2'.
controller.deleteForward('(415) 555-0132', 13, 13);
// { value: '(415) 555-013', region: 'US', selectionStart: 13, selectionEnd: 13 }

Replaces the whole value and resolves it in one batch, the same walk parsePhoneNumber performs. The caret lands at the end. Use it for initial values and for any external write that did not pass through insert.

const controller = createInternationalInputController({
display: { callingCodeInInput: true, plusPrefix: 'fixed' },
});
controller.setValue('+44 20 7946 0958');
// { value: '+44 20 7946 0958', region: 'GB', selectionStart: 16, selectionEnd: 16 }

Keeps the digits and re-resolves them under the given region. Formatting, validity, and caret follow the new region.

const controller = createNationalInputController({ defaultRegion: 'US' });
controller.setValue('(415) 555-0132');
controller.setRegion('GB');
// { value: '41555 50132', region: 'GB', selectionStart: 11, selectionEnd: 11 }
controller.setRegion('US');
// { value: '(415) 555-0132', region: 'US', selectionStart: 14, selectionEnd: 14 }

Every step that changes the value or region pushes an entry onto a bounded history. undo and redo move through it and return the restored InputState, caret included. canUndo and canRedo report whether a move is available; at a boundary, undo and redo return the current state unchanged instead of throwing.

const controller = createNationalInputController({ defaultRegion: 'AR' });
let state = controller.setValue('011 15-2345-678');
// { value: '011 15-2345-678', selectionStart: 15 }
state = controller.insert(state.value, '9', state.selectionStart, state.selectionEnd);
// { value: '011 15-2345-6789', selectionStart: 16 }
state = controller.deleteBackward(state.value, state.selectionStart, state.selectionEnd);
// { value: '011 15-2345-678', selectionStart: 15 }
state = controller.undo();
// { value: '011 15-2345-6789', selectionStart: 16 }
controller.canUndo; // true
controller.canRedo; // true
state = controller.redo();
// { value: '011 15-2345-678', selectionStart: 15 }

History depth and eviction are covered under History bounds.

Constrains which regions the resolve layer may match. Digits that resolve outside the filter lose their region and their formatting; the current value is re-resolved immediately and the new InputState is returned. Pass null to clear the filter.

const controller = createInternationalInputController({
display: { callingCodeInInput: true, plusPrefix: 'fixed' },
});
controller.setValue('+44 20 7946 0958');
// { value: '+44 20 7946 0958', region: 'GB' }
controller.setRegionFilter(['US', 'CA']);
// { value: '+442079460958', region: null }
controller.setRegionFilter(null);
// { value: '+44 20 7946 0958', region: 'GB' }

Constrains which number types count as valid. The value and region are unaffected; the restriction is visible through getPhoneNumber. Pass null to clear the filter. Valid entries are the members of NUMBER_TYPES.

controller.setValue('+44 20 7946 0958'); // a FIXED_LINE number
controller.setNumberTypeFilter(['MOBILE']);
controller.getPhoneNumber().isValid(); // false
controller.setNumberTypeFilter(null);
controller.getPhoneNumber().isValid(); // true

Drops the undo and redo stacks. The current state is untouched.

controller.clearHistory();
controller.canUndo; // false
controller.canRedo; // false

Returns a PhoneNumber for the digits currently in the machine: the per-keystroke query surface for validity, type, region, and formats. Each call snapshots the state at that moment; a returned PhoneNumber does not change when the controller steps again.

const controller = createNationalInputController({ defaultRegion: 'US' });
const state = controller.setValue('(415) 555-0132');
const before = controller.getPhoneNumber();
before.isValid(); // true
controller.deleteBackward(state.value, state.selectionStart, state.selectionEnd);
before.isValid(); // true, snapshot taken before the step
controller.getPhoneNumber().isValid(); // false
controller.getPhoneNumber().getValidationError(); // { kind: 'TOO_SHORT', minLength: 10 }

No extra parse happens here: the step already walked the automaton, so the snapshot reads the machine’s current resolution.

true when undo or redo has an entry to move to. Read them to enable or disable undo and redo affordances; see undo and redo.

The machine’s InputState right now, always equal to the state returned by the last step. Read it instead of caching returned states.

controller.currentState;
// { value: '011 15-2345-678', region: 'AR', selectionStart: 15, selectionEnd: 15 }

The returned selectionStart and selectionEnd are authoritative. After every step, write both value and the selection back to the widget. Formatting moves characters relative to where the widget’s native caret would land, so once you overwrite value, the native caret position is meaningless. Drive an input shows the write-back wiring; @telixon/web-sdk does it for you on DOM inputs.

Digits drive one deterministic automaton compiled from Google’s metadata, and formatting is part of each step, not a separate pass over the value. Separators appear the moment the digits determine them:

const controller = createInternationalInputController({
display: { callingCodeInInput: true, plusPrefix: 'fixed' },
});
let state = controller.currentState; // { value: '+', selectionStart: 1 }
for (const digit of ['4', '4', '2', '0', '7', '9']) {
state = controller.insert(state.value, digit, state.selectionStart, state.selectionEnd);
}
// '+4' -> '+44 ' -> '+44 2' -> '+44 20 ' -> '+44 20 7' -> '+44 20 79'
state.value; // '+44 20 79'
state.region; // 'GB'

The space after +44 appears in the same step that completes the calling code. Mechanism details are in How the input controller works.

insert keeps the characters of text that can participate in the number and discards the rest. A step with nothing to apply returns the state unchanged:

controller.insert('(415) 555-0132', 'x', 14, 14);
// { value: '(415) 555-0132', region: 'US', selectionStart: 14, selectionEnd: 14 }

Formatting characters are derived output, not input, so deleting one cannot mean anything: it would either be a no-op the user has to fight or an invisible digit removal. Instead, when a deletion targets a formatting character, the caret moves across the formatting run to the nearest digit in the deletion direction and nothing is removed; the next deletion in the same direction removes a digit.

// Backspace at caret 6: the characters before it, ') ', are formatting.
controller.deleteBackward('(415) 555-0132', 6, 6);
// { value: '(415) 555-0132', region: 'US', selectionStart: 4, selectionEnd: 4 }
// Delete at caret 0: '(' is formatting.
controller.deleteForward('(415) 555-0132', 0, 0);
// { value: '(415) 555-0132', region: 'US', selectionStart: 1, selectionEnd: 1 }

History is bounded by maxHistorySize, set at construction; the oldest entry is evicted when the bound is exceeded. The config is documented on createNationalInputController and createInternationalInputController. Two more rules keep the history meaningful:

  • A step that changes neither value nor region updates the current entry’s selection instead of pushing a new entry, so caret movement and rejected input never pollute undo.
  • A new edit after undo discards the redo branch, matching standard editor behavior.

Controllers cannot exist before the engine is ready: the factories throw EngineNotReadyError at construction when called before ensureEngineReady has resolved, so no method on an existing InputController ever throws it.

import { createNationalInputController } from '@telixon/core';
createNationalInputController({ defaultRegion: 'US' });
// EngineNotReadyError: Telixon engine is not initialized.

Bad input does not throw either: a step that has nothing to apply returns the state unchanged, and undo or redo at a history boundary returns the current state.