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 = '
'; +const FIELD_AREAS = ['filter', 'row', 'column', 'data']; + +const FIELD_NAVIGATION_DELTAS = { + ArrowUp: -1, + ArrowLeft: -1, + ArrowDown: 1, + ArrowRight: 1, +}; + +function isFieldNavigationEvent(e): boolean { + return e.type === 'keydown' + && FIELD_NAVIGATION_DELTAS[e.key] !== undefined + && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey; +} + class HeaderFilterView extends HeaderFilterViewBase { _getSearchExpr(options, headerFilterOptions) { options.useDefaultSearchExpr = true; @@ -97,6 +116,10 @@ const mixinWidget = sortingMixin( export class FieldChooserBase extends mixinWidget { private _focusedFieldIndex = -1; + // NOTE: no initializer — class field initializers do not run for classes + // built on the legacy DevExtreme class system in the systemjs build. + private _fieldNavigationMap: Record | undefined; + _getDefaultOptions() { return { ...super._getDefaultOptions(), @@ -142,11 +165,21 @@ export class FieldChooserBase extends mixinWidget { _refreshDataSource() { const dataSource = this.option('dataSource'); + // Field indexes are only meaningful within one data source, so the roving + // tab stops fall back to the first field of each area. + this._resetFieldNavigation(); + if (dataSource?.fields && dataSource.load/* instanceof DX.ui.dxPivotGrid.DataSource */) { this._dataSource = dataSource; } } + private _resetFieldNavigation(): void { + Object.values(this._fieldNavigationMap ?? {}).forEach((navigation) => { + navigation.reset(); + }); + } + _optionChanged(args) { switch (args.name) { case 'dataSource': @@ -211,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?) { } @@ -370,6 +419,11 @@ export class FieldChooserBase extends mixinWidget { return; } + if (isFieldNavigationEvent(e)) { + this._handleFieldNavigation(e, field); + return; + } + if (!isClick) { return; } @@ -389,6 +443,10 @@ export class FieldChooserBase extends mixinWidget { } this._focusedFieldIndex = field.index; + + if (field.area) { + this._getFieldNavigation(field.area).handleFocusIn(e.currentTarget); + } }; const focusOutHandler = (e) => { @@ -426,11 +484,101 @@ export class FieldChooserBase extends mixinWidget { } restoreFieldFocus(): void { + this.updateFieldsTabIndexes(); + if (this._focusedFieldIndex !== -1) { this.focusFieldElement(this._focusedFieldIndex); } } + updateFieldsTabIndexes(): void { + FIELD_AREAS.forEach((area) => { + this._getFieldNavigation(area).updateTabIndexes(); + }); + } + + private _getFieldNavigation(area: string): RovingTabIndex { + // The sorting and column state mixins lose the Widget typing, so the + // component contract has to be restored explicitly. + const component = this as unknown as RovingTabIndexComponent; + + this._fieldNavigationMap = this._fieldNavigationMap ?? {}; + this._fieldNavigationMap[area] = this._fieldNavigationMap[area] ?? new RovingTabIndex({ + component, + getItems: () => this._getAreaFieldElements(area), + scrollToItem: (item) => this._scrollFieldElementToView(item), + getItemId: (item) => this._getFieldElementId(item), + }); + + return this._fieldNavigationMap[area]; + } + + // field.index identifies the same logical field when fields are reordered, + // added or removed, so the tab stop does not drift to another field. + private _getFieldElementId(fieldElement: HTMLElement): string | undefined { + const field: any = $(fieldElement).data('field'); + + return isDefined(field?.index) ? String(field.index) : undefined; + } + + // The field chooser popup can be rendered inside the pivot grid element, so + // the fields of this instance are told apart from the other instance's + // fields by the closest field chooser root. + private _getAreaFieldElements(area: string): HTMLElement[] { + const ownFieldChooserRoot = this.$element().closest(`.${CLASSES.fieldChooser.self}`).get(0) ?? null; + // NOTE: get() without an index is not supported by the native renderer. + const fields: HTMLElement[] = this.$element() + .find(`[group="${area}"] .${CLASSES.area.field}.${CLASSES.area.box}`) + .toArray(); + + return fields.filter((field) => { + const fieldChooserRoot = $(field).closest(`.${CLASSES.fieldChooser.self}`).get(0) ?? null; + + return fieldChooserRoot === ownFieldChooserRoot; + }); + } + + private _scrollFieldElementToView(fieldElement: HTMLElement): void { + const $scrollable = $(fieldElement).closest(`.${CLASSES.scrollable.self}`); + const scrollable = $scrollable.length ? ($scrollable as any).dxScrollable('instance') : undefined; + + scrollable?.scrollToElement(fieldElement); + } + + private _getFieldNavigationDelta(key: string): number { + const isHorizontal = key === 'ArrowLeft' || key === 'ArrowRight'; + + if (isHorizontal && this.option('rtlEnabled')) { + return -FIELD_NAVIGATION_DELTAS[key]; + } + + return FIELD_NAVIGATION_DELTAS[key]; + } + + private _handleFieldNavigation(e, field): void { + if (!field.area) { + return; + } + + const navigation = this._getFieldNavigation(field.area); + const items = navigation.getItems(); + const index = items.indexOf(e.currentTarget); + + // The event can bubble to another instance subscribed to an ancestor + // element; the field then does not belong to this instance's items. + if (index < 0) { + return; + } + + e.preventDefault(); + + const targetIndex = index + this._getFieldNavigationDelta(e.key); + + if (targetIndex >= 0 && targetIndex < items.length) { + navigation.focusItem(targetIndex); + } + } + private _renderHeaderFilterIcon($fieldElement, field, showColumnLines): void { if (!fieldHasHeaderFilter(this._dataSource, field)) { return; 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..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 @@ -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,7 +149,10 @@ 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 = $(''); @@ -158,7 +165,9 @@ class FieldsArea extends AreaItem { each(data, (index: number, field) => { if (field.area === area && field.visible !== false) { - const td = $('
').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",