Skip to content

feat: Add text selection API for text input fields#9273

Open
Artur- wants to merge 15 commits into
mainfrom
feature/text-selection
Open

feat: Add text selection API for text input fields#9273
Artur- wants to merge 15 commits into
mainfrom
feature/text-selection

Conversation

@Artur-

@Artur- Artur- commented May 12, 2026

Copy link
Copy Markdown
Member

Summary

  • Add HasSelection mixin with selectAll, deselect, setSelectionRange, setCursorPosition, and a reactive Signal<SelectionRange> selectionSignal() so applications can drive the
    browser text selection from the server.
  • Implement on TextField, TextArea, PasswordField, and BigDecimalField. EmailField, NumberField, and IntegerField are deliberately excluded because their underlying <input type="email"> / type="number" elements throw InvalidStateError for these APIs per the HTML spec.

Fixes #1377

Details

Reactive read

selectionSignal() returns a Signal<SelectionRange> lazily backed by a ValueSignal. On first call it installs a client-side listener bundle (select, keyup, mouseup, input, focus) on the inner inputElement; each event dispatches a vaadin-selection-change event on the host, which the server picks up via Element.addEventListener(...).addEventData(...) and pushes into the signal. Subsequent calls return the same cached instance, surviving detach/re-attach.

This replaces the async getSelectionRange(callback) shape from the earlier PR #3194 attempt: a server-side click handler reads the latest selection synchronously via signal.peek() without an extra roundtrip, so timing races between user cursor moves and server reads no longer apply.

Imperative methods

SelectionRange is a record(int start, int end, String content) with length(), isEmpty(), and empty() helpers. Indices follow the HTMLInputElement.setSelectionRange() convention (zero-based, end-exclusive).

selectAll, setSelectionRange, and setCursorPosition focus the field by default so the highlight is rendered in the active color rather than the browser's faded inactive state. Each accepts a boolean focus overload to opt out. deselect is focus-neutral.

The generated JS applies the selection before focusing — inputElement.focus() scrolls to the current cursor position, so focusing first would scroll to the stale cursor (often end-of-value after a preceding setValue) and the subsequent range update would not re-scroll. selectAll uses setSelectionRange(0, length) rather than HTMLInputElement.select()
because the latter always implicitly focuses per the WHATWG spec, which would make the focus=false overload a lie. All mutators are wrapped in setTimeout(...,0) so they run after pending value/focus reflection on the web component, matching the workaround used in viritin/flow-viritin.

Artur- added 9 commits May 12, 2026 11:31
Adds a HasSelection mixin with selectAll, deselect, setSelectionRange,
setCursorPosition, and a reactive Signal<SelectionRange> selectionSignal().
TextField, TextArea, EmailField, PasswordField, IntegerField, NumberField
and BigDecimalField inherit the API via TextFieldBase.

Fixes #1377
Wrap selectAll, deselect, and setSelectionRange in setTimeout(...,0) so
the selection is applied after any pending value or focus reflection on
the web component. Without this, calling setValue + setSelectionRange or
focus + selectAll in the same request loses the selection when the
input re-renders. Same workaround used in viritin/flow-viritin.
Mirrors UC6: user selects text in a TextArea, clicks a server-side
button whose handler reads selectionSignal().peek() and uppercases the
selected substring in place. Verifies the result reflects the selection
that was active at click time and stays current across user actions —
the timing reliability that PR #3194's async getSelectionRange(callback)
could not guarantee.
Move the implements clause from TextFieldBase to the four subclasses
whose underlying input element actually supports the selection APIs:
TextField, TextArea, PasswordField, BigDecimalField.

EmailField, NumberField, and IntegerField wrap input types (email,
number) where the HTML spec disallows selectionStart and
setSelectionRange — Chrome and Firefox throw InvalidStateError. Having
HasSelection on those classes was a runtime trap. Drop the inheritance
so the type system reflects what works.
selectAll, setSelectionRange, and setCursorPosition now focus the field
as part of the call so the highlight is rendered in the active color
rather than the browser's faded inactive state. The previous Javadoc
told callers to focus the field themselves; in practice every use case
in the design doc wants focus, and forgetting it was a silent UX bug.

Each method gains a `boolean focus` overload for the rare cases where
the caller wants to change the selection without yanking focus.
deselect remains focus-neutral.
inputElement.focus() scrolls the field to the current cursor position.
With the previous order (focus → setSelectionRange) the field scrolled
to the old cursor location — typically the end of the value after a
preceding setValue — and the subsequent range update moved the cursor
without re-scrolling. Applying the range first and focusing second
makes focus scroll to the intended cursor location.

Also switch selectAll(boolean) from HTMLInputElement.select() to
setSelectionRange(0, length): per the WHATWG spec select() always
implicitly focuses the input, which made the focus=false opt-out a lie.
selectionSignal_serverHandlerSeesSelectionAtClickTime selects text in
the TextArea but the existing #selection-info div was bound to the
TextField's signal, so the waitUntil could never resolve and the test
timed out in CI. Add a separate #area-selection-info div bound to the
TextArea's signal and have the test wait on it.
@Artur- Artur- marked this pull request as ready for review May 15, 2026 07:10
@yuriy-fix

Copy link
Copy Markdown
Contributor

We will start with manual testing to see if previous concern of not working consistently between the browsers still persists.

@sonarqubecloud

Copy link
Copy Markdown

@sissbruecker

Copy link
Copy Markdown
Contributor

Did some manual testing, results below.

Various issues

  • Clicking into an existing selection collapses it, but the selection signal does not update (Video 1)
  • Clicking into a field and holding the mouse button first resets selection signal to 0:0, then when releasing mouse button updates selection to correct value. Depending on what the app does with the info it would first do an update for an empty selection, while the user is still selecting, and then update afterwards again after receiving the final selection. (Video 2)
  • Setting selection range without focusing the input
    • On pretty much all browsers I tested, clicking into the field or tabbing into it clears the selection, making this seemingly useless
    • Only Edge on Win 11 preserves selection when navigating to field with keyboard selection

iOS / Safari

  • Focus does not work, seems to be a technical limitation
    • When selecting with focus, Safari 18 does not show the selection range, Safari 26 does
  • Selection signal update works sporadically:
    • Works when just tapping into the field and editing using virtual keyboard, and with the cut / paste / select / select all actions
    • Does not work when using long press to adjust cursor position or when adjusting selection range

Android / Chrome

  • Focus works here, opens virtual keyboard
  • Selection signal update has similar issues as with iOS (Video 3)

UX

  • Due to syncing selection on every keypress, just quickly typing or moving the cursor with the arrow keys results in the loading indicator showing almost permanently. This is more noticeable when testing with a network delay.

Video 1 (Selection collapse on click)

Bildschirmaufnahme.2026-06-10.um.16.14.47.mov

Video 2 (Duplicate selection update when clicking into field)

Bildschirmaufnahme.2026-06-10.um.16.23.38.mov

Video 3 (Android selection update issues)

Bildschirmaufnahme.2026-06-10.um.15.53.46.mov

Artur- added 5 commits June 14, 2026 18:50
Remove the focus=false overloads of selectAll/setSelectionRange/
setCursorPosition. A selection on an unfocused field paints faded and
the browser clears it as soon as the user focuses the field, so the
opt-out did not deliver a usable pre-selection.
Listen to selectionchange instead of select/keyup/mouseup so collapsing
a selection by clicking inside it updates the signal, which the old
events missed. Debounce dispatch by 100ms so a burst of changes (typing,
drag-selecting, click collapse-then-settle) results in one server
round-trip with the final selection, instead of one per intermediate
state that keeps the loading indicator up and emits a transient empty
selection mid-gesture. Skip dispatch when the coalesced range is
unchanged.
@totally-not-ai

Copy link
Copy Markdown

Thanks for the detailed testing — here is where each finding landed.

Fixed (verified in a browser)

  • Clicking into a selection collapses it but the signal did not update (Video 1): the client now listens to selectionchange instead of select/keyup/mouseup. selectionchange fires for caret-only changes too, so the collapse is now reported.
  • 0:0 flash when clicking and holding before releasing (Video 2): selection updates are now debounced (100 ms). A burst of changes results in a single update carrying the final selection, so the transient empty selection mid-gesture is gone.
  • Loading indicator showing almost permanently while typing (UX): same debounce. Typing or moving the cursor with the arrow keys now causes one round-trip after you stop, instead of one per keystroke.

Removed

  • Setting a selection without focusing the field: an unfocused selection is painted faded and the browser clears it as soon as the user focuses the field (every browser except Edge on Win 11), so the option did not deliver a usable pre-selection. The focus=false overloads were removed and programmatic selection always focuses now. This is a breaking change to the new (unreleased) API only.

Documented as browser limitations (cannot fix from Flow)

  • iOS Safari does not honor programmatic focus, so the selection is painted faded (Safari 18 does not paint it at all; Safari 26 does).
  • On iOS and Android, the signal updates for tapping, typing, and the cut/copy/paste/select actions, but not for long-press cursor or selection-handle adjustments.

Both are now noted in the HasSelection javadoc.

I re-tested on desktop Chromium, so the mobile findings are unchanged from your run.

@sonarqubecloud

Copy link
Copy Markdown

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.

TextField Selection API

3 participants