Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c54700d
feat: add text selection API to TextFieldBase
Artur- May 8, 2026
65a6c57
fix: defer selection JS calls with setTimeout
Artur- May 10, 2026
6622097
test: add IT proving server handler sees selection at click time
Artur- May 10, 2026
a38b35c
refactor: scope HasSelection to fields whose input type supports it
Artur- May 10, 2026
8d65748
feat: focus the field by default in selection mutators
Artur- May 10, 2026
42fa7ed
docs: drop @since and @author from HasSelection and SelectionRange
Artur- May 10, 2026
c704d61
fix: apply selection before focusing in HasSelection mutators
Artur- May 10, 2026
28d4d8a
fix: wait on the textarea signal info div in transform IT
Artur- May 12, 2026
db28d9c
Merge remote-tracking branch 'origin/main' into feature/text-selection
Artur- May 13, 2026
1e95d0e
Merge branch 'main' into feature/text-selection
sissbruecker Jun 10, 2026
1ef1d79
Merge remote-tracking branch 'origin/main' into feature/text-selection
Artur- Jun 14, 2026
63dec82
refactor!: always focus field on programmatic selection
Artur- Jun 28, 2026
72086ae
fix: debounce selection signal and track caret via selectionchange
Artur- Jun 28, 2026
6902fb2
Merge remote-tracking branch 'origin/main' into feature/text-selection
Artur- Jun 28, 2026
083fd26
docs: note iOS/Android selection limitations on HasSelection
Artur- Jun 28, 2026
4b747b8
Merge remote-tracking branch 'origin/main' into feature/text-selection
Artur- Jul 25, 2026
6a88816
Merge remote-tracking branch 'origin/main' into feature/text-selection
Artur- Aug 18, 2026
fc6e126
Merge remote-tracking branch 'origin/main' into feature/text-selection
Artur- Aug 26, 2026
813dab3
Merge branch 'main' into feature/text-selection
sissbruecker Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.shared;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.HasElement;
import com.vaadin.flow.signals.Signal;

/**
* Mixin interface for field components that wrap a native HTML input and
* support programmatic control of the text selection.
* <p>
* The methods mirror {@code HTMLInputElement.setSelectionRange()} /
* {@code selectionStart} / {@code selectionEnd}: indices are zero-based, with
* {@code selectionStart} the index of the first selected character and
* {@code selectionEnd} the index after the last selected character.
* <p>
* <strong>Known browser limitations.</strong> Text selection depends on native
* browser behavior that is not consistent across platforms:
* <ul>
* <li>On iOS Safari, focusing a field programmatically does not work, so the
* selection is painted in the faded inactive color (Safari 18 does not paint it
* at all; Safari 26 does).</li>
* <li>On iOS and Android, {@link #selectionSignal()} updates reliably for
* tapping, typing, and the cut/copy/paste/select actions, but not for selection
* changes made by long-pressing to move the cursor or to adjust the selection
* handles.</li>
* </ul>
*/
public interface HasSelection extends HasElement {

/**
* Selects the entire current value and focuses the field.
* <p>
* The field is focused so the selection is painted in the active color; the
* browser otherwise renders a selection on an unfocused field in a faded
* color and clears it as soon as the user focuses the field.
*/
default void selectAll() {
// Defer with setTimeout so the call runs after any pending value or
// focus reflection on the web component finishes — otherwise a
// re-render of the input can wipe the selection we just set.
// Use setSelectionRange instead of HTMLInputElement.select(), which
// per the WHATWG spec always implicitly focuses the input; here we
// focus explicitly anyway. Apply the selection first, focus second, so
// the focus side-effects can't disturb it.
getElement().executeJs(
"setTimeout(() => { const i = this.inputElement; if (i) { i.setSelectionRange(0, (i.value || '').length); i.focus(); } }, 0)");
}

/**
* Collapses the current selection at its end position, leaving the cursor
* there. Has no visible effect on the value and does not change focus.
*/
default void deselect() {
getElement().executeJs(
"setTimeout(() => { const i = this.inputElement; if (i) { const e = i.selectionEnd || 0; i.setSelectionRange(e, e); } }, 0)");
}

/**
* Sets the text selection to the range
* {@code [selectionStart, selectionEnd)} and focuses the field.
* {@code selectionStart == selectionEnd} collapses the selection and moves
* the cursor to that position.
* <p>
* Indices outside the current value are clamped by the browser; passing
* {@code 0, Integer.MAX_VALUE} therefore selects the whole value.
* <p>
* The field is focused so the selection is painted in the active color; the
* browser otherwise renders a selection on an unfocused field in a faded
* color and clears it as soon as the user focuses the field.
*
* @param selectionStart
* the index of the first selected character, inclusive
* @param selectionEnd
* the index after the last selected character, exclusive
*/
default void setSelectionRange(int selectionStart, int selectionEnd) {
// Apply selection first, focus second, so focus side-effects can't
// disturb the selection.
getElement().executeJs(
"setTimeout(() => { const i = this.inputElement; if (i) { i.setSelectionRange($0, $1); i.focus(); } }, 0)",
selectionStart, selectionEnd);
}

/**
* Moves the cursor to the given position, collapsing any current selection,
* and focuses the field. Equivalent to {@link #setSelectionRange(int, int)
* setSelectionRange(position, position)}.
*
* @param position
* the cursor position, zero-based
*/
default void setCursorPosition(int position) {
setSelectionRange(position, position);
}

/**
* Returns a {@link Signal} that reactively reflects the current text
* selection in this field.
* <p>
* The signal value is updated whenever the user changes the selection or
* cursor position (via mouse, keyboard, or programmatic
* {@code setSelectionRange}). Reading via {@link Signal#peek()} or mapping
* with {@link Signal#map} provides the current {@link SelectionRange}
* without manual event-listener boilerplate.
* <p>
* The same signal instance is returned across calls. Until the field is
* first attached to a UI, the signal returns {@link SelectionRange#empty}.
*
* @return a {@link Signal} carrying the current {@link SelectionRange};
* never {@code null}
*/
default Signal<SelectionRange> selectionSignal() {
return SelectionSignalSupport.getOrCreate((Component) this);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.shared;

import java.io.Serializable;

/**
* Represents a range of selected text within a field that implements
* {@link HasSelection}.
* <p>
* The {@code start} and {@code end} indices follow the same semantics as
* {@code HTMLInputElement.selectionStart} and
* {@code HTMLInputElement.selectionEnd}: zero-based, with {@code start} the
* index of the first selected character and {@code end} the index after the
* last selected character. {@code start == end} represents a collapsed
* selection (a cursor position).
*
* @param start
* the index of the first selected character, inclusive
* @param end
* the index after the last selected character, exclusive
* @param content
* the selected substring, or an empty string when the selection is
* collapsed
*/
public record SelectionRange(int start, int end,
String content) implements Serializable {

/**
* Creates a new {@link SelectionRange}.
*
* @param start
* the index of the first selected character, inclusive; must be
* non-negative and not greater than {@code end}
* @param end
* the index after the last selected character, exclusive
* @param content
* the selected substring, not {@code null}
*/
public SelectionRange {
if (start < 0) {
throw new IllegalArgumentException(
"start must be non-negative, got " + start);
}
if (end < start) {
throw new IllegalArgumentException(
"end must be greater than or equal to start, got start="
+ start + ", end=" + end);
}
if (content == null) {
throw new IllegalArgumentException("content must not be null");
}
}

/**
* Returns the length of the selection.
*
* @return {@code end - start}
*/
public int length() {
return end - start;
}

/**
* Returns whether the selection is empty (a collapsed cursor position).
*
* @return {@code true} if {@code start == end}
*/
public boolean isEmpty() {
return start == end;
}

/**
* Returns an empty selection range at position 0.
*
* @return an empty {@link SelectionRange}
*/
public static SelectionRange empty() {
return new SelectionRange(0, 0, "");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2000-2026 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.shared;

import java.io.Serializable;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;

/**
* Internal helper that lazily creates a {@link ValueSignal} of
* {@link SelectionRange} for a {@link HasSelection} component and wires it to
* the corresponding client-side selection events.
* <p>
* Cached on the component via
* {@link ComponentUtil#setData(Component, Class, Object)} so subsequent calls
* return the same signal instance.
*/
final class SelectionSignalSupport implements Serializable {

private static final String INSTALL_JS = """
const host = this;
if (host._vaadinSelectionInstalled) return;
host._vaadinSelectionInstalled = true;
let last;
const fire = () => {
const i = host.inputElement;
if (!i) return;
const start = i.selectionStart || 0;
const end = i.selectionEnd || 0;
const value = i.value || '';
const content = value.substring(start, end);
// Coalesced events can report an unchanged selection; skip those
// so the server isn't pinged for a no-op.
const key = start + ':' + end + ':' + content;
if (key === last) return;
last = key;
host.dispatchEvent(new CustomEvent('vaadin-selection-change', {
detail: { start, end, content }
}));
};
// Debounce so a burst of changes (typing, drag-selecting, the
// collapse-then-settle of a click) results in a single server
// round-trip carrying the final selection, instead of one per
// intermediate state. The latter keeps the loading indicator up
// almost permanently while typing and emits a transient empty
// selection mid-gesture.
let timer;
const fireDebounced = () => {
clearTimeout(timer);
timer = setTimeout(fire, 100);
};
const ready = host.updateComplete || Promise.resolve();
ready.then(() => {
const i = host.inputElement;
if (!i) return;
// selectionchange fires for every caret/selection change,
// including clicking inside an existing selection to collapse it
// — which mouseup/keyup miss or read too early. select is kept as
// a fallback for browsers without input-level selectionchange;
// input and focus cover value edits and the initial state.
['selectionchange','select','input','focus'].forEach(evt =>
i.addEventListener(evt, fireDebounced));
fire();
});
""";

private final ValueSignal<SelectionRange> signal;

private SelectionSignalSupport(Component component) {
this.signal = new ValueSignal<>(SelectionRange.empty());

Element element = component.getElement();

element.addEventListener("vaadin-selection-change", e -> {
int start = (int) e.getEventData().get("event.detail.start")
.asLong();
int end = (int) e.getEventData().get("event.detail.end").asLong();
String content = e.getEventData().get("event.detail.content")
.asString();
signal.set(new SelectionRange(start, end, content));
}).addEventData("event.detail.start").addEventData("event.detail.end")
.addEventData("event.detail.content");

element.addAttachListener(e -> element.executeJs(INSTALL_JS));
if (component.isAttached()) {
element.executeJs(INSTALL_JS);
}
}

static Signal<SelectionRange> getOrCreate(Component component) {
SelectionSignalSupport support = ComponentUtil.getData(component,
SelectionSignalSupport.class);
if (support == null) {
support = new SelectionSignalSupport(component);
ComponentUtil.setData(component, SelectionSignalSupport.class,
support);
}
return support.signal;
}
}
Loading
Loading