diff --git a/dev/virtual-list.html b/dev/virtual-list.html
index 06a5134b0d4..3ee59593e72 100644
--- a/dev/virtual-list.html
+++ b/dev/virtual-list.html
@@ -1,4 +1,4 @@
-
+
@@ -9,23 +9,70 @@
+
+
+
+
+
+
+
+
diff --git a/packages/virtual-list/package.json b/packages/virtual-list/package.json
index ac0d2189596..b3e2ac85c81 100644
--- a/packages/virtual-list/package.json
+++ b/packages/virtual-list/package.json
@@ -39,6 +39,7 @@
"dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0",
+ "@vaadin/a11y-base": "24.7.0-alpha1",
"@vaadin/component-base": "24.7.0-alpha1",
"@vaadin/lit-renderer": "24.7.0-alpha1",
"@vaadin/vaadin-lumo-styles": "24.7.0-alpha1",
diff --git a/packages/virtual-list/src/vaadin-lit-virtual-list.js b/packages/virtual-list/src/vaadin-lit-virtual-list.js
index f5b8bb2db5f..1547eb3fce2 100644
--- a/packages/virtual-list/src/vaadin-lit-virtual-list.js
+++ b/packages/virtual-list/src/vaadin-lit-virtual-list.js
@@ -34,6 +34,8 @@ class VirtualList extends VirtualListMixin(ThemableMixin(ElementMixin(PolylitMix
+
+
`;
}
}
diff --git a/packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts b/packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts
index d19c2be17af..2539258be9e 100644
--- a/packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts
+++ b/packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts
@@ -3,15 +3,16 @@
* Copyright (c) 2021 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
-import type { Constructor } from '@open-wc/dedupe-mixin';
import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js';
import type { VirtualList } from './vaadin-virtual-list.js';
+import type { VirtualListSelectionMixinClass } from './vaadin-virtual-list-selection-mixin.js';
export type VirtualListDefaultItem = any;
export interface VirtualListItemModel {
index: number;
item: TItem;
+ selected?: boolean;
}
export type VirtualListRenderer = (
@@ -20,11 +21,18 @@ export type VirtualListRenderer = (
model: VirtualListItemModel,
) => void;
-export declare function VirtualListMixin>(
- base: T,
-): Constructor & Constructor> & T;
+/**
+ * Fired when the `selectedItems` property changes.
+ */
+export type VirtualListSelectedItemsChangedEvent = CustomEvent<{ value: TItem[] }>;
+
+export interface VirtualListCustomEventMap {
+ 'selected-items-changed': VirtualListSelectedItemsChangedEvent;
+}
+
+export interface VirtualListEventMap extends HTMLElementEventMap, VirtualListCustomEventMap {}
-export declare class VirtualListMixinClass {
+export declare class VirtualListBaseMixinClass {
/**
* Gets the index of the first visible item in the viewport.
*/
@@ -71,3 +79,8 @@ export declare class VirtualListMixinClass {
*/
requestContentUpdate(): void;
}
+
+export interface VirtualListMixinClass
+ extends VirtualListBaseMixinClass,
+ ControllerMixinClass,
+ VirtualListSelectionMixinClass {}
diff --git a/packages/virtual-list/src/vaadin-virtual-list-mixin.js b/packages/virtual-list/src/vaadin-virtual-list-mixin.js
index 0b583009702..e5f3832184c 100644
--- a/packages/virtual-list/src/vaadin-virtual-list-mixin.js
+++ b/packages/virtual-list/src/vaadin-virtual-list-mixin.js
@@ -7,13 +7,14 @@ import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'
import { OverflowController } from '@vaadin/component-base/src/overflow-controller.js';
import { processTemplates } from '@vaadin/component-base/src/templates.js';
import { Virtualizer } from '@vaadin/component-base/src/virtualizer.js';
+import { SelectionMixin } from './vaadin-virtual-list-selection-mixin.js';
/**
* @polymerMixin
* @mixes ControllerMixin
*/
-export const VirtualListMixin = (superClass) =>
- class VirtualListMixinClass extends ControllerMixin(superClass) {
+export const VirtualListBaseMixin = (superClass) =>
+ class extends ControllerMixin(superClass) {
static get properties() {
return {
/**
@@ -144,7 +145,7 @@ export const VirtualListMixin = (superClass) =>
}
if (this.renderer) {
- this.renderer(el, this, { item, index });
+ this.renderer(el, this, this.__getItemModel(index));
}
}
@@ -153,6 +154,13 @@ export const VirtualListMixin = (superClass) =>
el.role = 'listitem';
}
+ /**
+ * @private
+ */
+ __getItemModel(index) {
+ return { index, item: this.items[index] };
+ }
+
/**
* Clears the content of a render target.
* @private
@@ -213,3 +221,10 @@ export const VirtualListMixin = (superClass) =>
}
}
};
+
+/**
+ * @polymerMixin
+ * @mixes SelectionMixin
+ * @mixes VirtualListBaseMixin
+ */
+export const VirtualListMixin = (superClass) => class extends SelectionMixin(VirtualListBaseMixin(superClass)) {};
diff --git a/packages/virtual-list/src/vaadin-virtual-list-selection-mixin.d.ts b/packages/virtual-list/src/vaadin-virtual-list-selection-mixin.d.ts
new file mode 100644
index 00000000000..f191459e951
--- /dev/null
+++ b/packages/virtual-list/src/vaadin-virtual-list-selection-mixin.d.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (c) 2021 - 2024 Vaadin Ltd.
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
+ */
+import type { Constructor } from '@open-wc/dedupe-mixin';
+import type { VirtualListDefaultItem } from './vaadin-virtual-list.js';
+
+export declare function VirtualListSelectionMixin>(
+ base: T,
+): Constructor & T;
+
+export declare class VirtualListSelectionMixinClass {
+ /**
+ * Selection mode for the virtual list. Available modes are: `none`, `single` and `multi`.
+ */
+ selectionMode: 'none' | 'single' | 'multi';
+
+ /**
+ * Path to an item sub-property that identifies the item.
+ * @attr {string} item-id-path
+ */
+ itemIdPath: string | null | undefined;
+
+ /**
+ * An array that contains the selected items.
+ */
+ selectedItems: TItem[];
+}
diff --git a/packages/virtual-list/src/vaadin-virtual-list-selection-mixin.js b/packages/virtual-list/src/vaadin-virtual-list-selection-mixin.js
new file mode 100644
index 00000000000..fbb3e09c9ed
--- /dev/null
+++ b/packages/virtual-list/src/vaadin-virtual-list-selection-mixin.js
@@ -0,0 +1,471 @@
+/**
+ * @license
+ * Copyright (c) 2016 - 2024 Vaadin Ltd.
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
+ */
+
+import { getFocusableElements, isKeyboardActive } from '@vaadin/a11y-base';
+import { timeOut } from '@vaadin/component-base/src/async.js';
+import { Debouncer } from '@vaadin/component-base/src/debounce.js';
+import { get } from '@vaadin/component-base/src/path-utils.js';
+
+/**
+ * @polymerMixin
+ */
+export const SelectionMixin = (superClass) =>
+ class SelectionMixin extends superClass {
+ static get properties() {
+ return {
+ /**
+ * Selection mode for the virtual list. Available modes are: `none`, `single` and `multi`.
+ * @attr {string} selection-mode
+ */
+ selectionMode: {
+ type: String,
+ observer: '__selectionModeChanged',
+ value: 'none',
+ },
+
+ /**
+ * Path to an item sub-property that identifies the item.
+ * @attr {string} item-id-path
+ */
+ itemIdPath: {
+ type: String,
+ value: null,
+ sync: true,
+ },
+
+ /**
+ * An array that contains the selected items.
+ * @type {!Array}
+ */
+ selectedItems: {
+ type: Array,
+ notify: true,
+ value: () => [],
+ sync: true,
+ },
+
+ /**
+ * Set of selected item ids
+ * @private
+ */
+ __selectedKeys: {
+ type: Object,
+ computed: '__computeSelectedKeys(itemIdPath, selectedItems)',
+ },
+
+ /**
+ * The index of the focused item.
+ * @private
+ */
+ __focusIndex: {
+ type: Number,
+ value: 0,
+ sync: true,
+ },
+ };
+ }
+
+ static get observers() {
+ return ['__selectionChanged(itemIdPath, selectedItems)'];
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener('keydown', (e) => this.__onKeyDown(e));
+ this.addEventListener('click', (e) => this.__onClick(e));
+ this.addEventListener('focusin', (e) => this.__onFocusIn(e));
+ this.addEventListener('focusout', (e) => this.__onFocusOut(e));
+ }
+
+ ready() {
+ super.ready();
+
+ this._createPropertyObserver('items', '__selectionItemsUpdated');
+ }
+
+ /** @private */
+ get __isSelectable() {
+ return this.selectionMode !== 'none';
+ }
+
+ /**
+ * @private
+ * @override
+ */
+ __updateElement(el, index) {
+ const item = this.items[index];
+ el.__item = item;
+ el.__index = index;
+
+ el.toggleAttribute('selected', this.__isSelected(item));
+
+ const ariaSelected = this.__isSelectable ? String(this.__isSelected(item)) : null;
+ this.__updateArieaSelected(el, ariaSelected);
+
+ const isFocusable = this.__isNavigating() && this.__focusIndex === index;
+ el.tabIndex = isFocusable ? 0 : -1;
+
+ const isFocused = this.__isSelectable && this.__focusIndex === index && el.contains(this.__getActiveElement());
+ el.toggleAttribute('focused', isFocused);
+
+ super.__updateElement(el, index);
+ }
+
+ __updateArieaSelected(el, selected) {
+ if (this.selectionMode === 'single') {
+ // aria-selected must be applied this way to have VO announce it correctly on single-select mode
+ el.ariaSelected = null;
+ setTimeout(() => {
+ el.ariaSelected = selected;
+ });
+ } else {
+ el.ariaSelected = selected;
+ }
+ }
+
+ /**
+ * @private
+ * @override
+ */
+ __updateElementRole(el) {
+ if (this.__isSelectable) {
+ el.role = 'option';
+ } else {
+ super.__updateElementRole(el);
+ }
+ }
+
+ /**
+ * @private
+ * @override
+ */
+ __getItemModel(index) {
+ // Include "selected" property in the model passed to the renderer
+ return {
+ ...super.__getItemModel(index),
+ selected: this.__isSelected(this.items[index]),
+ };
+ }
+
+ /** @private */
+ __selectionModeChanged() {
+ this.__updateAria();
+ this.__updateNavigating(true);
+ }
+
+ /**
+ * @private
+ * @override
+ */
+ __updateAria() {
+ this.role = this.__isSelectable ? 'listbox' : 'list';
+ this.ariaMultiSelectable = this.selectionMode === 'multi' ? 'true' : null;
+ }
+
+ /** @private */
+ __updateFocusIndex(index) {
+ this.__focusIndex = Math.max(0, Math.min(index, (this.items || []).length - 1));
+ }
+
+ /** @private */
+ __selectionItemsUpdated() {
+ if (!this.__isSelectable) {
+ return;
+ }
+
+ const oldFocusIndex = this.__focusIndex;
+ this.__updateFocusIndex(this.__focusIndex);
+ if (oldFocusIndex !== this.__focusIndex) {
+ this.__scheduleContentUpdate();
+ }
+ // Items may have been emptied, need to update focusability
+ this.__updateFocusable();
+ }
+
+ /** @private */
+ __isSelected(item) {
+ return this.__selectedKeys.has(this.__getItemId(item));
+ }
+
+ /** @private */
+ __selectionChanged() {
+ this.__scheduleContentUpdate();
+ }
+
+ /** @private */
+ __computeSelectedKeys(_itemIdPath, selectedItems) {
+ return new Set((selectedItems || []).map((item) => this.__getItemId(item)));
+ }
+
+ /** @private */
+ __itemsEqual(item1, item2) {
+ return this.__getItemId(item1) === this.__getItemId(item2);
+ }
+
+ /** @private */
+ __getItemId(item) {
+ return this.itemIdPath ? get(this.itemIdPath, item) : item;
+ }
+
+ /** @private */
+ __toggleSelection(item) {
+ if (item === undefined) {
+ return;
+ }
+
+ if (this.selectionMode === 'single') {
+ this.selectedItems = this.__isSelected(item) ? [] : [item];
+ } else if (this.selectionMode === 'multi') {
+ this.selectedItems = this.__isSelected(item)
+ ? this.selectedItems.filter((selectedItem) => !this.__itemsEqual(selectedItem, item))
+ : [...this.selectedItems, item];
+ }
+ }
+
+ /** @private */
+ __ensureFocusedIndexInView() {
+ const focusElement = this.__getRenderedFocusIndexElement();
+ if (!focusElement) {
+ // The focused element is not rendered, scroll to the focused index
+ this.scrollToIndex(this.__focusIndex);
+ } else {
+ // The focused element is rendered. If it's not inside the visible area, scroll to it
+ const listRect = this.getBoundingClientRect();
+ const elementRect = focusElement.getBoundingClientRect();
+ if (elementRect.top < listRect.top) {
+ this.scrollTop -= listRect.top - elementRect.top;
+ } else if (elementRect.bottom > listRect.bottom) {
+ this.scrollTop += elementRect.bottom - listRect.bottom;
+ }
+ }
+ }
+
+ /** @private */
+ __focusElementWithFocusIndex() {
+ this.__ensureFocusedIndexInView();
+ const focusElement = this.__getRenderedFocusIndexElement();
+ if (focusElement) {
+ focusElement.focus();
+ }
+ }
+
+ /** @private */
+ __getRenderedRootElements() {
+ return [...this.children].filter((el) => !el.hidden);
+ }
+
+ /**
+ * Returns the rendered root element with the current focus index.
+ * @private
+ */
+ __getRenderedFocusIndexElement() {
+ return this.__getRenderedRootElements().find((el) => el.__index === this.__focusIndex);
+ }
+
+ /**
+ * Returns the rendered root element which contains focus.
+ * @private
+ */
+ __getRootElementWithFocus() {
+ return this.__getRenderedRootElements().find((el) => el.contains(this.__getActiveElement()));
+ }
+
+ /**
+ * Returns the rendered root element matching or containing the given child element.
+ * @private
+ */
+ __getRootElementByContent(element) {
+ return this.__getRenderedRootElements().find((el) => el.contains(element));
+ }
+
+ /** @private */
+ __isNavigating() {
+ return !!this.__navigating;
+ }
+
+ /** @private */
+ __updateNavigating(navigating) {
+ this.__navigating = this.__isSelectable && navigating;
+ this.toggleAttribute(
+ 'navigating',
+ this.__navigating && isKeyboardActive() && this.contains(this.__getActiveElement()),
+ );
+
+ const isInteracting = this.__isSelectable && !navigating;
+ this.toggleAttribute('interacting', isInteracting);
+
+ this.__updateFocusable();
+ this.__scheduleContentUpdate();
+ }
+
+ /** @private */
+ __updateFocusable() {
+ const isFocusable = !!(this.__isNavigating() && this.items && this.items.length);
+ if (this.__isSelectable) {
+ this.tabIndex = isFocusable ? 0 : -1;
+ } else {
+ this.removeAttribute('tabindex');
+ }
+ this.$.focusexit.hidden = !isFocusable;
+ }
+
+ /** @private */
+ __getActiveElement() {
+ return this.getRootNode().activeElement;
+ }
+
+ /** @private */
+ __onKeyDown(e) {
+ if (e.defaultPrevented || !this.__isSelectable) {
+ return;
+ }
+
+ if (this.__isNavigating()) {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ this.__onNavigationArrowKey(e.key === 'ArrowDown');
+ } else if (e.key === 'Enter') {
+ this.__onNavigationEnterKey();
+ } else if (e.key === ' ') {
+ e.preventDefault();
+ this.__onNavigationSpaceKey();
+ } else if (e.key === 'Tab') {
+ this.__onNavigationTabKey(e.shiftKey);
+ }
+ } else if (e.key === 'Escape') {
+ // Prevent closing a dialog etc. when returning to navigation mode on Escape
+ e.preventDefault();
+ e.stopPropagation();
+ this.__getRootElementWithFocus().focus();
+ }
+ }
+
+ /** @private */
+ __onNavigationArrowKey(down) {
+ this.__updateFocusIndex(this.__focusIndex + (down ? 1 : -1));
+ this.__focusElementWithFocusIndex();
+
+ // Flush the virtualizer's element reordering to have VO announce the element's pos-in-set correctly
+ // when navigating with arrow keys
+ this.__virtualizer.flush();
+
+ if (this.__debounceRequestContentUpdate) {
+ // Render synchronously to avoid jumpiness when navigating near viewport edges
+ this.__debounceRequestContentUpdate.flush();
+ }
+ }
+
+ /** @private */
+ __scheduleContentUpdate() {
+ this.__debounceRequestContentUpdate = Debouncer.debounce(
+ this.__debounceRequestContentUpdate,
+ timeOut.after(0),
+ () => {
+ this.requestContentUpdate();
+ },
+ );
+ }
+
+ /** @private */
+ __onNavigationTabKey(shift) {
+ if (shift) {
+ // Focus the virtual list itself when shift-tabbing so the focus actually ends
+ // up on the previous element in the tab order before the virtual list
+ // instead of some focusable child on another row.
+ this.focus();
+ } else {
+ // Focus the focus exit element when tabbing so the focus actually ends up on
+ // the next element in the tab order after the virtual list instead of some focusable child on another row.
+ this.$.focusexit.focus();
+ }
+ }
+
+ /** @private */
+ __onNavigationSpaceKey() {
+ // Ensure the focused item is in view and focused before toggling selection
+ this.__focusElementWithFocusIndex();
+ this.__toggleSelection(this.__getRenderedFocusIndexElement().__item);
+ }
+
+ /** @private */
+ __onNavigationEnterKey() {
+ // Get the focused item
+ const focusedItem = this.querySelector('[focused]');
+ if (!focusedItem) {
+ return;
+ }
+ // Find the first focusable element in the item and focus it
+ const focusableElement = getFocusableElements(focusedItem).find((el) => el !== focusedItem);
+ if (!focusableElement) {
+ return;
+ }
+ focusableElement.focus();
+ }
+
+ /** @private */
+ __onClick(e) {
+ if (!this.__isSelectable || !this.__isNavigating()) {
+ return;
+ }
+ if (this.__getActiveElement() === this) {
+ // If the virtual list itself is clicked, focus the root element matching focus index
+ this.__focusElementWithFocusIndex();
+ }
+
+ const clickedRootElement = this.__getRootElementByContent(e.target);
+ if (clickedRootElement) {
+ this.__updateFocusIndex(clickedRootElement.__index);
+ this.__toggleSelection(clickedRootElement.__item);
+ }
+
+ if (this.hasAttribute('navigating')) {
+ this.__updateNavigating(this.__isNavigating());
+ }
+ }
+
+ /** @private */
+ __onFocusIn(e) {
+ if (!this.__isSelectable) {
+ return;
+ }
+
+ // Set navigating state if one of the root elements, virtual-list or focusexit, is focused
+ // Set interacting state otherwise (child element is focused)
+ const navigating = [...this.children, this, this.$.focusexit].includes(e.target);
+ if (navigating || this.__isNavigating()) {
+ this.__updateNavigating(navigating);
+ }
+
+ // Update focus index based on the focused item
+ const rootElement = this.__getRootElementWithFocus();
+ if (rootElement) {
+ this.__updateFocusIndex(rootElement.__index);
+ }
+
+ // Focus the root element matching focus index if focus came from outside
+ if (navigating && !this.contains(e.relatedTarget)) {
+ this.__focusElementWithFocusIndex();
+ }
+ }
+
+ /** @private */
+ __onFocusOut(e) {
+ if (!this.__isSelectable) {
+ return;
+ }
+ if (!this.contains(e.relatedTarget)) {
+ // If the focus leaves the virtual list, restore navigating state
+ this.__updateNavigating(true);
+ }
+ }
+
+ /**
+ * Fired when the `selectedItems` property changes.
+ *
+ * @event selected-items-changed
+ */
+ };
diff --git a/packages/virtual-list/src/vaadin-virtual-list-styles.js b/packages/virtual-list/src/vaadin-virtual-list-styles.js
index b899d4940f1..06ffca1f532 100644
--- a/packages/virtual-list/src/vaadin-virtual-list-styles.js
+++ b/packages/virtual-list/src/vaadin-virtual-list-styles.js
@@ -25,4 +25,9 @@ export const virtualListStyles = css`
#items {
position: relative;
}
+
+ #focusexit {
+ position: absolute;
+ top: 0;
+ }
`;
diff --git a/packages/virtual-list/src/vaadin-virtual-list.d.ts b/packages/virtual-list/src/vaadin-virtual-list.d.ts
index 04c88d0f54a..926aedad0f6 100644
--- a/packages/virtual-list/src/vaadin-virtual-list.d.ts
+++ b/packages/virtual-list/src/vaadin-virtual-list.d.ts
@@ -7,12 +7,11 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import type {
VirtualListDefaultItem,
- VirtualListItemModel,
+ VirtualListEventMap,
VirtualListMixinClass,
- VirtualListRenderer,
} from './vaadin-virtual-list-mixin.js';
-export { VirtualListDefaultItem, VirtualListItemModel, VirtualListRenderer };
+export * from './vaadin-virtual-list-mixin.js';
/**
* `` is a Web Component for displaying a virtual/infinite list of items.
@@ -33,14 +32,31 @@ export { VirtualListDefaultItem, VirtualListItemModel, VirtualListRenderer };
*
* Attribute | Description
* -----------------|--------------------------------------------
- * `overflow` | Set to `top`, `bottom`, both, or none.
+ * `overflow` | Set to `top`, `bottom`, both, or none
+ * `interacting` | Keyboard navigation in interaction mode
+ * `navigating` | Keyboard navigation in navigation mode
+ * `selected` | Set on a child element when the item is selected
+ * `focused` | Set on a child element when the item is focused
*
* See [Virtual List](https://vaadin.com/docs/latest/components/virtual-list) documentation.
+ *
+ * @fires {CustomEvent} selected-items-changed - Fired when the `selectedItems` property changes.
*/
declare class VirtualList extends ThemableMixin(ElementMixin(HTMLElement)) {}
-// eslint-disable-next-line @typescript-eslint/no-empty-object-type
-interface VirtualList extends VirtualListMixinClass {}
+interface VirtualList extends VirtualListMixinClass {
+ addEventListener>(
+ type: K,
+ listener: (this: VirtualList, ev: VirtualListEventMap[K]) => void,
+ options?: AddEventListenerOptions | boolean,
+ ): void;
+
+ removeEventListener>(
+ type: K,
+ listener: (this: VirtualList, ev: VirtualListEventMap[K]) => void,
+ options?: EventListenerOptions | boolean,
+ ): void;
+}
declare global {
interface HTMLElementTagNameMap {
diff --git a/packages/virtual-list/src/vaadin-virtual-list.js b/packages/virtual-list/src/vaadin-virtual-list.js
index 6da97d3c130..e8a1db9f208 100644
--- a/packages/virtual-list/src/vaadin-virtual-list.js
+++ b/packages/virtual-list/src/vaadin-virtual-list.js
@@ -31,10 +31,16 @@ registerStyles('vaadin-virtual-list', virtualListStyles, { moduleId: 'vaadin-vir
*
* Attribute | Description
* -----------------|--------------------------------------------
- * `overflow` | Set to `top`, `bottom`, both, or none.
+ * `overflow` | Set to `top`, `bottom`, both, or none
+ * `interacting` | Keyboard navigation in interaction mode
+ * `navigating` | Keyboard navigation in navigation mode
+ * `selected` | Set on a child element when the item is selected
+ * `focused` | Set on a child element when the item is focused
*
* See [Virtual List](https://vaadin.com/docs/latest/components/virtual-list) documentation.
*
+ * @fires {CustomEvent} selected-items-changed - Fired when the `selectedItems` property changes.
+ *
* @customElement
* @extends HTMLElement
* @mixes ElementMixin
@@ -47,6 +53,8 @@ class VirtualList extends ElementMixin(ThemableMixin(VirtualListMixin(PolymerEle
+
+
`;
}
diff --git a/packages/virtual-list/test/lit-renderer-directives.common.js b/packages/virtual-list/test/lit-renderer-directives.common.js
index 8a919bb713e..ec4fe842f4f 100644
--- a/packages/virtual-list/test/lit-renderer-directives.common.js
+++ b/packages/virtual-list/test/lit-renderer-directives.common.js
@@ -71,6 +71,7 @@ describe('lit renderer directive', () => {
expect(rendererSpy.firstCall.args[1]).to.deep.equal({
item: 'Item',
index: 0,
+ selected: false,
});
});
diff --git a/packages/virtual-list/test/typings/virtual-list.types.ts b/packages/virtual-list/test/typings/virtual-list.types.ts
index b70ee662ac9..eaf3886a261 100644
--- a/packages/virtual-list/test/typings/virtual-list.types.ts
+++ b/packages/virtual-list/test/typings/virtual-list.types.ts
@@ -1,4 +1,5 @@
import '../../vaadin-virtual-list.js';
+import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js';
import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js';
import type { ThemableMixinClass } from '@vaadin/vaadin-themable-mixin';
import type { VirtualList, VirtualListItemModel, VirtualListRenderer } from '../../vaadin-virtual-list.js';
@@ -11,6 +12,7 @@ assertType(genericVirtualList);
assertType(genericVirtualList);
assertType(genericVirtualList);
+assertType(genericVirtualList);
genericVirtualList.items = [1, 2, 3];
@@ -31,6 +33,8 @@ virtualList.renderer = (root, virtualList, model) => {
assertType(virtualList);
assertType>(model);
assertType(model.index);
+ assertType(model.selected);
+ assertType(model.selected as undefined);
assertType(model.item);
};
@@ -41,4 +45,15 @@ assertType<(index: number) => void>(virtualList.scrollToIndex);
assertType(virtualList.firstVisibleIndex);
assertType(virtualList.lastVisibleIndex);
+assertType<'none' | 'single' | 'multi'>(virtualList.selectionMode);
+assertType(virtualList.selectedItems);
+assertType(virtualList.itemIdPath);
+assertType(virtualList.itemIdPath as undefined);
+
assertType<((item: TestVirtualListItem) => string) | undefined>(virtualList.itemAccessibleNameGenerator);
+assertType(virtualList.itemAccessibleNameGenerator as undefined);
+
+virtualList.addEventListener('selected-items-changed', (event) => {
+ assertType>(event);
+ assertType(event.detail.value);
+});
diff --git a/packages/virtual-list/test/virtual-list-selection-lit.test.ts b/packages/virtual-list/test/virtual-list-selection-lit.test.ts
new file mode 100644
index 00000000000..b6e469687f2
--- /dev/null
+++ b/packages/virtual-list/test/virtual-list-selection-lit.test.ts
@@ -0,0 +1,2 @@
+import '../vaadin-lit-virtual-list.js';
+import './virtual-list-selection.common.js';
diff --git a/packages/virtual-list/test/virtual-list-selection-polymer.test.ts b/packages/virtual-list/test/virtual-list-selection-polymer.test.ts
new file mode 100644
index 00000000000..ee6307b5076
--- /dev/null
+++ b/packages/virtual-list/test/virtual-list-selection-polymer.test.ts
@@ -0,0 +1,2 @@
+import '../vaadin-virtual-list.js';
+import './virtual-list-selection.common.js';
diff --git a/packages/virtual-list/test/virtual-list-selection.common.ts b/packages/virtual-list/test/virtual-list-selection.common.ts
new file mode 100644
index 00000000000..4092956c680
--- /dev/null
+++ b/packages/virtual-list/test/virtual-list-selection.common.ts
@@ -0,0 +1,751 @@
+import { expect } from '@vaadin/chai-plugins';
+import { click as helperClick, fixtureSync, isFirefox, nextFrame } from '@vaadin/testing-helpers';
+import { sendKeys } from '@web/test-runner-commands';
+import sinon from 'sinon';
+import { html, render } from 'lit';
+import type { VirtualList } from '../vaadin-virtual-list';
+
+type TestItem = { id: number; name: string } | undefined;
+
+async function click(el: HTMLElement) {
+ el.focus();
+ await new Promise((resolve) => {
+ queueMicrotask(() => resolve());
+ });
+ // Make focus-utils reset isKeyboardActive
+ window.dispatchEvent(new MouseEvent('mousedown'));
+ helperClick(el);
+ await nextFrame();
+}
+
+async function shiftTab() {
+ await sendKeys({ down: 'Shift' });
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ up: 'Shift' });
+}
+
+describe('selection', () => {
+ let list: VirtualList;
+ let beforeButton: HTMLButtonElement;
+ let afterButton: HTMLButtonElement;
+
+ function getRenderedItem(index: number) {
+ const childElements = Array.from(list.children) as Array;
+ return childElements.find((child) => !child.hidden && child.__item?.id === index);
+ }
+
+ beforeEach(async () => {
+ [beforeButton, list, afterButton] = fixtureSync(`
+
+
+
+
+
+ `).children as any;
+
+ list.style.height = '200px';
+ list.items = Array.from({ length: 100 }, (_, i) => ({ id: i, name: `Item ${i}` }));
+ list.renderer = (root, _, { item, selected }) => {
+ root.textContent = `${item?.name} ${selected ? 'selected' : ''}`;
+ };
+ await nextFrame();
+ });
+
+ it('should not be focusable by default', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect([...list.children].includes(document.activeElement!)).to.be.false;
+ });
+
+ it('should not be focusable backwards', async () => {
+ afterButton.focus();
+ await shiftTab();
+ if (isFirefox) {
+ expect(document.activeElement).to.equal(list);
+ } else {
+ expect(document.activeElement).to.equal(beforeButton);
+ }
+ });
+
+ it('should select an item programmatically', async () => {
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ list.selectedItems = [list.items![0]];
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should not re-render when focused', async () => {
+ const rendererSpy = sinon.spy(list.renderer!);
+ list.renderer = rendererSpy;
+ await nextFrame();
+
+ rendererSpy.resetHistory();
+ const firstItem = getRenderedItem(0)!;
+ firstItem.tabIndex = 0;
+ firstItem.focus();
+
+ expect(rendererSpy.called).to.be.false;
+ });
+
+ it('should not re-render when blurred', async () => {
+ const rendererSpy = sinon.spy(list.renderer!);
+ list.renderer = rendererSpy;
+ await nextFrame();
+ const firstItem = getRenderedItem(0)!;
+ firstItem.tabIndex = 0;
+ firstItem.focus();
+
+ rendererSpy.resetHistory();
+ await sendKeys({ press: 'Tab' });
+
+ expect(rendererSpy.called).to.be.false;
+ });
+
+ it('should not mark element focused', () => {
+ const firstItem = getRenderedItem(0)!;
+ firstItem.tabIndex = 0;
+ firstItem.focus();
+ list.requestContentUpdate();
+ expect(getRenderedItem(0)!.hasAttribute('focused')).to.be.false;
+ });
+
+ it('should not cancel Escape key default behavior', async () => {
+ const spy = sinon.spy();
+ list.addEventListener('keydown', spy);
+ const firstItem = getRenderedItem(0)!;
+ firstItem.tabIndex = 0;
+ firstItem.focus();
+ await sendKeys({ press: 'Escape' });
+ expect(spy.firstCall.args[0].defaultPrevented).to.be.false;
+ });
+
+ describe('selectable', () => {
+ beforeEach(async () => {
+ list.selectionMode = 'multi';
+ await nextFrame();
+ });
+
+ it('should be focusable', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(list.contains(document.activeElement)).to.be.true;
+ });
+
+ it('should be unfocusable forwards', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement).to.equal(afterButton);
+ });
+
+ it('should not scroll when tabbing through', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ await nextFrame();
+ expect(list.firstVisibleIndex).to.equal(0);
+ });
+
+ it('should be unfocusable backwards', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+
+ await shiftTab();
+ expect(document.activeElement).to.equal(beforeButton);
+ });
+
+ it('should not focus empty list', async () => {
+ list.items = [];
+ await nextFrame();
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement).to.equal(afterButton);
+ });
+
+ it('should focus first item after restoring items', async () => {
+ const items = list.items;
+ list.items = [];
+ await nextFrame();
+ list.items = items;
+ await nextFrame();
+
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should select an item by clicking', async () => {
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ await click(getRenderedItem(0)!);
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should re-render items once on click select', async () => {
+ const rendererSpy = sinon.spy(list.renderer!);
+ list.renderer = rendererSpy;
+ await nextFrame();
+
+ rendererSpy.resetHistory();
+ await click(getRenderedItem(0)!);
+ await nextFrame();
+
+ expect(rendererSpy.getCalls().filter((call) => call.args[2].index === 0).length).to.equal(1);
+ });
+
+ it('should mark a previously focused element focused by clicking', async () => {
+ // Get a reference to the element representing the first item
+ const itemElement = getRenderedItem(0)!;
+ const firstItemElementTextContent = itemElement.textContent;
+ // Focus the first item by clicking
+ await click(itemElement);
+ // Scroll manually downwards
+ list.scrollTop = list.scrollHeight;
+ await nextFrame();
+ // Expect the same elemnt instance to now represent a different item due to virtualization
+ expect(itemElement.textContent).not.to.equal(firstItemElementTextContent);
+ // Click the same element again
+ await click(itemElement);
+ // Expect the element to be marked as focused
+ expect(itemElement.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should select an item with keyboard', async () => {
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should scroll back to the focused item when selecting an item with keyboard', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ list.scrollToIndex(100);
+ await sendKeys({ press: 'Space' });
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ expect(getRenderedItem(0)!.hasAttribute('focused')).to.be.true;
+ expect(list.selectedItems).to.deep.equal([list.items![0]]);
+ });
+
+ it('should scroll back to the focused item when selecting an item with keyboard (manual scroll)', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ list.scrollTop = 1000;
+ await nextFrame();
+ await sendKeys({ press: 'Space' });
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ expect(getRenderedItem(0)!.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should ignore selection of undefined items (Flow VirtualList)', async () => {
+ list.items = [undefined];
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ expect(list.selectedItems).to.be.empty;
+ });
+
+ it('should select multiple items with keyboard', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ await sendKeys({ press: 'ArrowDown' });
+ await sendKeys({ press: 'Space' });
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should update selectedItems array', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ await sendKeys({ press: 'ArrowDown' });
+ await sendKeys({ press: 'Space' });
+ await nextFrame();
+ expect(list.selectedItems).to.deep.equal([list.items![0], list.items![1]]);
+ });
+
+ it('should dispatch selected-items-changed event', async () => {
+ const spy = sinon.spy();
+ list.addEventListener('selected-items-changed', spy);
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ expect(spy.calledOnce).to.be.true;
+ });
+
+ it('should unselect an item by clicking again', async () => {
+ await click(getRenderedItem(0)!);
+ await click(getRenderedItem(0)!);
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ });
+
+ it('should select multiple items by clicking', async () => {
+ await click(getRenderedItem(0)!);
+ await click(getRenderedItem(1)!);
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ expect(getRenderedItem(1)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should make the clicked item focusable', async () => {
+ await click(getRenderedItem(1)!);
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement).to.equal(getRenderedItem(1));
+ });
+
+ it('should select items by identity', async () => {
+ list.itemIdPath = 'id';
+ list.selectedItems = [{ id: 0, name: 'Item 0' }];
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should select items by identity when identity changed dynamically', async () => {
+ list.selectedItems = [{ id: 0, name: 'Item 0' }];
+ await nextFrame();
+ list.itemIdPath = 'id';
+ await nextFrame();
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should scroll the focused item into view', async () => {
+ list.scrollToIndex(100);
+ expect(list.firstVisibleIndex).not.to.equal(0);
+
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+
+ expect(list.contains(document.activeElement)).to.be.true;
+ expect(list.firstVisibleIndex).to.equal(0);
+ });
+
+ it('should not try to focus an index below 0', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'ArrowUp' });
+ expect(document.activeElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should not try to focus an index beyond items.length', async () => {
+ const lastIndex = list.items!.length - 1;
+ list.scrollToIndex(lastIndex);
+ await click(getRenderedItem(lastIndex)!);
+ await sendKeys({ press: 'ArrowDown' });
+ expect(document.activeElement).to.equal(getRenderedItem(lastIndex));
+ });
+
+ it('should focus the last visible index when items length reduced', async () => {
+ const lastIndex = list.items!.length - 1;
+ list.scrollToIndex(lastIndex);
+ await click(getRenderedItem(lastIndex)!);
+
+ list.items = list.items!.slice(0, -1);
+ await nextFrame();
+ expect(document.activeElement).to.equal(getRenderedItem(lastIndex - 1));
+ expect(getRenderedItem(lastIndex - 1)!.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should unselect an item by clicking another item on single-selection mode', async () => {
+ list.selectionMode = 'single';
+ await click(getRenderedItem(0)!);
+ await click(getRenderedItem(1)!);
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ expect(getRenderedItem(1)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should deselect all items when deselecting on single-selection mode', async () => {
+ list.selectionMode = 'single';
+ list.selectedItems = [list.items![0], list.items![1]];
+ await click(getRenderedItem(0)!);
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ expect(getRenderedItem(1)!.hasAttribute('selected')).to.be.false;
+ });
+
+ it('should deselect other items when selecting on single-selection mode', async () => {
+ list.selectionMode = 'single';
+ list.selectedItems = [list.items![0], list.items![1]];
+ await click(getRenderedItem(2)!);
+ expect(getRenderedItem(0)!.hasAttribute('selected')).to.be.false;
+ expect(getRenderedItem(1)!.hasAttribute('selected')).to.be.false;
+ expect(getRenderedItem(2)!.hasAttribute('selected')).to.be.true;
+ });
+
+ it('should cancel arrow keys default behavior', async () => {
+ const spy = sinon.spy();
+ list.addEventListener('keydown', spy);
+
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'ArrowDown' });
+ expect(spy.firstCall.args[0].defaultPrevented).to.be.true;
+ });
+
+ it('should cancel space keys default behavior', async () => {
+ const spy = sinon.spy();
+ list.addEventListener('keydown', spy);
+
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Space' });
+ expect(spy.firstCall.args[0].defaultPrevented).to.be.true;
+ });
+
+ it('should mark element focused', async () => {
+ expect(getRenderedItem(0)!.hasAttribute('focused')).to.be.false;
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(getRenderedItem(0)!.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should not mark unfocused element focused', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(getRenderedItem(1)!.hasAttribute('focused')).to.be.false;
+ });
+
+ it('should not mark element focused if it does not match virtual focus index', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ list.scrollToIndex(list.items!.length - 1);
+
+ expect([...list.children].some((child) => child.hasAttribute('focused'))).to.be.false;
+ });
+
+ it('should not throw on enter when there are no focusable child elements', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ });
+
+ it('should not throw on enter when there are no focusable root elements', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ list.scrollToIndex(list.items!.length - 1);
+ await sendKeys({ press: 'Enter' });
+ });
+
+ it('should ensure focused index in viewport when navigating down with arrow keys', async () => {
+ const lastVisibleIndex = list.lastVisibleIndex;
+ await click(getRenderedItem(lastVisibleIndex)!);
+ await sendKeys({ press: 'ArrowDown' });
+ await nextFrame();
+ const newLastVisibleIndex = list.lastVisibleIndex;
+ expect(newLastVisibleIndex).to.equal(lastVisibleIndex + 1);
+
+ const listRect = list.getBoundingClientRect();
+ const lastVisibleItemRect = getRenderedItem(newLastVisibleIndex)!.getBoundingClientRect();
+ expect(lastVisibleItemRect.bottom).to.be.closeTo(listRect.bottom, 1);
+ });
+
+ it('should ensure focused index in viewport when navigating up with arrow keys', async () => {
+ list.scrollToIndex(list.items!.length - 1);
+ const firstVisibleIndex = list.firstVisibleIndex;
+ await click(getRenderedItem(firstVisibleIndex)!);
+ await sendKeys({ press: 'ArrowUp' });
+ await nextFrame();
+ const newFirstVisibleIndex = list.firstVisibleIndex;
+ expect(newFirstVisibleIndex).to.equal(firstVisibleIndex - 1);
+
+ const listRect = list.getBoundingClientRect();
+ const firstVisibleItemRect = getRenderedItem(newFirstVisibleIndex)!.getBoundingClientRect();
+ expect(firstVisibleItemRect.top).to.equal(listRect.top);
+ });
+
+ it('should re-render items on selection', async () => {
+ expect(getRenderedItem(0)?.textContent).to.equal('Item 0 ');
+ list.selectedItems = [list.items![0]];
+ await nextFrame();
+ expect(getRenderedItem(0)?.textContent).to.equal('Item 0 selected');
+ list.selectedItems = [];
+ await nextFrame();
+ expect(getRenderedItem(0)?.textContent).to.equal('Item 0 ');
+ });
+
+ it('should re-render items once on selection', async () => {
+ const rendererSpy = sinon.spy(list.renderer!);
+ list.renderer = rendererSpy;
+ await nextFrame();
+
+ rendererSpy.resetHistory();
+ list.selectedItems = [list.items![0]];
+ await nextFrame();
+
+ expect(rendererSpy.getCalls().filter((call) => call.args[2].index === 0).length).to.equal(1);
+ });
+
+ it('should not throw on click when there are no items', async () => {
+ list.items = [];
+ await nextFrame();
+ await click(list);
+ });
+
+ it('should focus an item on virtual list click', async () => {
+ list.items = list.items!.slice(0, 5);
+ await nextFrame();
+ click(list);
+ click(list);
+ await nextFrame();
+ expect(document.activeElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should not select an item on virtual list click', async () => {
+ list.items = list.items!.slice(0, 5);
+ await nextFrame();
+ click(list);
+ expect(list.selectedItems).to.be.empty;
+ });
+
+ it('should not throw is items are unset', () => {
+ list.items = undefined;
+ });
+
+ it('should not change scroll position when tabbing backwards into the focus item', async () => {
+ click(getRenderedItem(5)!);
+ afterButton.focus();
+ const scrollTop = list.scrollTop;
+ await shiftTab();
+ expect(list.scrollTop).to.equal(scrollTop);
+ });
+
+ describe('focusable children', () => {
+ beforeEach(async () => {
+ list.renderer = (root, _, { item }) => {
+ render(html``, root);
+ };
+ await nextFrame();
+ });
+
+ it('should be in navigating state', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ expect(list.hasAttribute('navigating')).to.be.true;
+ });
+
+ it('should not focus child elements', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement).to.equal(afterButton);
+ });
+
+ it('should focus focusable child element', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ expect(document.activeElement!.localName).to.equal('button');
+ expect(document.activeElement!.parentElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should be in interacting state', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ expect(list.hasAttribute('interacting')).to.be.true;
+ });
+
+ it('should have the root element in focused state', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ expect(getRenderedItem(0)!.hasAttribute('focused')).to.be.true;
+ });
+
+ it('should focus tab to the next focusable child', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement!.localName).to.equal('button');
+ expect(document.activeElement!.parentElement).to.equal(getRenderedItem(1));
+ });
+
+ it('should not re-render when tabbing between focusable children inside a single parent', async () => {
+ // Create a renderer with two focusable children inside a single parent
+ const rendererSpy = sinon.spy((root, _, { item }) => {
+ render(html``, root);
+ });
+ list.renderer = rendererSpy;
+ await nextFrame();
+
+ // Enter interacting state
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ await nextFrame();
+ rendererSpy.resetHistory();
+
+ // Tab to the next focusable child inside the same parent
+ await sendKeys({ press: 'Tab' });
+ expect(rendererSpy.called).to.be.false;
+ });
+
+ it('should focus the item element on escape', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ await sendKeys({ press: 'Escape' });
+ expect(document.activeElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should return to navigating state', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ await sendKeys({ press: 'Escape' });
+ expect(list.hasAttribute('navigating')).to.be.true;
+ });
+
+ it('should tab backwards from a focusable child', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+
+ await shiftTab();
+ expect(document.activeElement).to.equal(beforeButton);
+ });
+
+ it('should focus tab to the next focusable child after clicking a child', async () => {
+ await click(getRenderedItem(0)!.querySelector('button')!);
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement!.parentElement).to.equal(getRenderedItem(1));
+ });
+
+ it('should focus the first item element after blurring from a focusable child', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ await shiftTab();
+ await sendKeys({ press: 'Tab' });
+
+ expect(document.activeElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should refocus the focused child element', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Enter' });
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Escape' });
+ await sendKeys({ press: 'Enter' });
+
+ expect(document.activeElement?.parentElement).to.equal(getRenderedItem(1));
+ });
+
+ it('should not select an item when clicking a child element', async () => {
+ await click(getRenderedItem(0)!.querySelector('button')!);
+ expect(list.selectedItems).to.be.empty;
+ });
+
+ it('should tab through focusable children when selection mode is unset', async () => {
+ // Because focus works diffrently on Firefox and Chrome, use a renderer with two focusable children
+ list.renderer = (root, _, { item }) => {
+ render(html``, root);
+ };
+ list.selectionMode = 'none';
+ await nextFrame();
+
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ expect(document.activeElement!.parentElement).to.equal(getRenderedItem(0));
+ });
+
+ it('should not have focused items when selection mode is unset', async () => {
+ list.selectionMode = 'none';
+ list.scrollToIndex(10);
+ await nextFrame();
+ expect(list.querySelector('[focused]')).to.be.null;
+ });
+
+ it('should clear navigating state when selection mode is unset', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ list.selectionMode = 'none';
+ await nextFrame();
+ expect(list.hasAttribute('navigating')).to.be.false;
+ });
+
+ it('should clear navigating state when clicking an item', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await nextFrame();
+
+ await click(getRenderedItem(0)!);
+ await nextFrame();
+ expect(list.hasAttribute('navigating')).to.be.false;
+ });
+
+ it('should clear navigating state when focus leaves the component', async () => {
+ beforeButton.focus();
+ await sendKeys({ press: 'Tab' });
+ await sendKeys({ press: 'Tab' });
+ await nextFrame();
+ expect(list.hasAttribute('navigating')).to.be.false;
+ });
+
+ it('should shift tab to an item from outside', async () => {
+ afterButton.focus();
+ await shiftTab();
+
+ expect(document.activeElement).to.equal(getRenderedItem(0));
+ });
+ });
+ });
+
+ describe('a11y', () => {
+ it('should have items without aria-selected', () => {
+ expect(getRenderedItem(0)!.ariaSelected).to.be.null;
+ });
+
+ describe('selectable', () => {
+ beforeEach(async () => {
+ list.selectionMode = 'multi';
+ await nextFrame();
+ });
+
+ it('should have role="listbox"', () => {
+ expect(list.role).to.equal('listbox');
+ });
+
+ it('should have items with role="option"', () => {
+ expect(getRenderedItem(0)!.role).to.equal('option');
+ });
+
+ it('should aria-selected="false" on non-selected items', () => {
+ expect(getRenderedItem(0)!.ariaSelected).to.equal('false');
+ });
+
+ it('should aria-selected="true" on selected items', async () => {
+ list.selectedItems = [list.items![0]];
+ await nextFrame();
+ expect(getRenderedItem(0)!.ariaSelected).to.equal('true');
+ });
+
+ it('should have aria-multiselectable="true"', () => {
+ expect(list.ariaMultiSelectable).to.equal('true');
+ });
+
+ it('should not have aria-multiselectable when selectionMode is "single"', async () => {
+ list.selectionMode = 'single';
+ await nextFrame();
+ expect(list.ariaMultiSelectable).to.be.null;
+ });
+
+ it('should revert to role="list"', async () => {
+ list.selectionMode = 'none';
+ await nextFrame();
+ expect(list.role).to.equal('list');
+ });
+
+ it('should revert to having items with role="listitem"', async () => {
+ list.selectionMode = 'none';
+ await nextFrame();
+ expect(getRenderedItem(0)!.role).to.equal('listitem');
+ });
+ });
+ });
+});
diff --git a/test/integration/dialog-virtual-list.test.js b/test/integration/dialog-virtual-list.test.js
new file mode 100644
index 00000000000..8f1b20e40a5
--- /dev/null
+++ b/test/integration/dialog-virtual-list.test.js
@@ -0,0 +1,53 @@
+import { expect } from '@vaadin/chai-plugins';
+import { click, fixtureSync, nextRender, oneEvent } from '@vaadin/testing-helpers';
+import { sendKeys } from '@web/test-runner-commands';
+import '@vaadin/dialog';
+import '@vaadin/virtual-list';
+
+describe('virtual-list in dialog', () => {
+ let dialog, overlay, list;
+
+ beforeEach(async () => {
+ dialog = fixtureSync(``);
+ dialog.renderer = (root) => {
+ if (!root.firstChild) {
+ root.$.overlay.style.width = '700px';
+ root.innerHTML = `
+
+ `;
+ const list = root.querySelector('vaadin-virtual-list');
+ list.items = [{ text: 'Hello 1' }, { text: 'Hello 2' }];
+ list.renderer = (root, _list, { item }) => {
+ if (!root.firstElementChild) {
+ root.innerHTML = ``;
+ }
+ };
+ }
+ };
+ await nextRender();
+ dialog.opened = true;
+ overlay = dialog.$.overlay;
+ await oneEvent(overlay, 'vaadin-overlay-open');
+ list = overlay.querySelector('vaadin-virtual-list');
+ });
+
+ it('should close the dialog on esc', async () => {
+ list.firstElementChild.focus();
+ click(list.firstElementChild);
+ await sendKeys({ press: 'Escape' });
+ expect(dialog.opened).to.be.false;
+ });
+
+ it('should not close the dialog on esc when returning to navigation mode', async () => {
+ const button = list.firstElementChild.querySelector('button');
+ // Enter interaction mode by clicking a button directly
+ button.focus();
+ click(button);
+ // Return to navigation mode
+ await sendKeys({ press: 'Escape' });
+ expect(dialog.opened).to.be.true;
+ // Close the dialog
+ await sendKeys({ press: 'Escape' });
+ expect(dialog.opened).to.be.false;
+ });
+});
diff --git a/test/integration/virtual-list-date-picker.test.js b/test/integration/virtual-list-date-picker.test.js
new file mode 100644
index 00000000000..ee1d0c14b3a
--- /dev/null
+++ b/test/integration/virtual-list-date-picker.test.js
@@ -0,0 +1,38 @@
+import { expect } from '@vaadin/chai-plugins';
+import { fixtureSync, nextFrame, nextRender } from '@vaadin/testing-helpers';
+import { sendKeys } from '@web/test-runner-commands';
+import '@vaadin/date-picker';
+import '@vaadin/virtual-list';
+
+describe('date-picker in virtual-list', () => {
+ let list;
+
+ beforeEach(async () => {
+ list = fixtureSync(``);
+ list.items = ['foo', 'bar'];
+ list.renderer = (root, _list) => {
+ if (!root.firstElementChild) {
+ root.innerHTML = ``;
+ }
+ };
+ await nextRender();
+ });
+
+ it('should not navigate the virtual list items on date picker arrow keys', async () => {
+ const [firstDatePicker] = list.querySelectorAll('vaadin-date-picker');
+ firstDatePicker.focus();
+ await nextFrame();
+ await nextFrame();
+ expect(firstDatePicker.parentElement.hasAttribute('focused')).to.be.true;
+
+ // Select a value for the date picker using arrow keys
+ await sendKeys({ press: 'Space' });
+ await sendKeys({ press: 'ArrowDown' });
+ await sendKeys({ press: 'Space' });
+ await sendKeys({ press: 'Enter' });
+
+ // Expect the keyboard interaction to not navigate nor select the virtual list items
+ expect(list.selectedItems).to.be.empty;
+ expect(firstDatePicker.parentElement.hasAttribute('focused')).to.be.true;
+ });
+});