diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts new file mode 100644 index 000000000000..404dbe343be2 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/fields.ts @@ -0,0 +1,316 @@ +import PivotGrid from 'devextreme-testcafe-models/pivotGrid'; +import HeaderFilter from 'devextreme-testcafe-models/dataGrid/headers/headerFilter'; +import url from '../../../../helpers/getPageUrl'; +import { createWidget } from '../../../../helpers/createWidget'; + +fixture.disablePageReloads`PivotGrid_KBN_fields` + .page(url(__dirname, '../../../container.html')); + +const PIVOT_GRID_SELECTOR = '#container'; + +const createConfig = () => ({ + width: 1000, + allowSortingBySummary: true, + allowSorting: true, + allowExpandAll: true, + allowFiltering: true, + showBorders: true, + fieldChooser: { + enabled: true, + height: 500, + }, + fieldPanel: { + visible: true, + }, + dataSource: { + fields: [{ + dataField: 'country', + area: 'filter', + }, { + dataField: 'city', + area: 'filter', + }, { + caption: 'Region', + width: 120, + dataField: 'region', + area: 'row', + }, { + caption: 'City', + dataField: 'city', + width: 150, + area: 'row', + }, { + dataField: 'id', + area: 'column', + }, { + dataField: 'date', + dataType: 'date', + area: 'column', + }, { + groupName: 'date', + groupInterval: 'year', + expanded: true, + area: 'column', + }, { + caption: 'Relative Sales', + dataField: 'amount', + dataType: 'number', + summaryType: 'sum', + area: 'data', + summaryDisplayMode: 'percentOfColumnGrandTotal', + }, { + dataField: 'data1', + dataType: 'number', + area: 'data', + }], + store: [{ + id: 10887, + region: 'Africa', + country: 'Egypt', + city: 'Cairo', + amount: 500, + date: new Date('2015-05-26'), + }, { + id: 10888, + region: 'South America', + country: 'Argentina', + city: 'Buenos Aires', + amount: 780, + date: '2015-05-07', + }], + }, +}); + +[true, false].forEach((isFieldChooser) => { + const getField = (pivotGrid: PivotGrid, area: string, index: number) => { + switch (area) { + case 'filter': + return isFieldChooser + ? pivotGrid.getFieldChooser().getFilterAreaItem(index) + : pivotGrid.getFilterHeaderArea().getField(index); + case 'data': + return isFieldChooser + ? pivotGrid.getFieldChooser().getDataFields().nth(index) + : pivotGrid.getDataHeaderArea().getField(index); + case 'column': + return isFieldChooser + ? pivotGrid.getFieldChooser().getColumnAreaItem(index) + : pivotGrid.getColumnHeaderArea().getField(index); + case 'row': + return isFieldChooser + ? pivotGrid.getFieldChooser().getRowAreaItem(index) + : pivotGrid.getRowHeaderArea().getField(index); + default: + throw new Error(`Unknown area: ${area}`); + } + }; + + const testTitlePrefix = isFieldChooser ? 'Field Chooser' : 'PivotGrid'; + + ['filter', 'data', 'column', 'row'].forEach((area) => { + test(`${testTitlePrefix}: Fields in ${area} area should be focusable by tab`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const firstField = getField(pivotGrid, area, 0); + const secondField = getField(pivotGrid, area, 1); + + await t + .click(firstField) + .expect(firstField.focused) + .ok('first field is focused after click'); + + await t + .pressKey(area === 'data' ? 'tab' : 'tab tab') + .expect(secondField.focused) + .ok('second field is focused after Tab Tab'); + + await t + .pressKey(area === 'data' ? 'shift+tab' : 'shift+tab shift+tab') + .expect(firstField.focused) + .ok('first field is focused after Shift+Tab Shift+Tab'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + }); + + ['filter', 'column', 'row'].forEach((area) => { + test(`${testTitlePrefix}: Fields in ${area} area on enter key press`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const firstField = getField(pivotGrid, area, 0); + const secondField = getField(pivotGrid, area, 1); + + await t + .click(firstField) + .pressKey('tab tab') + .expect(secondField.focused) + .ok('second field is focused after Tab Tab') + .expect(secondField.find('.dx-sort-up').exists) + .ok('second field has asc sort indicator initially'); + + await t + .pressKey('enter') + .expect(secondField.focused) + .ok('second field is focused after Enter') + .expect(secondField.find('.dx-sort-down').exists) + .ok('second field has desc sort indicator after Enter'); + + await t + .pressKey('enter') + .expect(secondField.focused) + .ok('second field is focused after second Enter') + .expect(secondField.find('.dx-sort-up').exists) + .ok('second field has asc sort indicator after second Enter'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + + test(`${testTitlePrefix}: Fields in ${area} area on space key press`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const firstField = getField(pivotGrid, area, 0); + const secondField = getField(pivotGrid, area, 1); + + await t + .click(firstField) + .pressKey('tab tab') + .expect(secondField.focused) + .ok('second field is focused after Tab Tab') + .expect(secondField.find('.dx-sort-up').exists) + .ok('second field has asc sort indicator initially'); + + await t + .pressKey('space') + .expect(secondField.focused) + .ok('second field is focused after Space') + .expect(secondField.find('.dx-sort-down').exists) + .ok('second field has desc sort indicator after Space'); + + await t + .pressKey('space') + .expect(secondField.focused) + .ok('second field is focused after second Space') + .expect(secondField.find('.dx-sort-up').exists) + .ok('second field has asc sort indicator after second Space'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + + test(`${testTitlePrefix}: Field in ${area} should have focus after header filter is closed`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const headerFilter = new HeaderFilter(); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const firstField = getField(pivotGrid, area, 0); + + await t + .click(firstField) + .pressKey('tab') + .pressKey('enter') + .expect(headerFilter.element.exists) + .ok('header filter popup is shown after Enter on icon'); + + await t + .pressKey('esc') + .expect(firstField.focused) + .ok('first field is focused after header filter is closed'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + + test(`${testTitlePrefix}: Field in ${area} should have focus after header filter is applied`, async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const headerFilter = new HeaderFilter(); + + if (isFieldChooser) { + await t.click(pivotGrid.getFieldChooserButton()); + } + + const firstField = getField(pivotGrid, area, 0); + + await t + .click(firstField) + .pressKey('tab') + .pressKey('enter') + .expect(headerFilter.element.exists) + .ok('header filter popup is shown after Enter on icon'); + + const list = headerFilter.getList(); + const okButton = headerFilter.getButtons().nth(0); + + await t + .click(list.getItem(0).element) + .click(okButton) + .expect(firstField.focused) + .ok('first field is focused after header filter is applied'); + }).before(async () => createWidget('dxPivotGrid', createConfig())); + }); +}); + +test('PivotGrid: Should traverse fields in all areas by tab', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + + const filterFirstField = pivotGrid.getFilterHeaderArea().getField(0); + const dataFirstField = pivotGrid.getDataHeaderArea().getField(0); + const columnFirstField = pivotGrid.getColumnHeaderArea().getField(0); + const rowFirstField = pivotGrid.getRowHeaderArea().getField(0); + + await t + .click(filterFirstField) + .expect(filterFirstField.focused) + .ok('first field in filter area is focused after click'); + + await t + .pressKey('tab tab tab tab') + .expect(dataFirstField.focused) + .ok('first field in data area is focused'); + + await t + .pressKey('tab tab') + .expect(columnFirstField.focused) + .ok('first field in column area is focused'); + + await t + .pressKey('tab tab tab tab tab tab') + .expect(rowFirstField.focused) + .ok('first field in row area is focused'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('FieldChooser: Should traverse fields in all areas by tab', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const fieldChooser = pivotGrid.getFieldChooser(); + + await t.click(pivotGrid.getFieldChooserButton()); + + const rowFirstField = fieldChooser.getRowAreaItem(0); + const columnFirstField = fieldChooser.getColumnAreaItem(0); + const filterFirstField = fieldChooser.getFilterAreaItem(0); + const dataFirstField = fieldChooser.getDataAreaItem(0); + + await t + .click(rowFirstField) + .expect(rowFirstField.focused) + .ok('first field in row area is focused after click'); + + await t + .pressKey('tab tab tab tab tab') + .expect(columnFirstField.focused) + .ok('first field in column area is focused'); + + await t + .pressKey('tab tab tab tab tab') + .expect(filterFirstField.focused) + .ok('first field in filter area is focused'); + + await t + .pressKey('tab tab tab tab 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 07e01126be7c..09f6b4fe4d47 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 @@ -118,6 +118,7 @@ export class FieldChooser extends FieldChooserBase { each(that._dataChangedHandlers, (_, func) => { func(); }); + that.restoreFieldFocus(); that._fireContentReadyAction(); that._skipStateChange = true; that.option('state', that._dataSource.state()); 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 67161398d56e..867b9e519e82 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 @@ -1,6 +1,7 @@ /* eslint-disable max-classes-per-file */ import { name as clickEventName } from '@js/common/core/events/click'; import eventsEngine from '@js/common/core/events/core/events_engine'; +import { addNamespace } from '@js/common/core/events/utils/index'; import localizationMessage from '@js/common/core/localization/message'; import ArrayStore from '@js/common/data/array_store'; import registerComponent from '@js/core/component_registrator'; @@ -91,6 +92,8 @@ const mixinWidget = headerFilterMixin( ); export class FieldChooserBase extends mixinWidget { + private _focusedFieldIndex = -1; + _getDefaultOptions() { return { ...super._getDefaultOptions(), @@ -179,6 +182,7 @@ export class FieldChooserBase extends mixinWidget { const $fieldElement = $(DIV) .addClass(CLASSES.area.field) .addClass(CLASSES.area.box) + .attr('tabIndex', 0) .data('field', field) .append($fieldContent); const mainGroupField = getMainGroupField(that._dataSource, field); @@ -350,83 +354,166 @@ export class FieldChooserBase extends mixinWidget { } subscribeToEvents(element?) { - const that = this; - const func = function (e) { + const fieldSelector = `.${CLASSES.area.field}.${CLASSES.area.box}`; + const targetElement = element ?? this.$element(); + + const handler = (e) => { + const shouldHandle = e.type === clickEventName + || (e.type === 'keydown' && (e.key === 'Enter' || e.key === ' ')); + + if (!shouldHandle) { + return; + } + const field: any = $(e.currentTarget).data('field'); - const mainGroupField = extend(true, {}, getMainGroupField(that._dataSource, field)); const isHeaderFilter = $(e.target).hasClass(CLASSES.headerFilter); - const dataSource = that._dataSource; - const type = mainGroupField.groupName ? 'tree' : 'list'; - const paginate = dataSource.paginate() && type === 'list'; if (isHeaderFilter) { - that._headerFilterView.showHeaderFilterMenu($(e.currentTarget), extend(mainGroupField, { - type, - encodeHtml: that.option('encodeHtml'), - dataSource: { - useDefaultSearch: !paginate, - // paginate: false, - load(options) { - const { userData } = options; - if (userData.store) { - return userData.store.load(options); - } - // @ts-expect-error - const d = new Deferred(); - dataSource.getFieldValues( - mainGroupField.index, - that.option('headerFilter.showRelevantValues'), - paginate - ? options - : undefined, - ).done((data) => { - const emptyValue = that.option('headerFilter.texts.emptyValue'); - - data.forEach((element) => { - if (!element.text) { - element.text = emptyValue; - } - }); - - if (paginate) { - d.resolve(data); - } else { - userData.store = new ArrayStore(data); - userData.store.load(options).done(d.resolve).fail(d.reject); - } - }).fail(d.reject); - return d; - }, - postProcess(data) { - processItems(data, mainGroupField); - return data; - }, - }, - - apply() { - that._applyChanges([mainGroupField], { - filterValues: this.filterValues, - filterType: this.filterType, - }); - }, - })); + e.preventDefault(); + this.handleHeaderFilterIconClick(e, field); } else if (field.allowSorting && field.area !== 'data') { - const isRemoteSort = that.option('remoteSort'); - const sortOrder = reverseSortOrder(field.sortOrder); + e.preventDefault(); + this.handleFieldClick(field); + } + }; - if (isRemoteSort) { - that._applyChanges([field], { sortOrder }); - } else { - that._applyLocalSortChanges(field.index, sortOrder); - } + const focusInHandler = (e) => { + const $field = $(e.currentTarget); + const field: any = $field.data('field'); + + if (!field) { + return; + } + + this._focusedFieldIndex = field.index; + }; + + const focusOutHandler = (e) => { + const relatedTarget = e.relatedTarget as Node | null; + + if (relatedTarget) { + this._focusedFieldIndex = -1; } }; - if (element) { - eventsEngine.on(element, clickEventName, `.${CLASSES.area.field}.${CLASSES.area.box}`, func); - return; + eventsEngine.on( + targetElement, + addNamespace(clickEventName, 'dxPivotGridFieldChooserBase'), + fieldSelector, + handler, + ); + eventsEngine.on( + targetElement, + addNamespace('keydown', 'dxPivotGridFieldChooserBase'), + fieldSelector, + handler, + ); + eventsEngine.on( + targetElement, + addNamespace('focusin', 'dxPivotGridFieldChooserBase'), + fieldSelector, + focusInHandler, + ); + eventsEngine.on( + targetElement, + addNamespace('focusout', 'dxPivotGridFieldChooserBase'), + fieldSelector, + focusOutHandler, + ); + } + + restoreFieldFocus(): void { + if (this._focusedFieldIndex !== -1) { + this.focusFieldElement(this._focusedFieldIndex); } - eventsEngine.on(that.$element(), clickEventName, `.${CLASSES.area.field}.${CLASSES.area.box}`, func); + } + + private handleHeaderFilterIconClick(e, field): void { + const that = this; + + const mainGroupField = extend(true, {}, getMainGroupField(that._dataSource, field)); + const type = mainGroupField.groupName ? 'tree' : 'list'; + const paginate = this._dataSource.paginate() && type === 'list'; + + this._headerFilterView.showHeaderFilterMenu($(e.currentTarget), extend(mainGroupField, { + type, + encodeHtml: this.option('encodeHtml'), + dataSource: { + useDefaultSearch: !paginate, + // paginate: false, + load(options) { + const { userData } = options; + if (userData.store) { + return userData.store.load(options); + } + // @ts-expect-error + const d = new Deferred(); + that._dataSource.getFieldValues( + mainGroupField.index, + that.option('headerFilter.showRelevantValues'), + paginate + ? options + : undefined, + ).done((data) => { + const emptyValue = that.option('headerFilter.texts.emptyValue'); + + data.forEach((element) => { + if (!element.text) { + element.text = emptyValue; + } + }); + + if (paginate) { + d.resolve(data); + } else { + userData.store = new ArrayStore(data); + userData.store.load(options).done(d.resolve).fail(d.reject); + } + }).fail(d.reject); + return d; + }, + postProcess(data) { + processItems(data, mainGroupField); + return data; + }, + }, + onHidden: () => { + this.focusFieldElement(field.index); + }, + apply() { + that._focusedFieldIndex = field.index; + + that._applyChanges([mainGroupField], { + filterValues: this.filterValues, + filterType: this.filterType, + }); + }, + })); + } + + private handleFieldClick(field): void { + const isRemoteSort = this.option('remoteSort'); + const sortOrder = reverseSortOrder(field.sortOrder); + + if (isRemoteSort) { + this._applyChanges([field], { sortOrder }); + } else { + this._applyLocalSortChanges(field.index, sortOrder); + } + } + + private focusFieldElement(fieldIndex: number): void { + const fieldElements = this.$element() + .find(`.${CLASSES.area.field}.${CLASSES.area.box}`) + .get(); + + fieldElements?.forEach((fieldElement) => { + const field: any = $(fieldElement).data('field'); + + if (field.index === fieldIndex) { + fieldElement.focus(); + } + }); } _initTemplates() { diff --git a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts index f4ab393f4a55..ace411b74735 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -1063,7 +1063,9 @@ class PivotGrid extends Widget { that._dataFields.render(dataHeaderContainer, dataSource.getAreaFields('data')); // @ts-expect-error ts-error - that.$element().dxPivotGridFieldChooserBase('instance').renderSortable(); + const fieldChooser = that.$element().dxPivotGridFieldChooserBase('instance'); + fieldChooser.renderSortable(); + fieldChooser.restoreFieldFocus(); } _createTableElement() { @@ -1559,6 +1561,11 @@ class PivotGrid extends Widget { when.apply($, updateScrollableResults).done(() => { that._updateScrollPosition(that._columnsArea, that._rowsArea, that._dataArea, true); that._subscribeToEvents(that._columnsArea, that._rowsArea, that._dataArea); + + // @ts-expect-error + const fieldChooser = that.$element().dxPivotGridFieldChooserBase('instance'); + fieldChooser.restoreFieldFocus(); + d.resolve(); }); }); diff --git a/packages/testcafe-models/pivotGrid/fieldChooser.ts b/packages/testcafe-models/pivotGrid/fieldChooser.ts index 32fad1f1b8c1..b4189ca26b4a 100644 --- a/packages/testcafe-models/pivotGrid/fieldChooser.ts +++ b/packages/testcafe-models/pivotGrid/fieldChooser.ts @@ -34,6 +34,10 @@ export default class FieldChooser extends Widget { return this.getAreas().nth(3).find(`.${CLASS.field}.${CLASS.box}`).nth(idx); } + getDataAreaItem(idx = 0): Selector { + return this.getAreas().nth(4).find(`.${CLASS.field}.${CLASS.box}`).nth(idx); + } + getDataFields(): Selector { return this.getAreas().nth(4).find(`.${CLASS.fields} .${CLASS.field}`); }