Skip to content
Draft
104 changes: 89 additions & 15 deletions e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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()));
});

Expand All @@ -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');

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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()));
Expand All @@ -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()));
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export class FieldChooser extends FieldChooserBase {
this.renderSortable();
this._renderContextMenu();
this.updateDimensions();
this.updateFieldsTabIndexes();
}

_fireContentReadyAction() {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading