Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 65 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,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) {
Expand All @@ -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()));
});

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

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

Expand Down Expand Up @@ -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()));
Expand All @@ -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()));
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,6 +31,21 @@ const { Sortable } = SortableModule;

const DIV = '<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;
Expand Down Expand Up @@ -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<string, RovingTabIndex> | undefined;

_getDefaultOptions() {
return {
...super._getDefaultOptions(),
Expand Down Expand Up @@ -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();

Comment on lines +166 to +169
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':
Expand Down Expand Up @@ -370,6 +401,11 @@ export class FieldChooserBase extends mixinWidget {
return;
}

if (isFieldNavigationEvent(e)) {
this._handleFieldNavigation(e, field);
return;
}

if (!isClick) {
return;
}
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down
Loading