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; + }); +});