Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3025411
PivotGrid - KBN - Add ARIA attributes to area fields
Jul 16, 2026
eaa4083
PivotGrid - KBN - Fix ascending sort ARIA field message key typo
Jul 16, 2026
ffafcff
Merge branch 'main' into pivotgrid-kbn-fields-aria-attrs-main
Jul 17, 2026
745ab50
PivotGrid - KBN - Reset stale menubar role on hidden field areas
Jul 17, 2026
683a793
Merge remote-tracking branch 'upstream/main' into pivotgrid-a11y-4196…
aleksei-semikozov Jul 17, 2026
81b084a
PivotGrid - Add axe accessibility tests
Jul 17, 2026
827e285
PivotGrid - Describe field area keyboard interactions via aria-descri…
Jul 17, 2026
089cf97
PivotGrid - Open field context menu from keyboard
Jul 17, 2026
982706d
PivotGrid - Handle the context menu handler option change and dispatc…
Jul 17, 2026
1a46891
PivotGrid - Describe data cells with their row and column headers
Jul 17, 2026
c201add
PivotGrid - Resolve area sections defensively for partially stubbed a…
Jul 17, 2026
e32ae6d
PivotGrid - Revert data-cell aria-describedby; deliver field-area key…
Jul 23, 2026
3ec81ca
Merge branch 'main' into pivotgrid-a11y-4196-polish
Jul 23, 2026
06e0710
PivotGrid - Stabilize axe a11y tests and guard empty field-area caption
Jul 23, 2026
1835da0
PivotGrid - Disable best-practice region rule for overlay axe scenarios
Jul 23, 2026
f312718
PivotGrid - Trim self-evident comments and address review nits (preve…
Jul 24, 2026
898b050
PivotGrid - Stop announcing expandable header cell captions twice
Jul 24, 2026
edd81fc
PivotGrid - Reserve aria-colindex columns for row headers so they are…
Jul 24, 2026
ff497a7
PivotGrid - Drop explanatory comments from a11y source changes
Jul 24, 2026
b9ef650
PivotGrid - Announce field hotkeys once on the component instead of p…
Jul 24, 2026
d80a192
PivotGrid - Derive reserved row-header width from the model and guard…
Jul 24, 2026
4b5d464
PivotGrid - Rename the field hotkeys aria message to dxPivotGrid-aria…
Jul 27, 2026
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
106 changes: 106 additions & 0 deletions e2e/testcafe-devextreme/tests/accessibility/pivotGrid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import PivotGrid from 'devextreme-testcafe-models/pivotGrid';
import { Selector } from 'testcafe';
import url from '../../helpers/getPageUrl';
import { createWidget } from '../../helpers/createWidget';
import { a11yCheck } from '../../helpers/accessibility/utils';

fixture.disablePageReloads`PivotGrid - Accessibility`
.page(url(__dirname, '../container.html'));

const PIVOT_GRID_SELECTOR = '#container';

const createConfig = () => ({
width: 1000,
allowSorting: true,
allowSortingBySummary: true,
allowFiltering: true,
allowExpandAll: true,
showBorders: true,
fieldPanel: {
visible: true,
},
fieldChooser: {
enabled: true,
height: 500,
},
dataSource: {
fields: [{
dataField: 'country',
area: 'filter',
}, {
caption: 'Region',
width: 120,
dataField: 'region',
area: 'row',
}, {
caption: 'City',
dataField: 'city',
width: 150,
area: 'row',
}, {
caption: 'Country',
dataField: 'country',
area: 'column',
}, {
dataField: 'date',
dataType: 'date',
area: 'column',
}, {
groupName: 'date',
groupInterval: 'year',
expanded: true,
area: 'column',
}, {
caption: 'Sales',
dataField: 'amount',
dataType: 'number',
summaryType: 'sum',
area: 'data',
}],
store: [{
region: 'Africa',
country: 'Egypt',
city: 'Cairo',
amount: 500,
date: new Date('2015-05-26'),
}, {
region: 'South America',
country: 'Argentina',
city: 'Buenos Aires',
amount: 780,
date: new Date('2015-05-07'),
}],
},
});

test('grid with field panel', async (t) => {
await a11yCheck(t, {}, PIVOT_GRID_SELECTOR);
}).before(async () => createWidget('dxPivotGrid', createConfig()));

test('field chooser popup', async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);

await t.click(pivotGrid.getFieldChooserButton());

await a11yCheck(t);
}).before(async () => createWidget('dxPivotGrid', createConfig()));

test('header filter popup', async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
const filterIcon = pivotGrid
.getColumnHeaderArea()
.getHeaderFilterIcon()
.element;

await t.click(filterIcon);

await a11yCheck(t);
}).before(async () => createWidget('dxPivotGrid', createConfig()));

test('cell context menu', async (t) => {
const columnHeaderCell = Selector('.dx-pivotgrid-horizontal-headers td[tabindex]');

await t.rightClick(columnHeaderCell);

await a11yCheck(t);
}).before(async () => createWidget('dxPivotGrid', createConfig()));
81 changes: 81 additions & 0 deletions e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,32 @@ const createConfig = () => ({
? Selector(`.dx-pivotgridfieldchooser .dx-area-fields[group="${area}"]`)
: Selector(`.dx-pivotgrid-fields-area[group="${area}"]`));

test(`${testTitlePrefix}: Fields should be exposed as menu items of an area menubar`, async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);

if (isFieldChooser) {
await t.click(pivotGrid.getFieldChooserButton());
}

const rowAreaContainer = getAreaFieldsContainer('row');
const menubar = rowAreaContainer.find('[role="menubar"]');
const firstField = getField(pivotGrid, 'row', 0);

await t
.expect(menubar.count)
.eql(1, 'the area has a single menubar')
.expect(menubar.getAttribute('aria-label'))
.eql('Row Fields', 'the menubar is labelled with the area name')
.expect(menubar.getAttribute('aria-description'))
.ok('the menubar describes the available keyboard interactions');

await t
.expect(firstField.getAttribute('role'))
.eql('menuitem', 'a field is exposed as a menu item')
.expect(firstField.getAttribute('aria-label'))
.eql('Field: Region, Sort order: ascending', 'the field label includes the name and the sorting state');
}).before(async () => createWidget('dxPivotGrid', createConfig()));

['filter', 'data', 'column', 'row'].forEach((area) => {
test(`${testTitlePrefix}: Fields in ${area} area should form a single tab stop`, async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
Expand Down Expand Up @@ -381,3 +407,58 @@ test('FieldChooser: Should traverse fields in all areas by tab', async (t) => {
.expect(dataFirstField.focused)
.ok('first field in data area is focused');
}).before(async () => createWidget('dxPivotGrid', createConfig()));

// TestCafe's pressKey does not support function keys (F1-F12), so the Shift+F10
// shortcut is reproduced by dispatching the keydown event on the focused field.
const SHIFT_F10_KEYDOWN = {
key: 'F10', code: 'F10', shiftKey: true, bubbles: true, cancelable: true,
};

test('PivotGrid: Should open the context menu by Shift+F10 on a field', async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
const firstField = pivotGrid.getRowHeaderArea().getField(0);
const contextMenuItem = Selector('.dx-context-menu .dx-menu-item-text').withText('Show Field Chooser');

await t
.click(firstField)
.expect(firstField.focused)
.ok('field is focused after click')
.dispatchEvent(firstField, 'keydown', SHIFT_F10_KEYDOWN)
.expect(contextMenuItem.visible)
.ok('the field context menu is shown after Shift+F10');
}).before(async () => createWidget('dxPivotGrid', createConfig()));

test('PivotGrid: Field should have focus after the context menu is closed', async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
const firstField = pivotGrid.getRowHeaderArea().getField(0);
const contextMenuItem = Selector('.dx-context-menu .dx-menu-item-text').withText('Show Field Chooser');

await t
.click(firstField)
.dispatchEvent(firstField, 'keydown', SHIFT_F10_KEYDOWN)
.expect(contextMenuItem.visible)
.ok('the field context menu is shown after Shift+F10');

await t
.pressKey('esc')
.expect(firstField.focused)
.ok('the field is focused after the context menu is closed');
}).before(async () => createWidget('dxPivotGrid', createConfig()));

test('FieldChooser: Shift+F10 on a popup field should not open the grid context menu', async (t) => {
const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR);
const fieldChooser = pivotGrid.getFieldChooser();
const contextMenu = Selector('.dx-context-menu .dx-menu-item-text');

await t.click(pivotGrid.getFieldChooserButton());

const firstField = fieldChooser.getRowAreaItem(0);

// Parity with the mouse: right-click on the popup fields does not show the
// grid context menu either.
await t
.click(firstField)
.dispatchEvent(firstField, 'keydown', SHIFT_F10_KEYDOWN)
.expect(contextMenu.exists)
.notOk('the grid context menu is not shown for a field chooser popup field');
}).before(async () => createWidget('dxPivotGrid', createConfig()));
Comment thread
aleksei-semikozov marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from '@jest/globals';

import { describeDataCellsWithHeaders } from './data_cell_description';

const createSection = (tag: 'thead' | 'tbody', rowsHtml: string): HTMLTableSectionElement => {
const table = document.createElement('table');
table.innerHTML = `<${tag}>${rowsHtml}</${tag}>`;
return table.querySelector(tag) as HTMLTableSectionElement;
};
Comment thread
aleksei-semikozov marked this conversation as resolved.
Outdated

const getDescribingCells = (cell: HTMLTableCellElement | null): (Element | null)[] => (cell?.getAttribute('aria-describedby') ?? '')
.split(' ')
.filter((id) => !!id)
.map((id) => document.getElementById(id));

describe('describeDataCellsWithHeaders', () => {
it('should reference the column header path including colspan groups', () => {
const columns = createSection('thead', `
<tr><td id="group" colspan="2" aria-colindex="1">2015</td><td id="total" rowspan="2" aria-colindex="3">Grand Total</td></tr>
<tr><td id="q1" aria-colindex="1">Q1</td><td id="q2" aria-colindex="2">Q2</td></tr>
`);
const data = createSection('tbody', `
<tr><td aria-colindex="1" aria-rowindex="1">10</td><td aria-colindex="2" aria-rowindex="1">20</td><td aria-colindex="3" aria-rowindex="1">30</td></tr>
`);
document.body.append(columns, data);

describeDataCellsWithHeaders(columns, undefined, data);

const cells = data.querySelectorAll('td');
expect(getDescribingCells(cells[0])).toEqual([
document.getElementById('group'),
document.getElementById('q1'),
]);
expect(getDescribingCells(cells[1])).toEqual([
document.getElementById('group'),
document.getElementById('q2'),
]);
expect(getDescribingCells(cells[2])).toEqual([
document.getElementById('total'),
]);
});

it('should reference the row header path including rowspan groups', () => {
const rows = createSection('tbody', `
<tr><td id="africa" rowspan="2" aria-rowindex="1">Africa</td><td id="cairo" aria-rowindex="1">Cairo</td></tr>
<tr><td id="luxor" aria-rowindex="2">Luxor</td></tr>
`);
const data = createSection('tbody', `
<tr><td aria-colindex="1" aria-rowindex="1">10</td></tr>
<tr><td aria-colindex="1" aria-rowindex="2">20</td></tr>
`);
document.body.append(rows, data);

describeDataCellsWithHeaders(undefined, rows, data);

const cells = data.querySelectorAll('td');
expect(getDescribingCells(cells[0])).toEqual([
document.getElementById('africa'),
document.getElementById('cairo'),
]);
expect(getDescribingCells(cells[1])).toEqual([
document.getElementById('africa'),
document.getElementById('luxor'),
]);
});

it('should combine row and column paths in the row-then-column order', () => {
const columns = createSection('thead', '<tr><td id="c" aria-colindex="1">2015</td></tr>');
const rows = createSection('tbody', '<tr><td id="r" aria-rowindex="1">Africa</td></tr>');
const data = createSection('tbody', '<tr><td aria-colindex="1" aria-rowindex="1">10</td></tr>');
document.body.append(columns, rows, data);

describeDataCellsWithHeaders(columns, rows, data);

expect(getDescribingCells(data.querySelector('td'))).toEqual([
document.getElementById('r'),
document.getElementById('c'),
]);
});

it('should respect the virtual scrolling index offset', () => {
const columns = createSection('thead', '<tr><td id="c5" aria-colindex="5">2015</td><td id="c6" aria-colindex="6">2016</td></tr>');
const data = createSection('tbody', '<tr><td aria-colindex="5" aria-rowindex="1">10</td><td aria-colindex="6" aria-rowindex="1">20</td></tr>');
document.body.append(columns, data);

describeDataCellsWithHeaders(columns, undefined, data);

const cells = data.querySelectorAll('td');
expect(getDescribingCells(cells[0])).toEqual([document.getElementById('c5')]);
expect(getDescribingCells(cells[1])).toEqual([document.getElementById('c6')]);
});

it('should reuse existing header cell ids and keep generated ones stable across runs', () => {
const columns = createSection('thead', '<tr><td id="existing" aria-colindex="1">2015</td><td aria-colindex="2">2016</td></tr>');
const data = createSection('tbody', '<tr><td aria-colindex="1" aria-rowindex="1">10</td><td aria-colindex="2" aria-rowindex="1">20</td></tr>');
document.body.append(columns, data);

describeDataCellsWithHeaders(columns, undefined, data);
const generatedId = (columns.querySelectorAll('td')[1]).id;
describeDataCellsWithHeaders(columns, undefined, data);

expect((columns.querySelectorAll('td')[0]).id).toBe('existing');
expect((columns.querySelectorAll('td')[1]).id).toBe(generatedId);
expect(generatedId).toMatch(/^dx-/);
});

it('should remove a stale description when no header chains match', () => {
const data = createSection('tbody', '<tr><td aria-colindex="1" aria-rowindex="1" aria-describedby="stale">10</td></tr>');
document.body.append(data);

describeDataCellsWithHeaders(undefined, undefined, data);

expect(data.querySelector('td')?.hasAttribute('aria-describedby')).toBe(false);
});
});
Loading
Loading