diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts index 86a80a6d87a2..faed3c6abb61 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,42 @@ 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}"]`)); + ['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 +159,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 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 Tab'); + .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 +198,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 +231,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 +335,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 +367,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/m_field_chooser.ts b/packages/devextreme/js/__internal/grids/pivot_grid/field_chooser/m_field_chooser.ts index bf0bb3312ec2..b9278b15106f 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() { 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..d901fff7e551 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,6 +19,8 @@ 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 { ATTRIBUTES, CLASSES } from './const'; @@ -29,6 +31,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 +114,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 +163,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': @@ -370,6 +401,11 @@ export class FieldChooserBase extends mixinWidget { return; } + if (isFieldNavigationEvent(e)) { + this._handleFieldNavigation(e, field); + return; + } + if (!isClick) { return; } @@ -389,6 +425,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 +466,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;