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: + *

+ */ +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 - implements HasAllowedCharPattern, HasThemeVariant { +public class TextArea extends TextFieldBase implements + HasAllowedCharPattern, HasSelection, HasThemeVariant { private TextAreaI18n i18n; diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextField.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextField.java index 47c3a82dbb2..8f73a4030dd 100644 --- a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextField.java +++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/main/java/com/vaadin/flow/component/textfield/TextField.java @@ -25,6 +25,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-field") @NpmPackage(value = "@vaadin/text-field", version = "25.4.0-alpha1") @JsModule("@vaadin/text-field/src/vaadin-text-field.js") -public class TextField extends TextFieldBase - implements HasAllowedCharPattern, HasThemeVariant { +public class TextField extends TextFieldBase implements + HasAllowedCharPattern, HasSelection, HasThemeVariant { private TextFieldI18n i18n; diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/test/java/com/vaadin/flow/component/textfield/tests/HasSelectionTest.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/test/java/com/vaadin/flow/component/textfield/tests/HasSelectionTest.java new file mode 100644 index 00000000000..4b63608246f --- /dev/null +++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/test/java/com/vaadin/flow/component/textfield/tests/HasSelectionTest.java @@ -0,0 +1,150 @@ +/* + * 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.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.component.shared.HasSelection; +import com.vaadin.flow.component.textfield.BigDecimalField; +import com.vaadin.flow.component.textfield.EmailField; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.PasswordField; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.tests.MockUIExtension; + +class HasSelectionTest { + + @RegisterExtension + MockUIExtension ui = new MockUIExtension(); + + private TextField textField; + + @BeforeEach + void setup() { + textField = new TextField(); + ui.add(textField); + } + + @Test + void selectAll_setsRangeAndFocuses() { + textField.selectAll(); + + PendingJavaScriptInvocation call = lastInvocation(); + String js = call.getInvocation().getExpression(); + // Uses setSelectionRange rather than HTMLInputElement.select() — both + // focus the input, but setSelectionRange keeps the apply-then-focus + // ordering explicit and consistent with the other methods. + Assertions.assertTrue(js.contains("i.setSelectionRange(0,"), js); + Assertions.assertFalse(js.contains("i.select()"), js); + // Wrapped in setTimeout to defer past any pending value/focus + // reflection that would otherwise wipe the selection. + Assertions.assertTrue(js.contains("setTimeout"), js); + Assertions.assertTrue(js.contains("i.focus()"), js); + } + + @Test + void deselect_collapsesAtSelectionEndWithoutFocus() { + textField.deselect(); + + PendingJavaScriptInvocation call = lastInvocation(); + String js = call.getInvocation().getExpression(); + Assertions.assertTrue(js.contains("selectionEnd"), js); + Assertions.assertTrue(js.contains("setSelectionRange"), js); + Assertions.assertTrue(js.contains("setTimeout"), js); + // deselect never moves focus. + Assertions.assertFalse(js.contains("focus()"), js); + } + + @Test + void setSelectionRange_appliesRangeAndFocuses() { + textField.setSelectionRange(3, 8); + + PendingJavaScriptInvocation call = lastInvocation(); + String js = call.getInvocation().getExpression(); + Assertions.assertTrue(js.contains("i.setSelectionRange($0, $1)"), js); + Assertions.assertTrue(js.contains("setTimeout"), js); + Assertions.assertTrue(js.contains("i.focus()"), js); + Assertions.assertEquals(3, call.getInvocation().getParameters().get(0)); + Assertions.assertEquals(8, call.getInvocation().getParameters().get(1)); + } + + @Test + void setCursorPosition_collapsesAtPositionAndFocuses() { + textField.setCursorPosition(5); + + PendingJavaScriptInvocation call = lastInvocation(); + Assertions.assertEquals(5, call.getInvocation().getParameters().get(0)); + Assertions.assertEquals(5, call.getInvocation().getParameters().get(1)); + } + + @Test + void textArea_implementsHasSelection() { + TextArea area = new TextArea(); + ui.add(area); + area.selectAll(); + Assertions.assertTrue(area instanceof HasSelection); + Assertions.assertFalse(ui.dumpPendingJavaScriptInvocations().isEmpty()); + } + + @Test + void passwordField_implementsHasSelection() { + PasswordField password = new PasswordField(); + ui.add(password); + password.setSelectionRange(1, 4); + Assertions.assertTrue(password instanceof HasSelection); + Assertions.assertFalse(ui.dumpPendingJavaScriptInvocations().isEmpty()); + } + + @Test + void bigDecimalField_implementsHasSelection() { + BigDecimalField field = new BigDecimalField(); + ui.add(field); + field.setCursorPosition(2); + Assertions.assertTrue(field instanceof HasSelection); + Assertions.assertFalse(ui.dumpPendingJavaScriptInvocations().isEmpty()); + } + + /** + * EmailField, NumberField, and IntegerField wrap input types ({@code email} + * / {@code number}) where the browser disallows {@code selectionStart} / + * {@code setSelectionRange} per the HTML spec — Chrome and Firefox throw + * {@code InvalidStateError}. The mixin is therefore deliberately not + * implemented on these classes so that the type system reflects what + * actually works at runtime. + */ + @Test + void emailNumberInteger_doNotImplementHasSelection() { + Assertions.assertFalse(new EmailField() instanceof HasSelection); + Assertions.assertFalse(new NumberField() instanceof HasSelection); + Assertions.assertFalse(new IntegerField() instanceof HasSelection); + } + + private PendingJavaScriptInvocation lastInvocation() { + List calls = ui + .dumpPendingJavaScriptInvocations(); + Assertions.assertFalse(calls.isEmpty(), + "expected a pending JavaScript invocation"); + return calls.get(calls.size() - 1); + } +} diff --git a/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/test/java/com/vaadin/flow/component/textfield/tests/SelectionSignalTest.java b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/test/java/com/vaadin/flow/component/textfield/tests/SelectionSignalTest.java new file mode 100644 index 00000000000..c2a0004804d --- /dev/null +++ b/vaadin-text-field-flow-parent/vaadin-text-field-flow/src/test/java/com/vaadin/flow/component/textfield/tests/SelectionSignalTest.java @@ -0,0 +1,132 @@ +/* + * 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.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.shared.SelectionRange; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.dom.DomEvent; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.internal.nodefeature.ElementListenerMap; +import com.vaadin.flow.signals.Signal; +import com.vaadin.tests.AbstractSignalsTest; + +import tools.jackson.databind.node.ObjectNode; + +class SelectionSignalTest extends AbstractSignalsTest { + + private TextField textField; + + @BeforeEach + void setup() { + textField = new TextField(); + } + + @Test + void initialValue_isEmpty() { + Signal signal = textField.selectionSignal(); + Assertions.assertEquals(SelectionRange.empty(), signal.peek()); + } + + @Test + void sameInstance_returnedAcrossCalls() { + Signal first = textField.selectionSignal(); + Signal second = textField.selectionSignal(); + Assertions.assertSame(first, second); + } + + @Test + void clientSelectionEvent_updatesSignal() { + UI.getCurrent().add(textField); + textField.setValue("Hello world"); + Signal signal = textField.selectionSignal(); + + fireSelectionChange(textField.getElement(), 6, 11, "world"); + + SelectionRange current = signal.peek(); + Assertions.assertEquals(6, current.start()); + Assertions.assertEquals(11, current.end()); + Assertions.assertEquals("world", current.content()); + Assertions.assertEquals(5, current.length()); + Assertions.assertFalse(current.isEmpty()); + } + + @Test + void collapsedSelection_signalReportsEmptyRange() { + UI.getCurrent().add(textField); + Signal signal = textField.selectionSignal(); + + fireSelectionChange(textField.getElement(), 4, 4, ""); + + Assertions.assertTrue(signal.peek().isEmpty()); + Assertions.assertEquals(4, signal.peek().start()); + } + + @Test + void detachedField_eventListenerStillRegistered() { + // Signal is established while detached; later attachment + event + // should still flow through to the signal. + Signal signal = textField.selectionSignal(); + UI.getCurrent().add(textField); + textField.setValue("abcdef"); + + fireSelectionChange(textField.getElement(), 1, 4, "bcd"); + + Assertions.assertEquals(new SelectionRange(1, 4, "bcd"), signal.peek()); + } + + @Test + void signalSurvivesDetachAndReattach() { + UI.getCurrent().add(textField); + Signal signal = textField.selectionSignal(); + + textField.removeFromParent(); + UI.getCurrent().add(textField); + + fireSelectionChange(textField.getElement(), 0, 3, "Hel"); + Assertions.assertEquals(new SelectionRange(0, 3, "Hel"), signal.peek()); + + // The cached signal must still be the same instance after re-attach. + Assertions.assertSame(signal, textField.selectionSignal()); + } + + @Test + void textArea_signalWorks() { + TextArea area = new TextArea(); + UI.getCurrent().add(area); + Signal signal = area.selectionSignal(); + + fireSelectionChange(area.getElement(), 2, 7, "lorem"); + Assertions.assertEquals(new SelectionRange(2, 7, "lorem"), + signal.peek()); + } + + private static void fireSelectionChange(Element element, int start, int end, + String content) { + ObjectNode data = JacksonUtils.createObjectNode(); + data.put("event.detail.start", start); + data.put("event.detail.end", end); + data.put("event.detail.content", content); + DomEvent event = new DomEvent(element, "vaadin-selection-change", data); + element.getNode().getFeature(ElementListenerMap.class).fireEvent(event); + } +}