diff --git a/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/HasSelection.java b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/HasSelection.java
new file mode 100644
index 00000000000..7ec11e216b3
--- /dev/null
+++ b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/HasSelection.java
@@ -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.
+ *
+ * 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.
+ *
+ * Known browser limitations. Text selection depends on native
+ * browser behavior that is not consistent across platforms:
+ *
+ * - 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).
+ * - 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.
+ *
+ */
+public interface HasSelection extends HasElement {
+
+ /**
+ * Selects the entire current value and focuses the field.
+ *
+ * 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.
+ *
+ * Indices outside the current value are clamped by the browser; passing
+ * {@code 0, Integer.MAX_VALUE} therefore selects the whole value.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 selectionSignal() {
+ return SelectionSignalSupport.getOrCreate((Component) this);
+ }
+}
diff --git a/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/SelectionRange.java b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/SelectionRange.java
new file mode 100644
index 00000000000..aaf716948fe
--- /dev/null
+++ b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/SelectionRange.java
@@ -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}.
+ *
+ * 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, "");
+ }
+}
diff --git a/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/SelectionSignalSupport.java b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/SelectionSignalSupport.java
new file mode 100644
index 00000000000..592413f1d38
--- /dev/null
+++ b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/main/java/com/vaadin/flow/component/shared/SelectionSignalSupport.java
@@ -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.
+ *
+ * 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 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 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;
+ }
+}
diff --git a/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/test/java/com/vaadin/flow/component/shared/SelectionRangeTest.java b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/test/java/com/vaadin/flow/component/shared/SelectionRangeTest.java
new file mode 100644
index 00000000000..967e5dbb6e1
--- /dev/null
+++ b/vaadin-flow-components-shared-parent/vaadin-flow-components-base/src/test/java/com/vaadin/flow/component/shared/SelectionRangeTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class SelectionRangeTest {
+
+ @Test
+ void length_returnsEndMinusStart() {
+ Assertions.assertEquals(0, new SelectionRange(0, 0, "").length());
+ Assertions.assertEquals(4, new SelectionRange(2, 6, "abcd").length());
+ Assertions.assertEquals(7,
+ new SelectionRange(0, 7, "Lorem i").length());
+ }
+
+ @Test
+ void isEmpty_trueWhenStartEqualsEnd() {
+ Assertions.assertTrue(new SelectionRange(0, 0, "").isEmpty());
+ Assertions.assertTrue(new SelectionRange(7, 7, "").isEmpty());
+ Assertions.assertFalse(new SelectionRange(2, 6, "abcd").isEmpty());
+ }
+
+ @Test
+ void empty_returnsZeroZeroEmpty() {
+ SelectionRange empty = SelectionRange.empty();
+ Assertions.assertEquals(0, empty.start());
+ Assertions.assertEquals(0, empty.end());
+ Assertions.assertEquals("", empty.content());
+ Assertions.assertTrue(empty.isEmpty());
+ }
+
+ @Test
+ void equality_recordSemantics() {
+ Assertions.assertEquals(new SelectionRange(2, 5, "abc"),
+ new SelectionRange(2, 5, "abc"));
+ Assertions.assertEquals(new SelectionRange(2, 5, "abc").hashCode(),
+ new SelectionRange(2, 5, "abc").hashCode());
+ Assertions.assertNotEquals(new SelectionRange(2, 5, "abc"),
+ new SelectionRange(2, 6, "abcd"));
+ }
+
+ @Test
+ void negativeStart_throws() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SelectionRange(-1, 0, ""));
+ }
+
+ @Test
+ void endBeforeStart_throws() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SelectionRange(5, 2, ""));
+ }
+
+ @Test
+ void nullContent_throws() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SelectionRange(0, 0, null));
+ }
+}
diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow-integration-tests/src/main/java/com/vaadin/flow/component/textfield/tests/SelectionPage.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow-integration-tests/src/main/java/com/vaadin/flow/component/textfield/tests/SelectionPage.java
new file mode 100644
index 00000000000..5d277377812
--- /dev/null
+++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow-integration-tests/src/main/java/com/vaadin/flow/component/textfield/tests/SelectionPage.java
@@ -0,0 +1,118 @@
+/*
+ * 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.textfield.tests;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.NativeButton;
+import com.vaadin.flow.component.shared.SelectionRange;
+import com.vaadin.flow.component.textfield.PasswordField;
+import com.vaadin.flow.component.textfield.TextArea;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.router.Route;
+import com.vaadin.flow.signals.Signal;
+
+/**
+ * Test view for the {@code HasSelection} mixin on text fields.
+ */
+@Route("vaadin-text-field/selection-test")
+public class SelectionPage extends Div {
+
+ public SelectionPage() {
+ TextField textField = new TextField();
+ textField.setId("text-field");
+ textField.setValue("Hello world");
+
+ TextArea textArea = new TextArea();
+ textArea.setId("text-area");
+ textArea.setValue("Lorem ipsum dolor sit amet");
+
+ PasswordField passwordField = new PasswordField();
+ passwordField.setId("password-field");
+ passwordField.setValue("secret123");
+
+ Div info = new Div();
+ info.setId("selection-info");
+ info.setText("(no selection)");
+ Signal.effect(this,
+ ctx -> info.setText(format(textField.selectionSignal().get())));
+
+ Div areaInfo = new Div();
+ areaInfo.setId("area-selection-info");
+ areaInfo.setText("(no selection)");
+ Signal.effect(this, ctx -> areaInfo
+ .setText(format(textArea.selectionSignal().get())));
+
+ add(textField, textArea, passwordField, info, areaInfo);
+
+ addButton("select-all", "Select all", () -> textField.selectAll());
+ addButton("set-range", "setSelectionRange(2, 7)",
+ () -> textField.setSelectionRange(2, 7));
+ addButton("set-cursor", "setCursorPosition(4)",
+ () -> textField.setCursorPosition(4));
+ addButton("deselect", "Deselect", () -> textField.deselect());
+ addButton("focus", "Focus", () -> textField.focus());
+ addButton("blur", "Blur",
+ () -> textField.getElement().executeJs("this.blur()"));
+
+ addButton("area-select-range", "Area setSelectionRange(0, 5)",
+ () -> textArea.setSelectionRange(0, 5));
+ addButton("area-focus", "Area focus", () -> textArea.focus());
+
+ // Mirrors UC6: server-side click handler reads the current selection
+ // synchronously via selectionSignal().peek() and replaces it in place.
+ // Proves the timing the PR #3194 thread was worried about: when the
+ // user selects text and clicks a server button, the handler sees the
+ // selection that was active at click time, with no extra roundtrip.
+ Div transformInfo = new Div();
+ transformInfo.setId("transform-info");
+ transformInfo.setText("(no transform yet)");
+ AtomicInteger transformCount = new AtomicInteger();
+ addButton("area-uppercase", "Uppercase area selection", () -> {
+ SelectionRange sel = textArea.selectionSignal().peek();
+ int n = transformCount.incrementAndGet();
+ if (sel.isEmpty()) {
+ transformInfo.setText("#" + n + " empty");
+ return;
+ }
+ String value = textArea.getValue();
+ String replaced = sel.content().toUpperCase();
+ textArea.setValue(value.substring(0, sel.start()) + replaced
+ + value.substring(sel.end()));
+ textArea.setSelectionRange(sel.start(),
+ sel.start() + replaced.length());
+ transformInfo.setText("#" + n + " " + sel.start() + "-" + sel.end()
+ + ":" + sel.content());
+ });
+ add(transformInfo);
+
+ addButton("password-select-all", "Password selectAll",
+ () -> passwordField.selectAll());
+ addButton("password-focus", "Password focus",
+ () -> passwordField.focus());
+ }
+
+ private void addButton(String id, String label, Runnable action) {
+ NativeButton button = new NativeButton(label, e -> action.run());
+ button.setId(id);
+ add(button);
+ }
+
+ private static String format(SelectionRange r) {
+ return r.start() + "-" + r.end() + ":" + r.length() + ":" + r.content();
+ }
+}
diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow-integration-tests/src/test/java/com/vaadin/flow/component/textfield/tests/SelectionPageIT.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow-integration-tests/src/test/java/com/vaadin/flow/component/textfield/tests/SelectionPageIT.java
new file mode 100644
index 00000000000..eb791cff564
--- /dev/null
+++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow-integration-tests/src/test/java/com/vaadin/flow/component/textfield/tests/SelectionPageIT.java
@@ -0,0 +1,198 @@
+/*
+ * 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.textfield.tests;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.interactions.Actions;
+
+import com.vaadin.flow.component.textfield.testbench.PasswordFieldElement;
+import com.vaadin.flow.component.textfield.testbench.TextAreaElement;
+import com.vaadin.flow.component.textfield.testbench.TextFieldElement;
+import com.vaadin.flow.testutil.TestPath;
+import com.vaadin.testbench.TestBenchElement;
+import com.vaadin.tests.AbstractComponentIT;
+
+/**
+ * Integration tests for the {@code HasSelection} mixin on text fields.
+ */
+@TestPath("vaadin-text-field/selection-test")
+public class SelectionPageIT extends AbstractComponentIT {
+
+ private TextFieldElement textField;
+
+ @Before
+ public void init() {
+ open();
+ textField = $(TextFieldElement.class).id("text-field");
+ }
+
+ @Test
+ public void selectAll_focusesAndSelectsEntireValue() {
+ clickButton("select-all");
+
+ Assert.assertEquals(0, selectionStart(textField));
+ Assert.assertEquals("Hello world".length(), selectionEnd(textField));
+ Assert.assertTrue("selectAll should focus the field by default",
+ isFocused(textField));
+ }
+
+ @Test
+ public void setSelectionRange_focusesAndAppliesGivenRange() {
+ clickButton("set-range");
+
+ Assert.assertEquals(2, selectionStart(textField));
+ Assert.assertEquals(7, selectionEnd(textField));
+ Assert.assertTrue("setSelectionRange should focus the field by default",
+ isFocused(textField));
+ }
+
+ @Test
+ public void setCursorPosition_focusesAndCollapsesAtPosition() {
+ clickButton("set-cursor");
+
+ Assert.assertEquals(4, selectionStart(textField));
+ Assert.assertEquals(4, selectionEnd(textField));
+ Assert.assertTrue(isFocused(textField));
+ }
+
+ @Test
+ public void deselect_collapsesAtSelectionEnd() {
+ clickButton("focus");
+ clickButton("set-range");
+ clickButton("deselect");
+
+ Assert.assertEquals(7, selectionStart(textField));
+ Assert.assertEquals(7, selectionEnd(textField));
+ }
+
+ @Test
+ public void selectionSignal_updatesOnProgrammaticChange() {
+ clickButton("focus");
+ clickButton("set-range");
+
+ waitUntil(driver -> infoText().equals("2-7:5:llo w"));
+ }
+
+ @Test
+ public void selectionSignal_updatesOnUserSelection() {
+ Actions actions = new Actions(getDriver());
+ actions.moveToElement(textField).click().perform();
+ executeScript("arguments[0].inputElement.setSelectionRange(0, 5);"
+ + " arguments[0].inputElement.dispatchEvent(new Event('select'));",
+ textField);
+
+ waitUntil(driver -> infoText().equals("0-5:5:Hello"));
+ }
+
+ @Test
+ public void textArea_inheritsHasSelection() {
+ TextAreaElement area = $(TextAreaElement.class).id("text-area");
+ clickButton("area-focus");
+ clickButton("area-select-range");
+
+ Assert.assertEquals(0, selectionStart(area));
+ Assert.assertEquals(5, selectionEnd(area));
+ }
+
+ /**
+ * Reproduces the scenario PR #3194 worried about with the async
+ * {@code getSelectionRange(callback)} API: user selects text, clicks a
+ * server-side button, and the handler must see the selection that was
+ * active at click time. With {@code selectionSignal().peek()} this is a
+ * synchronous read and the transform applies to exactly the selected
+ * substring.
+ */
+ @Test
+ public void selectionSignal_serverHandlerSeesSelectionAtClickTime() {
+ TextAreaElement area = $(TextAreaElement.class).id("text-area");
+ // Select "Lorem" (the first word, indices 0-5)
+ executeScript("arguments[0].inputElement.focus();"
+ + "arguments[0].inputElement.setSelectionRange(0, 5);"
+ + "arguments[0].inputElement.dispatchEvent(new Event('select'));",
+ area);
+ waitUntil(driver -> areaInfoText().startsWith("0-5:5:"));
+
+ clickButton("area-uppercase");
+
+ waitUntil(driver -> findElement(By.id("transform-info")).getText()
+ .equals("#1 0-5:Lorem"));
+ Assert.assertEquals("LOREM ipsum dolor sit amet", area.getValue());
+ Assert.assertEquals(0, selectionStart(area));
+ Assert.assertEquals(5, selectionEnd(area));
+
+ // Move the selection to "ipsum" (indices 6-11) and transform again.
+ // The second click must read the *new* selection, not the previous
+ // one — proving the signal stays current across user actions.
+ executeScript("arguments[0].inputElement.setSelectionRange(6, 11);"
+ + "arguments[0].inputElement.dispatchEvent(new Event('select'));",
+ area);
+ waitUntil(driver -> areaInfoText().startsWith("6-11:5:"));
+
+ clickButton("area-uppercase");
+
+ waitUntil(driver -> findElement(By.id("transform-info")).getText()
+ .equals("#2 6-11:ipsum"));
+ Assert.assertEquals("LOREM IPSUM dolor sit amet", area.getValue());
+ }
+
+ @Test
+ public void passwordField_implementsHasSelection() {
+ PasswordFieldElement password = $(PasswordFieldElement.class)
+ .id("password-field");
+ clickButton("password-focus");
+ clickButton("password-select-all");
+
+ Assert.assertEquals(0, selectionStart(password));
+ Assert.assertEquals("secret123".length(), selectionEnd(password));
+ }
+
+ private void clickButton(String id) {
+ findElement(By.id(id)).click();
+ }
+
+ private String infoText() {
+ WebElement info = findElement(By.id("selection-info"));
+ return info.getText();
+ }
+
+ private String areaInfoText() {
+ WebElement info = findElement(By.id("area-selection-info"));
+ return info.getText();
+ }
+
+ private int selectionStart(TestBenchElement element) {
+ return ((Long) executeScript(
+ "return arguments[0].inputElement.selectionStart", element))
+ .intValue();
+ }
+
+ private int selectionEnd(TestBenchElement element) {
+ return ((Long) executeScript(
+ "return arguments[0].inputElement.selectionEnd", element))
+ .intValue();
+ }
+
+ private boolean isFocused(TestBenchElement element) {
+ return Boolean.TRUE.equals(executeScript(
+ "return document.activeElement === arguments[0].inputElement"
+ + " || arguments[0].contains(document.activeElement)",
+ element));
+ }
+}
diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/BigDecimalField.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/BigDecimalField.java
index 79c43734a21..12c09479716 100644
--- a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/BigDecimalField.java
+++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/BigDecimalField.java
@@ -28,6 +28,7 @@
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.Uses;
+import com.vaadin.flow.component.shared.HasSelection;
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.ValidationUtil;
import com.vaadin.flow.component.shared.internal.ValidationController;
@@ -88,7 +89,7 @@
@JsModule("./vaadin-big-decimal-field.js")
@Uses(TextField.class)
public class BigDecimalField extends TextFieldBase
- implements HasThemeVariant {
+ implements HasSelection, HasThemeVariant {
private BigDecimalFieldI18n i18n;
diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/PasswordField.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/PasswordField.java
index 0e4dea76511..b17f875133c 100644
--- a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/PasswordField.java
+++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/PasswordField.java
@@ -24,6 +24,7 @@
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.shared.HasAllowedCharPattern;
+import com.vaadin.flow.component.shared.HasSelection;
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.ValidationUtil;
import com.vaadin.flow.component.shared.internal.ValidationController;
@@ -88,7 +89,8 @@
@NpmPackage(value = "@vaadin/password-field", version = "25.4.0-alpha1")
@JsModule("@vaadin/password-field/src/vaadin-password-field.js")
public class PasswordField extends TextFieldBase
- implements HasAllowedCharPattern, HasThemeVariant {
+ implements HasAllowedCharPattern, HasSelection,
+ HasThemeVariant {
private PasswordFieldI18n i18n;
diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextArea.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextArea.java
index e3f433ff4c6..49294ad1a95 100644
--- a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextArea.java
+++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextArea.java
@@ -24,6 +24,7 @@
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.shared.HasAllowedCharPattern;
+import com.vaadin.flow.component.shared.HasSelection;
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.ValidationUtil;
import com.vaadin.flow.component.shared.internal.ValidationController;
@@ -87,8 +88,8 @@
@Tag("vaadin-text-area")
@NpmPackage(value = "@vaadin/text-area", version = "25.4.0-alpha1")
@JsModule("@vaadin/text-area/src/vaadin-text-area.js")
-public class TextArea extends TextFieldBase