Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright 2000-2025 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.stream.IntStream;

import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.NativeButton;
import com.vaadin.flow.component.virtuallist.VirtualList;
import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.router.Route;

@Route("vaadin-virtual-list/virtual-list-focus")
public class VirtualListFocusPage extends Div {

private VirtualList<String> list;
private Div statusDiv;
private boolean firstItemFocused = false;

public VirtualListFocusPage() {
// Create virtual list with items
list = new VirtualList<>();
list.setId("virtual-list");
list.setItems(
IntStream.range(0, 100).mapToObj(i -> "Item " + i).toList());

// Use component renderer with focusable elements
list.setRenderer(new ComponentRenderer<>(item -> {
NativeButton button = new NativeButton(item);
button.setId("item-" + item.replace(" ", "-"));
button.getElement().setAttribute("tabindex", "0");
return button;
}));

// Status div to report focus state
statusDiv = new Div();
statusDiv.setId("status");
statusDiv.setText("Not focused");

// Button to trigger focus on first item
NativeButton focusFirstButton = new NativeButton("Focus First Item",
e -> {
focusFirstItem();
});
focusFirstButton.setId("focus-first-button");

// Button to reset the list (simulating navigation)
NativeButton resetButton = new NativeButton("Reset List", e -> {
resetList();
});
resetButton.setId("reset-button");

add(focusFirstButton, resetButton, statusDiv, list);
}

@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
// Try to focus first item when page loads
focusFirstItem();
}

private void focusFirstItem() {
// Execute focus after a small delay to ensure rendering is complete
getElement().executeJs(
"""
setTimeout(() => {
const list = document.getElementById('virtual-list');
const firstItem = list.querySelector('[id^="item-"]');
if (firstItem) {
firstItem.focus();
document.getElementById('status').textContent = 'First item focused: ' + document.activeElement.id;
return true;
} else {
document.getElementById('status').textContent = 'No item found to focus';
return false;
}
}, 100);
""");
}

private void resetList() {
// Simulate resetting the list (like navigating away and back)
list.setItems(
IntStream.range(0, 100).mapToObj(i -> "Item " + i).toList());
statusDiv.setText("List reset");

// Try to focus first item after reset
getElement().executeJs("""
setTimeout(() => {
document.getElementById('focus-first-button').click();
}, 200);
""");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright 2000-2025 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 org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;

import com.vaadin.flow.testutil.TestPath;
import com.vaadin.tests.AbstractComponentIT;

@TestPath("vaadin-virtual-list/virtual-list-focus")
public class VirtualListFocusIT extends AbstractComponentIT {

@Before
public void init() {
open();
waitForElementPresent(By.id("virtual-list"));
}

@Test
public void firstItemShouldBeFocusableOnInitialLoad() {
// Wait for the virtual list to render items
waitForElementPresent(By.id("item-Item-0"));

// Click the focus button to ensure focus is attempted
WebElement focusButton = findElement(By.id("focus-first-button"));
focusButton.click();

// Wait for status update
waitUntil(driver -> {
WebElement status = findElement(By.id("status"));
String text = status.getText();
return text.contains("First item focused")
|| text.contains("No item found");
});

// Check that first item was successfully focused
WebElement status = findElement(By.id("status"));
Assert.assertTrue(
"First item should be focused on initial load. Status: "
+ status.getText(),
status.getText().contains("First item focused: item-Item-0"));
}

@Test
public void firstItemShouldBeFocusableAfterReset() {
// Wait for initial render
waitForElementPresent(By.id("item-Item-0"));

// Reset the list (simulates navigation)
WebElement resetButton = findElement(By.id("reset-button"));
resetButton.click();

// Wait for the focus attempt after reset
waitUntil(driver -> {
WebElement status = findElement(By.id("status"));
String text = status.getText();
return text.contains("First item focused")
|| text.contains("No item found");
}, 5);

// Verify first item is focused after reset
WebElement status = findElement(By.id("status"));
Assert.assertTrue(
"First item should be focused after reset. Status: "
+ status.getText(),
status.getText().contains("First item focused: item-Item-0"));
}

@Test
public void focusShouldNotBeLostDueToUnnecessaryRangeUpdate() {
// Wait for initial render
waitForElementPresent(By.id("item-Item-0"));

// Focus the first item
WebElement focusButton = findElement(By.id("focus-first-button"));
focusButton.click();

// Wait for focus to be set (includes the 100ms delay from
// focusFirstItem)
waitUntil(driver -> {
WebElement status = findElement(By.id("status"));
return status.getText().contains("First item focused");
});

// Wait a bit more to ensure focus has settled after the async operation
try {
Thread.sleep(200);
} catch (InterruptedException e) {
// Ignore
}

// Execute JavaScript to verify the focused element hasn't changed
// This checks that no unexpected re-render occurred
Object result = executeScript(
"""
const activeId = document.activeElement.id;
return activeId === 'item-Item-0' ? 'still-focused' : 'focus-lost: ' + activeId;
""");

Assert.assertEquals("Focus should remain on first item",
"still-focused", result.toString());

// Verify the element still exists in DOM (wasn't replaced)
WebElement firstItem = findElement(By.id("item-Item-0"));
Assert.assertNotNull("First item should still exist in DOM", firstItem);

// Double-check by attempting to interact with it
Assert.assertTrue("First item should be displayed",
firstItem.isDisplayed());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ window.Vaadin.Flow.virtualListConnector = {

const extraItemsBuffer = 20;

let lastRequestedRange = [0, 0];
let lastRequestedRange = null;

list.$connector = {};
list.$connector.placeholderItem = { __placeholder: true };
Expand All @@ -34,7 +34,7 @@ window.Vaadin.Flow.virtualListConnector = {
let first = Math.max(0, firstNeededItem - extraItemsBuffer);
let last = Math.min(lastNeededItem + extraItemsBuffer, list.items.length);

if (lastRequestedRange[0] != first || lastRequestedRange[1] != last) {
if (lastRequestedRange === null || lastRequestedRange[0] != first || lastRequestedRange[1] != last) {
lastRequestedRange = [first, last];
const count = 1 + last - first;
list.$server.setViewportRange(first, count);
Expand Down