feat: Add text selection API for text input fields#9273
Conversation
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.
|
We will start with manual testing to see if previous concern of not working consistently between the browsers still persists. |
|
|
Did some manual testing, results below. Various issues
iOS / Safari
Android / Chrome
UX
Video 1 (Selection collapse on click) Bildschirmaufnahme.2026-06-10.um.16.14.47.movVideo 2 (Duplicate selection update when clicking into field) Bildschirmaufnahme.2026-06-10.um.16.23.38.movVideo 3 (Android selection update issues) Bildschirmaufnahme.2026-06-10.um.15.53.46.mov |
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.
|
Thanks for the detailed testing — here is where each finding landed. Fixed (verified in a browser)
Removed
Documented as browser limitations (cannot fix from Flow)
Both are now noted in the I re-tested on desktop Chromium, so the mobile findings are unchanged from your run. |
|



Summary
HasSelectionmixin withselectAll,deselect,setSelectionRange,setCursorPosition, and a reactiveSignal<SelectionRange> selectionSignal()so applications can drive thebrowser text selection from the server.
TextField,TextArea,PasswordField, andBigDecimalField.EmailField,NumberField, andIntegerFieldare deliberately excluded because their underlying<input type="email">/type="number"elements throwInvalidStateErrorfor these APIs per the HTML spec.Fixes #1377
Details
Reactive read
selectionSignal()returns aSignal<SelectionRange>lazily backed by aValueSignal. On first call it installs a client-side listener bundle (select,keyup,mouseup,input,focus) on the innerinputElement; each event dispatches avaadin-selection-changeevent on the host, which the server picks up viaElement.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 viasignal.peek()without an extra roundtrip, so timing races between user cursor moves and server reads no longer apply.Imperative methods
SelectionRangeis arecord(int start, int end, String content)withlength(),isEmpty(), andempty()helpers. Indices follow theHTMLInputElement.setSelectionRange()convention (zero-based, end-exclusive).selectAll,setSelectionRange, andsetCursorPositionfocus the field by default so the highlight is rendered in the active color rather than the browser's faded inactive state. Each accepts aboolean focusoverload to opt out.deselectis 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 precedingsetValue) and the subsequent range update would not re-scroll.selectAllusessetSelectionRange(0, length)rather thanHTMLInputElement.select()because the latter always implicitly focuses per the WHATWG spec, which would make the
focus=falseoverload a lie. All mutators are wrapped insetTimeout(...,0)so they run after pending value/focus reflection on the web component, matching the workaround used inviritin/flow-viritin.