Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: improve virtual-list a11y #8328

Merged
merged 1 commit into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
vursen marked this conversation as resolved.
Show resolved Hide resolved
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
Loading