From de4463ccab76324c758277db554a130db5172272 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 10 Jul 2026 14:28:33 -0300 Subject: [PATCH 1/2] PivotGrid - KBN - Add ARIA attributes to area fields --- .../tests/common/pivotGrid/kbn/fields.ts | 24 ++++++++++ .../pivot_grid/field_chooser/a11y.test.ts | 44 +++++++++++++++++++ .../grids/pivot_grid/field_chooser/a11y.ts | 41 +++++++++++++++++ .../field_chooser/m_field_chooser.ts | 5 ++- .../field_chooser/m_field_chooser_base.ts | 18 ++++++++ .../pivot_grid/fields_area/m_fields_area.ts | 33 +++++++++++--- .../js/localization/messages/en.json | 4 ++ 7 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.test.ts create mode 100644 packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/a11y.ts diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts index faed3c6abb61..c49b7f2fde9d 100644 --- a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts @@ -112,6 +112,30 @@ 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'); + + 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 form a single tab stop`, async (t) => { const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); 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 4144a191d29a..8a60ee7d7e3d 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 @@ -578,7 +578,10 @@ export class FieldChooser extends FieldChooserBase { if (area !== 'all') { $fieldsContainer.attr('group', area).attr(ATTRIBUTES.allowScrolling, true); - $fieldsContent = $(DIV).addClass(CLASSES.area.fieldContainer).appendTo($fieldsContainer); + $fieldsContent = $(DIV).addClass(CLASSES.area.fieldContainer) + .attr('role', 'menubar') + .attr('aria-label', caption) + .appendTo($fieldsContainer); render = function () { that._renderAreaFields($fieldsContent, 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 85897bb20c14..2b3ea4e62b0f 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 @@ -23,6 +23,8 @@ import type { RovingTabIndexComponent } from '../keyboard_navigation/roving_tab_ 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'; @@ -242,9 +244,25 @@ export class FieldChooserBase extends mixinWidget { $fieldElement.attr(ATTRIBUTES.itemGroup, field.groupName); } + $fieldElement + .attr('role', 'menuitem') + .attr('aria-label', this._getFieldItemAriaLabel(field, caption)); + return $fieldElement; } + private _getFieldItemAriaLabel(field, caption: string): string { + // The sort indicator is rendered for every sortable non-data field, with + // ascending shown by default, so the label mirrors the visible state. + const sortOrder = field.allowSorting && field.area !== 'data' + ? (field.sortOrder === 'desc' ? 'desc' : 'asc') as SortOrderType + : undefined; + const hasHeaderFilterValue = fieldHasHeaderFilter(this._dataSource, field) + && !!getMainGroupField(this._dataSource, field).filterValues?.length; + + return getFieldItemA11yLabel(caption, { sortOrder, hasHeaderFilterValue }); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars _clean(value?) { } diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts b/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts index 4ae33e0c31d1..fc3cb152cb1f 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts @@ -46,7 +46,7 @@ class FieldsArea extends AreaItem { .attr('group', this._area); } - _createTableElement() { + _getAreaLabel() { const localizationMessageMap = { row: 'dxPivotGrid-rowFields', column: 'dxPivotGrid-columnFields', @@ -54,9 +54,13 @@ class FieldsArea extends AreaItem { filter: 'dxPivotGrid-filterFields', }; + return localizationMessage.format(localizationMessageMap[this._area]); + } + + _createTableElement() { return $('') .attr('role', 'group') - .attr('aria-label', localizationMessage.format(localizationMessageMap[this._area])); + .attr('aria-label', this._getAreaLabel()); } isVisible() { @@ -145,9 +149,16 @@ class FieldsArea extends AreaItem { const groupElement = this.groupElement(); const isVisible = this.isVisible(); const fieldChooserBase = that.component.$element().dxPivotGridFieldChooserBase('instance'); - const head = $('').addClass('dx-pivotgrid-fields-area-head').appendTo(tableElement); + const head = $('') + .addClass('dx-pivotgrid-fields-area-head') + .attr('role', 'presentation') + .appendTo(tableElement); const area = that._area; - const row = $(''); + // The table row exposes the fields as a menubar (as in the field chooser), + // so the intermediate table cells are removed from the accessibility tree. + const row = $('') + .attr('role', 'menubar') + .attr('aria-label', this._getAreaLabel()); groupElement.toggleClass('dx-hidden', !isVisible); tableElement.addClass('dx-area-field-container'); @@ -158,7 +169,9 @@ class FieldsArea extends AreaItem { each(data, (index: number, field) => { if (field.area === area && field.visible !== false) { - const td = $('') - .attr('role', 'menubar') - .attr('aria-label', this._getAreaLabel()); + const row = $(''); groupElement.toggleClass('dx-hidden', !isVisible); tableElement.addClass('dx-area-field-container'); @@ -180,16 +176,16 @@ class FieldsArea extends AreaItem { renderGroupConnector(field, data[index + 1], data[index - 1], td); } }); - if (!row.children().length) { - $('
').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()); @@ -168,7 +181,15 @@ class FieldsArea extends AreaItem { } }); if (!row.children().length) { - $('').append($(DIV).addClass('dx-empty-area-text').text(this.option(`fieldPanel.texts.${area}FieldArea`))).appendTo(row); + $('') + .attr('role', 'none') + .append( + $(DIV) + .addClass('dx-empty-area-text') + .attr('role', 'menuitem') + .text(this.option(`fieldPanel.texts.${area}FieldArea`)), + ) + .appendTo(row); } if (that._shouldCreateButton()) { 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", From 6d03d82027245d5a28c2e2d50ae21340ce1f17a4 Mon Sep 17 00:00:00 2001 From: Aleksey Semikozov Date: Fri, 10 Jul 2026 17:06:45 -0300 Subject: [PATCH 2/2] PivotGrid - KBN - Keep empty field areas out of the menubar semantics --- .../field_chooser/m_field_chooser.ts | 12 ++++++--- .../pivot_grid/fields_area/m_fields_area.ts | 26 ++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) 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 8a60ee7d7e3d..b0e368ca563a 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 @@ -548,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) { @@ -578,10 +585,7 @@ export class FieldChooser extends FieldChooserBase { if (area !== 'all') { $fieldsContainer.attr('group', area).attr(ATTRIBUTES.allowScrolling, true); - $fieldsContent = $(DIV).addClass(CLASSES.area.fieldContainer) - .attr('role', 'menubar') - .attr('aria-label', caption) - .appendTo($fieldsContainer); + $fieldsContent = $(DIV).addClass(CLASSES.area.fieldContainer).appendTo($fieldsContainer); render = function () { that._renderAreaFields($fieldsContent, area); }; diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts b/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts index fc3cb152cb1f..3877c3afe331 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/fields_area/m_fields_area.ts @@ -154,11 +154,7 @@ class FieldsArea extends AreaItem { .attr('role', 'presentation') .appendTo(tableElement); const area = that._area; - // The table row exposes the fields as a menubar (as in the field chooser), - // so the intermediate table cells are removed from the accessibility tree. - const row = $('
') - .attr('role', 'none') - .append( - $(DIV) - .addClass('dx-empty-area-text') - .attr('role', 'menuitem') - .text(this.option(`fieldPanel.texts.${area}FieldArea`)), - ) - .appendTo(row); + 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); } if (that._shouldCreateButton()) {