diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml
index 68ac366ae7a..5a77402eeab 100644
--- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml
+++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/pom.xml
@@ -27,6 +27,11 @@
com.vaadinflow-html-components
+
+ com.vaadin
+ vaadin-lumo-theme
+ ${project.version}
+ com.vaadinflow-data
diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/main/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionPage.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/main/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionPage.java
new file mode 100644
index 00000000000..acde304e609
--- /dev/null
+++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/main/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionPage.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2000-2024 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.virtuallist.tests;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H2;
+import com.vaadin.flow.component.html.NativeButton;
+import com.vaadin.flow.component.virtuallist.VirtualList;
+import com.vaadin.flow.component.virtuallist.VirtualList.SelectionMode;
+import com.vaadin.flow.data.provider.DataProvider;
+import com.vaadin.flow.data.renderer.LitRenderer;
+import com.vaadin.flow.data.selection.SelectionListener;
+import com.vaadin.flow.data.selection.SelectionModel;
+import com.vaadin.flow.router.Route;
+
+/**
+ * Test view for {@link VirtualList}
+ *
+ * @author Vaadin Ltd.
+ */
+@Route("vaadin-virtual-list/selection")
+public class VirtualListSelectionPage extends Div {
+
+ public VirtualListSelectionPage() {
+ var list = new VirtualList();
+ list.setHeight("200px");
+
+ var items = createItems();
+ list.setDataProvider(DataProvider.ofCollection(items));
+
+ list.setRenderer(LitRenderer. of("
${item.name}
")
+ .withProperty("name", item -> item.name));
+
+ list.setItemAccessibleNameGenerator(item -> "Accessible " + item.name);
+
+ add(list);
+
+ var selectedIndexes = new Div();
+ selectedIndexes.setHeight("30px");
+ selectedIndexes.setId("selected-indexes");
+ SelectionListener, Item> selectionListener = event -> {
+ selectedIndexes.setText(event.getAllSelectedItems().stream()
+ .map(item -> String.valueOf(items.indexOf(item)))
+ .collect(Collectors.joining(", ")));
+ };
+
+ add(new Div(new H2("Selected item indexes"), selectedIndexes));
+
+ var selectFirstButton = new NativeButton("Select first item", e -> {
+ list.select(items.get(0));
+ });
+ selectFirstButton.setId("select-first");
+
+ var deselectAllButton = new NativeButton("Deselect all", e -> {
+ list.deselectAll();
+ });
+ deselectAllButton.setId("deselect-all");
+
+ add(new Div(new H2("Actions"), selectFirstButton, deselectAllButton));
+
+ var noneSelectionModeButton = new NativeButton("None", e -> {
+ list.setSelectionMode(SelectionMode.NONE);
+ });
+ noneSelectionModeButton.setId("none-selection-mode");
+
+ var singleSelectionModeButton = new NativeButton("Single", e -> {
+ list.setSelectionMode(SelectionMode.SINGLE);
+ list.addSelectionListener(selectionListener);
+ });
+ singleSelectionModeButton.setId("single-selection-mode");
+
+ var singleSelectionModeDeselectionDisallowedButton = new NativeButton(
+ "Single (deselection disallowed)", e -> {
+ var model = list.setSelectionMode(SelectionMode.SINGLE);
+ ((SelectionModel.Single, Item>) model)
+ .setDeselectAllowed(false);
+ list.addSelectionListener(selectionListener);
+ });
+ singleSelectionModeDeselectionDisallowedButton
+ .setId("single-selection-mode-deselection-disallowed");
+
+ var multiSelectionModeButton = new NativeButton("Multi", e -> {
+ list.setSelectionMode(SelectionMode.MULTI);
+ list.addSelectionListener(selectionListener);
+ });
+ multiSelectionModeButton.setId("multi-selection-mode");
+
+ add(new Div(new H2("Selection mode"), noneSelectionModeButton,
+ singleSelectionModeButton,
+ singleSelectionModeDeselectionDisallowedButton,
+ multiSelectionModeButton));
+
+ }
+
+ private List createItems() {
+ return IntStream.range(0, 1000).mapToObj(i -> new Item("Item " + i, i))
+ .collect(Collectors.toList());
+ }
+
+ public static record Item(String name, int value) {
+ };
+
+}
diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionIT.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionIT.java
new file mode 100644
index 00000000000..8fb42233d2b
--- /dev/null
+++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow-integration-tests/src/test/java/com/vaadin/flow/component/virtuallist/tests/VirtualListSelectionIT.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2000-2024 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.virtuallist.tests;
+
+import java.util.Set;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.openqa.selenium.By;
+
+import com.vaadin.flow.component.virtuallist.testbench.VirtualListElement;
+import com.vaadin.flow.testutil.TestPath;
+import com.vaadin.testbench.TestBenchElement;
+import com.vaadin.tests.AbstractComponentIT;
+
+@TestPath("vaadin-virtual-list/selection")
+public class VirtualListSelectionIT extends AbstractComponentIT {
+ private VirtualListElement virtualList;
+ private TestBenchElement singleSelectionModeButton;
+ private TestBenchElement singleSelectionModeDeselectionDisallowedButton;
+ private TestBenchElement multiSelectionModeButton;
+ private TestBenchElement selectFirstButton;
+ private TestBenchElement deselectAllButton;
+ private TestBenchElement selectedIndexes;
+
+ @Before
+ public void init() {
+ open();
+ virtualList = $(VirtualListElement.class).waitForFirst();
+ singleSelectionModeButton = $("button").id("single-selection-mode");
+ singleSelectionModeDeselectionDisallowedButton = $("button")
+ .id("single-selection-mode-deselection-disallowed");
+ multiSelectionModeButton = $("button").id("multi-selection-mode");
+ selectFirstButton = $("button").id("select-first");
+ deselectAllButton = $("button").id("deselect-all");
+ selectedIndexes = $("div").id("selected-indexes");
+ }
+
+ @Test
+ public void select_shouldNotSelect() {
+ virtualList.select(0);
+
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ }
+
+ @Test
+ public void singleSelectionMode_select() {
+ singleSelectionModeButton.click();
+ virtualList.select(0);
+ Assert.assertTrue(virtualList.isRowSelected(0));
+ }
+
+ @Test
+ public void singleSelectionMode_selectAnother() {
+ singleSelectionModeButton.click();
+ virtualList.select(0);
+ virtualList.select(1);
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ Assert.assertTrue(virtualList.isRowSelected(1));
+ }
+
+ @Test
+ public void singleSelectionMode_deselect() {
+ singleSelectionModeButton.click();
+ virtualList.select(0);
+ virtualList.deselect(0);
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ }
+
+ @Test
+ public void singleSelectionModeDeselectionDisallowed_deselectionNotAllowed() {
+ singleSelectionModeDeselectionDisallowedButton.click();
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ virtualList.select(0);
+ virtualList.deselect(0);
+ Assert.assertTrue(virtualList.isRowSelected(0));
+ }
+
+ @Test
+ public void multiSelectionMode_selectMultiple() {
+ multiSelectionModeButton.click();
+ virtualList.select(0);
+ virtualList.select(2);
+ Assert.assertTrue(virtualList.isRowSelected(0));
+ Assert.assertFalse(virtualList.isRowSelected(1));
+ Assert.assertTrue(virtualList.isRowSelected(2));
+ }
+
+ @Test
+ public void multiSelectionMode_deselect() {
+ multiSelectionModeButton.click();
+ virtualList.select(0);
+ virtualList.select(2);
+ virtualList.select(1);
+ virtualList.deselect(0);
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ Assert.assertTrue(virtualList.isRowSelected(1));
+ Assert.assertTrue(virtualList.isRowSelected(2));
+ }
+
+ @Test
+ public void multiSelectionMode_serverSideSelection() {
+ multiSelectionModeButton.click();
+ selectFirstButton.click();
+ virtualList.select(3);
+ virtualList.select(80);
+ virtualList.deselect(0);
+
+ var selectedIndexesSet = Set.of(selectedIndexes.getText().split(", "));
+ Assert.assertEquals(Set.of("3", "80"), selectedIndexesSet);
+ }
+
+ @Test
+ public void programmaticSelection() {
+ singleSelectionModeButton.click();
+ selectFirstButton.click();
+ Assert.assertTrue(virtualList.isRowSelected(0));
+
+ var selectedIndexesSet = Set.of(selectedIndexes.getText().split(", "));
+ Assert.assertEquals(Set.of("0"), selectedIndexesSet);
+
+ deselectAllButton.click();
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ Assert.assertTrue(selectedIndexes.getText().isEmpty());
+ }
+
+ @Test
+ public void accessibleName() {
+ var firstChildElement = virtualList
+ .findElement(By.xpath("child::div[@aria-posinset='1']"));
+ Assert.assertEquals("Accessible Item 0",
+ firstChildElement.getAttribute("aria-label"));
+ }
+
+ @Test
+ public void changeSelectionMode_resetSelection() {
+ singleSelectionModeButton.click();
+ virtualList.select(0);
+ multiSelectionModeButton.click();
+ Assert.assertFalse(virtualList.isRowSelected(0));
+ }
+
+}
diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml
index 1c5b7a9253d..37e6be9de79 100644
--- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml
+++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/pom.xml
@@ -21,6 +21,11 @@
vaadin-renderer-flow${project.version}
+
+ org.mockito
+ mockito-core
+ test
+ com.vaadinflow-test-generic
diff --git a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java
index a6f28ab6f42..2c9e945c761 100644
--- a/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java
+++ b/vaadin-virtual-list-flow-parent/vaadin-virtual-list-flow/src/main/java/com/vaadin/flow/component/virtuallist/VirtualList.java
@@ -19,9 +19,14 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import com.vaadin.flow.component.ClientCallable;
import com.vaadin.flow.component.Component;
+import com.vaadin.flow.component.ComponentEventListener;
+import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.Focusable;
import com.vaadin.flow.component.HasSize;
import com.vaadin.flow.component.HasStyle;
@@ -30,6 +35,7 @@
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.virtuallist.paging.PagelessDataCommunicator;
+import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.binder.HasDataProvider;
import com.vaadin.flow.data.provider.ArrayUpdater;
import com.vaadin.flow.data.provider.ArrayUpdater.Update;
@@ -39,13 +45,22 @@
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.data.renderer.LitRenderer;
import com.vaadin.flow.data.renderer.Renderer;
+import com.vaadin.flow.data.selection.MultiSelect;
+import com.vaadin.flow.data.selection.MultiSelectionEvent;
+import com.vaadin.flow.data.selection.SelectionListener;
+import com.vaadin.flow.data.selection.SelectionModel;
+import com.vaadin.flow.data.selection.SelectionModel.Single;
+import com.vaadin.flow.data.selection.SingleSelect;
+import com.vaadin.flow.data.selection.SingleSelectionEvent;
import com.vaadin.flow.dom.DisabledUpdateMode;
+import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.function.ValueProvider;
import com.vaadin.flow.internal.JsonUtils;
import com.vaadin.flow.server.Command;
import com.vaadin.flow.shared.Registration;
import elemental.json.Json;
+import elemental.json.JsonArray;
import elemental.json.JsonValue;
/**
@@ -119,6 +134,10 @@ public void initialize() {
private Renderer renderer;
+ private SelectionMode selectionMode;
+ private SelectionModel, T> selectionModel;
+ private SerializableFunction itemAccessibleNameGenerator = item -> null;
+
private final CompositeDataGenerator dataGenerator = new CompositeDataGenerator<>();
private final List renderingRegistrations = new ArrayList<>();
private transient T placeholderItem;
@@ -134,6 +153,11 @@ public void initialize() {
public VirtualList() {
setRenderer((ValueProvider) String::valueOf);
addAttachListener((e) -> this.setPlaceholderItem(this.placeholderItem));
+
+ // Use NONE selection mode by default
+ setSelectionMode(SelectionMode.NONE);
+
+ initSelection();
}
private void initConnector() {
@@ -144,6 +168,42 @@ private void initConnector() {
getElement());
}
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ private void initSelection() {
+ // Generate "selected" property for selected items
+ dataGenerator.addDataGenerator((item, jsonObject) -> {
+ if (this.getSelectionModel().isSelected(item)) {
+ jsonObject.put("selected", true);
+ }
+ var accessibleName = this.itemAccessibleNameGenerator.apply(item);
+ if (accessibleName != null) {
+ jsonObject.put("accessibleName", accessibleName);
+ }
+ });
+
+ // Set up SingleSelectionEvent and MultiSelectionEvent listeners to
+ // refresh the items in data communicator when selection changes. This
+ // is to ensure the data generator labels selected items with "selected"
+ // property on selection change.
+ ComponentUtil.addListener(this, SingleSelectionEvent.class,
+ (ComponentEventListener) ((ComponentEventListener, T>>) event -> {
+ Stream.of(event.getValue(), event.getOldValue())
+ .filter(Objects::nonNull)
+ .forEach(item -> getDataCommunicator()
+ .refresh((T) item));
+ }));
+
+ ComponentUtil.addListener(this, MultiSelectionEvent.class,
+ (ComponentEventListener) ((ComponentEventListener, T>>) event -> {
+ Stream.concat(event.getAddedSelection().stream(),
+ event.getRemovedSelection().stream())
+ .filter(Objects::nonNull)
+ .forEach(item -> getDataCommunicator()
+ .refresh((T) item));
+ }));
+
+ }
+
@Override
public void setDataProvider(DataProvider dataProvider) {
Objects.requireNonNull(dataProvider, "The dataProvider cannot be null");
@@ -324,4 +384,289 @@ public void scrollToStart() {
public void scrollToEnd() {
scrollToIndex(Integer.MAX_VALUE);
}
+
+ /**
+ * Returns the selection mode for this virtual list.
+ *
+ * @return the selection mode, not null
+ */
+ public SelectionMode getSelectionMode() {
+ assert selectionMode != null : "No selection mode set by "
+ + getClass().getName() + " constructor";
+ return selectionMode;
+ }
+
+ /**
+ * Sets the virtual list's selection mode.
+ *
+ * @param selectionMode
+ * the selection mode to switch to, not {@code null}
+ * @return the used selection model
+ *
+ * @see SelectionMode
+ * @see VirtualListSingleSelectionModel
+ * @see VirtualListMultiSelectionModel
+ * @see VirtualListNoneSelectionModel
+ */
+ public SelectionModel, T> setSelectionMode(
+ SelectionMode selectionMode) {
+ Objects.requireNonNull(selectionMode, "Selection mode cannot be null.");
+
+ if (selectionMode == SelectionMode.SINGLE) {
+ setSelectionModel(new VirtualListSingleSelectionModel<>(this),
+ selectionMode);
+ } else if (selectionMode == SelectionMode.MULTI) {
+ setSelectionModel(new VirtualListMultiSelectionModel<>(this),
+ selectionMode);
+ } else {
+ setSelectionModel(new VirtualListNoneSelectionModel<>(),
+ selectionMode);
+ }
+ return selectionModel;
+ }
+
+ private Set getItemsFromKeys(JsonArray keys) {
+ return JsonUtils.stream(keys).map(
+ key -> getDataCommunicator().getKeyMapper().get(key.asString()))
+ .filter(Objects::nonNull).collect(Collectors.toSet());
+ }
+
+ @SuppressWarnings("unchecked")
+ @ClientCallable
+ private void updateSelection(JsonArray addedKeys, JsonArray removedKeys) {
+ var addedItems = getItemsFromKeys(addedKeys);
+ var removedItems = getItemsFromKeys(removedKeys);
+
+ if (selectionModel instanceof VirtualListSingleSelectionModel model) {
+ model.setSelectedItem(
+ addedItems.isEmpty() ? null : addedItems.iterator().next());
+ } else if (selectionModel instanceof VirtualListMultiSelectionModel model) {
+ model.updateSelection(addedItems, removedItems);
+ }
+ }
+
+ /**
+ * Sets the selection model for the virtual list.
+ *