diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts index 86a80a6d87a2..c49b7f2fde9d 100644 --- a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts @@ -1,5 +1,6 @@ import PivotGrid from 'devextreme-testcafe-models/pivotGrid'; import HeaderFilter from 'devextreme-testcafe-models/dataGrid/headers/headerFilter'; +import { Selector } from 'testcafe'; import url from '../../../../helpers/getPageUrl'; import { createWidget } from '../../../../helpers/createWidget'; @@ -107,8 +108,66 @@ const createConfig = () => ({ const testTitlePrefix = isFieldChooser ? 'Field Chooser' : 'PivotGrid'; + const getAreaFieldsContainer = (area: string) => (isFieldChooser + ? 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'); + + await t + .expect(firstField.getAttribute('role')) + .eql('menuitem', 'a field is exposed as a menu item') + .expect(firstField.getAttribute('aria-label')) + .eql('Field name Region, Sorted in ascending order', '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 be focusable by tab`, async (t) => { + test(`${testTitlePrefix}: Fields in ${area} area should form a single tab stop`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const areaContainer = getAreaFieldsContainer(area); + + await t + .expect(areaContainer.find('.dx-area-field[tabindex="0"]').count) + .eql(1, 'only one field of the area is in the tab order'); + + const firstField = getField(pivotGrid, area, 0); + const secondField = getField(pivotGrid, area, 1); + + await t + .click(secondField) + .expect(secondField.focused) + .ok('second field is focused after click') + .expect(secondField.getAttribute('tabindex')) + .eql('0', 'focused field is the tab stop') + .expect(firstField.getAttribute('tabindex')) + .eql('-1', 'first field is removed from the tab order'); + + await t + .expect(areaContainer.find('.dx-area-field[tabindex="0"]').count) + .eql(1, 'the focused field is the only tab stop'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + + test(`${testTitlePrefix}: Fields in ${area} area should be navigable by arrows`, async (t) => { const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); if (isFieldChooser) { @@ -124,14 +183,29 @@ const createConfig = () => ({ .ok('first field is focused after click'); await t - .pressKey('tab') + .pressKey('right') .expect(secondField.focused) - .ok('second field is focused after Tab'); + .ok('second field is focused after ArrowRight'); + + await t + .pressKey('left') + .expect(firstField.focused) + .ok('first field is focused after ArrowLeft'); + + await t + .pressKey('down') + .expect(secondField.focused) + .ok('second field is focused after ArrowDown'); + + await t + .pressKey('up') + .expect(firstField.focused) + .ok('first field is focused after ArrowUp'); await t - .pressKey('shift+tab') + .pressKey('up') .expect(firstField.focused) - .ok('first field is focused after Shift+Tab'); + .ok('focus stays on the first field at the area boundary'); }).before(async () => createWidget('dxPivotGrid', createConfig())); }); @@ -148,9 +222,9 @@ const createConfig = () => ({ await t .click(firstField) - .pressKey('tab') + .pressKey('right') .expect(secondField.focused) - .ok('second field is focused after Tab') + .ok('second field is focused after ArrowRight') .expect(secondField.find('.dx-sort-up').exists) .ok('second field has asc sort indicator initially'); @@ -181,9 +255,9 @@ const createConfig = () => ({ await t .click(firstField) - .pressKey('tab') + .pressKey('right') .expect(secondField.focused) - .ok('second field is focused after Tab') + .ok('second field is focused after ArrowRight') .expect(secondField.find('.dx-sort-up').exists) .ok('second field has asc sort indicator initially'); @@ -285,17 +359,17 @@ test('PivotGrid: Should traverse fields in all areas by tab', async (t) => { .ok('first field in filter area is focused after click'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(dataFirstField.focused) .ok('first field in data area is focused'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(columnFirstField.focused) .ok('first field in column area is focused'); await t - .pressKey('tab tab tab tab') + .pressKey('tab') .expect(rowFirstField.focused) .ok('first field in row area is focused'); }).before(async () => createWidget('dxPivotGrid', createConfig())); @@ -317,17 +391,17 @@ test('FieldChooser: Should traverse fields in all areas by tab', async (t) => { .ok('first field in row area is focused after click'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(columnFirstField.focused) .ok('first field in column area is focused'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(filterFirstField.focused) .ok('first field in filter area is focused'); await t - .pressKey('tab tab') + .pressKey('tab') .expect(dataFirstField.focused) .ok('first field in data area is focused'); }).before(async () => createWidget('dxPivotGrid', createConfig())); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.test.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.test.ts new file mode 100644 index 000000000000..440eece1eb57 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.test.ts @@ -0,0 +1,44 @@ +import { + beforeAll, describe, expect, it, +} from '@jest/globals'; +import messageLocalization from '@js/common/core/localization/message'; + +import { getFieldItemA11yLabel } from './a11y'; + +describe('getFieldItemA11yLabel', () => { + beforeAll(() => { + // @ts-expect-error load is not declared on the localization typing + messageLocalization.load({ + en: { + 'dxPivotGrid-ariaFieldItemLabel': 'Field name {0}', + 'dxPivotGrid-ariaFieldItemHasHeaderFilterLabel': 'Header filter applied', + 'dxPivotGrid-ariaFieldItemSortingAscendingLabel': 'Sorted in ascending order', + 'dxPivotGrid-ariaFieldItemSortingDescendingLabel': 'Sorted in descending order', + }, + }); + }); + + it('should contain only the field name by default', () => { + expect(getFieldItemA11yLabel('Region', {})).toBe('Field name Region'); + }); + + it('should append the ascending sorting state', () => { + expect(getFieldItemA11yLabel('Region', { sortOrder: 'asc' })) + .toBe('Field name Region, Sorted in ascending order'); + }); + + it('should append the descending sorting state', () => { + expect(getFieldItemA11yLabel('Region', { sortOrder: 'desc' })) + .toBe('Field name Region, Sorted in descending order'); + }); + + it('should append the header filter state', () => { + expect(getFieldItemA11yLabel('Region', { hasHeaderFilterValue: true })) + .toBe('Field name Region, Header filter applied'); + }); + + it('should compose all parts in a stable order', () => { + expect(getFieldItemA11yLabel('Region', { sortOrder: 'asc', hasHeaderFilterValue: true })) + .toBe('Field name Region, Header filter applied, Sorted in ascending order'); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.ts new file mode 100644 index 000000000000..5aace3f46f47 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.ts @@ -0,0 +1,41 @@ +import messageLocalization from '@js/common/core/localization/message'; + +import type { SortOrderType } from './const'; +import { SORT_ORDER } from './const'; + +const I18N_KEYS = { + fieldLabel: 'dxPivotGrid-ariaFieldItemLabel', + hasHeaderFilter: 'dxPivotGrid-ariaFieldItemHasHeaderFilterLabel', + sortingAsc: 'dxPivotGrid-ariaFieldItemSortingAscendingLabel', + sortingDesc: 'dxPivotGrid-ariaFieldItemSortingDescendingLabel', +}; + +const I18N_MESSAGE_SEPARATOR = ', '; + +export interface FieldItemA11yOptions { + sortOrder?: SortOrderType; + hasHeaderFilterValue?: boolean; +} + +const getSortingLabel = (sortOrder?: SortOrderType): string | null => { + switch (sortOrder) { + case SORT_ORDER.ascending: + return messageLocalization.format(I18N_KEYS.sortingAsc); + case SORT_ORDER.descending: + return messageLocalization.format(I18N_KEYS.sortingDesc); + default: + return null; + } +}; + +export const getFieldItemA11yLabel = ( + caption: string, + { sortOrder, hasHeaderFilterValue }: FieldItemA11yOptions, +): string => [ + // @ts-expect-error format typing does not accept substitution args + messageLocalization.format(I18N_KEYS.fieldLabel, caption), + hasHeaderFilterValue ? messageLocalization.format(I18N_KEYS.hasHeaderFilter) : null, + getSortingLabel(sortOrder), +] + .filter((message) => !!message) + .join(I18N_MESSAGE_SEPARATOR); diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts index bf0bb3312ec2..b045f3620481 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts @@ -287,6 +287,7 @@ export class FieldChooser extends FieldChooserBase { this.renderSortable(); this._renderContextMenu(); this.updateDimensions(); + this.updateFieldsTabIndexes(); } _fireContentReadyAction() { @@ -547,6 +548,13 @@ export class FieldChooser extends FieldChooserBase { that.renderField(field, true).appendTo($container); } }); + + // A menubar without menu items is invalid ARIA, so an empty area stays + // without the role until fields are dropped into it. + const hasFields = !!$container.children().length; + $container + .attr('role', hasFields ? 'menubar' : null) + .attr('aria-label', hasFields ? that.option(`texts.${area}Fields`) : null); } _renderArea(container, area) { diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts index 3323acb54ba1..1fbd0eed1d7f 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser_base.ts @@ -19,8 +19,12 @@ import { import gridCoreUtils from '@ts/grids/grid_core/m_utils'; import sortingMixin from '@ts/grids/grid_core/sorting/m_sorting_mixin'; +import type { RovingTabIndexComponent } from '../keyboard_navigation/roving_tab_index'; +import { RovingTabIndex } from '../keyboard_navigation/roving_tab_index'; import { createPath, foreachTree } from '../m_widget_utils'; import SortableModule from '../sortable/m_sortable'; +import { getFieldItemA11yLabel } from './a11y'; +import type { SortOrderType } from './const'; import { ATTRIBUTES, CLASSES } from './const'; import { dragAndDropItemRender } from './dom'; import { reverseSortOrder, shouldCancelDragging } from './utils'; @@ -29,6 +33,21 @@ const { Sortable } = SortableModule; const DIV = '
| ').append(fieldChooserBase.renderField(field, field.area === 'row')); + const td = $(' | ') + .attr('role', 'none') + .append(fieldChooserBase.renderField(field, field.area === 'row')); const indicators = td.find('.dx-column-indicators'); if (indicators.length && that._shouldCreateButton()) { indicators.insertAfter((indicators as any).next()); @@ -167,7 +176,15 @@ class FieldsArea extends AreaItem { renderGroupConnector(field, data[index + 1], data[index - 1], td); } }); - if (!row.children().length) { + if (row.children().length) { + // The table row exposes the fields as a menubar (as in the field + // chooser); the intermediate table cells are removed from the + // accessibility tree. An empty area keeps the plain placeholder text — + // a menubar without menu items is invalid ARIA. + row + .attr('role', 'menubar') + .attr('aria-label', this._getAreaLabel()); + } else { $(' | ').append($(DIV).addClass('dx-empty-area-text').text(this.option(`fieldPanel.texts.${area}FieldArea`))).appendTo(row); } diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index f7e629931ce9..0f63d1cf4550 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -284,6 +284,10 @@ "dxPivotGrid-dataFieldArea": "Drop Data Fields Here", "dxPivotGrid-rowFieldArea": "Drop Row Fields Here", "dxPivotGrid-filterFieldArea": "Drop Filter Fields Here", + "dxPivotGrid-ariaFieldItemLabel": "Field name {0}", + "dxPivotGrid-ariaFieldItemHasHeaderFilterLabel": "Header filter applied", + "dxPivotGrid-ariaFieldItemSortingAscendingLabel": "Sorted in ascending order", + "dxPivotGrid-ariaFieldItemSortingDescendingLabel": "Sorted in descending order", "dxScheduler-dateRange": "from {0} to {1}", "dxScheduler-ariaLabel": "Scheduler. {0} view: {1} with {2} appointments", |