diff --git a/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_medium (material.blue.light).png b/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_medium (material.blue.light).png index 44e4685dc751..e6d4e5f0d12d 100644 Binary files a/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_medium (material.blue.light).png and b/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_medium (material.blue.light).png differ diff --git a/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_thin (material.blue.light).png b/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_thin (material.blue.light).png index 5fb650559769..fa8ac084f727 100644 Binary files a/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_thin (material.blue.light).png and b/apps/demos/testing/widgets/datagrid/etalons/toolbar_customization_thin (material.blue.light).png differ diff --git a/apps/demos/testing/widgets/toolbar/etalons/toolbar_singleline_mode_menu_open (material.blue.light).png b/apps/demos/testing/widgets/toolbar/etalons/toolbar_singleline_mode_menu_open (material.blue.light).png index 78a0c290d765..ff9ff8080edf 100644 Binary files a/apps/demos/testing/widgets/toolbar/etalons/toolbar_singleline_mode_menu_open (material.blue.light).png and b/apps/demos/testing/widgets/toolbar/etalons/toolbar_singleline_mode_menu_open (material.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/column-separator-with-expand-columns (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/column-separator-with-expand-columns (fluent.blue.light).png index 2c40d74c015a..b8161b53f67d 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/column-separator-with-expand-columns (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/column-separator-with-expand-columns (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/dragging_grouped_column_to_same_position (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/dragging_grouped_column_to_same_position (fluent.blue.light).png index c020b017f48b..78dd2508dfc7 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/dragging_grouped_column_to_same_position (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/columnReordering/etalons/dragging_grouped_column_to_same_position (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-dropdown-button-in-menu (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-dropdown-button-in-menu (fluent.blue.light).png index 2d8ace4b34c1..46e4e7092fef 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-dropdown-button-in-menu (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-dropdown-button-in-menu (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-one-button-in-menu (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-one-button-in-menu (fluent.blue.light).png index 20a6131d4f58..a72a828acaf9 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-one-button-in-menu (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/etalons/grid-export-one-button-in-menu (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_false (fluent.blue.light).png index 55eed1afa5ba..102d88bfcd3c 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_true (fluent.blue.light).png index 4a149a2feca3..a58f73a835b9 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_left_when_rtlEnabled_=_true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_false (fluent.blue.light).png index 9723960a7508..814e1350368d 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_true (fluent.blue.light).png index 33e7c2bd1513..1e20e8c4f99d 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_to_right_when_rtlEnabled_=_true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_group_panel_allowColumnDragging_is_false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_group_panel_allowColumnDragging_is_false (fluent.blue.light).png index 18aeeb965da9..be4cc47c1204 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_group_panel_allowColumnDragging_is_false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_group_panel_allowColumnDragging_is_false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_onKeyDown_args_handled_=_true (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_onKeyDown_args_handled_=_true (fluent.blue.light).png index 18aeeb965da9..be4cc47c1204 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_onKeyDown_args_handled_=_true (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_when_onKeyDown_args_handled_=_true (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_with_allowGrouping_is_false (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_with_allowGrouping_is_false (fluent.blue.light).png index 18aeeb965da9..be4cc47c1204 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_with_allowGrouping_is_false (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/reorder_group_column_with_allowGrouping_is_false (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/ungroup_column_when_pressing_delete (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/ungroup_column_when_pressing_delete (fluent.blue.light).png index 3abd9290e2e3..45139ea41998 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/ungroup_column_when_pressing_delete (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/etalons/ungroup_column_when_pressing_delete (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/dataGrid/sticky/common/etalons/header_row_highlight_with_fixed_columns (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/dataGrid/sticky/common/etalons/header_row_highlight_with_fixed_columns (fluent.blue.light).png index 5f94b48a43cc..47f4dccbad7a 100644 Binary files a/e2e/testcafe-devextreme/tests/dataGrid/sticky/common/etalons/header_row_highlight_with_fixed_columns (fluent.blue.light).png and b/e2e/testcafe-devextreme/tests/dataGrid/sticky/common/etalons/header_row_highlight_with_fixed_columns (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar RTL focus border visible after keyboard navigation (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar RTL focus border visible after keyboard navigation (fluent.blue.light).png new file mode 100644 index 000000000000..a8d2f9e65349 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar RTL focus border visible after keyboard navigation (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar RTL no focus border after mouse click (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar RTL no focus border after mouse click (fluent.blue.light).png new file mode 100644 index 000000000000..4f65802fa0f4 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar RTL no focus border after mouse click (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar focus border on item after Arrow-Home-End navigation with disabled items (fluent.blue.light).png b/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar focus border on item after Arrow-Home-End navigation with disabled items (fluent.blue.light).png new file mode 100644 index 000000000000..06491df7267e Binary files /dev/null and b/e2e/testcafe-devextreme/tests/navigation/toolbar/etalons/Toolbar focus border on item after Arrow-Home-End navigation with disabled items (fluent.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.nonAPG.ts b/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.nonAPG.ts new file mode 100644 index 000000000000..c0672da0a1d3 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.nonAPG.ts @@ -0,0 +1,170 @@ +import { ClientFunction, Selector } from 'testcafe'; +import Toolbar from 'devextreme-testcafe-models/toolbar/toolbar'; +import url from '../../../helpers/getPageUrl'; +import { createWidget } from '../../../helpers/createWidget'; +import { appendElementTo } from '../../../helpers/domUtils'; + +fixture.disablePageReloads`Toolbar_keyboard_navigation_nonAPG` + .page(url(__dirname, '../../container.html')); + +const itemHasFocus = (item: Selector): Selector => item.filter( + (node) => node === document.activeElement + || node.contains(document.activeElement as Node | null), +); + +const setupThreeButtonsFixture = async (): Promise => { + await appendElementTo('#container', 'div', 'externalBefore'); + await appendElementTo('#container', 'div', 'toolbar'); + await appendElementTo('#container', 'div', 'externalAfter'); + + await createWidget('dxButton', { text: 'External Before' }, '#externalBefore'); + + await createWidget('dxToolbar', { + allowKeyboardNavigation: false, + items: [ + { + location: 'before', + widget: 'dxButton', + options: { text: 'A', focusStateEnabled: true }, + }, + { + location: 'before', + widget: 'dxButton', + options: { text: 'B', focusStateEnabled: true }, + }, + { + location: 'before', + widget: 'dxButton', + options: { text: 'C', focusStateEnabled: true }, + }, + ], + }, '#toolbar'); + + await createWidget('dxButton', { text: 'External After' }, '#externalAfter'); +}; + +test('Tab walks through every toolbar item; Shift+Tab walks back', async (t) => { + const externalBefore = Selector('#externalBefore'); + const externalAfter = Selector('#externalAfter'); + const toolbar = new Toolbar('#toolbar'); + + await t.click(externalBefore); + await t.expect(externalBefore.focused).ok('external before is focused'); + + await t.pressKey('tab'); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('Tab #1 -> item[0]'); + + await t.pressKey('tab'); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok('Tab #2 -> item[1]'); + + await t.pressKey('tab'); + await t.expect(itemHasFocus(toolbar.getItem(2)).exists) + .ok('Tab #3 -> item[2]'); + + await t.pressKey('tab'); + await t.expect(externalAfter.focused) + .ok('Tab #4 -> external after'); + + await t.pressKey('shift+tab'); + await t.expect(itemHasFocus(toolbar.getItem(2)).exists) + .ok('Shift+Tab #1 -> item[2]'); + + await t.pressKey('shift+tab'); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok('Shift+Tab #2 -> item[1]'); + + await t.pressKey('shift+tab'); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('Shift+Tab #3 -> item[0]'); + + await t.pressKey('shift+tab'); + await t.expect(externalBefore.focused) + .ok('Shift+Tab #4 -> external before'); +}).before(setupThreeButtonsFixture); + +test('Arrow keys do not move focus across toolbar items', async (t) => { + const externalBefore = Selector('#externalBefore'); + const toolbar = new Toolbar('#toolbar'); + + await t.click(externalBefore); + await t.pressKey('tab'); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('item[0] focused'); + + const keys = ['right', 'left', 'home', 'end', 'down', 'up']; + await keys.reduce(async (prev, key) => { + await prev; + await t.pressKey(key); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok(`focus stays on item[0] after "${key}"`); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .notOk(`focus does not jump to item[1] on "${key}"`); + }, Promise.resolve()); +}).before(setupThreeButtonsFixture); + +const getTabsSelectedIndex = ClientFunction( + () => ($('#toolbar .dx-tabs') as unknown as { + dxTabs: (m: string, n: string) => number; + }).dxTabs('option', 'selectedIndex'), +); + +test('Arrow keys are forwarded to the focused widget (dxTabs changes selectedIndex)', async (t) => { + const externalBefore = Selector('#externalBefore'); + const toolbar = new Toolbar('#toolbar'); + + await t.click(externalBefore); + await t.pressKey('tab tab'); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok('focus is inside dxTabs item'); + + await t.expect(getTabsSelectedIndex()).eql(0, 'initial selectedIndex is 0'); + + await t.pressKey('right'); + await t.expect(getTabsSelectedIndex()).eql( + 1, + 'ArrowRight reaches dxTabs and advances selectedIndex', + ); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok('focus stays inside dxTabs, did not jump to item[2]'); + + await t.pressKey('left'); + await t.expect(getTabsSelectedIndex()).eql( + 0, + 'ArrowLeft reaches dxTabs and decreases selectedIndex', + ); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok('focus still inside dxTabs'); +}).before(async () => { + await appendElementTo('#container', 'div', 'externalBefore'); + await appendElementTo('#container', 'div', 'toolbar'); + + await createWidget('dxButton', { text: 'External Before' }, '#externalBefore'); + + await createWidget('dxToolbar', { + allowKeyboardNavigation: false, + items: [ + { + location: 'before', + widget: 'dxButton', + options: { text: 'Prev', focusStateEnabled: true }, + }, + { + location: 'before', + widget: 'dxTabs', + options: { + items: [{ text: 'Home' }, { text: 'Insert' }, { text: 'Layout' }], + selectedIndex: 0, + width: 'auto', + focusStateEnabled: true, + }, + }, + { + location: 'before', + widget: 'dxButton', + options: { text: 'Next', focusStateEnabled: true }, + }, + ], + }, '#toolbar'); +}); diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.ts b/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.ts new file mode 100644 index 000000000000..cf47598d25b5 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.ts @@ -0,0 +1,301 @@ +import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; +import { Selector } from 'testcafe'; +import Toolbar from 'devextreme-testcafe-models/toolbar/toolbar'; +import url from '../../../helpers/getPageUrl'; +import { testScreenshot } from '../../../helpers/themeUtils'; +import { createWidget } from '../../../helpers/createWidget'; +import { appendElementTo, setAttribute } from '../../../helpers/domUtils'; + +fixture.disablePageReloads`Toolbar_keyboard_navigation` + .page(url(__dirname, '../../container.html')); + +const itemHasFocus = (item: Selector): Selector => item.filter( + (node) => node === document.activeElement + || node.contains(document.activeElement as Node | null), +); + +const toolbarWidgets = [ + { + widget: 'dxButton', + options: { text: 'Button' }, + }, + { + widget: 'dxTextBox', + options: { value: 'text', showClearButton: false }, + }, + { + widget: 'dxAutocomplete', + options: { value: 'auto', showClearButton: false }, + }, + { + widget: 'dxCheckBox', + options: { value: true }, + }, + { + widget: 'dxDateBox', + options: { + value: new Date(2021, 9, 17), + openOnFieldClick: false, + showClearButton: false, + showDropDownButton: false, + }, + }, + { + widget: 'dxSelectBox', + options: { + items: ['Item 1', 'Item 2'], + value: 'Item 1', + showClearButton: false, + showDropDownButton: false, + }, + }, + { + widget: 'dxMenu', + options: { + items: [{ text: 'Menu Item 1' }, { text: 'Menu Item 2' }], + }, + }, + { + widget: 'dxTabs', + options: { + items: [{ text: 'Tab 1' }, { text: 'Tab 2' }], + }, + }, + { + widget: 'dxButtonGroup', + options: { + items: [{ text: 'Left' }, { text: 'Right' }], + }, + }, + { + widget: 'dxDropDownButton', + options: { + text: 'Drop', + items: [{ text: 'Action 1' }, { text: 'Action 2' }], + }, + }, +] as const; + +const setupOverflowMenuFixture = async (): Promise => { + await appendElementTo('#container', 'div', 'toolbar'); + await appendElementTo('#container', 'div', 'externalAfter'); + + await createWidget('dxToolbar', { + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu B' } }, + ], + }, '#toolbar'); + + await createWidget('dxButton', { text: 'External After' }, '#externalAfter'); +}; + +test('Tab inside overflow menu closes popup and moves focus past the toolbar', async (t) => { + const externalAfter = Selector('#externalAfter'); + const toolbar = new Toolbar('#toolbar'); + const menu = toolbar.getOverflowMenu(); + + await t.click(menu.element); + await t.expect(menu.option('opened')).eql(true); + + await t.pressKey('tab'); + + await t.expect(menu.option('opened')).eql(false); + await t.expect(externalAfter.focused).ok(); +}).before(setupOverflowMenuFixture); + +test('Outside click closes overflow menu without stealing focus to overflow button', async (t) => { + const externalAfter = Selector('#externalAfter'); + const toolbar = new Toolbar('#toolbar'); + const menu = toolbar.getOverflowMenu(); + + await t.click(menu.element); + await t.expect(menu.option('opened')).eql(true); + + await t.click(externalAfter); + + await t.expect(menu.option('opened')).eql(false); + await t.expect(externalAfter.focused).ok(); + await t.expect(menu.isFocused).notOk(); +}).before(setupOverflowMenuFixture); + +toolbarWidgets.forEach(({ widget, options }) => { + test(`${widget}: Tab leaves and Shift+Tab returns focus`, async (t) => { + const externalBefore = Selector('#externalBefore'); + const externalAfter = Selector('#externalAfter'); + const toolbar = new Toolbar('#toolbar'); + + await t.click(externalBefore); + await t + .expect(externalBefore.focused) + .ok('external before button should be focused'); + + await t.pressKey('tab'); + await t + .expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('first toolbar item should be focused after Tab'); + + await t.pressKey('right'); + await t + .expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok(`${widget} should be focused after arrow right`); + + await t.pressKey('tab'); + await t + .expect(externalAfter.focused) + .ok('external after button should be focused after Tab'); + + await t.pressKey('shift+tab'); + await t + .expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok(`${widget} should be focused after Shift+Tab`); + }).before(async () => { + await appendElementTo('#container', 'div', 'externalBefore'); + await appendElementTo('#container', 'div', 'toolbar'); + await appendElementTo('#container', 'div', 'externalAfter'); + + await createWidget('dxButton', { + text: 'External Before', + }, '#externalBefore'); + + await createWidget('dxToolbar', { + items: [ + { + location: 'before', + widget: 'dxButton', + options: { text: 'Prev', focusStateEnabled: true }, + }, + { + location: 'before', + widget, + options: { ...options, focusStateEnabled: true }, + }, + { + location: 'before', + widget: 'dxButton', + options: { text: 'Next', focusStateEnabled: true }, + }, + ], + }, '#toolbar'); + + await createWidget('dxButton', { + text: 'External After', + }, '#externalAfter'); + }); +}); + +test('Arrow / Home / End skip disabled items and the focus border lands on the right item', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const externalBefore = Selector('#externalBefore'); + const toolbar = new Toolbar('#toolbar'); + + await t.click(externalBefore); + await t.pressKey('tab'); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('Tab -> item[0] (first enabled)'); + + await t.pressKey('right right'); + await t.expect(itemHasFocus(toolbar.getItem(4)).exists) + .ok('ArrowRight x2 -> item[4] (5th item, disabled items skipped)'); + + await t.pressKey('home'); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('Home -> item[0] (1st item)'); + + await t.pressKey('end'); + await t.expect(itemHasFocus(toolbar.getItem(5)).exists) + .ok('End -> item[5] (6th item, trailing disabled items skipped)'); + + await testScreenshot( + t, + takeScreenshot, + 'Toolbar focus border on item after Arrow-Home-End navigation with disabled items.png', + { element: '#container' }, + ); + + await t.expect(compareResults.isValid()).ok(compareResults.errorMessages()); +}).before(async () => { + await appendElementTo('#container', 'div', 'externalBefore'); + await appendElementTo('#container', 'div', 'toolbar'); + + await createWidget('dxButton', { text: 'External Before' }, '#externalBefore'); + + const labels = ['E1', 'E2', 'D1', 'D2', 'E3', 'E4', 'D3', 'D4']; + const disabledIndexes = new Set([2, 3, 6, 7]); + + await createWidget('dxToolbar', { + items: labels.map((text, index) => ({ + location: 'before', + widget: 'dxButton', + options: { + text, + focusStateEnabled: true, + disabled: disabledIndexes.has(index), + }, + })), + }, '#toolbar'); +}); + +test('RTL: focus border appears on keyboard navigation but not on mouse click; ArrowLeft moves to the logical next item', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const externalBefore = Selector('#externalBefore'); + const toolbar = new Toolbar('#toolbar'); + + await t.click(externalBefore); + await t.pressKey('tab'); + await t.expect(itemHasFocus(toolbar.getItem(0)).exists) + .ok('Tab -> item[0] (first item in logical order)'); + + await t.pressKey('left').wait(1000); + await t.expect(itemHasFocus(toolbar.getItem(1)).exists) + .ok('ArrowLeft in RTL moves focus to the logical-next item[1]'); + + await testScreenshot( + t, + takeScreenshot, + 'Toolbar RTL focus border visible after keyboard navigation.png', + { element: '#container' }, + ); + + await t.click(toolbar.getItem(2)); + await t.expect(itemHasFocus(toolbar.getItem(2)).exists) + .ok('mouse click -> item[2] becomes the focused item'); + + await testScreenshot( + t, + takeScreenshot, + 'Toolbar RTL no focus border after mouse click.png', + { element: '#container' }, + ); + + await t.expect(compareResults.isValid()).ok(compareResults.errorMessages()); +}).before(async () => { + await appendElementTo('#container', 'div', 'externalBefore'); + await appendElementTo('#container', 'div', 'toolbar'); + + await setAttribute('#container', 'dir', 'rtl'); + + await createWidget('dxButton', { text: 'External Before' }, '#externalBefore'); + + await createWidget('dxToolbar', { + rtlEnabled: true, + items: [ + { + location: 'before', + widget: 'dxButton', + options: { text: 'A', focusStateEnabled: true }, + }, + { + location: 'before', + widget: 'dxButton', + options: { text: 'B', focusStateEnabled: true }, + }, + { + location: 'before', + widget: 'dxButton', + options: { text: 'C', focusStateEnabled: true }, + }, + ], + }, '#toolbar'); +}); diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts b/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts index db5a5ad9558a..14a94f4dd26f 100644 --- a/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts +++ b/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts @@ -71,6 +71,7 @@ test('Drop down button should lost hover and active state', async (t) => { }); return createWidget('dxToolbar', { + allowKeyboardNavigation: false, items: [ { text: 'item1', locateInMenu: 'always' }, { text: 'item2', locateInMenu: 'always' }, @@ -214,6 +215,7 @@ test('Toolbar buttons in menu appearance', async (t) => { await createWidget('dxToolbar', { width: 50, multiline: false, + allowKeyboardNavigation: false, items, }); }); @@ -260,6 +262,7 @@ test('Toolbar buttons as custom template appearance', async (t) => { })); await createWidget('dxToolbar', { + allowKeyboardNavigation: false, width: 50, multiline: false, items, @@ -311,6 +314,7 @@ test('Toolbar button group appearance', async (t) => { }); await createWidget('dxToolbar', { + allowKeyboardNavigation: false, width: 50, items, }); @@ -363,6 +367,7 @@ test('Toolbar button group as custom template appearance', async (t) => { }); await createWidget('dxToolbar', { + allowKeyboardNavigation: false, width: 50, items, }); diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/header/viewSwitcher.ts b/e2e/testcafe-devextreme/tests/scheduler/common/header/viewSwitcher.ts index ad646ec285ee..beaaf7f428cc 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/header/viewSwitcher.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/header/viewSwitcher.ts @@ -80,6 +80,7 @@ test('Changing view does not reset toolbar items state', async (t) => { }, 'viewSwitcher', ], + allowKeyboardNavigation: false, }, })); diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/hotkeysBehaviour/hotkeysBehaviour.ts b/e2e/testcafe-devextreme/tests/scheduler/common/hotkeysBehaviour/hotkeysBehaviour.ts index 579b4de5c622..1d52910c83f6 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/hotkeysBehaviour/hotkeysBehaviour.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/hotkeysBehaviour/hotkeysBehaviour.ts @@ -125,6 +125,9 @@ test('Navigate between toolbar items', async (t) => { }).before(async () => createScheduler({ views: ['day', 'week'], currentView: 'day', + toolbar: { + allowKeyboardNavigation: false, + }, })); test('Navigate between custom toolbar items', async (t) => { @@ -188,5 +191,6 @@ test('Navigate between custom toolbar items', async (t) => { name: 'dateNavigator', }, ], + allowKeyboardNavigation: false, }, })); diff --git a/packages/devextreme-angular/src/ui/toolbar/index.ts b/packages/devextreme-angular/src/ui/toolbar/index.ts index bedf9a4dc117..e672848351f8 100644 --- a/packages/devextreme-angular/src/ui/toolbar/index.ts +++ b/packages/devextreme-angular/src/ui/toolbar/index.ts @@ -74,6 +74,19 @@ export class DxToolbarComponent extends DxComponent imp instance: DxToolbar = null; + /** + * [descr:dxToolbarOptions.allowKeyboardNavigation] + + */ + @Input() + get allowKeyboardNavigation(): boolean { + return this._getOption('allowKeyboardNavigation'); + } + set allowKeyboardNavigation(value: boolean) { + this._setOption('allowKeyboardNavigation', value); + } + + /** * [descr:dxToolbarOptions.dataSource] @@ -319,6 +332,13 @@ export class DxToolbarComponent extends DxComponent imp */ @Output() onOptionChanged: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() allowKeyboardNavigationChange: EventEmitter; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -438,6 +458,7 @@ export class DxToolbarComponent extends DxComponent imp { subscribe: 'itemHold', emit: 'onItemHold' }, { subscribe: 'itemRendered', emit: 'onItemRendered' }, { subscribe: 'optionChanged', emit: 'onOptionChanged' }, + { emit: 'allowKeyboardNavigationChange' }, { emit: 'dataSourceChange' }, { emit: 'disabledChange' }, { emit: 'elementAttrChange' }, diff --git a/packages/devextreme-scss/scss/widgets/base/_dropDownMenu.scss b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_index.scss similarity index 100% rename from packages/devextreme-scss/scss/widgets/base/_dropDownMenu.scss rename to packages/devextreme-scss/scss/widgets/base/dropDownMenu/_index.scss diff --git a/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss new file mode 100644 index 000000000000..129adac34b1d --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss @@ -0,0 +1,22 @@ +@mixin dx-dropdownmenu-focus-outline( + $accent-color, + $border-radius, + $section-horizontal-margin, +) { + .dx-dropdownmenu-popup-wrapper.dx-dropdownmenu-list-focus-mode { + .dx-dropdownmenu-list { + .dx-list-item:has([tabindex="0"]:focus-visible), + .dx-list-item[tabindex="0"]:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; + } + } + + .dx-toolbar-menu-section { + margin-inline: $section-horizontal-margin; + } + } +} diff --git a/packages/devextreme-scss/scss/widgets/base/_toolbar.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_index.scss similarity index 99% rename from packages/devextreme-scss/scss/widgets/base/_toolbar.scss rename to packages/devextreme-scss/scss/widgets/base/toolbar/_index.scss index 6ad17a55dcc7..dc986ce20148 100644 --- a/packages/devextreme-scss/scss/widgets/base/_toolbar.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_index.scss @@ -1,4 +1,4 @@ -@use "./mixins" as *; +@use "../mixins" as *; // adduse diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss new file mode 100644 index 000000000000..e76f9e4da44e --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -0,0 +1,18 @@ +@mixin dx-toolbar-focus-outline( + $accent-color, + $border-radius, +) { + .dx-toolbar.dx-toolbar-focus-mode { + [tabindex="0"]:focus-visible:not(.dx-toolbar-item) { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; + } + + .dx-toolbar-item[tabindex="0"]:focus-visible .dx-menu { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; + } + } +} diff --git a/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss index b762c89e5c5e..2572b0b74486 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss @@ -1,8 +1,11 @@ @use "sizes" as *; @use "../sizes" as *; +@use "colors" as *; +@use "../colors" as *; @use "../common/mixins" as *; @use "../common/sizes" as *; @use "../../base/dropDownMenu"; +@use "../../base/dropDownMenu/mixins" as *; // adduse @@ -18,3 +21,5 @@ box-shadow: $fluent-base-dropdown-widgets-shadow; } } + +@include dx-dropdownmenu-focus-outline($base-accent, $base-border-radius, $fluent-dropdownmenu-section-horizontal-margin); diff --git a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss index bb487960ea0d..4c44cb2fce84 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss @@ -12,6 +12,7 @@ @use "../checkBox/sizes" as *; @use "mixins" as *; @use "../../base/toolbar"; +@use "../../base/toolbar/mixins" as *; // adduse @use "../dropDownMenu"; @@ -180,3 +181,5 @@ line-height: 0; } } + +@include dx-toolbar-focus-outline($base-accent, $fluent-base-border-radius); diff --git a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss index 8f7b3eb50e09..ec869eeff6cd 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss @@ -1,5 +1,6 @@ @use "sizes" as *; @use "../typography/sizes" as *; +@use "../colors" as *; @mixin dx-toolbar-sizing($height, $padding, $label-font-size, $item-spacing) { padding: $padding; diff --git a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss index e2a4af5e110a..240330f4862e 100644 --- a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss @@ -1 +1,10 @@ +@use "sizes" as *; +@use "../sizes" as *; +@use "colors" as *; +@use "../colors" as *; @use "../../base/dropDownMenu"; +@use "../../base/dropDownMenu/mixins" as *; + +// adduse + +@include dx-dropdownmenu-focus-outline($base-accent, $base-border-radius, $generic-dropdownmenu-section-horizontal-margin); diff --git a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss index 43b104010f9e..ff60f2dca0eb 100644 --- a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss @@ -2,3 +2,4 @@ // adduse +$generic-dropdownmenu-section-horizontal-margin: 4px !default; diff --git a/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss index 4c0e7824965e..0946be17dcce 100644 --- a/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss @@ -10,6 +10,7 @@ @use "../button/sizes" as *; @use "mixins" as *; @use "../../base/toolbar"; +@use "../../base/toolbar/mixins" as *; // adduse @use "../dropDownMenu"; @@ -99,3 +100,5 @@ } } } + +@include dx-toolbar-focus-outline($base-accent, $base-border-radius); diff --git a/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss index 31d4b6b141ef..0f0443dbe2ad 100644 --- a/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss @@ -1,4 +1,5 @@ @use "sizes" as *; +@use "../colors" as *; @mixin dx-toolbar-sizing($height, $padding, $label-font-size, $item-spacing) { padding: $padding; diff --git a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss index 5d9758c7333f..a21ee2606da8 100644 --- a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss @@ -1,5 +1,9 @@ +@use "sizes" as *; @use "../sizes" as *; +@use "colors" as *; +@use "../colors" as *; @use "../../base/dropDownMenu"; +@use "../../base/dropDownMenu/mixins" as *; // adduse @@ -8,3 +12,5 @@ box-shadow: $material-base-dropdown-widgets-shadow; } } + +@include dx-dropdownmenu-focus-outline($base-accent, $base-border-radius, $material-dropdownmenu-section-horizontal-margin); diff --git a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss index 43b104010f9e..ff5de9a2df49 100644 --- a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss @@ -2,3 +2,4 @@ // adduse +$material-dropdownmenu-section-horizontal-margin: 4px !default; diff --git a/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss index c9c230ab741e..7358c45b77f9 100644 --- a/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss @@ -12,6 +12,7 @@ @use "../checkBox/sizes" as *; @use "mixins" as *; @use "../../base/toolbar"; +@use "../../base/toolbar/mixins" as *; // adduse @use "../dropDownMenu"; @@ -207,3 +208,4 @@ padding: 4px; } +@include dx-toolbar-focus-outline($base-accent, $material-base-border-radius); diff --git a/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss index a2f24cf1c6cd..7e2faf16ac9a 100644 --- a/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss @@ -1,5 +1,6 @@ @use "sizes" as *; @use "../typography/sizes" as *; +@use "../colors" as *; @mixin dx-toolbar-sizing($height, $padding, $label-font-size, $item-spacing) { padding: $padding; diff --git a/packages/devextreme-vue/src/toolbar.ts b/packages/devextreme-vue/src/toolbar.ts index 7dd398f1668e..5fe8158c5fea 100644 --- a/packages/devextreme-vue/src/toolbar.ts +++ b/packages/devextreme-vue/src/toolbar.ts @@ -30,6 +30,7 @@ import { import { prepareConfigurationComponentConfig } from "./core/index"; type AccessibleOptions = Pick) | DataSource | DataSourceOptions | null | Store | string | Record>, disabled: Boolean, elementAttr: Object as PropType>, @@ -86,6 +88,7 @@ const componentConfig = { emits: { "update:isActive": null, "update:hoveredElement": null, + "update:allowKeyboardNavigation": null, "update:dataSource": null, "update:disabled": null, "update:elementAttr": null, diff --git a/packages/devextreme/js/__internal/core/utils/m_public_component.ts b/packages/devextreme/js/__internal/core/utils/m_public_component.ts index a43883b6796d..faa0a91b5cb8 100644 --- a/packages/devextreme/js/__internal/core/utils/m_public_component.ts +++ b/packages/devextreme/js/__internal/core/utils/m_public_component.ts @@ -1,6 +1,7 @@ import eventsEngine from '@js/common/core/events/core/events_engine'; import { removeEvent } from '@js/common/core/events/remove'; import { data as elementData } from '@js/core/element_data'; +import type { dxElementWrapper } from '@js/core/renderer'; import { isDefined } from '@js/core/utils/type'; const COMPONENT_NAMES_DATA_KEY = 'dxComponents'; @@ -49,5 +50,18 @@ export function getInstanceByElement($element, componentClass): T { return elementData($element.get(0), name); } +export function getComponentInstance($element: dxElementWrapper): T | undefined { + const element = $element.get(0); + + if (!element) { + return undefined; + } + + const names = elementData(element, COMPONENT_NAMES_DATA_KEY) as string[] | undefined; + const componentName = names?.[0]; + + return componentName ? (elementData(element, componentName) as T) : undefined; +} + export { getName as name }; export default { name: getName }; diff --git a/packages/devextreme/js/__internal/core/widget/widget.ts b/packages/devextreme/js/__internal/core/widget/widget.ts index 9af6f1b88503..b39ef02d5972 100644 --- a/packages/devextreme/js/__internal/core/widget/widget.ts +++ b/packages/devextreme/js/__internal/core/widget/widget.ts @@ -29,7 +29,7 @@ import type { OptionChanged } from '@ts/core/widget/types'; import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; export const WIDGET_CLASS = 'dx-widget'; -const DISABLED_STATE_CLASS = 'dx-state-disabled'; +export const DISABLED_STATE_CLASS = 'dx-state-disabled'; export const ACTIVE_STATE_CLASS = 'dx-state-active'; export const FOCUSED_STATE_CLASS = 'dx-state-focused'; export const HOVER_STATE_CLASS = 'dx-state-hover'; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/__snapshots__/card.test.tsx.snap b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/__snapshots__/card.test.tsx.snap index 5a7e22fcc3c9..641a47850188 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/__snapshots__/card.test.tsx.snap +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/__snapshots__/card.test.tsx.snap @@ -19,7 +19,7 @@ exports[`Rendering should be rendered correctly 1`] = ` class="dx-cardview-card-header" >