diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts new file mode 100644 index 000000000000..966926fa4abe --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/kbn/contextMenu.ts @@ -0,0 +1,201 @@ +import PivotGrid from 'devextreme-testcafe-models/pivotGrid'; +import { ClientFunction, Selector } from 'testcafe'; +import { createWidget } from '../../../../helpers/createWidget'; +import url from '../../../../helpers/getPageUrl'; +import { sales } from '../data'; + +fixture.disablePageReloads`pivotGrid_kbn_contextMenu` + .page(url(__dirname, '../../../container.html')); + +const PIVOT_GRID_SELECTOR = '#container'; +const CONTEXT_MENU_SELECTOR = '.dx-context-menu.dx-overlay-content'; +const MENU_ITEM_SELECTOR = `${CONTEXT_MENU_SELECTOR} .dx-menu-item`; +const ROW_HEADERS_CELL_SELECTOR = 'tbody.dx-pivotgrid-vertical-headers td'; + +const blurActiveElement = ClientFunction(() => { + const activeElement = document.activeElement as HTMLElement | null; + activeElement?.blur(); +}); + +// TestCafe's pressKey does not support function keys (F1-F12), so the Shift+F10 +// shortcut is reproduced by dispatching the keydown event on the focused cell. +const SHIFT_F10_KEYDOWN = { + key: 'F10', code: 'F10', shiftKey: true, bubbles: true, cancelable: true, +}; + +const createConfig = () => ({ + width: 800, + allowExpandAll: true, + allowSortingBySummary: true, + fieldChooser: { + enabled: false, + }, + dataSource: { + fields: [{ + dataField: 'region', + area: 'row', + expanded: true, + }, { + dataField: 'city', + area: 'row', + }, { + dataField: 'date', + dataType: 'date', + area: 'column', + }, { + dataField: 'amount', + area: 'data', + summaryType: 'sum', + dataType: 'number', + }], + store: sales, + }, +}); + +test('Shift+F10 should open the context menu anchored to the focused row header cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const rowsArea = pivotGrid.getRowsArea(); + const cell = rowsArea.getCellByPosition(0, 0); + + await blurActiveElement(); + + await t + .pressKey('tab tab') + .expect(cell.focused) + .ok('a row header cell is focused') + .dispatchEvent(cell, 'keydown', SHIFT_F10_KEYDOWN); + + const menu = Selector(CONTEXT_MENU_SELECTOR); + + await t + .expect(menu.visible) + .ok('the context menu is opened by Shift+F10') + .expect(menu.focused) + .ok('focus is moved into the menu') + .expect(Selector(MENU_ITEM_SELECTOR).withText('Expand All').exists) + .ok('the menu contains the header cell items'); + + const menuRect = await menu.boundingClientRect; + const cellRect = await cell.boundingClientRect; + + await t + .expect(menuRect.left) + .within((cellRect.left ?? 0) - 1, cellRect.right ?? 0, 'the menu is anchored to the cell horizontally') + .expect(menuRect.top) + .within((cellRect.top ?? 0) - 1, cellRect.bottom ?? 0, 'the menu is anchored to the cell vertically'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Shift+F10 should open the context menu for a column header cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const columnsArea = pivotGrid.getColumnsArea(); + + await blurActiveElement(); + + await t + .pressKey('tab') + .expect(columnsArea.getCell(0, 0).focused) + .ok('a column header cell is focused') + .dispatchEvent(columnsArea.getCell(0, 0), 'keydown', SHIFT_F10_KEYDOWN); + + await t + .expect(Selector(CONTEXT_MENU_SELECTOR).visible) + .ok('the context menu is opened by Shift+F10') + .expect(Selector(MENU_ITEM_SELECTOR).withText('Sort "Region" by This Column').exists) + .ok('the menu contains the sorting by summary items'); +}).before(async () => createWidget('dxPivotGrid', { + ...createConfig(), + dataSource: { + fields: [{ + caption: 'Region', + dataField: 'region', + area: 'row', + allowSortingBySummary: true, + }, { + dataField: 'city', + area: 'column', + }, { + dataField: 'amount', + area: 'data', + summaryType: 'sum', + dataType: 'number', + }], + store: sales, + }, +})); + +test('Escape should close the menu and return focus to the originating cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const rowsArea = pivotGrid.getRowsArea(); + const cell = rowsArea.getCellByPosition(0, 0); + + await blurActiveElement(); + + await t + .pressKey('tab tab') + .expect(cell.focused) + .ok('a row header cell is focused') + .dispatchEvent(cell, 'keydown', SHIFT_F10_KEYDOWN) + .expect(Selector(CONTEXT_MENU_SELECTOR).focused) + .ok('the context menu is opened and focused'); + + await t + .pressKey('esc') + .expect(Selector(CONTEXT_MENU_SELECTOR).visible) + .notOk('the context menu is closed by Escape') + .expect(cell.focused) + .ok('focus is returned to the originating cell'); +}).before(async () => createWidget('dxPivotGrid', createConfig())); + +test('Menu items should be operable by keyboard and focus should return to the cell', async (t) => { + const pivotGrid = new PivotGrid(PIVOT_GRID_SELECTOR); + const rowsArea = pivotGrid.getRowsArea(); + const initialRowsCount = await Selector(ROW_HEADERS_CELL_SELECTOR).count; + + await blurActiveElement(); + + await t + .pressKey('tab tab') + .expect(rowsArea.getCellByPosition(0, 0).focused) + .ok('a row header cell is focused') + .dispatchEvent(rowsArea.getCellByPosition(0, 0), 'keydown', SHIFT_F10_KEYDOWN) + .expect(Selector(CONTEXT_MENU_SELECTOR).focused) + .ok('the context menu is opened and focused') + .pressKey('down') + .expect(Selector(MENU_ITEM_SELECTOR).withText('Expand All').hasClass('dx-state-focused')) + .ok('the first menu item is focused by ArrowDown'); + + await t + .pressKey('enter') + .expect(Selector(CONTEXT_MENU_SELECTOR).visible) + .notOk('the context menu is closed after the item is executed') + .expect(Selector(ROW_HEADERS_CELL_SELECTOR).count) + .gt(initialRowsCount, 'Expand All is executed'); + + const focusedCell = Selector(ROW_HEADERS_CELL_SELECTOR) + .filter((node) => node === document.activeElement); + + await t + .expect(focusedCell.count) + .eql(1, 'focus is returned to the originating cell'); +}).before(async () => createWidget('dxPivotGrid', { + ...createConfig(), + dataSource: { + fields: [{ + dataField: 'region', + area: 'row', + }, { + dataField: 'city', + area: 'row', + }, { + dataField: 'date', + dataType: 'date', + area: 'column', + }, { + dataField: 'amount', + area: 'data', + summaryType: 'sum', + dataType: 'number', + }], + store: sales, + }, +})); 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 d46fc2096804..1f0416414458 100644 --- a/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts +++ b/packages/devextreme/js/__internal/grids/pivot_grid/m_widget.ts @@ -126,6 +126,8 @@ class PivotGrid extends Widget { _rowsAreaNavigation: RovingTabIndex | undefined; + _contextMenuOwnerNavigation: RovingTabIndex | undefined; + _scrollLeft: any; _scrollTop: any; @@ -712,9 +714,22 @@ class PivotGrid extends Widget { const items = that._getContextMenuItems(args); if (items) { actionArgs.component.option('items', items); + if (event.type === 'keydown') { + // A keyboard event carries no pointer coordinates, so the menu is + // anchored to the focused cell instead. + if (actionArgs.position) { + actionArgs.position.of = $(targetElement); + } + } else { + that._contextMenuOwnerNavigation = undefined; + } actionArgs.cancel = false; } }, + onHidden() { + that._contextMenuOwnerNavigation?.refocusFocusedItem(); + that._contextMenuOwnerNavigation = undefined; + }, onItemClick(params) { params.itemData.onItemClick && params.itemData.onItemClick(params); }, @@ -1034,6 +1049,23 @@ class PivotGrid extends Widget { this._getCellAreaNavigation(e.currentTarget)?.handleFocusIn(e.currentTarget); } + _handleCellContextMenuKeyDown(e) { + const cell = e.currentTarget; + const navigation = this._getCellAreaNavigation(cell); + + if (!navigation || !this._contextMenu) { + return; + } + + // The internal _show is called instead of the public show() because only + // _show accepts the initiating event that onPositioning builds items from. + this._contextMenu._show(e); + + if (this._contextMenu.option('visible')) { + this._contextMenuOwnerNavigation = navigation; + } + } + _handleCellKeyDown(e) { const direction = ARROW_KEY_DIRECTIONS[e.key]; if (direction) { @@ -1043,6 +1075,10 @@ class PivotGrid extends Widget { if (e.repeat) { return; } + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { + this._handleCellContextMenuKeyDown(e); + return; + } if (e.key !== 'Enter' && e.key !== ' ') { return; }