Skip to content

feat(ui): add text input fields backed by a hidden DOM input - #730

Merged
stormmuller merged 7 commits into
devfrom
claude/design-text-input-field
Oct 7, 2026
Merged

stormmuller merged 7 commits into
devfrom
claude/design-text-input-field

Conversation

@stormmuller

@stormmuller stormmuller commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Implements design/text-input-field.md (all phases) and ui-system.md backlog item 3.5 (TextInputEcsComponent). The design doc is deleted, ui-system.md's status and row 3.5 are updated, and the link in input-action-state.md is reworded. demo-findings.md is left for the coordinator.

Phase 1: keyboard source. KeyboardInputSource ignores keys whose keydown targets an editable element (<input>, <textarea>, <select>, contentEditable). It reads the target from composedPath()[0], so elements inside a shadow root count too. Those keys never become held, and a keyup for a key that isn't held is ignored. A key held before typing started is still released. The unused _keyPressesDown/_keyPressesUps sets are deleted. That left reset() empty, so the source no longer implements Resettable.

Phase 2: text entry and field.

  • createTextEntry(container) (src/input/text-entry) creates one hidden <input>, placed over a CSS rect inside the container. It exposes the value, the selection and the IME composition range. It enforces the held-key-doesn't-repeat rule, treats Enter and Escape as submit and cancel except during IME composition (holding Enter submits once), and cancels Tab. Events (changed/submitted/cancelled/blurred) are drained once per tick with takeEvents().
  • shapeText returns caretStops: the final position of every UTF-16 boundary of the shaped text, including whitespace, missing glyphs and the inner boundary of a surrogate pair. TextMeshEcsComponent.caretStops carries them.
  • TextEcsComponent.richText (and shapeText's richText option), default true. With false, <b>/<color> tags are drawn as written. See "Merged with rich text tags" below.
  • The raycast hit test is now a plain function, raycastUiCanvas. createUiRaycastEcsSystem calls it once per canvas.
  • TextInputEcsComponent, setTextInputValue, editTextInput, createTextInput, and createUiTextInputEcsSystem, which registerUiSystems registers.
  • Docs: new ui/text-input.md and input/text-entry.md guides, a note in input/keyboard.md, and richText: false in text/rendering-text.md.
  • Demo: ui-text-input, a name entry form with two fields and filters.
  • E2E: a ui-text-input scene and spec.

Merged with rich text tags (#729)

#729 landed while this PR was open, and shapeText now strips <b>/<color> tags before shaping. A text field can't use that. A player who typed <b> would see it vanish and their text turn bold, and the caret stops (computed for the tag-free string) would stop matching the input's value. #729 has no escape syntax, so the exact text <b> can't be written in tagged text at all. So TextEcsComponent gains richText (default true, so authored labels keep the tags), and a text field's labels set it to false. This is TextMeshPro's richText flag, which input fields turn off for the same reason. It's a real per-text choice (authored labels versus what a player typed), not a switch between an old and a new behavior. Reviewers: say so if you'd rather settle this another way.

Changes from the design, and why

  • The entry lives on the component. TextInputEcsComponent.entry holds the field's TextEntry, and isEditing is simply entry.isFocused. So setTextInputValue writes the DOM input, editTextInput calls focus(), and submit and cancel call blur(). No request fields are needed, and the browser's one-focused-element rule gives "one editing field per page" for free. The system keeps a map of the entries it has seen so it can dispose them when a component is removed and in cleanup. (Suggested by the solution reviewer; see below.)
  • placeholder is a label entity, not a string. The design had placeholder: string on the component. Instead the component holds placeholderLabel, whose TextEcsComponent owns the text and colour, and the system only toggles its enabled. This follows the pattern of the slider's handle/fill and Unity's TMP_InputField.placeholder, and the string has one owner.
  • maxLength isn't set as the DOM maxlength. The browser counted characters the font/game filters were about to drop. In the demo, typing ax-7q99 into a 6-character field ended at AX7Q9. The field applies maxLength itself, after filtering, and TextInputAttributes omits it. TextEntryAttributes.maxLength stays available to direct users of the text entry.
  • The tap handler. On pointerup it focuses a field only if the press and the release both hit that field (a drag doesn't count), and only if it is the single element hit across all canvases. Without a draw order between canvases, that's what keeps a field under a modal on another canvas from being edited. mousedown on a field is preventDefaulted, so clicking the field you're typing in doesn't blur it for a frame.
  • Caret lag. Text shaping and layout run after the UI systems, so the caret and selection trail the glyphs by one or two frames. That can't be avoided in this pipeline; the stop index is clamped and the guide says so.
  • resolveCanvasPointerPosition takes a viewport position instead of a UiPointerSource. The tap handler has an event position, not a pointer source. Callers updated; there's a changelog bullet.
  • World-space canvases. Fields on them work, but their hidden input isn't moved over them, as the design scoped.
  • Galactic Journey. That demo lives in a separate repo, so §10's "delete the demo's three files" doesn't apply here.

Decisions taken from the design's open questions

  1. Should hover still set UI focus? (a) Yes. Hover still sets focus, and editing is a separate state.
  2. Tab between fields? (a) Left out. Tab does nothing inside a field.
  3. Selection by pointer drag? (a) Keyboard selection only. The hidden input keeps pointer-events: none.

Solution reviewer verdict

REVISE, before any code was written. All of its required changes were applied except one:

  • The request fields (a two-writer violation) were replaced by putting the entry on the component, with editing following DOM focus.
  • The caret-lag check was dropped; the lag is accepted and documented.
  • A drag that started elsewhere no longer starts editing, and cross-canvas occlusion is handled.
  • mousedown on a field is preventDefaulted.
  • Entries are tracked per component object, not per entity.
  • Attributes are written to the DOM only when they change.
  • The changelog notes that Resettable was dropped.
  • Not applied: the reviewer asked to keep design/text-input-field.md as historical rationale. The task said to delete a fully implemented design doc, and docs(design): remove design documents that have been implemented #720 did the same, so it is deleted.
  • Kept as is: the reviewer asked for value/isEditing to be readonly. The system has to write them without casts, so they follow UiInteractableEcsComponent's convention of mutable fields documented as system-owned.

It approved the placeholder entity and starting editing on wasInvokedThisFrame. The richText addition came later, from merging #729, and wasn't part of that review.

Related issue(s)

Closes #586

Verification checklist

  • npm run check-types passes with 0 errors
  • npm test passes (2170 tests, on the latest dev)
  • npm run lint passes with 0 errors (2 existing TODO warnings)
  • npm run cspell passes with 0 errors
  • npm run check-exports passes
  • Any new/changed public API is exported from the module's index.ts
  • Documentation under /documentation-site/docs/docs is updated
  • Demo checked: npm run build, docs typecheck and build, then the built site opened in Chromium (again after merging dev). Typing, filtering (leading spaces and é dropped, badge upper-cased), submit and the caret all work in the new demo. The button, slider, dropdown and main-menu demos still render, and dragging in the slider demo moves its handle.
  • E2E: the full suite passes (51 tests on the latest dev), including the new ui-text-input.spec.ts. That spec checks real typing grows the drawn text (a relative, same-run pixel check), held Enter submits once, Escape cancels, arrow keys typed into the field don't move UI focus, hovering another element doesn't end editing, and a tap on a panel covering the field doesn't start editing it.

Changelog

  • Bullets added under ## [Unreleased]: Added (ui text inputs, createTextEntry, TextEcsComponent.richText, caretStops, raycastUiCanvas), Changed (resolveCanvasPointerPosition), Fixed (keyboard source and editable elements).

🤖 Generated with Claude Code

https://claude.ai/code/session_014VktGvQk7RUMN8yxuAUXER

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/ui/systems/ui-text-input-system.ts 95.97% 2 Missing and 5 partials ⚠️
src/input/text-entry/text-entry.ts 95.04% 0 Missing and 6 partials ⚠️

📢 Thoughts on this report? Let us know!

claude added 3 commits October 6, 2026 22:34
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VktGvQk7RUMN8yxuAUXER
…ut-field

Caret stops are rebuilt on top of the rich text tag parser. Text fields set
the new TextEcsComponent.richText to false, so text a player types is
drawn as written and its caret stops line up with the input's value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VktGvQk7RUMN8yxuAUXER
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VktGvQk7RUMN8yxuAUXER
@stormmuller
stormmuller enabled auto-merge (squash) October 7, 2026 06:58
@stormmuller
stormmuller merged commit 5cb6c54 into dev Oct 7, 2026
13 checks passed
@stormmuller
stormmuller deleted the claude/design-text-input-field branch October 7, 2026 07:02
stormmuller pushed a commit that referenced this pull request Oct 7, 2026
Resolve conflicts with text input fields (#730): keep caretStops and
richText on the shaping side without the renderable fields, move
raycast-ui-canvas onto sprite.category, and migrate the new text-input
demo, e2e scene and tests to Texture, createImageSprite(texture),
FontAtlasCache(renderContext) and createTextShapingEcsSystem().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcdXTDthW5D8wqauepMX2e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(input): text entry primitive + text input field

2 participants