Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
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
116 changes: 116 additions & 0 deletions e2e/testcafe-devextreme/tests/accessibility/pivotGrid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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()));

// The popup/menu widgets are portaled to <body>, outside the harness page
// landmarks, so the best-practice 'region' rule fires on them regardless of
// the widget's own accessibility (same convention as dataGrid/cardView a11y
// tests). It is disabled for the overlay scenarios.
const overlayA11yOptions = { rules: { region: { enabled: false } } };

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

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

await a11yCheck(t, overlayA11yOptions);
}).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 t.expect(Selector('.dx-header-filter-menu .dx-list-item').visible).ok();

await a11yCheck(t, overlayA11yOptions);
}).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 t.expect(Selector('.dx-context-menu .dx-menu-item').visible).ok();

await a11yCheck(t, overlayA11yOptions);
}).before(async () => createWidget('dxPivotGrid', createConfig()));
53 changes: 53 additions & 0 deletions e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,56 @@ 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);

await t
.click(firstField)
.dispatchEvent(firstField, 'keydown', SHIFT_F10_KEYDOWN)
.expect(contextMenu.visible)
.notOk('the grid context menu is not shown for a field chooser popup field');
}).before(async () => createWidget('dxPivotGrid', createConfig()));
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ abstract class AreaItem {
div.setAttribute('aria-expanded', String(cell.expanded));
div.setAttribute('tabindex', isCellNavigationEnabled ? '-1' : '0');

// With cell navigation the cell itself is the focus target, so it
// must expose the expanded state to assistive technologies.
span.setAttribute('aria-hidden', 'true');

if (isCellNavigationEnabled) {
td.setAttribute('aria-expanded', String(cell.expanded));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ import {
} from '@jest/globals';
import messageLocalization from '@js/common/core/localization/message';

import { getFieldItemA11yLabel } from './a11y';
import { getFieldItemA11yLabel, getFieldsHotkeysA11yDescription, getFieldsHotkeysA11yLabel } from './a11y';

describe('getFieldItemA11yLabel', () => {
beforeAll(() => {
// @ts-expect-error load is not declared on the localization typing
messageLocalization.load({
en: {
'dxPivotGrid-ariaFieldLabel': 'Field: {0}',
'dxPivotGrid-ariaFieldHeaderFilterLabel': 'Header filter applied',
'dxPivotGrid-ariaFieldSortAscLabel': 'Sort order: ascending',
'dxPivotGrid-ariaFieldSortDescLabel': 'Sort order: descending',
},
});
const AREA_DESCRIPTION = 'Press Enter or Space to sort by the field, Alt + Down arrow to open the header filter, Shift + F10 to open the context menu';

beforeAll(() => {
// @ts-expect-error load is not declared on the localization typing
messageLocalization.load({
en: {
'dxPivotGrid-ariaFieldLabel': 'Field: {0}',
'dxPivotGrid-ariaFieldHeaderFilterLabel': 'Header filter applied',
'dxPivotGrid-ariaFieldSortAscLabel': 'Sort order: ascending',
'dxPivotGrid-ariaFieldSortDescLabel': 'Sort order: descending',
'dxPivotGrid-ariaFieldsAreaDescription': AREA_DESCRIPTION,
},
});
});

describe('getFieldItemA11yLabel', () => {
it('should contain only the field name by default', () => {
expect(getFieldItemA11yLabel('Region', {})).toBe('Field: Region');
});
Expand All @@ -42,3 +45,20 @@ describe('getFieldItemA11yLabel', () => {
.toBe('Field: Region, Header filter applied, Sort order: ascending');
});
});

describe('getFieldsHotkeysA11yDescription', () => {
it('should return the localized interaction description', () => {
expect(getFieldsHotkeysA11yDescription()).toBe(AREA_DESCRIPTION);
});
});

describe('getFieldsHotkeysA11yLabel', () => {
it('should compose the base label with the interaction description', () => {
expect(getFieldsHotkeysA11yLabel('Pivot grid')).toBe(`Pivot grid. ${AREA_DESCRIPTION}`);
});

it('should fall back to the description alone when the base label is empty', () => {
expect(getFieldsHotkeysA11yLabel(undefined)).toBe(AREA_DESCRIPTION);
expect(getFieldsHotkeysA11yLabel('')).toBe(AREA_DESCRIPTION);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const I18N_KEYS = {
hasHeaderFilter: 'dxPivotGrid-ariaFieldHeaderFilterLabel',
sortingAsc: 'dxPivotGrid-ariaFieldSortAscLabel',
sortingDesc: 'dxPivotGrid-ariaFieldSortDescLabel',
fieldsAreaDescription: 'dxPivotGrid-ariaFieldsAreaDescription',
};

const I18N_MESSAGE_SEPARATOR = ', ';
Expand All @@ -28,6 +29,14 @@ const getSortingLabel = (sortOrder?: SortOrderType): string | null => {
}
};

export const getFieldsHotkeysA11yDescription = (): string => messageLocalization
.format(I18N_KEYS.fieldsAreaDescription);

export const getFieldsHotkeysA11yLabel = (baseLabel?: string): string => {
const description = getFieldsHotkeysA11yDescription();
return baseLabel ? `${baseLabel}. ${description}` : description;
};

export const getFieldItemA11yLabel = (
caption: string,
{ sortOrder, hasHeaderFilterValue }: FieldItemA11yOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ContextMenu from '@js/ui/context_menu';
import TreeView from '@js/ui/tree_view';

import { foreachDataLevel, getCompareFunction } from '../m_widget_utils';
import { getFieldsHotkeysA11yLabel } from './a11y';
import { ATTRIBUTES, CLASSES, ICONS } from './const';
import { FieldChooserBase } from './m_field_chooser_base';

Expand Down Expand Up @@ -261,7 +262,9 @@ export class FieldChooser extends FieldChooserBase {

$element
.addClass(CLASSES.fieldChooser.self)
.addClass(CLASSES.pivotGrid.fieldsContainer);
.addClass(CLASSES.pivotGrid.fieldsContainer)
.attr('role', 'group')
.attr('aria-label', getFieldsHotkeysA11yLabel(localizationMessage.format('dxPivotGrid-fieldChooserTitle')));

that._dataChangedHandlers = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ function isFieldNavigationEvent(e): boolean {
&& !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
}

function isContextMenuKeyEvent(e): boolean {
return e.type === 'keydown'
&& !e.repeat
&& (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'));
}

class HeaderFilterView extends HeaderFilterViewBase {
_getSearchExpr(options, headerFilterOptions) {
options.useDefaultSearchExpr = true;
Expand Down Expand Up @@ -127,6 +133,7 @@ export class FieldChooserBase extends mixinWidget {
allowFieldDragging: true,
applyChangesMode: 'instantly',
state: undefined,
onFieldContextMenuKeyDown: null,
headerFilter: {
width: 252,
height: 325,
Expand Down Expand Up @@ -187,6 +194,7 @@ export class FieldChooserBase extends mixinWidget {
break;
case 'applyChangesMode':
case 'remoteSort':
case 'onFieldContextMenuKeyDown':
break;
case 'state':
if (this._skipStateChange || !this._dataSource) {
Expand Down Expand Up @@ -424,6 +432,11 @@ export class FieldChooserBase extends mixinWidget {
return;
}

if (isContextMenuKeyEvent(e)) {
this._handleFieldContextMenuKey(e, field);
return;
}

if (!isClick) {
return;
}
Expand Down Expand Up @@ -555,6 +568,24 @@ export class FieldChooserBase extends mixinWidget {
return FIELD_NAVIGATION_DELTAS[key];
}

private _handleFieldContextMenuKey(e, field): void {
const onContextMenuKey = this.option('onFieldContextMenuKeyDown');

if (!field.area || !onContextMenuKey) {
return;
}

const navigation = this._getFieldNavigation(field.area);

// The event can bubble to another instance subscribed to an ancestor
// element; the field then does not belong to this instance's items.
if (!navigation.getItems().includes(e.currentTarget)) {
return;
}

onContextMenuKey(e, navigation);
}

private _handleFieldNavigation(e, field): void {
if (!field.area) {
return;
Expand Down
Loading
Loading