Skip to content

Commit

Permalink
feat: improve virtual-list a11y (#8328)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomivirkki authored Dec 12, 2024
1 parent a9ff39d commit 4c0d03b
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 4 deletions.
5 changes: 5 additions & 0 deletions packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export declare class VirtualListMixinClass<TItem = VirtualListDefaultItem> {
*/
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.
*/
Expand Down
29 changes: 27 additions & 2 deletions packages/virtual-list/src/vaadin-virtual-list-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)'];
}

/**
Expand Down Expand Up @@ -84,6 +92,7 @@ export const VirtualListMixin = (superClass) =>
this.addController(this.__overflowController);

processTemplates(this);
this.__updateAria();
}

/** @protected */
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/virtual-list/test/typings/virtual-list.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ assertType<(index: number) => void>(virtualList.scrollToIndex);

assertType<number>(virtualList.firstVisibleIndex);
assertType<number>(virtualList.lastVisibleIndex);

assertType<((item: TestVirtualListItem) => string) | undefined>(virtualList.itemAccessibleNameGenerator);
37 changes: 35 additions & 2 deletions packages/virtual-list/test/virtual-list.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
describe('virtual-list', () => {
let list;

beforeEach(() => {
beforeEach(async () => {
list = fixtureSync(`<vaadin-virtual-list></vaadin-virtual-list>`);
await nextFrame();
});

it('should have a default height', () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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');
Expand Down

0 comments on commit 4c0d03b

Please sign in to comment.