diff --git a/vaadin-combo-box-flow-parent/vaadin-combo-box-flow-integration-tests/src/main/java/com/vaadin/flow/component/combobox/test/ComboBoxFocusSelectedItemPage.java b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow-integration-tests/src/main/java/com/vaadin/flow/component/combobox/test/ComboBoxFocusSelectedItemPage.java new file mode 100644 index 00000000000..988473adca0 --- /dev/null +++ b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow-integration-tests/src/main/java/com/vaadin/flow/component/combobox/test/ComboBoxFocusSelectedItemPage.java @@ -0,0 +1,285 @@ +/* + * 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.combobox.test; + +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.html.H4; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.router.Route; + +@Route("vaadin-combo-box/focus-selected-item") +public class ComboBoxFocusSelectedItemPage extends VerticalLayout { + + private static final int ITEM_COUNT = 10_000; + + private final List allItems = IntStream.range(0, ITEM_COUNT) + .mapToObj(i -> "Item " + i).toList(); + + public ComboBoxFocusSelectedItemPage() { + addMainLazySection(); + addSmallInMemorySection(); + addDefaultOffSection(); + addNoProviderSection(); + addThrowingProviderSection(); + addDomainTypeSection(); + addBinderSection(); + } + + private void addMainLazySection() { + ComboBox comboBox = new ComboBox<>("Lazy combo-box"); + comboBox.setId("combo"); + comboBox.setWidth("16em"); + + setLazyItems(comboBox); + // ItemIndexProvider resolves the flat index of an item *within the + // currently filtered list*. The query carries the filter so the + // index stays accurate while the user is typing. + setFilteredIndexProvider(comboBox); + + comboBox.setFocusSelectedItem(true); + comboBox.setValue("Item 5000"); + + Span status = new Span(); + status.setId("status"); + updateStatus(status, comboBox); + comboBox.addValueChangeListener(e -> updateStatus(status, comboBox)); + + NativeButton setDeep = button("set-deep", "Select Item 9000", + () -> comboBox.setValue("Item 9000")); + NativeButton setShallow = button("set-shallow", "Select Item 123", + () -> comboBox.setValue("Item 123")); + NativeButton clear = button("clear", "Clear value", comboBox::clear); + NativeButton toggle = button("toggle", "Toggle focusSelectedItem", + () -> { + comboBox.setFocusSelectedItem( + !comboBox.isFocusSelectedItem()); + updateStatus(status, comboBox); + }); + + // Push-style server-side value change while the dropdown may still be + // open (C3). In a real app this would come from a background thread or + // a collaborative event; clicking the button is equivalent from the + // connector's perspective. + NativeButton pushUpdate = button("push-update", + "Push value = Item 7500", () -> comboBox.setValue("Item 7500")); + + // Detach/reattach the combo (C4). Remove it from the layout and add it + // back to verify the connector still works after reattachment. + NativeButton detachReattach = button("detach-reattach", + "Detach & reattach", () -> { + remove(comboBox); + add(comboBox); + }); + + // Replace the backing data provider with a list that does NOT contain + // the current value (C6). The combo retains its `value` but + // `selectedItem` should either clear or not resolve; opening the + // dropdown must not crash and must not land focus on an unrelated row. + NativeButton replaceItems = button("replace-items", + "Replace items (drops current value)", + () -> comboBox.setItems("Alpha", "Beta", "Gamma")); + + add(new H4("Lazy combo-box"), comboBox, status, + new HorizontalLayout(setDeep, setShallow, clear, toggle), + new HorizontalLayout(pushUpdate, detachReattach, replaceItems)); + } + + private void addSmallInMemorySection() { + // Small in-memory combo-box: itemCount <= pageSize activates + // _clientSideFilter mode, where the server never sees the typed + // filter. Exercises the resolveSelectedItemIndex client-side-filter + // guard: after filtering out the selected item, reopening must not + // scroll to the unfiltered position. + ComboBox smallCombo = new ComboBox<>("Small (client-filter)"); + smallCombo.setId("small-combo"); + smallCombo.setItems("apple", "banana", "cherry", "date", "elderberry"); + smallCombo.setFocusSelectedItem(true); + smallCombo.setValue("elderberry"); + add(new H4("Small in-memory combo-box"), smallCombo); + } + + private void addDefaultOffSection() { + // A1: focusSelectedItem defaults to false. Opening this combo with a + // preset value must not auto-scroll (regression guard for PR #6055). + ComboBox combo = new ComboBox<>("Default off"); + combo.setId("combo-default"); + setLazyItems(combo); + combo.setValue("Item 200"); + add(new H4("Default off (focusSelectedItem unset)"), combo); + } + + private void addNoProviderSection() { + // C8 / C9: lazy combo with focusSelectedItem=true but NO + // ItemIndexProvider. Falls back to the web-component's client-side + // best-effort: focuses only if the selected item is in the loaded + // cache. + ComboBox combo = new ComboBox<>("Lazy (no ItemIndexProvider)"); + combo.setId("combo-no-provider"); + setLazyItems(combo); + combo.setFocusSelectedItem(true); + + NativeButton near = button("set-no-provider-near", + "Select Item 5 (in first page)", + () -> combo.setValue("Item 5")); + NativeButton far = button("set-no-provider-far", + "Select Item 500 (not in first page)", + () -> combo.setValue("Item 500")); + + add(new H4("Lazy without ItemIndexProvider"), combo, + new HorizontalLayout(near, far)); + } + + private void addThrowingProviderSection() { + // C7: ItemIndexProvider that throws a RuntimeException when armed. + // Opening the dropdown with the throw enabled must not crash or leave + // the combo in a broken state; the feature should silently fall back. + boolean[] throwOnResolve = { false }; + ComboBox combo = new ComboBox<>("Lazy (provider throws)"); + combo.setId("combo-throws"); + setLazyItems(combo); + combo.getLazyDataView().setItemIndexProvider((item, query) -> { + if (throwOnResolve[0]) { + throw new RuntimeException( + "Simulated ItemIndexProvider failure"); + } + String filterText = query.getFilter().map(Object::toString) + .orElse(""); + return resolveItemIndex(item, filterText); + }); + combo.setFocusSelectedItem(true); + combo.setValue("Item 300"); + + NativeButton toggleThrow = button("toggle-throw", + "Toggle provider-throws mode", + () -> throwOnResolve[0] = !throwOnResolve[0]); + + add(new H4("Lazy with throwing ItemIndexProvider"), combo, toggleThrow); + } + + private void addDomainTypeSection() { + // C2: custom domain type with ItemLabelGenerator + IdentifierProvider. + // Id-based identity ensures the preset value is found even if a + // re-fetched Person is a different object reference. + List persons = IntStream.range(0, 100) + .mapToObj(i -> new Person(i, "Person " + i)).toList(); + ComboBox combo = new ComboBox<>("Persons"); + combo.setId("combo-person"); + combo.setItems(persons); + combo.setItemLabelGenerator(Person::name); + combo.getListDataView().setIdentifierProvider(Person::id); + combo.setFocusSelectedItem(true); + combo.setValue(new Person(42, "Person 42")); + add(new H4("Domain type (Person)"), combo); + } + + private void addBinderSection() { + // C1: combo wired to a Binder-managed bean. Tests that value changes + // propagated via Binder go through the same code path. + Bean bean = new Bean(); + ComboBox combo = new ComboBox<>("Bound to Binder"); + combo.setId("combo-bound"); + setLazyItems(combo); + setFilteredIndexProvider(combo); + combo.setFocusSelectedItem(true); + + Binder binder = new Binder<>(Bean.class); + binder.forField(combo).bind("itemName"); + binder.setBean(bean); + + NativeButton updateBean = button("update-bean", "Bean → Item 2500", + () -> { + bean.setItemName("Item 2500"); + binder.readBean(bean); + }); + + add(new H4("Binder-wired combo"), combo, + new HorizontalLayout(updateBean)); + } + + private void setLazyItems(ComboBox comboBox) { + comboBox.setItems( + query -> filter(query.getFilter().orElse("")) + .skip(query.getOffset()).limit(query.getLimit()), + query -> (int) filter(query.getFilter().orElse("")).count()); + } + + private void setFilteredIndexProvider(ComboBox comboBox) { + comboBox.getLazyDataView().setItemIndexProvider((item, query) -> { + String filterText = query.getFilter().map(Object::toString) + .orElse(""); + return resolveItemIndex(item, filterText); + }); + } + + private Integer resolveItemIndex(String item, String filterText) { + int index = filter(filterText).toList().indexOf(item); + return index >= 0 ? index : null; + } + + private Stream filter(String filter) { + if (filter == null || filter.isEmpty()) { + return allItems.stream(); + } + String lower = filter.toLowerCase(); + return allItems.stream() + .filter(item -> item.toLowerCase().contains(lower)); + } + + private void updateStatus(Span status, ComboBox comboBox) { + status.setText("value=" + comboBox.getValue() + ", focusSelectedItem=" + + comboBox.isFocusSelectedItem()); + } + + private NativeButton button(String id, String text, Runnable action) { + NativeButton b = new NativeButton(text, e -> action.run()); + b.setId(id); + return b; + } + + public record Person(int id, String name) { + } + + public static class Bean { + private String itemName; + + public String getItemName() { + return itemName; + } + + public void setItemName(String itemName) { + this.itemName = itemName; + } + + @Override + public boolean equals(Object o) { + return o instanceof Bean b && Objects.equals(itemName, b.itemName); + } + + @Override + public int hashCode() { + return Objects.hash(itemName); + } + } +} diff --git a/vaadin-combo-box-flow-parent/vaadin-combo-box-flow-integration-tests/src/test/java/com/vaadin/flow/component/combobox/test/ComboBoxFocusSelectedItemIT.java b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow-integration-tests/src/test/java/com/vaadin/flow/component/combobox/test/ComboBoxFocusSelectedItemIT.java new file mode 100644 index 00000000000..22483410db1 --- /dev/null +++ b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow-integration-tests/src/test/java/com/vaadin/flow/component/combobox/test/ComboBoxFocusSelectedItemIT.java @@ -0,0 +1,402 @@ +/* + * 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.combobox.test; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebElement; + +import com.vaadin.flow.component.combobox.testbench.ComboBoxElement; +import com.vaadin.flow.testutil.TestPath; + +@TestPath("vaadin-combo-box/focus-selected-item") +public class ComboBoxFocusSelectedItemIT extends AbstractComboBoxIT { + + private ComboBoxElement combo; + private ComboBoxElement small; + private ComboBoxElement comboDefault; + private ComboBoxElement comboNoProvider; + private ComboBoxElement comboThrows; + private ComboBoxElement comboPerson; + private ComboBoxElement comboBound; + + @Before + public void init() { + open(); + waitUntil(driver -> !findElements(By.id("combo-bound")).isEmpty()); + combo = $(ComboBoxElement.class).id("combo"); + small = $(ComboBoxElement.class).id("small-combo"); + comboDefault = $(ComboBoxElement.class).id("combo-default"); + comboNoProvider = $(ComboBoxElement.class).id("combo-no-provider"); + comboThrows = $(ComboBoxElement.class).id("combo-throws"); + comboPerson = $(ComboBoxElement.class).id("combo-person"); + comboBound = $(ComboBoxElement.class).id("combo-bound"); + } + + @Test + public void defaultOff_presetValue_open_doesNotAutoScroll() { + comboDefault.openPopup(); + assertLoadingStateResolved(comboDefault); + Assert.assertEquals(-1L, getFocusedIndex(comboDefault)); + } + + @Test + public void presetValue_open_focusesSelectedItem() { + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + Assert.assertEquals("Item 5000", getItemLabelAtFocusedIndex(combo)); + } + + @Test + public void noValue_open_doesNotFocus() { + clickButton("clear"); + combo.openPopup(); + assertLoadingStateResolved(combo); + Assert.assertEquals(-1L, getFocusedIndex(combo)); + } + + @Test + public void defaultOff_escape_closesInOnePress() { + // Regression guard for flow#5142: with focusSelectedItem=false the web + // component does not auto-scroll, so _focusedIndex stays -1 and Escape + // closes the dropdown in a single press. + comboDefault.openPopup(); + assertLoadingStateResolved(comboDefault); + executeScript( + "arguments[0].inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true }));", + comboDefault); + waitUntil(driver -> !comboDefault.getPropertyBoolean("opened")); + } + + @Test + public void escape_clearsFocusThenCloses() { + // Documented behavior of the web component: when _focusedIndex is set, + // the first Escape clears focus and reverts the input; the second + // Escape closes the overlay. + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + executeScript( + "arguments[0].inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true }));", + combo); + waitUntil(driver -> getFocusedIndex(combo) == -1L); + Assert.assertTrue("First Escape should not close the overlay", + combo.getPropertyBoolean("opened")); + executeScript( + "arguments[0].inputElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true }));", + combo); + waitUntil(driver -> !combo.getPropertyBoolean("opened")); + } + + @Test + public void toggleOff_reopen_doesNotAutoScroll() { + clickButton("toggle"); + combo.openPopup(); + assertLoadingStateResolved(combo); + Assert.assertEquals(-1L, getFocusedIndex(combo)); + } + + @Test + public void reopen_loadingResolves_focusesSelectedItem() { + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + + combo.closePopup(); + waitUntil(driver -> !combo.getPropertyBoolean("opened")); + // Let the server-side close event fully round-trip before reopening. + // Without the pause, the client reopens before the server's + // close-side processing lands and the no-op range match doesn't + // occur, so the bug we're guarding against doesn't reproduce. + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + combo.openPopup(); + assertLoadingStateResolved(combo); + waitForFocusedIndex(combo, 5000); + Assert.assertEquals("Item 5000", getItemLabelAtFocusedIndex(combo)); + } + + @Test + public void setValueWhileClosed_reopenWithFilter_doesNotFocus() { + clickButton("set-shallow"); + combo.openPopup(); + waitForFocusedIndex(combo, 123); + combo.closePopup(); + waitUntil(driver -> !combo.getPropertyBoolean("opened")); + + setFilterViaInput(combo, "3"); + waitUntil(driver -> "3".equals(combo.getPropertyString("filter"))); + assertLoadingStateResolved(combo); + Assert.assertEquals( + "Reopening with a filter should not auto-scroll to the selected item", + -1L, getFocusedIndex(combo)); + } + + @Test + public void filterWhileOpen_doesNotReFocusSelectedItem() { + clickButton("set-shallow"); + combo.openPopup(); + waitForFocusedIndex(combo, 123); + + clearInputAndType(combo, "3"); + waitUntil(driver -> "3".equals(combo.getPropertyString("filter"))); + assertLoadingStateResolved(combo); + Assert.assertNotEquals( + "Typing a filter while open must not leave focus on the selected item", + "Item 123", getItemLabelAtFocusedIndex(combo)); + } + + @Test + public void filterExcludingSelectedItem_doesNotFocus() { + clickButton("set-shallow"); + combo.openPopup(); + waitForFocusedIndex(combo, 123); + + clearInputAndType(combo, "zzz-no-match"); + assertLoadingStateResolved(combo); + Assert.assertEquals(-1L, getFocusedIndex(combo)); + } + + @Test + public void rapidTyping_doesNotReFocusSelectedItem() { + clickButton("set-shallow"); + combo.openPopup(); + waitForFocusedIndex(combo, 123); + + // Three rapid synthetic input events without awaiting in between. + executeScript("const cb = arguments[0];" + + "const fire = (v) => { cb.inputElement.value = v;" + + " cb.inputElement.dispatchEvent(new InputEvent('input', {bubbles:true, data:v, inputType:'insertText'})); };" + + "fire('1'); fire('2'); fire('3');", combo); + waitUntil(driver -> "3".equals(combo.getPropertyString("filter"))); + assertLoadingStateResolved(combo); + Assert.assertNotEquals( + "Rapid filter typing must not leave focus on the selected item", + "Item 123", getItemLabelAtFocusedIndex(combo)); + } + + @Test + public void clientSideFilter_open_doesNotFocus() { + // Small in-memory data sets activate the web component's + // _clientSideFilter mode, where the server never sees the typed + // filter. The Flow connector's resolveSelectedItemIndex returns + // null in that mode (an authoritative index would not match the + // client-filtered list), so the dropdown opens at the top with no + // scroll — even when no filter has been typed yet. + small.openPopup(); + assertLoadingStateResolved(small); + Assert.assertEquals(-1L, getFocusedIndex(small)); + + clearInputAndType(small, "app"); + waitUntil(driver -> "app".equals(small.getPropertyString("filter"))); + Assert.assertEquals(-1L, getFocusedIndex(small)); + } + + @Test + public void typeFilterThenClear_doesNotReFocusSelectedItem() { + // Open with empty filter focuses Item 5000. Typing a filter and + // clearing it back to empty must NOT re-focus the selected item: + // once the user has interacted with the filter, the dropdown stays + // wherever the web component left it. + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + + clearInputAndType(combo, "5000"); + waitUntil(driver -> "5000".equals(combo.getPropertyString("filter"))); + assertLoadingStateResolved(combo); + + // Clear the filter back to empty. The dropdown stays open. + dispatchInput(combo, "", "deleteContentBackward"); + waitUntil(driver -> combo.getPropertyString("filter") == null + || combo.getPropertyString("filter").isEmpty()); + assertLoadingStateResolved(combo); + Assert.assertNotEquals( + "Clearing the filter back to empty must not re-focus the selected item", + 5000L, getFocusedIndex(combo)); + } + + @Test + public void binderUpdate_open_focusesNewValue() { + clickButton("update-bean"); + comboBound.openPopup(); + waitForLabelAtFocusedIndex(comboBound, "Item 2500"); + } + + @Test + public void customType_withIdentifierProvider_focusesSelectedItem() { + comboPerson.openPopup(); + waitForFocusedIndex(comboPerson, 42); + Assert.assertEquals("Person 42", + getItemLabelAtFocusedIndex(comboPerson)); + } + + @Test + public void serverChangedValue_reopen_focusesNewValue() { + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + clickButton("push-update"); + // A pure value change does not re-run focusSelectedItem on an + // already-open dropdown. Close and reopen to pick up the new value. + combo.closePopup(); + waitUntil(driver -> !combo.getPropertyBoolean("opened")); + combo.openPopup(); + waitForFocusedIndex(combo, 7500); + } + + @Test + public void detachReattach_open_focusesSelectedItem() { + clickButton("detach-reattach"); + waitUntil(driver -> findElements(By.id("combo")).size() == 1); + combo = $(ComboBoxElement.class).id("combo"); + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + } + + @Test + public void twoCombos_independentFocusResolution() { + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + combo.closePopup(); + waitUntil(driver -> !combo.getPropertyBoolean("opened")); + + comboPerson.openPopup(); + waitForFocusedIndex(comboPerson, 42); + } + + @Test + public void replaceItemsDroppingValue_open_doesNotCrash() { + clickButton("replace-items"); + combo.openPopup(); + assertLoadingStateResolved(combo); + long focused = getFocusedIndex(combo); + Assert.assertTrue( + "Replacing items should not leave a stray focus on an unrelated row. Got " + + focused, + focused == -1L + || "Item 7500".equals(getItemLabelAtFocusedIndex(combo)) + || "Item 5000" + .equals(getItemLabelAtFocusedIndex(combo))); + } + + @Test + public void throwingItemIndexProvider_open_doesNotCrashDropdown() { + clickButton("toggle-throw"); + comboThrows.openPopup(); + assertLoadingStateResolved(comboThrows); + // Either the web-component found Item 300 in the cache or + // _focusedIndex stays -1; either way the dropdown must remain usable. + long focused = getFocusedIndex(comboThrows); + Assert.assertTrue( + "A throwing ItemIndexProvider must not leave the combo broken; focused=" + + focused, + focused == -1L || focused == 300L); + } + + @Test + public void noItemIndexProvider_valueInLoadedPage_doesNotFocus() { + // Without an ItemIndexProvider on the lazy data view, the server + // returns null from resolveSelectedItemIndex and the connector has + // no in-cache fallback. The dropdown opens at the top, even when + // the selected item happens to be in the loaded first page. + clickButton("set-no-provider-near"); + comboNoProvider.openPopup(); + assertLoadingStateResolved(comboNoProvider); + Assert.assertEquals(-1L, getFocusedIndex(comboNoProvider)); + } + + @Test + public void noItemIndexProvider_valueNotInLoadedPage_doesNotFocus() { + clickButton("set-no-provider-far"); + comboNoProvider.openPopup(); + assertLoadingStateResolved(comboNoProvider); + Assert.assertEquals(-1L, getFocusedIndex(comboNoProvider)); + } + + @Test + public void arrowDown_afterAutoFocus_movesFocusDown() { + // Verify the auto-focused item is a real navigation target — pressing + // ArrowDown moves the focused index by one. The Enter-to-commit + // assertion was dropped because WC keyboard commit semantics for + // programmatically-set _focusedIndex are out of scope for this fix. + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + WebElement input = (WebElement) executeScript( + "return arguments[0].inputElement", combo); + input.sendKeys(Keys.ARROW_DOWN); + waitUntil(driver -> getFocusedIndex(combo) == 5001L); + } + + @Test + public void ariaActiveDescendant_pointsToFocusedItem() { + combo.openPopup(); + waitForFocusedIndex(combo, 5000); + String activeDescendant = (String) executeScript( + "return arguments[0].inputElement.getAttribute('aria-activedescendant')", + combo); + Assert.assertNotNull("aria-activedescendant should be set", + activeDescendant); + String focusedItemId = (String) executeScript( + "const items = arguments[0]._scroller.querySelectorAll('vaadin-combo-box-item');" + + "return Array.from(items).find(el => el.index === arguments[0]._focusedIndex)?.id || null;", + combo); + Assert.assertEquals("aria-activedescendant must match focused item id", + focusedItemId, activeDescendant); + } + + private long getFocusedIndex(ComboBoxElement cb) { + Object value = executeScript("return arguments[0]._focusedIndex", cb); + return value == null ? -1L : ((Number) value).longValue(); + } + + private String getItemLabelAtFocusedIndex(ComboBoxElement cb) { + Object value = executeScript( + "const it = arguments[0]._dropdownItems?.[arguments[0]._focusedIndex];" + + "return it ? (it.label ?? String(it)) : null;", + cb); + return value == null ? null : String.valueOf(value); + } + + private void waitForFocusedIndex(ComboBoxElement cb, long expected) { + waitUntil(driver -> getFocusedIndex(cb) == expected); + } + + private void waitForLabelAtFocusedIndex(ComboBoxElement cb, String label) { + waitUntil(driver -> label.equals(getItemLabelAtFocusedIndex(cb))); + } + + private void setFilterViaInput(ComboBoxElement cb, String text) { + dispatchInput(cb, text, "insertText"); + } + + private void clearInputAndType(ComboBoxElement cb, String text) { + dispatchInput(cb, "", "deleteContentBackward"); + dispatchInput(cb, text, "insertText"); + } + + private void dispatchInput(ComboBoxElement cb, String value, + String inputType) { + executeScript("const cb = arguments[0]; cb.inputElement.focus();" + + "cb.inputElement.value = arguments[1];" + + "cb.inputElement.dispatchEvent(new InputEvent('input', " + + "{bubbles:true, data:arguments[1], inputType:arguments[2]}));", + cb, value, inputType); + } +} diff --git a/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/java/com/vaadin/flow/component/combobox/ComboBox.java b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/java/com/vaadin/flow/component/combobox/ComboBox.java index 09dbfdf9ff9..9f5cab47258 100644 --- a/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/java/com/vaadin/flow/component/combobox/ComboBox.java +++ b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/java/com/vaadin/flow/component/combobox/ComboBox.java @@ -20,9 +20,11 @@ import java.util.Objects; import java.util.stream.Stream; +import com.vaadin.flow.component.ClientCallable; import com.vaadin.flow.component.HasSize; import com.vaadin.flow.component.Tag; import com.vaadin.flow.component.Unit; +import com.vaadin.flow.component.combobox.dataview.ComboBoxLazyDataView; import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.component.dependency.NpmPackage; import com.vaadin.flow.component.shared.HasPrefix; @@ -31,6 +33,7 @@ import com.vaadin.flow.data.provider.DataCommunicator; import com.vaadin.flow.data.provider.DataKeyMapper; import com.vaadin.flow.data.provider.DataProvider; +import com.vaadin.flow.data.provider.ItemIndexProvider; import com.vaadin.flow.function.SerializableBiPredicate; import com.vaadin.flow.internal.JacksonUtils; @@ -88,7 +91,7 @@ * @author Vaadin Ltd */ @Tag("vaadin-combo-box") -@NpmPackage(value = "@vaadin/combo-box", version = "25.2.0-alpha9") +@NpmPackage(value = "@vaadin/combo-box", version = "25.2.0-alpha8") @JsModule("@vaadin/combo-box/src/vaadin-combo-box.js") @JsModule("./flow-component-renderer.js") @JsModule("./comboBoxConnector.js") @@ -98,6 +101,8 @@ public class ComboBox extends ComboBoxBase, T, T> private static final String PROP_SELECTED_ITEM = "selectedItem"; private static final String PROP_VALUE = "value"; + private boolean focusSelectedItem = false; + /** * A callback method for fetching items. The callback is provided with a * non-null string filter, offset index and limit. @@ -393,6 +398,82 @@ public void setOverlayWidth(float width, Unit unit) { setOverlayWidth(HasSize.getCssSize(width, unit)); } + /** + * When set to {@code true}, the dropdown opens scrolled to the currently + * selected item (if any) and focuses it. + *

+ * With an in-memory data source the selected item's index is resolved via + * the list data view. With a lazy data provider, an + * {@link ItemIndexProvider} should be set via + * {@link ComboBoxLazyDataView#setItemIndexProvider(ItemIndexProvider)} so + * the flat index can be computed authoritatively. When neither path can + * resolve an index — no {@link ItemIndexProvider} configured for a lazy + * data provider, or an in-memory dataset small enough for the browser to + * filter locally — the dropdown opens at the top with no scroll. + *

+ * When a filter is active, the dropdown does not auto-scroll to the + * selected item: keyboard navigation begins from the top of the filtered + * list, even when the selected item is part of the filtered set. + * + * @param focusSelectedItem + * whether to auto-scroll to the selected item on open + * @since 25.2 + */ + public void setFocusSelectedItem(boolean focusSelectedItem) { + this.focusSelectedItem = focusSelectedItem; + // The connector's setFocusSelectedItem becomes available only once + // initLazy has run. Both this call and initLazy are queued via + // executeJs, but on the first attach there's no guarantee initLazy + // runs first. Poll via microtask until $connector is ready instead + // of relying on runBeforeClientResponse ordering. + runBeforeClientResponse(ui -> getElement().executeJs( + "const apply = (val) => {" + " if (this.$connector) {" + + " this.$connector.setFocusSelectedItem(val);" + + " } else {" + " queueMicrotask(() => apply(val));" + + " }" + "};" + "apply($0);", + focusSelectedItem)); + } + + /** + * @return whether the dropdown auto-scrolls to the selected item on open + * @see #setFocusSelectedItem(boolean) + * @since 25.2 + */ + public boolean isFocusSelectedItem() { + return focusSelectedItem; + } + + /** + * Called by the client-side connector when the dropdown opens. Returns the + * flat index of the current value under the current filter, or {@code null} + * if it cannot be resolved (no value, no data provider, or no + * {@link ItemIndexProvider} for a lazy data provider). + */ + @ClientCallable + private Integer resolveSelectedItemIndex() { + T selected = getValue(); + if (selected == null || getDataProvider() == null) { + return null; + } + try { + if (getDataProvider().isInMemory()) { + // With client-side filtering the server never receives the + // typed filter, so an index computed here would not match the + // filtered list shown in the dropdown. Returning null lets + // the dropdown open at the top with no scroll, which is the + // correct behavior when filtering is active anyway. The + // connector also skips this RPC when a filter is present. + if (getElement().getProperty("_clientSideFilter", false)) { + return null; + } + return getListDataView().getItemIndex(selected).orElse(null); + } + return getLazyDataView().getItemIndex(selected).orElse(null); + } catch (UnsupportedOperationException e) { + return null; + } + } + /** * Gets the internationalization object previously set for this component. *

diff --git a/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/resources/META-INF/resources/frontend/comboBoxConnector.js b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/resources/META-INF/resources/frontend/comboBoxConnector.js index a2a65c87399..66c623d6c0a 100644 --- a/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/resources/META-INF/resources/frontend/comboBoxConnector.js +++ b/vaadin-combo-box-flow-parent/vaadin-combo-box-flow/src/main/resources/META-INF/resources/frontend/comboBoxConnector.js @@ -345,6 +345,120 @@ window.Vaadin.Flow.comboBoxConnector.initLazy = (comboBox) => { // Prevent setting the custom value as the 'value'-prop automatically comboBox.addEventListener('custom-value-set', (e) => e.preventDefault()); + // Feature flag for the focus-selected-item behavior. Set from the Java side + // via `comboBox.$connector.setFocusSelectedItem(value)` so it doesn't need + // to live as an element property the web component would otherwise ignore. + let focusSelectedItemEnabled = false; + comboBox.$connector.setFocusSelectedItem = (value) => { + focusSelectedItemEnabled = !!value; + }; + + // Ask the server for the selected item's index when the dropdown opens. + // + // Control flow has three async boundaries, each a point where a newer + // invocation (from a subsequent dropdown open) can overtake us: + // - microtask deferral so `_onOpened`'s synchronous work populates + // `_focusedIndex` first; + // - waiting for `loading` to settle so the server sees the filter the + // client just pushed via setViewportRange; + // - awaiting the server RPC response. + // + // `focusSelectedItemToken` is a monotonic per-instance counter: each call + // captures its own value at entry and compares against the latest at each + // async boundary ("switch"-style cancellation, similar to RxJS `switchMap`). + // An older invocation that's been superseded bails out — on the outside + // edges to save wasted work, at the RPC-response edge to avoid the actual + // bug of scrolling to a stale-filter index inside a newer-filter dropdown. + let focusSelectedItemToken = 0; + const resolveFocusSelectedItem = () => { + if (!focusSelectedItemEnabled) return; + // When the user is filtering, keyboard navigation should start from the + // top of the filtered list rather than jumping to the selected item — so + // skip the resolve while a filter is active. We never re-focus once a + // filter has been typed, so a separate `filter-changed` listener isn't + // needed either. + if (comboBox.filter) return; + const token = ++focusSelectedItemToken; + queueMicrotask(() => { + // Superseded by a later sync call that queued its own microtask. Bail + // early to avoid registering a redundant `page-loaded` listener. + if (token !== focusSelectedItemToken) return; + if (!comboBox.selectedItem) return; + const selectedValue = comboBox._getItemValue(comboBox.selectedItem); + const idxOfSelected = comboBox.__getItemIndexByValue(comboBox._dropdownItems, selectedValue); + if (idxOfSelected >= 0 && idxOfSelected === comboBox._focusedIndex) { + return; + } + const invoke = () => { + // Superseded while we waited for `loading` to settle. Skip the RPC + // — its answer would be dropped on arrival anyway. + if (token !== focusSelectedItemToken) return; + comboBox.$server.resolveSelectedItemIndex().then( + (index) => { + // Superseded during the server round-trip. The response reflects + // an older state; applying it would scroll into the wrong + // array. Correctness-critical guard — do not remove. + if (token !== focusSelectedItemToken) return; + if (index != null) { + comboBox.scrollToIndex(index); + } + }, + () => {} + ); + }; + if (comboBox.loading) { + // Wait for loading to fully settle — under overlapping fetches, + // page-loaded fires once per fetch but the server's filter state + // isn't guaranteed to match the latest client filter until all + // pending fetches land. + const onPageLoaded = () => { + // Self-remove if superseded, so stale listeners don't linger on + // the controller. + if (token !== focusSelectedItemToken) { + comboBox.__dataProviderController.removeEventListener('page-loaded', onPageLoaded); + return; + } + if (!comboBox.loading) { + comboBox.__dataProviderController.removeEventListener('page-loaded', onPageLoaded); + invoke(); + } + }; + comboBox.__dataProviderController.addEventListener('page-loaded', onPageLoaded); + } else { + invoke(); + } + }); + }; + + comboBox.addEventListener('vaadin-combo-box-dropdown-opened', resolveFocusSelectedItem); + + // Arm a DataCommunicator reset when the dropdown closes with + // focusSelectedItem enabled. On the next open, the feature scrolls to the + // selected item's position — the same viewport range the server last + // sent — and the server would no-op the fetch, leaving pending callbacks + // unresolved. Forcing a reset on the next setViewportRange RPC ensures + // the server re-sends data for the requested range. + // + // The reset also re-keys items via the data communicator's normal + // passivate-and-unregister flow — items outside the post-reset active + // range have their KeyMapper entries removed and get fresh keys when + // re-fetched. Cached pages in the connector and the data-provider + // controller would then hold items with stale keys that no longer match + // the freshly-keyed selectedItem, breaking selection highlighting (and + // leaving them as targets for the focusSelectedItem RPC's scrollToIndex + // when it sees a real item at the resolved index but with a stale key). + // Wipe all client-side cache state on close so the next open populates + // from the post-reset server data with current keys. + comboBox.addEventListener('opened-changed', (e) => { + if (e.detail.value === false && focusSelectedItemEnabled && comboBox.selectedItem) { + serverFacade.needsDataCommunicatorReset(); + cache = {}; + committedPages.clear(); + comboBox.__dataProviderController.clearCache(); + comboBox._forceNextRequest = true; + } + }); + comboBox.itemClassNameGenerator = function (item) { return item.className || ''; };