Skip to content

Commit 4c0d03b

Browse files
authored
feat: improve virtual-list a11y (#8328)
1 parent a9ff39d commit 4c0d03b

File tree

4 files changed

+69
-4
lines changed

4 files changed

+69
-4
lines changed

packages/virtual-list/src/vaadin-virtual-list-mixin.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export declare class VirtualListMixinClass<TItem = VirtualListDefaultItem> {
5353
*/
5454
items: TItem[] | undefined;
5555

56+
/**
57+
* A function that generates accessible names for virtual list items.
58+
*/
59+
itemAccessibleNameGenerator?: (item: TItem) => string;
60+
5661
/**
5762
* Scroll to a specific index in the virtual list.
5863
*/

packages/virtual-list/src/vaadin-virtual-list-mixin.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,21 @@ export const VirtualListMixin = (superClass) =>
3636
*/
3737
renderer: { type: Function, sync: true },
3838

39+
/**
40+
* A function that generates accessible names for virtual list items.
41+
*/
42+
itemAccessibleNameGenerator: {
43+
type: Function,
44+
sync: true,
45+
},
46+
3947
/** @private */
4048
__virtualizer: Object,
4149
};
4250
}
4351

4452
static get observers() {
45-
return ['__itemsOrRendererChanged(items, renderer, __virtualizer)'];
53+
return ['__itemsOrRendererChanged(items, renderer, __virtualizer, itemAccessibleNameGenerator)'];
4654
}
4755

4856
/**
@@ -84,6 +92,7 @@ export const VirtualListMixin = (superClass) =>
8492
this.addController(this.__overflowController);
8593

8694
processTemplates(this);
95+
this.__updateAria();
8796
}
8897

8998
/** @protected */
@@ -116,18 +125,34 @@ export const VirtualListMixin = (superClass) =>
116125
return [...Array(count)].map(() => document.createElement('div'));
117126
}
118127

128+
/** @private */
129+
__updateAria() {
130+
this.role = 'list';
131+
}
132+
119133
/** @private */
120134
__updateElement(el, index) {
135+
const item = this.items[index];
136+
el.ariaSetSize = String(this.items.length);
137+
el.ariaPosInSet = String(index + 1);
138+
el.ariaLabel = this.itemAccessibleNameGenerator ? this.itemAccessibleNameGenerator(item) : null;
139+
this.__updateElementRole(el);
140+
121141
if (el.__renderer !== this.renderer) {
122142
el.__renderer = this.renderer;
123143
this.__clearRenderTargetContent(el);
124144
}
125145

126146
if (this.renderer) {
127-
this.renderer(el, this, { item: this.items[index], index });
147+
this.renderer(el, this, { item, index });
128148
}
129149
}
130150

151+
/** @private */
152+
__updateElementRole(el) {
153+
el.role = 'listitem';
154+
}
155+
131156
/**
132157
* Clears the content of a render target.
133158
* @private

packages/virtual-list/test/typings/virtual-list.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@ assertType<(index: number) => void>(virtualList.scrollToIndex);
4040

4141
assertType<number>(virtualList.firstVisibleIndex);
4242
assertType<number>(virtualList.lastVisibleIndex);
43+
44+
assertType<((item: TestVirtualListItem) => string) | undefined>(virtualList.itemAccessibleNameGenerator);

packages/virtual-list/test/virtual-list.common.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
44
describe('virtual-list', () => {
55
let list;
66

7-
beforeEach(() => {
7+
beforeEach(async () => {
88
list = fixtureSync(`<vaadin-virtual-list></vaadin-virtual-list>`);
9+
await nextFrame();
910
});
1011

1112
it('should have a default height', () => {
@@ -36,6 +37,10 @@ describe('virtual-list', () => {
3637
expect(flexBox.firstElementChild.offsetWidth).to.equal(flexBox.offsetWidth);
3738
});
3839

40+
it('should have role="list"', () => {
41+
expect(list.role).to.equal('list');
42+
});
43+
3944
describe('with items', () => {
4045
beforeEach(async () => {
4146
const size = 100;
@@ -101,7 +106,7 @@ describe('virtual-list', () => {
101106
it('should have a last visible index', () => {
102107
const item = [...list.children].find((el) => el.textContent === `value-${list.lastVisibleIndex}`);
103108
const itemRect = item.getBoundingClientRect();
104-
expect(list.getBoundingClientRect().bottom).to.be.within(itemRect.top, itemRect.bottom);
109+
expect(list.getBoundingClientRect().bottom).to.be.within(itemRect.top, itemRect.bottom + 1);
105110
});
106111

107112
it('should clear the old content after assigning a new renderer', () => {
@@ -126,6 +131,34 @@ describe('virtual-list', () => {
126131
expect(list.children[0].textContent.trim()).to.equal('bar');
127132
});
128133

134+
it('should have items with role="listitem"', () => {
135+
expect(list.children[0].role).to.equal('listitem');
136+
});
137+
138+
it('should assign aria-setsize and aria-posinset', () => {
139+
list.scrollToIndex(list.items.length - 1);
140+
const item = [...list.children].find((el) => el.textContent === `value-${list.lastVisibleIndex}`);
141+
expect(item.ariaSetSize).to.equal('100');
142+
expect(item.ariaPosInSet).to.equal('100');
143+
});
144+
145+
describe('item accessible name generator', () => {
146+
beforeEach(async () => {
147+
list.itemAccessibleNameGenerator = (item) => `Accessible ${item.value}`;
148+
await nextFrame();
149+
});
150+
151+
it('should generate aria-label to the items', () => {
152+
expect(list.children[0].ariaLabel).to.equal('Accessible value-0');
153+
});
154+
155+
it('should remove aria-label from the items', async () => {
156+
list.itemAccessibleNameGenerator = undefined;
157+
await nextFrame();
158+
expect(list.children[0].ariaLabel).to.be.null;
159+
});
160+
});
161+
129162
describe('overflow attribute', () => {
130163
it('should set overflow attribute to "bottom" when scroll is at the beginning', () => {
131164
expect(list.getAttribute('overflow')).to.equal('bottom');

0 commit comments

Comments
 (0)