The edit/commit state machine shared by every value-controlled field — the part of a field that
has nothing to do with what it edits: the two baselines, the dirty flag, the live vs commit
channels, and the done-key policy.
The rules it owns, once, for every field:
Two channels, one mode.onChange is the value while it is edited; onDone is the edit
ending. changeOn governs only the first (and who owns Enter): an edit ENDS the same way in
both modes — Enter, Tab, a blur to another input, the ✓/✗, commit()/cancel(), Escape.
Two baselines.committedText is the last ACCEPTED value; baselineText is the text when the
current session started. They differ because the live-change model applies every intermediate
change — "the value before" can then only mean before-the-session, not before-the-last-change.
Escape is two-level. Session open + TOUCHED → cancel (revert to the baseline, onDone with
reason: 'cancel'). Idle + dirty → a plain revert to the committed text, no commit channel.
An UNTOUCHED session → the key is NOT captured, so a host dialog still closes on it. "Touched",
not "changed": an edit that lands back on the starting text is still one to Escape out of.
Enter belongs to whoever owns the commit.changeOn: 'done' captures it (the field is the
commit point); 'live' lets it bubble to the host's default button, finishing any open session
first.
Tab commits then advances — un-captured, so the natural tab order still runs. Invalid text
CANCELS instead of committing, so focus never advances carrying an error.
Invalid never commits on any accept path; blur commits only when dirty; and a done key
plus the blur it causes are ONE gesture, delivered once (the one-shot guard).
The text is the user's while they edit. A live host echoes each change back as its value, and
the echo is often the value NORMALIZED (09 → 9, 9/2 → 9/2/2026). Rewriting the display
with it would retype the field under the caret, so an outside value that lands mid-edit only
becomes the committed text; the accept that ends the edit shows it. An untouched field follows
outside changes at once.
The input owns its text keys. Delete / caret motion / the native chords are consumed
(EditCommitApi.handleTextKeys) so they never reach the app's command dispatch.
Hosts keep their own concerns — a formula field's reference bus and grammar, a plain field's
validation — and wire them in through UseEditCommitOptions.parse and
EditCommitSession.
The edit/commit state machine shared by every value-controlled field — the part of a field that has nothing to do with what it edits: the two baselines, the dirty flag, the live vs commit channels, and the done-key policy.
The rules it owns, once, for every field:
onChangeis the value while it is edited;onDoneis the edit ending.changeOngoverns only the first (and who owns Enter): an edit ENDS the same way in both modes — Enter, Tab, a blur to another input, the ✓/✗,commit()/cancel(), Escape.committedTextis the last ACCEPTED value;baselineTextis the text when the current session started. They differ because the live-change model applies every intermediate change — "the value before" can then only mean before-the-session, not before-the-last-change.onDonewithreason: 'cancel'). Idle + dirty → a plain revert to the committed text, no commit channel. An UNTOUCHED session → the key is NOT captured, so a host dialog still closes on it. "Touched", not "changed": an edit that lands back on the starting text is still one to Escape out of.changeOn: 'done'captures it (the field is the commit point);'live'lets it bubble to the host's default button, finishing any open session first.09→9,9/2→9/2/2026). Rewriting the display with it would retype the field under the caret, so an outside value that lands mid-edit only becomes the committed text; the accept that ends the edit shows it. An untouched field follows outside changes at once.Hosts keep their own concerns — a formula field's reference bus and grammar, a plain field's validation — and wire them in through UseEditCommitOptions.parse and EditCommitSession.