Skip to content

Input controller

The input controller turns raw edit events into resolved input states: give it the edit a user just made, get back the formatted value, the caret position, the inferred region, and a queryable number. It is the per-keystroke half of the resolve layer: parsePhoneNumber and the input controller are the same shared resolve layer, queried in batch versus per keystroke. This page explains the mechanism; the contract lives on InputController.

An edit reaches the controller as three facts: the current string, the inserted text, and the selection range. From those the controller extracts the digit sequence the edited string represents and drives the engine with it: digits drive one deterministic automaton compiled from Google’s metadata. The state the walk ends in determines everything at once, in the same step: the formatted value, the caret inside it, the inferred region, and the resolver snapshot behind validity.

This is what puts the controller a generation ahead of as-you-type formatters. It does not re-format a string after the fact; it advances the machine and derives value, caret, region, and validity from the new state in the same step. There is no second pass that could disagree with the first: the displayed string, the caret, and the verdict all come from one automaton state.

Typing 442079460958 into an international controller, one insert per key:

import { ensureEngineReady, createInternationalInputController } from '@telixon/core';
await ensureEngineReady();
const controller = createInternationalInputController({
display: { callingCodeInInput: true, plusPrefix: 'fixed' },
});
let state = controller.currentState;
// { value: '+', region: null, selectionStart: 1, selectionEnd: 1 }
for (const key of '442079460958') {
state = controller.insert(state.value, key, state.selectionStart, state.selectionEnd);
}

Each step is one resolve, and each resolved state carries its own value, caret, and region:

key value caret region
4 '+4' 2 GB
4 '+44 ' 4 GB
2 '+44 2' 5 GB
0 '+44 20 ' 7 GB
7 '+44 20 7' 8 GB
9 '+44 20 79' 9 GB
4 '+44 20 794' 10 GB
6 '+44 20 7946 ' 12 GB
0 '+44 20 7946 0' 13 GB
9 '+44 20 7946 09' 14 GB
5 '+44 20 7946 095' 15 GB
8 '+44 20 7946 0958' 16 GB

The value regroups live as digits commit the walk: the separator after 44 appears the moment the calling code completes, and the 20 xxxx xxxx grouping appears as the London pattern locks in. While the calling code is still incomplete, region reports the primary candidate region of the calling-code walk so far, which is why +4 already reads GB.

Querying the controller goes through the same walk, so the verdict is the same one parsePhoneNumber returns for the same digits:

controller.getPhoneNumber().formatE164(); // '+442079460958'
controller.getPhoneNumber().isValid(); // true

The same mechanism drives the national variant; createNationalInputController seeds the walk with the region’s calling code and reads the digits in national notation.

The caret is never located by searching the displayed string. Inside a resolve it is carried as a digit index: a position in the digit sequence, not in the formatted value. Applying the edit counts the digits before the selection and the digits being inserted, so the digit index is exact by construction. The formatter then renders the digit sequence through the selected format’s mask, and because the mask states which produced character is a digit and which is formatting, the digit index maps to a string index deterministically. No heuristic re-locates the caret, so regrouping cannot strand it.

Inserting into the middle of a number shows the whole chain:

controller.setValue('+44 20 7946 095');
// Caret at index 9, right after '79'; type '1'.
const state = controller.insert('+44 20 7946 095', '1', 9, 9);
state.value; // '+44 20 7914 6095'
state.selectionStart; // 10, immediately after the inserted '1'

Every digit to the right regrouped, and the caret still sits directly after the inserted digit, because the new string and the new caret came out of the same mask rendering.

The mapping is direction-aware: insertions map the caret forward across formatting the mask emits, deletions map it backward, so the caret lands on the natural side of a group boundary.

// Typing '4420' completes a group; the caret lands after the emitted separator.
// value: '+44 20 ' caret: 7
// Backspace first drops the trailing formatting, then consumes a digit.
// value: '+44 20' caret: 6
// value: '+44 2' caret: 5

Formatting characters themselves are not editable. Backspace with the caret right after a group separator, while digits remain ahead of it, moves the caret to just past the previous digit and resolves nothing: the digit sequence did not change, so there is no new state to derive. The next backspace consumes an actual digit.

Undo and redo do not diff strings and do not re-resolve. Every accepted edit pushes the complete resolved state onto a bounded history stack: value, caret, region, the resolver snapshot, and display bits such as plusErased. Undo moves the index back and returns that state as-is; redo moves it forward. Because the caret and region live inside each entry, undo restores the exact editing position, not just the text:

// After typing '4420': value '+44 20 ', caret 7
controller.undo(); // { value: '+44 2', selectionStart: 5, ... }
controller.redo(); // { value: '+44 20 ', selectionStart: 7, ... }

State-based history has two direct consequences. First, pure caret movement never creates an entry: in the formatting-character backspace above, one undo jumps straight over the caret move to the previous edit. Second, display state is undoable. With an erasable plus (see createInternationalInputController), plus visibility is a field of the history entry, so erasing the plus is one undoable step like any other edit:

const controller = createInternationalInputController({
display: { callingCodeInInput: true, plusPrefix: 'erasable' },
});
// Type '+44 20 7946 0958', then backspace with the caret right after the plus.
const state = controller.deleteBackward('+44 20 7946 0958', 1, 1);
state.value; // '44 20 7946 0958', plus erased, digits kept
controller.getPhoneNumber().formatE164(); // '+442079460958', still read as international
controller.undo().value; // '+44 20 7946 0958', the plus is back

Edits that change neither the digits nor the region, such as selection updates, replace the current entry instead of stacking. History depth is bounded and configurable per controller through maxHistorySize; see createNationalInputController and createInternationalInputController.

setRegionFilter and setNumberTypeFilter install predicates on the resolver. They do not change the contract: the same edit methods, the same InputState shape, the same history semantics. They change which paths of the walk count as accepting. Validity is an accepting state, and every ValidationError variant is a named way of not being in one, so a number outside the filter stops being in an accepting state and reports the corresponding error.

Installing a filter re-resolves the current digits in place; the user made no edit, so the current history entry is replaced rather than pushed:

controller.setValue('+44 20 7946 0958');
controller.getPhoneNumber().isValid(); // true
controller.getPhoneNumber().getNumberType(); // 'FIXED_LINE'
controller.setNumberTypeFilter(['MOBILE']);
controller.currentState.value; // '+44 20 7946 0958', unchanged
controller.getPhoneNumber().isValid(); // false
controller.getPhoneNumber().getValidationError(); // { kind: 'PATTERN_MISMATCH' }
controller.setNumberTypeFilter(null);
controller.getPhoneNumber().isValid(); // true

The controller never touches the DOM. It consumes edit descriptions (value, inserted text, selection) and returns an InputState to render; producing those descriptions from input events and writing the returned value and caret back into an element is the binding’s job. That binding, including beforeinput handling and selection sync, is @telixon/web-sdk.