diff --git a/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.html b/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.html index 8b80eb690a41..12a1f4a30eaa 100644 --- a/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.html +++ b/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.html @@ -3,6 +3,7 @@ [dataSource]="orders" keyExpr="ID" [showBorders]="true" + (onToolbarPreparing)="onToolbarPreparing($event)" > diff --git a/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.ts b/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.ts index 3ff5deb9da76..666fafd4990d 100644 --- a/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.ts +++ b/apps/demos/Demos/DataGrid/Toolbar/Angular/app/app.component.ts @@ -6,6 +6,7 @@ import { DxButtonModule, } from 'devextreme-angular'; import { DxButtonTypes } from 'devextreme-angular/ui/button'; +import { DxDataGridTypes } from 'devextreme-angular/ui/data-grid'; import { query } from 'devextreme-angular/common/data'; import { DxSelectBoxModule, DxSelectBoxTypes } from 'devextreme-angular/ui/select-box'; import { Service, Order } from './app.service'; @@ -88,6 +89,10 @@ export class AppComponent { toggleExpandAll() { this.expandAll = !this.expandAll; } + + onToolbarPreparing(e: DxDataGridTypes.ToolbarPreparingEvent) { + e.toolbarOptions.allowKeyboardNavigation = false; + } } bootstrapApplication(AppComponent, { diff --git a/apps/demos/Demos/DataGrid/Toolbar/React/App.tsx b/apps/demos/Demos/DataGrid/Toolbar/React/App.tsx index 06d59d536146..fa2231de0af1 100644 --- a/apps/demos/Demos/DataGrid/Toolbar/React/App.tsx +++ b/apps/demos/Demos/DataGrid/Toolbar/React/App.tsx @@ -4,7 +4,7 @@ import SelectBox, { type SelectBoxTypes } from 'devextreme-react/select-box'; import DataGrid, { Grouping, Column, ColumnChooser, LoadPanel, Toolbar, Item, } from 'devextreme-react/data-grid'; -import type { DataGridRef } from 'devextreme-react/data-grid'; +import type { DataGridRef, DataGridTypes } from 'devextreme-react/data-grid'; import { query } from 'devextreme-react/common/data'; import { orders } from './data.ts'; @@ -54,13 +54,18 @@ const App = () => { }, }), []); + const onToolbarPreparing = useCallback((e: DataGridTypes.ToolbarPreparingEvent) => { + e.toolbarOptions.allowKeyboardNavigation = false; + }, []); + return ( + showBorders={true} + onToolbarPreparing={onToolbarPreparing}> diff --git a/apps/demos/Demos/DataGrid/Toolbar/ReactJs/App.js b/apps/demos/Demos/DataGrid/Toolbar/ReactJs/App.js index c1c8e283c46e..b4e9d57a2c28 100644 --- a/apps/demos/Demos/DataGrid/Toolbar/ReactJs/App.js +++ b/apps/demos/Demos/DataGrid/Toolbar/ReactJs/App.js @@ -58,6 +58,9 @@ const App = () => { }), [], ); + const onToolbarPreparing = useCallback((e) => { + e.toolbarOptions.allowKeyboardNavigation = false; + }, []); return ( { dataSource={orders} keyExpr="ID" showBorders={true} + onToolbarPreparing={onToolbarPreparing} > diff --git a/apps/demos/Demos/DataGrid/Toolbar/Vue/App.vue b/apps/demos/Demos/DataGrid/Toolbar/Vue/App.vue index a0faeed71130..4d425969fb64 100644 --- a/apps/demos/Demos/DataGrid/Toolbar/Vue/App.vue +++ b/apps/demos/Demos/DataGrid/Toolbar/Vue/App.vue @@ -5,6 +5,7 @@ :data-source="orders" key-expr="ID" :show-borders="true" + @toolbar-preparing="onToolbarPreparing" > @@ -86,6 +87,7 @@ import { DxToolbar, DxItem, } from 'devextreme-vue/data-grid'; +import type { DxDataGridTypes } from 'devextreme-vue/data-grid'; import { DxSelectBox, type DxSelectBoxTypes } from 'devextreme-vue/select-box'; import { query } from 'devextreme-vue/common/data'; import { orders } from './data.ts'; @@ -130,6 +132,10 @@ const refreshButtonOptions = { dataGridRef.value!.instance!.refresh(); }, }; + +const onToolbarPreparing = (e: DxDataGridTypes.ToolbarPreparingEvent) => { + e.toolbarOptions.allowKeyboardNavigation = false; +}; + +
+
+
+
+ `; + + $('#qunit-fixture').html(markup); + $('#widthRootStyle').css('width', '300px'); +}); + + +const TOOLBAR_SELECTOR = '#toolbar'; + +const buttonItem = (text, extra = {}) => ({ + widget: 'dxButton', + locateInMenu: 'never', + ...extra, + options: { text, ...(extra.options || {}) }, +}); + +const editorItem = (widget, options = {}, extra = {}) => ({ + widget, + locateInMenu: 'never', + ...extra, + options, +}); + +const labelItem = (text) => ({ text, locateInMenu: 'never' }); + +const overflowButtonItem = (text, extra = {}) => ({ + widget: 'dxButton', + locateInMenu: 'always', + ...extra, + options: { text, ...(extra.options || {}) }, +}); + +const createToolbar = (items, options = {}, selector = TOOLBAR_SELECTOR) => + $(selector).dxToolbar({ items, ...options }).dxToolbar('instance'); + +const press = (key, target, modifiers = {}) => { + const el = target instanceof Element + ? target + : (target && target.get ? target.get(0) : $(TOOLBAR_SELECTOR).get(0)); + el.dispatchEvent(new KeyboardEvent('keydown', { + key, bubbles: true, cancelable: true, ...modifiers, + })); +}; + +const focusItemAt = (toolbar, index) => { + const $items = toolbar._getAvailableItems(); + const $item = $items.eq(index); + toolbar.option('focusedElement', $item.get(0)); + toolbar._focusItemWidget($item); + return $item; +}; + +const findFocusTarget = ($item) => { + const $dropDownButton = $item.find('.dx-dropdownbutton').first(); + if($dropDownButton.length) { + return $item.find('.dx-buttongroup').first(); + } + const $button = $item.find('.dx-button').first(); + if($button.length) return $button; + const $textEditor = $item.find('.dx-texteditor').first(); + if($textEditor.length) return $textEditor; + const $buttonGroup = $item.find('.dx-buttongroup').first(); + if($buttonGroup.length) return $buttonGroup; + const $menu = $item.find('.dx-menu').first(); + if($menu.length) return $menu; + const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href], [tabindex]').first(); + if($native.length) return $native; + const $tabEl = $item.find('[tabindex]').first(); + if($tabEl.length) return $tabEl; + return $item; +}; + +const findInput = ($item) => $item.find('.dx-texteditor-input').first(); +const getActiveItem = (toolbar) => $(toolbar.option('focusedElement')); + +const assertFocusedItemAt = (assert, toolbar, expectedIndex, message) => { + const $items = toolbar._getAvailableItems(); + assert.strictEqual( + getActiveItem(toolbar).get(0), + $items.eq(expectedIndex).get(0), + message || `focusedElement is item #${expectedIndex}`, + ); +}; + +const assertOneTabStop = (assert, $toolbar, message) => { + const $stops = $toolbar.find('[tabindex="0"]').not('.dx-texteditor-input'); + assert.strictEqual($stops.length, 1, message || 'exactly one tab stop in toolbar'); +}; + +const assertActiveTabIndex = (assert, $item, expected, message) => { + const actual = parseInt(findFocusTarget($item).attr('tabindex'), 10); + assert.strictEqual(actual, expected, message || `active item tabindex=${expected}`); +}; + +const EDITOR_FIXTURES = { + textInput: [ + { widget: 'dxTextBox', options: { value: 'hello', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxNumberBox', options: { value: 42, inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxAutocomplete', options: { items: ['Item 1', 'Item 2'], inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxSelectBox', options: { items: ['A', 'B', 'C'], value: 'A', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateBox', options: { type: 'date', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateRangeBox', options: { startDateInputAttr: { 'aria-label': 'Start' }, endDateInputAttr: { 'aria-label': 'End' } } }, + { widget: 'dxColorBox', options: { value: '#ff0000', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxTagBox', options: { items: ['x', 'y', 'z'], inputAttr: { 'aria-label': 'Test' } } }, + ], + popup: [ + { widget: 'dxSelectBox', options: { items: ['A', 'B', 'C'], inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateBox', options: { type: 'date', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxColorBox', options: { value: '#0080ff', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateRangeBox', options: { startDateInputAttr: { 'aria-label': 'Start' }, endDateInputAttr: { 'aria-label': 'End' } } }, + { widget: 'dxTagBox', options: { items: ['x', 'y', 'z'], inputAttr: { 'aria-label': 'Test' } } }, + ], + toggle: [ + { widget: 'dxSwitch', options: { value: false } }, + { widget: 'dxCheckBox', options: { value: false } }, + ], + collection: [ + { widget: 'dxButtonGroup', options: { items: [{ text: 'A' }, { text: 'B' }, { text: 'C' }] } }, + ], +}; + +const moduleConfig = { + beforeEach: function() { + fx.off = true; + this.clock = sinon.useFakeTimers(); + this.$element = $(TOOLBAR_SELECTOR); + }, + afterEach: function() { + fx.off = false; + this.clock.restore(); + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}; + + +QUnit.module('Enter/Exit: text input editors', moduleConfig, function() { + const setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); + + EDITOR_FIXTURES.textInput.forEach(({ widget, options }) => { + QUnit.test(`${widget}: Enter focuses input`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + + const $input = findInput(toolbar._getAvailableItems().eq(1)); + assert.strictEqual(getActiveElement(), $input.get(0), + `Enter focuses ${widget} input`); + }); + + QUnit.test(`${widget}: arrows blocked while input focused`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('ArrowLeft', $input.get(0)); + press('ArrowRight', $input.get(0)); + + assertFocusedItemAt(assert, toolbar, 1, + `arrows do not navigate toolbar while ${widget} input is focused`); + }); + + QUnit.test(`${widget}: Esc keeps focusedElement on the editor item`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('Escape', $input.get(0)); + this.clock.tick(50); + + assertFocusedItemAt(assert, toolbar, 1, + `Esc keeps focusedElement on ${widget} item`); + }); + + QUnit.test(`${widget}: arrows navigate toolbar after Esc exits the editor`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('Escape', $input.get(0)); + this.clock.tick(50); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 2, + `ArrowRight navigates after Esc from ${widget}`); + }); + + QUnit.test(`${widget}: enter→exit→arrow cycle preserves single tab stop`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('Escape', $input.get(0)); + this.clock.tick(50); + press('ArrowRight'); + + assertOneTabStop(assert, this.$element, + `single tab stop preserved through ${widget} enter/exit/navigate cycle`); + }); + + QUnit.test(`${widget}: editor stays unfocused during plain toolbar navigation`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + const $editor = toolbar._getAvailableItems().eq(1).find('.dx-texteditor').first(); + assert.notOk($editor.hasClass('dx-state-focused'), + `${widget} root has no dx-state-focused before Enter`); + }); + + QUnit.test(`${widget}: editor gets dx-state-focused after Enter`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + + const $editor = toolbar._getAvailableItems().eq(1).find('.dx-texteditor').first(); + assert.ok($editor.hasClass('dx-state-focused'), + `${widget} root has dx-state-focused after Enter`); + }); + }); +}); + +QUnit.module('Enter/Exit: dropdown/popup editors (matrix)', moduleConfig, function() { + const POPUP_WIDGETS = [ + { + widget: 'dxDropDownButton', + options: { items: ['Option 1', 'Option 2'], text: 'Actions' }, + getInstance($item) { + return $item.find('.dx-dropdownbutton').dxDropDownButton('instance'); + }, + getFocusTarget($item) { + return $item.find('.dx-buttongroup'); + }, + prepareFocus($item) { + const bgInstance = $item.find('.dx-buttongroup').dxButtonGroup('instance'); + const $firstItem = bgInstance._buttonsCollection._itemElements().eq(0); + bgInstance._buttonsCollection.option('focusedElement', $firstItem.get(0)); + }, + }, + ]; + + const setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); + + POPUP_WIDGETS.forEach(({ widget, options, getInstance, getFocusTarget, prepareFocus }) => { + const focusInner = (toolbar) => { + const $item = focusItemAt(toolbar, 1); + prepareFocus($item); + return $item; + }; + + ['Enter', ' ', 'ArrowDown'].forEach((key) => { + const label = key === ' ' ? 'Space' : key; + QUnit.test(`${widget}: ${label} opens popup`, function(assert) { + const toolbar = setupSandwich(widget, options); + const $item = key === 'ArrowDown' ? focusItemAt(toolbar, 1) : focusInner(toolbar); + + press(key, getFocusTarget($item).get(0)); + this.clock.tick(300); + + assert.strictEqual(getInstance($item).option('opened'), true, + `${label} opens ${widget} popup`); + }); + }); + + QUnit.test(`${widget}: arrows blocked while popup is open`, function(assert) { + const toolbar = setupSandwich(widget, options); + const $item = focusInner(toolbar); + + press('Enter', getFocusTarget($item).get(0)); + this.clock.tick(300); + + press('ArrowRight'); + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 1, + `arrows do not navigate toolbar while ${widget} popup is open`); + }); + + QUnit.test(`${widget}: Esc closes popup and keeps toolbar focus`, function(assert) { + const toolbar = setupSandwich(widget, options); + const $item = focusItemAt(toolbar, 1); + const instance = getInstance($item); + instance.option('opened', true); + this.clock.tick(300); + + press('Escape', getFocusTarget($item).get(0)); + this.clock.tick(300); + + assert.strictEqual(instance.option('opened'), false, + `Esc closes ${widget} popup`); + assertFocusedItemAt(assert, toolbar, 1, + `toolbar focus stays on ${widget} item after Esc`); + }); + }); +}); + +QUnit.module('Enter/Exit: toggle widgets', moduleConfig, function() { + const TOGGLES = [ + { + widget: 'dxSwitch', + options: { value: false, width: 70 }, + containerSelector: '.dx-switch', + toggledByEnter: true, + }, + { + widget: 'dxCheckBox', + options: { text: 'Check', value: false }, + containerSelector: '.dx-checkbox', + toggledByEnter: false, + }, + ]; + + const setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); + + TOGGLES.forEach(({ widget, options, containerSelector, toggledByEnter }) => { + const buildAndFocusInner = (toolbar) => { + const $widgetEl = toolbar.$element().find(containerSelector); + const widgetInstance = $widgetEl[widget]('instance'); + $widgetEl.get(0).focus(); + return { $widgetEl, widgetInstance }; + }; + + const enterLabel = toggledByEnter ? 'toggles' : 'does not toggle'; + QUnit.test(`${widget}: Enter ${enterLabel} value`, function(assert) { + const toolbar = setupSandwich(widget, options); + const { $widgetEl, widgetInstance } = buildAndFocusInner(toolbar); + const valueBefore = widgetInstance.option('value'); + + press('Enter', $widgetEl.get(0)); + this.clock.tick(50); + + const valueAfter = widgetInstance.option('value'); + if(toggledByEnter) { + assert.notStrictEqual(valueAfter, valueBefore, `Enter toggles ${widget} value`); + } else { + assert.strictEqual(valueAfter, valueBefore, `Enter does not toggle ${widget} value`); + } + }); + + QUnit.test(`${widget}: Space toggles value`, function(assert) { + const toolbar = setupSandwich(widget, options); + const { $widgetEl, widgetInstance } = buildAndFocusInner(toolbar); + const valueBefore = widgetInstance.option('value'); + + press(' ', $widgetEl.get(0)); + this.clock.tick(50); + + assert.notStrictEqual(widgetInstance.option('value'), valueBefore, + `Space toggles ${widget} value`); + }); + + QUnit.test(`${widget}: ArrowRight navigates toolbar (no inner edit mode)`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 2, + `ArrowRight navigates from ${widget} (no inner edit mode)`); + }); + + QUnit.test(`${widget}: ArrowLeft navigates toolbar`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 0, + `ArrowLeft navigates from ${widget} (no inner edit mode)`); + }); + }); +}); + +QUnit.module('Enter/Exit: collection widgets', moduleConfig, function() { + const COLLECTIONS = [ + { + widget: 'dxMenu', + options: { + items: [ + { text: 'File', items: [{ text: 'New' }, { text: 'Open' }] }, + { text: 'Edit', items: [{ text: 'Cut' }, { text: 'Copy' }] }, + ], + }, + innerFocusableSelector: '.dx-menu-item', + }, + ]; + + const setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); + + COLLECTIONS.forEach(({ widget, options, innerFocusableSelector }) => { + QUnit.test(`${widget}: Enter activates inner navigation`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + + const $item = toolbar._getAvailableItems().eq(1); + assert.ok($item.get(0).contains(getActiveElement()), + `Enter places DOM focus inside ${widget}`); + assert.ok($item.find(innerFocusableSelector).length > 0, + `${widget} has inner focusable elements`); + }); + + QUnit.test(`${widget}: arrows do not navigate toolbar while inner mode is active`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + + const activeEl = getActiveElement(); + press('ArrowRight', activeEl); + press('ArrowLeft', activeEl); + + assertFocusedItemAt(assert, toolbar, 1, + `arrows do not navigate toolbar while inside ${widget}`); + }); + + QUnit.test(`${widget}: Esc returns focus to the toolbar item`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + press('Escape', getActiveElement()); + this.clock.tick(50); + + const $item = toolbar._getAvailableItems().eq(1); + assert.ok($item.get(0).contains(getActiveElement()), + `Esc keeps DOM focus inside the ${widget} toolbar item`); + }); + + QUnit.test(`${widget}: arrows navigate toolbar after Esc`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + press('Escape', getActiveElement()); + this.clock.tick(50); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 2, + `ArrowRight navigates toolbar after Esc from ${widget}`); + }); + + QUnit.test(`${widget}: enter/exit cycle preserves single tab stop`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); + + press('Enter'); + this.clock.tick(50); + press('Escape', getActiveElement()); + this.clock.tick(50); + press('ArrowRight'); + + const $tabZero = this.$element.find('[tabindex="0"]'); + assert.strictEqual($tabZero.length, 1, + `single tab stop preserved through ${widget} enter/exit/navigate cycle`); + }); + }); +}); + +QUnit.module('Enter/Exit: dxTabs in toolbar', moduleConfig, function() { + const tabsItem = editorItem('dxTabs', { + items: [{ text: 'Home' }, { text: 'Insert' }, { text: 'Layout' }], + selectedIndex: 0, + width: 'auto', + }); + const setupTabsToolbar = () => createToolbar([buttonItem('Prev'), tabsItem, buttonItem('Next')]); + + const focusTabsContainer = (toolbar, clock) => { + focusItemAt(toolbar, 1); + const $tabs = toolbar._getAvailableItems().eq(1).find('.dx-tabs'); + $tabs.get(0).focus(); + clock.tick(50); + return $tabs.dxTabs('instance'); + }; + + QUnit.test('ArrowRight on tabs moves toolbar focus to next item', function(assert) { + const toolbar = setupTabsToolbar(); + focusItemAt(toolbar, 1); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 2, + 'ArrowRight navigates toolbar away from dxTabs'); + }); + + QUnit.test('ArrowLeft on tabs moves toolbar focus to previous item', function(assert) { + const toolbar = setupTabsToolbar(); + focusItemAt(toolbar, 1); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft navigates toolbar away from dxTabs'); + }); + + QUnit.test('ArrowDown on focused tabs switches tabs and does not move toolbar focus', function(assert) { + const toolbar = setupTabsToolbar(); + const tabs = focusTabsContainer(toolbar, this.clock); + const selectedBefore = tabs.option('selectedIndex'); + + press('ArrowDown', getActiveElement()); + this.clock.tick(50); + + assertFocusedItemAt(assert, toolbar, 1, 'ArrowDown keeps toolbar focus on tabs item'); + assert.strictEqual(tabs.option('selectedIndex'), selectedBefore + 1, + 'ArrowDown selects the next tab'); + }); + + QUnit.test('ArrowUp on focused tabs switches tabs and does not move toolbar focus', function(assert) { + const toolbar = setupTabsToolbar(); + const tabs = focusTabsContainer(toolbar, this.clock); + tabs.option('selectedIndex', 1); + const selectedBefore = tabs.option('selectedIndex'); + + press('ArrowUp', getActiveElement()); + this.clock.tick(50); + + assertFocusedItemAt(assert, toolbar, 1, 'ArrowUp keeps toolbar focus on tabs item'); + assert.strictEqual(tabs.option('selectedIndex'), selectedBefore - 1, + 'ArrowUp selects the previous tab'); + }); +}); + +const dispatchKeydown = (element, key, options = {}) => press(key, element, options); +const getItemFocusTarget = findFocusTarget; + +QUnit.module('Core Navigation', moduleConfig, function() { + const makeButtonItems = (count) => + Array.from({ length: count }, (_, i) => buttonItem(String.fromCharCode(65 + i))); + + QUnit.test('first available item is the roving tabindex anchor on init', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + const $available = toolbar._getAvailableItems(); + + const $tabZeroElements = this.$element.find('[tabindex="0"]'); + assert.strictEqual($tabZeroElements.length, 1, 'exactly one element with tabindex=0'); + assert.strictEqual( + $tabZeroElements.closest(`.${TOOLBAR_ITEM_CLASS}`).get(0), + $available.eq(0).get(0), + 'the anchor belongs to the first available item', + ); + }); + + QUnit.test('ArrowRight moves focus to the next item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 0); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 1, 'focus moved to item[1]'); + assertOneTabStop(assert, this.$element); + }); + + QUnit.test('ArrowRight on last item wraps focus to first item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 2); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 0, 'focus wrapped to first item'); + }); + + QUnit.test('ArrowLeft on first item wraps focus to last item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 0); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 2, 'focus wrapped to last item'); + }); + + QUnit.test('Home moves focus to the first item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 2); + + press('Home'); + + assertFocusedItemAt(assert, toolbar, 0, 'focus moved to first item'); + }); + + QUnit.test('End moves focus to the last item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 0); + + press('End'); + + assertFocusedItemAt(assert, toolbar, 2, 'focus moved to last item'); + }); + + const disabledScenarios = [ + { + name: 'options.disabled', + items: [ + buttonItem('A'), + buttonItem('B', { options: { disabled: true } }), + buttonItem('C'), + ], + }, + { + name: 'item.disabled (item-level flag)', + items: [ + buttonItem('A'), + buttonItem('B', { disabled: true }), + buttonItem('C'), + ], + }, + ]; + + disabledScenarios.forEach(({ name, items }) => { + QUnit.test(`ArrowRight skips disabled item (${name})`, function(assert) { + const toolbar = createToolbar(items); + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 2, 'only 2 available items (disabled filtered out)'); + + focusItemAt(toolbar, 0); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 1, + `ArrowRight skips disabled (${name}) and lands on next enabled item`); + }); + }); + + QUnit.test('ArrowLeft skips a disabled item between two enabled items', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B', { options: { disabled: true } }), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft skips disabled item and lands on A'); + }); + + QUnit.test('Home skips leading disabled items', function(assert) { + const toolbar = createToolbar([ + buttonItem('A', { disabled: true }), + buttonItem('B'), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); + + press('Home'); + + assertFocusedItemAt(assert, toolbar, 0, + 'Home lands on first enabled item, skipping disabled leader'); + }); + + QUnit.test('End skips trailing disabled items', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B'), + buttonItem('C', { disabled: true }), + ]); + focusItemAt(toolbar, 0); + + press('End'); + + assertFocusedItemAt(assert, toolbar, 1, + 'End lands on last enabled item, skipping disabled trailer'); + }); + + QUnit.test('multiple consecutive disabled items are all skipped', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B', { disabled: true }), + buttonItem('C', { options: { disabled: true } }), + buttonItem('D'), + ]); + assert.strictEqual(toolbar._getAvailableItems().length, 2, 'only 2 available items'); + + focusItemAt(toolbar, 0); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 1, + 'ArrowRight skips two consecutive disabled items and lands on D'); + }); + + QUnit.test('disabled item never has tabindex=0', function(assert) { + createToolbar([ + buttonItem('A'), + buttonItem('B', { options: { disabled: true } }), + buttonItem('C'), + ]); + + const $disabledButton = this.$element.find('.dx-button.dx-state-disabled'); + assert.strictEqual($disabledButton.attr('tabindex'), '-1', + 'disabled button has tabindex=-1'); + }); + + QUnit.test('toolbar.disabled=true sets all items to tabindex=-1', function(assert) { + createToolbar([buttonItem('A'), buttonItem('B')], { disabled: true }); + + const $buttons = this.$element.find('.dx-button'); + $buttons.each(function() { + assert.strictEqual($(this).attr('tabindex'), '-1', + 'button has tabindex=-1 when toolbar is disabled'); + }); + }); + + QUnit.test('exactly one tabindex=0 is maintained after a sequence of navigation keys', function(assert) { + const toolbar = createToolbar(makeButtonItems(4)); + focusItemAt(toolbar, 0); + + ['ArrowRight', 'ArrowRight', 'End', 'Home'].forEach((key) => { + press(key); + assertOneTabStop(assert, this.$element, `one tab stop after ${key}`); + }); + }); + + QUnit.test('ArrowRight transfers tabindex=0 from previous to newly focused item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + const $items = toolbar._getAvailableItems(); + focusItemAt(toolbar, 0); + + press('ArrowRight'); + + assertActiveTabIndex(assert, $items.eq(1), 0, 'item[1] is now the stop'); + assertActiveTabIndex(assert, $items.eq(0), -1, 'item[0] released the stop'); + assertActiveTabIndex(assert, $items.eq(2), -1, 'item[2] remained at -1'); + }); + + QUnit.test('focusing an item via pointer makes it the roving tabindex anchor', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); + const $items = toolbar._getAvailableItems(); + + $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + assertOneTabStop(assert, this.$element); + const $tabZero = this.$element.find('[tabindex="0"]'); + assert.strictEqual( + $tabZero.closest(`.${TOOLBAR_ITEM_CLASS}`).get(0), + $items.eq(1).get(0), + 'item[1] is now the anchor', + ); + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement updated to item[1] after pointer focus'); + }); +}); + +QUnit.module('Widget interaction', moduleConfig, function() { + const triggerKey = (element, key) => press(key, element); + + QUnit.test('Enter on dxButton fires click', function(assert) { + let clicked = false; + this.$element.dxToolbar({ + items: [{ widget: 'dxButton', options: { text: 'A', onClick: () => { clicked = true; } } }] + }); + + triggerKey(this.$element.find('.dx-button').get(0), 'Enter'); + this.clock.tick(10); + + assert.strictEqual(clicked, true, 'Enter fires click on dxButton'); + }); + + QUnit.test('Space on dxButton fires click', function(assert) { + let clicked = false; + this.$element.dxToolbar({ + items: [{ widget: 'dxButton', options: { text: 'A', onClick: () => { clicked = true; } } }] + }); + + triggerKey(this.$element.find('.dx-button').get(0), ' '); + this.clock.tick(10); + + assert.strictEqual(clicked, true, 'Space fires click on dxButton'); + }); + + function createButtonGroupToolbar($el) { + return $el.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxButtonGroup', options: { items: [{ text: 'B' }, { text: 'I' }], keyExpr: 'text' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ] + }).dxToolbar('instance'); + } + + QUnit.test('ArrowDown/Up on dxButtonGroup pass through: toolbar focus stays on ButtonGroup', function(assert) { + const toolbar = createButtonGroupToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $buttonGroupItem = $items.eq(1); + + toolbar.option('focusedElement', $buttonGroupItem.get(0)); + const $buttonGroupFocusTarget = $buttonGroupItem.find('.dx-buttongroup'); + + triggerKey($buttonGroupFocusTarget.get(0), 'ArrowDown'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $buttonGroupItem.get(0), 'ArrowDown keeps toolbar focus on ButtonGroup'); + + triggerKey($buttonGroupFocusTarget.get(0), 'ArrowUp'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $buttonGroupItem.get(0), 'ArrowUp keeps toolbar focus on ButtonGroup'); + }); + + QUnit.test('ArrowLeft on dxButtonGroup moves toolbar focus to previous item', function(assert) { + const toolbar = createButtonGroupToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowLeft'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'ArrowLeft moves toolbar focus to previous item'); + }); + + QUnit.test('ArrowRight on dxButtonGroup moves toolbar focus to next item', function(assert) { + const toolbar = createButtonGroupToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), 'ArrowRight moves toolbar focus to next item'); + }); + + function createDropDownButtonToolbar($el) { + return $el.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxDropDownButton', options: { items: ['Option 1', 'Option 2'], text: 'Actions' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ] + }).dxToolbar('instance'); + } + + function getDropDownButton($el) { + return $el.find('.dx-dropdownbutton').dxDropDownButton('instance'); + } + + function setButtonGroupFocusedItem($dropDownButtonItem) { + const bgInstance = $dropDownButtonItem.find('.dx-buttongroup').dxButtonGroup('instance'); + const $firstItem = bgInstance._buttonsCollection._itemElements().eq(0); + bgInstance._buttonsCollection.option('focusedElement', $firstItem.get(0)); + } + + QUnit.test('Enter on dxDropDownButton opens popup', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $dropDownButtonItem = toolbar._getAvailableItems().eq(1); + const dropDownButton = getDropDownButton(this.$element); + + setButtonGroupFocusedItem($dropDownButtonItem); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'Enter'); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), true, 'popup opens on Enter'); + }); + + QUnit.test('Space on dxDropDownButton opens popup', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $dropDownButtonItem = toolbar._getAvailableItems().eq(1); + const dropDownButton = getDropDownButton(this.$element); + + setButtonGroupFocusedItem($dropDownButtonItem); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), ' '); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), true, 'popup opens on Space'); + }); + + QUnit.test('ArrowDown on dxDropDownButton opens popup', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $dropDownButtonItem = toolbar._getAvailableItems().eq(1); + const dropDownButton = getDropDownButton(this.$element); + + toolbar.option('focusedElement', $dropDownButtonItem.get(0)); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'ArrowDown'); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), true, 'popup opens on ArrowDown'); + }); + + QUnit.test('Esc on dxDropDownButton (open) closes popup and keeps toolbar focus', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $dropDownButtonItem = toolbar._getAvailableItems().eq(1); + const dropDownButton = getDropDownButton(this.$element); + + dropDownButton.option('opened', true); + this.clock.tick(300); + + toolbar.option('focusedElement', $dropDownButtonItem.get(0)); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'Escape'); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), false, 'popup closes on Esc'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $dropDownButtonItem.get(0), 'toolbar focus stays on DropDownButton item'); + }); + + QUnit.test('ArrowLeft/Right on dxDropDownButton (popup closed) navigates toolbar', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), 'ArrowRight moves to next toolbar item'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'ArrowLeft moves to previous toolbar item'); + }); + + QUnit.test('ArrowLeft/Right on dxDropDownButton (popup open) does NOT navigate toolbar', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $dropDownButtonItem = $items.eq(1); + + toolbar.option('focusedElement', $dropDownButtonItem.get(0)); + setButtonGroupFocusedItem($dropDownButtonItem); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'Enter'); + this.clock.tick(300); + + const dropDownButton = getDropDownButton(this.$element); + assert.strictEqual(dropDownButton.option('opened'), true, 'popup opened via Enter'); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $dropDownButtonItem.get(0), + 'ArrowRight does not move focus when popup is open'); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + this.clock.tick(0); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $dropDownButtonItem.get(0), + 'ArrowLeft does not move focus when popup is open'); + }); + + QUnit.test('selecting item in dxDropDownButton popup via keyboard preserves toolbar focusedElement', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $dropDownButtonItem = $items.eq(1); + + toolbar.option('focusedElement', $dropDownButtonItem.get(0)); + setButtonGroupFocusedItem($dropDownButtonItem); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'Enter'); + this.clock.tick(300); + + const dropDownButton = getDropDownButton(this.$element); + assert.strictEqual(dropDownButton.option('opened'), true, 'popup opened'); + + const $listItem = $(dropDownButton._list.$element().find('.dx-list-item').first()); + $listItem.trigger('dxclick'); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), false, 'popup closed after item click'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $dropDownButtonItem.get(0), + 'toolbar focusedElement stays on DropDownButton item after selection'); + }); + + QUnit.test('focus moves to popup content on open — toolbar does not lose focusedElement', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $dropDownButtonItem = $items.eq(1); + + toolbar.option('focusedElement', $dropDownButtonItem.get(0)); + setButtonGroupFocusedItem($dropDownButtonItem); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'Enter'); + this.clock.tick(300); + + const dropDownButton = getDropDownButton(this.$element); + assert.strictEqual(dropDownButton.option('opened'), true, 'popup opened'); + + const $listItem = $(dropDownButton._list.$element().find('.dx-list-item').first()); + $listItem.get(0).focus(); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $dropDownButtonItem.get(0), + 'focusedElement preserved when focus is inside popup overlay'); + }); + + QUnit.test('tabindex stays on DropDownButton after selecting item via keyboard', function(assert) { + const toolbar = createDropDownButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $dropDownButtonItem = $items.eq(1); + + toolbar.option('focusedElement', $dropDownButtonItem.get(0)); + setButtonGroupFocusedItem($dropDownButtonItem); + triggerKey($dropDownButtonItem.find('.dx-buttongroup').get(0), 'Enter'); + this.clock.tick(300); + + const dropDownButton = getDropDownButton(this.$element); + const $listItem = $(dropDownButton._list.$element().find('.dx-list-item').first()); + $listItem.trigger('dxclick'); + this.clock.tick(300); + + assert.strictEqual(getItemFocusTarget($dropDownButtonItem).attr('tabindex'), '0', + 'DropDownButton focus target retains tabindex=0 after selection'); + + $items.not($dropDownButtonItem).each(function() { + assert.strictEqual(getItemFocusTarget($(this)).attr('tabindex'), '-1', + 'other toolbar items have tabindex=-1'); + }); + }); + + function createSelectBoxToolbar($element) { + return $element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxSelectBox', options: { items: ['A', 'B', 'C'], value: 'A' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + } + + QUnit.test('Enter on dxSelectBox (toolbar mode) focuses the input', function(assert) { + const toolbar = createSelectBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $input = $items.eq(1).find('.dx-texteditor-input'); + assert.strictEqual(getActiveElement(), $input.get(0), 'Enter focuses SelectBox input'); + }); + + QUnit.test('ArrowDown on dxSelectBox (toolbar mode) does not open list', function(assert) { + const toolbar = createSelectBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + const selectBox = $items.eq(1).find('.dx-selectbox').dxSelectBox('instance'); + triggerKey(this.$element.get(0), 'ArrowDown'); + this.clock.tick(100); + + assert.strictEqual(selectBox.option('opened'), false, 'ArrowDown in toolbar mode does not open SelectBox list'); + }); + + QUnit.test('Esc on dxSelectBox (list open) closes list; ←/→ stay in input mode', function(assert) { + const toolbar = createSelectBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const selectBox = $items.eq(1).find('.dx-selectbox').dxSelectBox('instance'); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + selectBox.option('opened', true); + this.clock.tick(300); + $input.get(0).focus(); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(100); + + assert.strictEqual(selectBox.option('opened'), false, 'Esc closes SelectBox list'); + triggerKey($input.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowLeft does not navigate toolbar while input is focused'); + }); + + QUnit.test('Esc on dxSelectBox (list closed, input focused) returns focus to root div', function(assert) { + const toolbar = createSelectBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + const $rootDiv = $items.eq(1).find('.dx-selectbox'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + $input.get(0).focus(); + this.clock.tick(50); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + assert.strictEqual(getActiveElement(), $rootDiv.get(0), 'Esc returns focus to SelectBox root div'); + }); + + QUnit.test('arrows on dxSelectBox (toolbar mode) navigates toolbar', function(assert) { + const toolbar = createSelectBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'ArrowLeft moves to previous item'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), 'ArrowRight moves to next item'); + }); + + function createTextBoxToolbar($el) { + return $el.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxTextBox', options: { value: 'hello' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + } + + QUnit.test('arrows on dxTextBox (toolbar mode) navigates toolbar', function(assert) { + const toolbar = createTextBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'ArrowLeft navigates to previous item'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), 'ArrowRight navigates to next item'); + }); + + QUnit.test('Enter on dxTextBox focuses input; arrows do not navigate toolbar', function(assert) { + const toolbar = createTextBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + assert.strictEqual(getActiveElement(), $input.get(0), 'Enter focuses TextBox input'); + + triggerKey($input.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowLeft does not navigate toolbar while in input mode'); + }); + + QUnit.test('Esc on dxTextBox (input focused) returns to toolbar mode; arrows navigate', function(assert) { + const toolbar = createTextBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + 'ArrowLeft navigates toolbar after Esc from TextBox'); + }); + + QUnit.test('Esc from TextBox then ArrowRight: TextBox input has tabindex=-1', function(assert) { + const toolbar = createTextBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + const $textEditor = $items.eq(1).find('.dx-textbox'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($input.attr('tabindex'), '-1', + 'TextBox input has tabindex=-1 after navigating away'); + assert.strictEqual($textEditor.attr('tabindex'), '-1', + 'TextBox container has tabindex=-1 after navigating away'); + assert.strictEqual(getItemFocusTarget($items.eq(2)).attr('tabindex'), '0', + 'target button has tabindex=0'); + }); + + QUnit.test('Esc from TextBox then ArrowLeft: TextBox input has tabindex=-1', function(assert) { + const toolbar = createTextBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + this.clock.tick(0); + + assert.strictEqual($input.attr('tabindex'), '-1', + 'TextBox input has tabindex=-1 after ArrowLeft away'); + assert.strictEqual(getItemFocusTarget($items.eq(0)).attr('tabindex'), '0', + 'Prev button has tabindex=0'); + }); + + QUnit.test('Esc from SelectBox then ArrowRight: SelectBox input has tabindex=-1', function(assert) { + const toolbar = createSelectBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + const $selectBox = $items.eq(1).find('.dx-selectbox'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + $input.get(0).focus(); + this.clock.tick(50); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($input.attr('tabindex'), '-1', + 'SelectBox input has tabindex=-1 after navigating away'); + assert.strictEqual($selectBox.attr('tabindex'), '-1', + 'SelectBox container has tabindex=-1 after navigating away'); + assert.strictEqual(getItemFocusTarget($items.eq(2)).attr('tabindex'), '0', + 'Next button has tabindex=0'); + }); + + QUnit.test('TextBox stays active after Esc: only TextBox has tabindex=0', function(assert) { + const toolbar = createTextBoxToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + const $textEditor = $items.eq(1).find('.dx-textbox'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + assert.strictEqual($textEditor.attr('tabindex'), '0', + 'TextBox container has tabindex=0 while it is the active item'); + assert.strictEqual($input.attr('tabindex'), '-1', + 'TextBox input has tabindex=-1 while TextBox is the active item'); + assert.strictEqual(getItemFocusTarget($items.eq(0)).attr('tabindex'), '-1', + 'Prev button has tabindex=-1'); + assert.strictEqual(getItemFocusTarget($items.eq(2)).attr('tabindex'), '-1', + 'Next button has tabindex=-1'); + }); +}); + +QUnit.module('Mouse and keyboard sync', moduleConfig, function() { + const threeButtons = () => [buttonItem('A'), buttonItem('B'), buttonItem('C')]; + + const focusInner = ($el) => $el.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + QUnit.test('focusin on item[j] sets it as the roving anchor (others release the stop)', function(assert) { + const toolbar = createToolbar(threeButtons()); + const $items = toolbar._getAvailableItems(); + + focusInner($items.eq(1).find('.dx-button')); + + assert.strictEqual($items.eq(1).find('.dx-button').attr('tabindex'), '0', + 'focused item has tabindex=0'); + assert.strictEqual($items.eq(0).find('.dx-button').attr('tabindex'), '-1', + 'previous item released the stop'); + assert.strictEqual($items.eq(2).find('.dx-button').attr('tabindex'), '-1', + 'next item released the stop'); + }); + + QUnit.test('focusin on item[j], then ArrowRight moves to item[j+1]', function(assert) { + const toolbar = createToolbar(threeButtons()); + const $items = toolbar._getAvailableItems(); + + focusInner($items.eq(1).find('.dx-button')); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 2, + 'ArrowRight from click-focused item moves to next'); + }); + + QUnit.test('focusin on item[j], then ArrowLeft moves to item[j-1]', function(assert) { + const toolbar = createToolbar(threeButtons()); + const $items = toolbar._getAvailableItems(); + + focusInner($items.eq(1).find('.dx-button')); + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft from click-focused item moves to previous'); + }); + + QUnit.test('focusin on TextBox input keeps focusedElement on its item; arrows do not navigate', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxTextBox', { value: 'hello' }), + buttonItem('Next'), + ]); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + focusInner($input); + press('ArrowLeft', $input.get(0)); + + assertFocusedItemAt(assert, toolbar, 1, + 'ArrowLeft does not navigate toolbar after clicking TextBox input'); + }); + + QUnit.test('focusin on TextBox → Esc → ArrowLeft navigates toolbar', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxTextBox', { value: 'hello' }), + buttonItem('Next'), + ]); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + focusInner($input); + press('Escape', $input.get(0)); + this.clock.tick(50); + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft navigates toolbar after Esc from click-focused TextBox'); + }); + + QUnit.test('focusin on SelectBox input promotes its item to be focusedElement', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxSelectBox', { items: ['A', 'B', 'C'], value: 'A' }), + buttonItem('Next'), + ]); + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + focusInner($input); + + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement promoted to SelectBox item'); + }); + + QUnit.test('focusin on DropDownButton item promotes it and Enter opens its popup', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxDropDownButton', { items: ['Option 1', 'Option 2'], text: 'Actions' }), + buttonItem('Next'), + ]); + const $items = toolbar._getAvailableItems(); + const $buttonGroup = $items.eq(1).find('.dx-buttongroup'); + const dropDownButton = this.$element.find('.dx-dropdownbutton').dxDropDownButton('instance'); + + focusInner($buttonGroup); + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement promoted to DropDownButton item'); + + const bgInstance = $buttonGroup.dxButtonGroup('instance'); + const $firstItem = bgInstance._buttonsCollection._itemElements().eq(0); + bgInstance._buttonsCollection.option('focusedElement', $firstItem.get(0)); + press('Enter', $buttonGroup.get(0)); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), true, + 'Enter opens DropDownButton popup after click-focus'); + }); +}); + +QUnit.module('Disabled items skip (focusin-driven)', moduleConfig, function() { + + const triadWithMiddleDisabled = () => [ + buttonItem('A'), + buttonItem('Disabled', { disabled: true }), + buttonItem('C'), + ]; + + const triggerFocusinOn = ($item, clock) => { + $(TOOLBAR_SELECTOR).trigger($.Event('focusin', { target: findFocusTarget($item).get(0) })); + clock.tick(0); + }; + + QUnit.test('ArrowRight skips disabled middle item (focusin-driven)', function(assert) { + const toolbar = createToolbar(triadWithMiddleDisabled()); + const $available = toolbar._getAvailableItems(); + + triggerFocusinOn($available.eq(0), this.clock); + press('ArrowRight', findFocusTarget($available.eq(0)).get(0)); + + assertFocusedItemAt(assert, toolbar, 1, + 'ArrowRight skipped disabled item and landed on C'); + }); + + QUnit.test('ArrowLeft skips disabled middle item (focusin-driven)', function(assert) { + const toolbar = createToolbar(triadWithMiddleDisabled()); + const $available = toolbar._getAvailableItems(); + + triggerFocusinOn($available.eq(1), this.clock); + press('ArrowLeft', findFocusTarget($available.eq(1)).get(0)); + + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft skipped disabled item and landed on A'); + }); + + QUnit.test('Home skips leading disabled items (focusin-driven)', function(assert) { + const toolbar = createToolbar([ + buttonItem('Disabled', { disabled: true }), + buttonItem('B'), + buttonItem('C'), + ]); + const $available = toolbar._getAvailableItems(); + + triggerFocusinOn($available.eq(1), this.clock); + press('Home', findFocusTarget($available.eq(1)).get(0)); + + assertFocusedItemAt(assert, toolbar, 0, + 'Home landed on first enabled item (B), skipping leading disabled'); + }); + + QUnit.test('End skips trailing disabled items (focusin-driven)', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B'), + buttonItem('Disabled', { disabled: true }), + ]); + const $available = toolbar._getAvailableItems(); + + triggerFocusinOn($available.eq(0), this.clock); + press('End', findFocusTarget($available.eq(0)).get(0)); + + assertFocusedItemAt(assert, toolbar, 1, + 'End landed on last enabled item (B), skipping trailing disabled'); + }); + + QUnit.test('disabled item never receives tabindex=0 even after navigation', function(assert) { + const toolbar = createToolbar(triadWithMiddleDisabled()); + const $available = toolbar._getAvailableItems(); + const $disabled = this.$element.find(`.${TOOLBAR_ITEM_CLASS}.${DISABLED_STATE_CLASS}`).first(); + + triggerFocusinOn($available.eq(0), this.clock); + press('ArrowRight', findFocusTarget($available.eq(0)).get(0)); + + const tabIndexOnDisabled = parseInt(findFocusTarget($disabled).attr('tabindex'), 10); + assert.notStrictEqual(tabIndexOnDisabled, 0, + 'disabled item focus target never has tabindex=0'); + }); +}); + +QUnit.module('Resize and overflow', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$container = $('
').width(1000).appendTo('#qunit-fixture'); + this.$element = $('
').appendTo(this.$container); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + this.$container.remove(); + } +}, function() { + + QUnit.test('item moved to overflow menu loses tabindex=0; first visible gets it', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'A', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'B', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'C', width: 200 } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 3, 'all 3 items visible initially'); + + toolbar.option('focusedElement', $items.eq(2).get(0)); + assert.strictEqual(getItemFocusTarget($items.eq(2)).attr('tabindex'), '0', + 'item C has tabindex=0 before resize'); + + this.$container.width(300); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $visibleAfter = toolbar._getAvailableItems(); + assert.ok($visibleAfter.length < 3, 'fewer items visible after shrink'); + + assert.strictEqual(getItemFocusTarget($visibleAfter.eq(0)).attr('tabindex'), '0', + 'first visible item has tabindex=0 after resize'); + }); + + QUnit.test('item returns from overflow menu: tabindex stays on current active item', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'A', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'B', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'C', width: 200 } }, + ], + }).dxToolbar('instance'); + + this.$container.width(300); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $visibleSmall = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $visibleSmall.eq(0).get(0)); + + this.$container.width(1000); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $visibleLarge = toolbar._getAvailableItems(); + assert.strictEqual($visibleLarge.length, 3, 'all items visible after expand'); + assert.strictEqual(getItemFocusTarget($visibleLarge.eq(0)).attr('tabindex'), '0', + 'active item A still has tabindex=0'); + assert.strictEqual(getItemFocusTarget($visibleLarge.eq(1)).attr('tabindex'), '-1', + 'item B has tabindex=-1'); + assert.strictEqual(getItemFocusTarget($visibleLarge.eq(2)).attr('tabindex'), '-1', + 'returned item C has tabindex=-1'); + }); + + QUnit.test('only one tabindex=0 exists after resize shrinks toolbar', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'A', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'B', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'C', width: 200 } }, + ], + }).dxToolbar('instance'); + + toolbar.option('focusedElement', toolbar._getAvailableItems().eq(1).get(0)); + + this.$container.width(100); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $tabZero = this.$element.find('[tabindex="0"]'); + assert.strictEqual($tabZero.length, 1, 'exactly one tabindex=0 after shrink'); + }); + + QUnit.test('TextBox input tabindex=-1 after TextBox item moves to overflow', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { location: 'before', widget: 'dxButton', locateInMenu: 'never', options: { text: 'A' } }, + { location: 'before', widget: 'dxTextBox', locateInMenu: 'auto', options: { value: 'text', width: 300 } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + this.$container.width(100); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $input = this.$element.find('.dx-texteditor-input'); + assert.strictEqual($input.attr('tabindex'), '-1', + 'hidden TextBox input has tabindex=-1'); + }); + + QUnit.test('overflow button gets tabindex=0 after all items move to menu', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'A', width: 300 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'B', width: 300 } }, + ], + }).dxToolbar('instance'); + + toolbar.option('focusedElement', toolbar._getAvailableItems().eq(0).get(0)); + + this.$container.width(50); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $overflowBtn = this.$element.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + assert.strictEqual($overflowBtn.attr('tabindex'), '0', + 'overflow button has tabindex=0 when it is the only focusable element'); + }); + + QUnit.test('resize shrink then expand: tabindex restored correctly on all items', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'A', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'B', width: 200 } }, + { location: 'before', widget: 'dxButton', locateInMenu: 'auto', options: { text: 'C', width: 200 } }, + ], + }).dxToolbar('instance'); + + toolbar.option('focusedElement', toolbar._getAvailableItems().eq(1).get(0)); + + this.$container.width(100); + toolbar.updateDimensions(); + this.clock.tick(0); + + this.$container.width(1000); + toolbar.updateDimensions(); + this.clock.tick(0); + + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 3, 'all items visible'); + assert.strictEqual(getItemFocusTarget($items.eq(1)).attr('tabindex'), '0', + 'previously focused item B has tabindex=0'); + assert.strictEqual(getItemFocusTarget($items.eq(0)).attr('tabindex'), '-1', + 'item A has tabindex=-1'); + assert.strictEqual(getItemFocusTarget($items.eq(2)).attr('tabindex'), '-1', + 'item C has tabindex=-1'); + }); +}); + +QUnit.module('Overflow menu', moduleConfig, function() { + const makeOverflowToolbar = function($el) { + return $el.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' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu C' } }, + ], + }).dxToolbar('instance'); + }; + + const getOverflowBtn = ($el) => $el.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + + QUnit.test('Enter on overflow button opens menu; first item is focused', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + assert.strictEqual($overflowBtn.length > 0, true, 'Overflow button is rendered'); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + dispatchKeydown($overflowBtn.get(0), 'Enter'); + this.clock.tick(0); + + const menu = toolbar._layoutStrategy._menu; + assert.strictEqual(menu.option('opened'), true, 'Menu is opened after Enter'); + + const $popup = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); + assert.strictEqual($popup.length > 0, true, 'Popup wrapper exists in DOM'); + + const list = menu._list; + const $firstListItem = list._getAvailableItems().first(); + assert.strictEqual($firstListItem.length > 0, true, 'List has at least one item'); + + const $firstFocusTarget = getItemFocusTarget($firstListItem); + assert.strictEqual( + getActiveElement() === $firstFocusTarget.get(0), + true, + 'Focus is on first menu item after Enter', + ); + }); + + QUnit.test('Space on overflow button opens menu; first item is focused', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + dispatchKeydown($overflowBtn.get(0), ' '); + this.clock.tick(0); + + const menu = toolbar._layoutStrategy._menu; + assert.strictEqual(menu.option('opened'), true, 'Menu is opened after Space'); + }); + + QUnit.test('ArrowDown/Up navigate inside menu; ArrowRight/Left do not navigate toolbar', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + + menu.openWithFocus('first'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu opened'); + + const list = menu._list; + const $items = list._getAvailableItems(); + assert.strictEqual($items.length >= 2, true, 'At least 2 items in menu'); + + const $firstFocusTarget = getItemFocusTarget($items.first()); + dispatchKeydown($firstFocusTarget.get(0), 'ArrowDown'); + this.clock.tick(0); + + const { focusedElement: afterDown } = list.option(); + assert.strictEqual( + $(afterDown).get(0) !== $items.first().get(0), + true, + 'ArrowDown moved focus inside menu', + ); + + const { focusedElement: toolbarFocused } = toolbar.option(); + const $currentListFocus = $(list.option('focusedElement')); + const $currentFocusTarget = getItemFocusTarget($currentListFocus); + dispatchKeydown($currentFocusTarget.get(0), 'ArrowRight'); + this.clock.tick(0); + + const { focusedElement: toolbarFocusedAfterRight } = toolbar.option(); + assert.strictEqual( + $(toolbarFocusedAfterRight).get(0), + $(toolbarFocused).get(0), + 'ArrowRight inside menu does not change toolbar focusedElement', + ); + }); + + QUnit.test('Escape closes menu; focus returns to overflow button', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + const $overflowBtn = getOverflowBtn(this.$element); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + + menu.openWithFocus('first'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu opened'); + + const list = menu._list; + const $firstItem = list._getAvailableItems().first(); + const $focusTarget = getItemFocusTarget($firstItem); + dispatchKeydown($focusTarget.get(0), 'Escape'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu closed after Escape'); + assert.strictEqual( + getActiveElement(), + $overflowBtn.get(0), + 'Focus returned to overflow button after Escape', + ); + }); + + QUnit.test('item click closes menu; focus returns to overflow button', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + const $overflowBtn = getOverflowBtn(this.$element); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + menu.openWithFocus('first'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu opened'); + + const $popup = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); + const $listItems = $popup.find(`.${LIST_ITEM_CLASS}`); + assert.strictEqual($listItems.length > 0, true, 'Popup has list items'); + + $listItems.first().trigger('dxclick'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu closed after item click'); + assert.strictEqual( + getActiveElement(), + $overflowBtn.get(0), + 'Focus returned to overflow button after item click', + ); + }); + + QUnit.test('Tab inside menu closes popup and moves focus to overflow button (allows Tab default to exit toolbar)', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + + menu.openWithFocus('first'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu opened'); + + const $firstFocusTarget = getItemFocusTarget(menu._list._getAvailableItems().first()); + this.clock.tick(0); + assert.strictEqual( + getActiveElement(), + $firstFocusTarget.get(0), + 'First item is focused before Tab', + ); + + dispatchKeydown($firstFocusTarget.get(0), 'Tab'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu is closed after Tab (APG-compliant)'); + assert.strictEqual( + getActiveElement() === $overflowBtn.get(0), + true, + 'Focus is on overflow button — in a real browser, Tab default will then move focus to the next element after the toolbar', + ); + }); + + QUnit.test('Shift+Tab inside menu closes popup and moves focus to overflow button', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + + menu.openWithFocus('first'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu opened'); + + const $firstFocusTarget = getItemFocusTarget(menu._list._getAvailableItems().first()); + + dispatchKeydown($firstFocusTarget.get(0), 'Tab', { shiftKey: true }); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu is closed after Shift+Tab'); + assert.strictEqual( + getActiveElement() === $overflowBtn.get(0), + true, + 'Focus is on overflow button after Shift+Tab', + ); + }); + + QUnit.skip('after close, overflow button retains tabindex=0; others have tabindex=-1', function(assert) { + const toolbar = this.$element.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' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + const $overflowBtn = this.$element.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $firstItem = list._getAvailableItems().first(); + dispatchKeydown(getItemFocusTarget($firstItem).get(0), 'Escape'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu closed after Escape'); + assert.strictEqual( + parseInt($overflowBtn.attr('tabindex'), 10), + 0, + 'Overflow button has tabindex=0 after close', + ); + + const $otherButtons = this.$element.find(`.${BUTTON_CLASS}`).not(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + const allTabindexMinus1 = $otherButtons.toArray().every( + el => parseInt($(el).attr('tabindex'), 10) === -1, + ); + assert.strictEqual(allTabindexMinus1, true, 'All other buttons have tabindex=-1'); + }); + + QUnit.test('ArrowDown on overflow button opens menu; first item focused', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + const $overflowBtn = getOverflowBtn(this.$element); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + dispatchKeydown($overflowBtn.get(0), 'ArrowDown'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), true, 'Menu opened via ArrowDown'); + + const list = menu._list; + const $firstItem = list._getAvailableItems().first(); + const $focusTarget = getItemFocusTarget($firstItem); + assert.strictEqual( + getActiveElement(), + $focusTarget.get(0), + 'First menu item is focused after ArrowDown', + ); + }); + + QUnit.test('ArrowUp on overflow button opens menu; last item focused', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + const $overflowBtn = getOverflowBtn(this.$element); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + dispatchKeydown($overflowBtn.get(0), 'ArrowUp'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), true, 'Menu opened via ArrowUp'); + + const list = menu._list; + const $items = list._getAvailableItems(); + const $lastItem = $items.last(); + const $focusTarget = getItemFocusTarget($lastItem); + assert.strictEqual( + getActiveElement(), + $focusTarget.get(0), + 'Last menu item is focused after ArrowUp', + ); + }); + + QUnit.test('disabled items inside menu are skipped by ArrowDown', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + { widget: 'dxButton', locateInMenu: 'always', disabled: true, options: { text: 'Menu B (disabled)' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu C' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + assert.strictEqual($items.length, 2, 'disabled item filtered out of available menu items'); + + const $firstFocusTarget = getItemFocusTarget($items.first()); + dispatchKeydown($firstFocusTarget.get(0), 'ArrowDown'); + this.clock.tick(0); + + const $focused = $(list.option('focusedElement')); + assert.strictEqual($focused.get(0), $items.eq(1).get(0), + 'ArrowDown skips disabled item and lands on Menu C'); + }); + + QUnit.test('disabled items inside menu are skipped by ArrowUp', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + { widget: 'dxButton', locateInMenu: 'always', disabled: true, options: { text: 'Menu B (disabled)' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu C' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.openWithFocus('last'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + + const $lastFocusTarget = getItemFocusTarget($items.last()); + dispatchKeydown($lastFocusTarget.get(0), 'ArrowUp'); + this.clock.tick(0); + + const $focused = $(list.option('focusedElement')); + assert.strictEqual($focused.get(0), $items.eq(0).get(0), + 'ArrowUp skips disabled item and lands on Menu A'); + }); + + QUnit.test('disabled item in menu never gets tabindex=0', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + { widget: 'dxButton', locateInMenu: 'always', disabled: true, options: { text: 'Menu B (disabled)' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu C' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.openWithFocus('first'); + this.clock.tick(0); + + const $popup = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); + const $disabledItems = $popup.find('.dx-list-item.dx-state-disabled'); + $disabledItems.each(function() { + const $btn = $(this).find('.dx-button'); + assert.strictEqual(parseInt($btn.attr('tabindex'), 10), -1, + 'disabled menu item button has tabindex=-1'); + }); + }); + + QUnit.test('options.disabled item inside menu is skipped by navigation', function(assert) { + const toolbar = this.$element.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', disabled: true } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu C' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + assert.strictEqual($items.length, 2, 'options.disabled item filtered from menu available items'); + + const $firstFocusTarget = getItemFocusTarget($items.first()); + dispatchKeydown($firstFocusTarget.get(0), 'ArrowDown'); + this.clock.tick(0); + + const $focused = $(list.option('focusedElement')); + assert.strictEqual($focused.get(0), $items.eq(1).get(0), + 'ArrowDown skips options.disabled item in menu'); + }); + + QUnit.test('opening menu with leading disabled items focuses first available item', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { widget: 'dxButton', locateInMenu: 'always', disabled: true, options: { text: 'Menu A (disabled)' } }, + { widget: 'dxButton', locateInMenu: 'always', disabled: true, options: { text: 'Menu B (disabled)' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu C' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + const $overflowBtn = getOverflowBtn(this.$element); + + toolbar.option('focusedElement', $overflowBtn.get(0)); + dispatchKeydown($overflowBtn.get(0), 'Enter'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), true, 'Menu opened'); + + const list = menu._list; + const $items = list._getAvailableItems(); + assert.strictEqual($items.length, 1, 'Only 1 non-disabled item available'); + + const $firstAvailableFocus = getItemFocusTarget($items.first()); + assert.strictEqual( + getActiveElement() === $firstAvailableFocus.get(0), + true, + 'Focus lands on first available (non-disabled) menu item, skipping disabled leading items', + ); + }); + + QUnit.test('focused menu item does not get dx-state-focused class', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + const $firstItem = $items.first(); + + assert.strictEqual($firstItem.hasClass('dx-state-focused'), false, + 'focused list item does not have dx-state-focused'); + }); + + QUnit.test('navigating menu items never adds dx-state-focused to list items', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + const $firstFocusTarget = getItemFocusTarget($items.first()); + + dispatchKeydown($firstFocusTarget.get(0), 'ArrowDown'); + this.clock.tick(0); + + const $focused = $(list.option('focusedElement')); + assert.strictEqual($focused.hasClass('dx-state-focused'), false, + 'second item does not have dx-state-focused after ArrowDown'); + + assert.strictEqual($items.first().hasClass('dx-state-focused'), false, + 'first item lost dx-state-focused class'); + + const $allFocused = list.$element().find('.dx-list-item.dx-state-focused'); + assert.strictEqual($allFocused.length, 0, + 'no dx-state-focused list items in the menu list'); + }); + + QUnit.test('overflow button is included in toolbar keyboard navigation sequence', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const $available = toolbar._getAvailableItems(); + + assert.strictEqual($available.last().get(0), $overflowBtn.get(0), + 'overflow button is the last available item in the navigation sequence'); + }); + + QUnit.test('overflow button gets tabindex=0 when it becomes the active toolbar item', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const $available = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $available.last().get(0)); + + assert.strictEqual($overflowBtn.get(0).getAttribute('tabindex'), '0', + 'overflow button has tabindex=0 when it is the active toolbar item'); + }); + + QUnit.test('focused menu item gets tabindex=0 after ArrowDown; previously focused item gets tabindex=-1', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + dispatchKeydown(getItemFocusTarget($items.first()).get(0), 'ArrowDown'); + this.clock.tick(0); + + assert.strictEqual(getItemFocusTarget($items.eq(1)).get(0).getAttribute('tabindex'), '0', + 'item[1] (newly focused) has tabindex=0'); + assert.strictEqual(getItemFocusTarget($items.eq(0)).get(0).getAttribute('tabindex'), '-1', + 'item[0] (previously focused) has tabindex=-1'); + }); + + QUnit.test('all non-focused menu items have tabindex=-1 after navigation', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $items = list._getAvailableItems(); + dispatchKeydown(getItemFocusTarget($items.first()).get(0), 'ArrowDown'); + this.clock.tick(0); + + assert.strictEqual(getItemFocusTarget($items.eq(0)).get(0).getAttribute('tabindex'), '-1', + 'item[0] has tabindex=-1 after focus moved away'); + assert.strictEqual(getItemFocusTarget($items.eq(2)).get(0).getAttribute('tabindex'), '-1', + 'item[2] has tabindex=-1 (never focused)'); + }); + + QUnit.test('mouse click on overflow button opens menu; first item is focused (allowKeyboardNavigation=true)', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + + assert.strictEqual(toolbar.option('allowKeyboardNavigation'), true, 'allowKeyboardNavigation is true (default)'); + + $overflowBtn.trigger('dxclick'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), true, 'Menu is opened after click'); + + const list = menu._list; + const $firstFocusTarget = getItemFocusTarget(list._getAvailableItems().first()); + + assert.strictEqual( + getActiveElement() === $firstFocusTarget.get(0), + true, + 'First menu item is focused after mouse click (same behavior as Enter)', + ); + }); + + QUnit.test('popup overlay content does not steal focus when menu opens (focus goes to first list item)', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + + $overflowBtn.trigger('dxclick'); + this.clock.tick(0); + + const popupContent = menu._popup.$overlayContent().get(0); + const list = menu._list; + const $firstFocusTarget = getItemFocusTarget(list._getAvailableItems().first()); + + assert.strictEqual( + getActiveElement() === popupContent, + false, + 'Popup overlay content is NOT the active element', + ); + assert.strictEqual( + getActiveElement() === $firstFocusTarget.get(0), + true, + 'Focus is on the first menu item, not on the popup overlay', + ); + }); + + QUnit.test('Escape closes menu after mouse open; focus returns to overflow button', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + + $overflowBtn.trigger('dxclick'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu is opened'); + + const list = menu._list; + const $firstFocusTarget = getItemFocusTarget(list._getAvailableItems().first()); + + assert.strictEqual( + getActiveElement() === $firstFocusTarget.get(0), + true, + 'First item is focused after mouse open', + ); + + dispatchKeydown($firstFocusTarget.get(0), 'Escape'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu is closed after Escape'); + assert.strictEqual( + getActiveElement() === $overflowBtn.get(0), + true, + 'Focus returns to overflow button after Escape', + ); + }); + + QUnit.test('Escape closes menu after keyboard open; focus returns to overflow button', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + + toolbar.option('focusedElement', $overflowBtn.get(0)); + dispatchKeydown($overflowBtn.get(0), 'Enter'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'Menu is opened after Enter'); + + const list = menu._list; + const $firstFocusTarget = getItemFocusTarget(list._getAvailableItems().first()); + + assert.strictEqual( + getActiveElement() === $firstFocusTarget.get(0), + true, + 'First item is focused after keyboard open', + ); + + dispatchKeydown($firstFocusTarget.get(0), 'Escape'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu is closed after Escape'); + assert.strictEqual( + getActiveElement() === $overflowBtn.get(0), + true, + 'Focus returns to overflow button after Escape', + ); + }); + + QUnit.test('closing menu while focus is outside popup keeps focus on the outside element', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const $overflowBtn = getOverflowBtn(this.$element); + const menu = toolbar._layoutStrategy._menu; + const $outside = $('').appendTo(document.body); + + try { + menu.openWithFocus('first'); + this.clock.tick(0); + + $outside.get(0).focus(); + this.clock.tick(0); + assert.strictEqual(getActiveElement(), $outside.get(0), 'Focus moved outside popup'); + + menu.option('opened', false); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu is closed'); + assert.notStrictEqual( + getActiveElement(), + $overflowBtn.get(0), + 'Focus is NOT moved to overflow button when it was already outside the popup', + ); + } finally { + $outside.remove(); + } + }); + + QUnit.test('ArrowDown on dxMenu item at overflow list nav level navigates list, does not activate menu', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { + locateInMenu: 'always', + widget: 'dxMenu', + options: { + items: [ + { text: 'File', items: [{ text: 'New' }, { text: 'Open' }] }, + ], + }, + }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'After Menu' } }, + ], + }).dxToolbar('instance'); + const $overflowBtn = this.$element.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + const menu = toolbar._layoutStrategy._menu; + + $overflowBtn.get(0).focus(); + this.clock.tick(0); + assert.strictEqual(getActiveElement(), $overflowBtn.get(0), + 'overflow button is focused before opening'); + + dispatchKeydown(getActiveElement(), 'ArrowDown'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'overflow popup opened'); + + const $listItems = menu._list._getAvailableItems(); + const $menuListItem = $listItems.toArray().map((el) => $(el)).find(($i) => $i.find('.dx-menu').length > 0); + assert.ok($menuListItem, 'found a list item containing dxMenu'); + + const $menuRoot = $menuListItem.find('.dx-menu').first(); + const menuInstance = $menuRoot.dxMenu('instance'); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu is at list nav level — internal focusedElement is null'); + assert.strictEqual(getActiveElement(), $menuListItem.get(0), + 'DOM focus is on the overflow list item wrapper, not inside dxMenu'); + + dispatchKeydown(getActiveElement(), 'ArrowDown'); + this.clock.tick(0); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu did NOT activate on ArrowDown — its keyboard handler did not process the key'); + + const newFocused = $(menu._list.option('focusedElement')).get(0); + assert.notStrictEqual(newFocused, $menuListItem.get(0), + 'list moved to the next item on ArrowDown (instead of menu reacting)'); + }); + +}); + +QUnit.module('Template items', moduleConfig, function() { + const focusToolbarItem = (toolbar, index, clock) => { + const $item = toolbar._getAvailableItems().eq(index); + getItemFocusTarget($item).get(0).focus(); + clock.tick(0); + return $item; + }; + + const pressActive = (key, clock) => { + dispatchKeydown(getActiveElement(), key); + clock.tick(0); + }; + + QUnit.test('template item with focusable content is in roving tabindex sequence', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', template: () => $('