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 54b7209ff1..d19c2be17a 100644 --- a/packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts +++ b/packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts @@ -53,6 +53,11 @@ export declare class VirtualListMixinClass { */ items: TItem[] | undefined; + /** + * A function that generates accessible names for virtual list items. + */ + itemAccessibleNameGenerator?: (item: TItem) => string; + /** * Scroll to a specific index in the virtual list. */ diff --git a/packages/virtual-list/src/vaadin-virtual-list-mixin.js b/packages/virtual-list/src/vaadin-virtual-list-mixin.js index e5b3d427e2..0b58300970 100644 --- a/packages/virtual-list/src/vaadin-virtual-list-mixin.js +++ b/packages/virtual-list/src/vaadin-virtual-list-mixin.js @@ -36,13 +36,21 @@ export const VirtualListMixin = (superClass) => */ renderer: { type: Function, sync: true }, + /** + * A function that generates accessible names for virtual list items. + */ + itemAccessibleNameGenerator: { + type: Function, + sync: true, + }, + /** @private */ __virtualizer: Object, }; } static get observers() { - return ['__itemsOrRendererChanged(items, renderer, __virtualizer)']; + return ['__itemsOrRendererChanged(items, renderer, __virtualizer, itemAccessibleNameGenerator)']; } /** @@ -84,6 +92,7 @@ export const VirtualListMixin = (superClass) => this.addController(this.__overflowController); processTemplates(this); + this.__updateAria(); } /** @protected */ @@ -116,18 +125,34 @@ export const VirtualListMixin = (superClass) => return [...Array(count)].map(() => document.createElement('div')); } + /** @private */ + __updateAria() { + this.role = 'list'; + } + /** @private */ __updateElement(el, index) { + const item = this.items[index]; + el.ariaSetSize = String(this.items.length); + el.ariaPosInSet = String(index + 1); + el.ariaLabel = this.itemAccessibleNameGenerator ? this.itemAccessibleNameGenerator(item) : null; + this.__updateElementRole(el); + if (el.__renderer !== this.renderer) { el.__renderer = this.renderer; this.__clearRenderTargetContent(el); } if (this.renderer) { - this.renderer(el, this, { item: this.items[index], index }); + this.renderer(el, this, { item, index }); } } + /** @private */ + __updateElementRole(el) { + el.role = 'listitem'; + } + /** * Clears the content of a render target. * @private diff --git a/packages/virtual-list/test/typings/virtual-list.types.ts b/packages/virtual-list/test/typings/virtual-list.types.ts index fc99602ab2..b70ee662ac 100644 --- a/packages/virtual-list/test/typings/virtual-list.types.ts +++ b/packages/virtual-list/test/typings/virtual-list.types.ts @@ -40,3 +40,5 @@ assertType<(index: number) => void>(virtualList.scrollToIndex); assertType(virtualList.firstVisibleIndex); assertType(virtualList.lastVisibleIndex); + +assertType<((item: TestVirtualListItem) => string) | undefined>(virtualList.itemAccessibleNameGenerator); diff --git a/packages/virtual-list/test/virtual-list.common.js b/packages/virtual-list/test/virtual-list.common.js index 79b3aae6f5..ebf27201e9 100644 --- a/packages/virtual-list/test/virtual-list.common.js +++ b/packages/virtual-list/test/virtual-list.common.js @@ -4,8 +4,9 @@ import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; describe('virtual-list', () => { let list; - beforeEach(() => { + beforeEach(async () => { list = fixtureSync(``); + await nextFrame(); }); it('should have a default height', () => { @@ -36,6 +37,10 @@ describe('virtual-list', () => { expect(flexBox.firstElementChild.offsetWidth).to.equal(flexBox.offsetWidth); }); + it('should have role="list"', () => { + expect(list.role).to.equal('list'); + }); + describe('with items', () => { beforeEach(async () => { const size = 100; @@ -101,7 +106,7 @@ describe('virtual-list', () => { it('should have a last visible index', () => { const item = [...list.children].find((el) => el.textContent === `value-${list.lastVisibleIndex}`); const itemRect = item.getBoundingClientRect(); - expect(list.getBoundingClientRect().bottom).to.be.within(itemRect.top, itemRect.bottom); + expect(list.getBoundingClientRect().bottom).to.be.within(itemRect.top, itemRect.bottom + 1); }); it('should clear the old content after assigning a new renderer', () => { @@ -126,6 +131,34 @@ describe('virtual-list', () => { expect(list.children[0].textContent.trim()).to.equal('bar'); }); + it('should have items with role="listitem"', () => { + expect(list.children[0].role).to.equal('listitem'); + }); + + it('should assign aria-setsize and aria-posinset', () => { + list.scrollToIndex(list.items.length - 1); + const item = [...list.children].find((el) => el.textContent === `value-${list.lastVisibleIndex}`); + expect(item.ariaSetSize).to.equal('100'); + expect(item.ariaPosInSet).to.equal('100'); + }); + + describe('item accessible name generator', () => { + beforeEach(async () => { + list.itemAccessibleNameGenerator = (item) => `Accessible ${item.value}`; + await nextFrame(); + }); + + it('should generate aria-label to the items', () => { + expect(list.children[0].ariaLabel).to.equal('Accessible value-0'); + }); + + it('should remove aria-label from the items', async () => { + list.itemAccessibleNameGenerator = undefined; + await nextFrame(); + expect(list.children[0].ariaLabel).to.be.null; + }); + }); + describe('overflow attribute', () => { it('should set overflow attribute to "bottom" when scroll is at the beginning', () => { expect(list.getAttribute('overflow')).to.equal('bottom');