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..f30e0d0c51a7 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.ts @@ -0,0 +1,179 @@ +import { 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` + .page(url(__dirname, '../../container.html')); + +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(toolbar.getItem(0).find(':focus').exists) + .ok('first toolbar item should be focused after Tab'); + + await t.pressKey('right'); + await t + .expect(toolbar.getItem(1).find(':focus').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(toolbar.getItem(1).find(':focus').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'); + }); +}); diff --git a/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts b/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts index db5a5ad9558a..1184243a5196 100644 --- a/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts +++ b/e2e/testcafe-devextreme/tests/navigation/toolbar/overflowMenu.ts @@ -214,6 +214,7 @@ test('Toolbar buttons in menu appearance', async (t) => { await createWidget('dxToolbar', { width: 50, multiline: false, + focusStateEnabled: false, items, }); }); @@ -260,6 +261,7 @@ test('Toolbar buttons as custom template appearance', async (t) => { })); await createWidget('dxToolbar', { + focusStateEnabled: false, width: 50, multiline: false, items, @@ -311,6 +313,7 @@ test('Toolbar button group appearance', async (t) => { }); await createWidget('dxToolbar', { + focusStateEnabled: false, width: 50, items, }); @@ -363,6 +366,7 @@ test('Toolbar button group as custom template appearance', async (t) => { }); await createWidget('dxToolbar', { + focusStateEnabled: false, width: 50, items, }); 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/widget/widget.ts b/packages/devextreme/js/__internal/core/widget/widget.ts index 1b3fe71e4dcb..69be45e87a3a 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/grid_core/header_panel/m_header_panel.ts b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts index a3a06d9bcde8..6255c7d5d99f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts @@ -133,6 +133,7 @@ export class HeaderPanel extends ColumnsView { const options: { toolbarOptions: ToolbarProperties } = { toolbarOptions: { items: sortedToolbarItems, + focusStateEnabled: true, visible: userToolbarOptions?.visible, disabled: userToolbarOptions?.disabled, onItemRendered(e) { diff --git a/packages/devextreme/js/__internal/scheduler/header/header.ts b/packages/devextreme/js/__internal/scheduler/header/header.ts index 6da327f2d94f..d1a794e5e1a5 100644 --- a/packages/devextreme/js/__internal/scheduler/header/header.ts +++ b/packages/devextreme/js/__internal/scheduler/header/header.ts @@ -171,6 +171,8 @@ export class SchedulerHeader extends Widget { return { ...toolbar, + // @ts-expect-error ts-error + focusStateEnabled: false, items: parsedItems, }; } diff --git a/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts b/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts index 6c06ad0d7df3..de9ef2c0fce8 100644 --- a/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts +++ b/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts @@ -278,6 +278,7 @@ class ChatTextArea extends TextArea { const toolbarOptions = { items: toolbarItems, + focusStateEnabled: false, }; this._$toolbar = $('
') diff --git a/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts b/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts index 7a83562515b8..28107e2a6c96 100644 --- a/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts +++ b/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts @@ -326,6 +326,7 @@ class DiagramToolbar extends DiagramPanel { this._prepareToolbarItems(afterCommands, 'after', this._executeCommand), ); this._toolbarInstance = this._createComponent($toolbar, Toolbar, { + focusStateEnabled: false, dataSource, }); } diff --git a/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_editor.ts b/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_editor.ts index 7f83c9b7abd5..e918e811430e 100644 --- a/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_editor.ts +++ b/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_editor.ts @@ -158,10 +158,12 @@ class DropDownEditor< : this._getFirstPopupElement(); if ($focusableElement) { + const $input = $focusableElement.hasClass('dx-texteditor') ? $focusableElement.find('.dx-texteditor-input').first() : $(); + const $focusTarget = $input.length ? $input : $focusableElement; // @ts-expect-error ts-error - eventsEngine.trigger($focusableElement, 'focus'); + eventsEngine.trigger($focusTarget, 'focus'); // @ts-expect-error ts-error - $focusableElement.select(); + $focusTarget.select(); } e.preventDefault(); }, diff --git a/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts b/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts index 757380ae0cf6..0b6171570ab2 100644 --- a/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts +++ b/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts @@ -166,7 +166,6 @@ class FileManagerToolbar extends Widget { _isRefreshVisibleInFileToolbar?: boolean; - // eslint-disable-next-line no-restricted-globals _refreshItemTextTimeout?: ReturnType; _init(): void { @@ -224,6 +223,7 @@ class FileManagerToolbar extends Widget { const $toolbar = $('
').appendTo(this.$element()); const toolbar = this._createComponent($toolbar, Toolbar, { items: toolbarItems, + focusStateEnabled: false, visible: !hidden, onItemClick: (args) => this._raiseItemClicked(args), }) as Toolbar & { compactMode?: boolean }; @@ -732,7 +732,7 @@ class FileManagerToolbar extends Widget { isDeferredUpdate, text, showText, - // eslint-disable-next-line no-restricted-globals + ): ReturnType | undefined { const options = { showText, diff --git a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts index afc71c31e703..dbb34837ed09 100644 --- a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts +++ b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts @@ -209,6 +209,7 @@ if (Quill) { get toolbarConfig() { return { dataSource: this._prepareToolbarItems(), + focusStateEnabled: true, disabled: this.isInteractionDisabled, menuContainer: this._$toolbarContainer, multiline: this.isMultilineMode(), diff --git a/packages/devextreme/js/__internal/ui/list/list.base.ts b/packages/devextreme/js/__internal/ui/list/list.base.ts index 970fd478ac03..ab3c9c4ea485 100644 --- a/packages/devextreme/js/__internal/ui/list/list.base.ts +++ b/packages/devextreme/js/__internal/ui/list/list.base.ts @@ -69,7 +69,7 @@ import { getElementMargin } from '@ts/ui/scroll_view/utils/get_element_style'; const LIST_CLASS = 'dx-list'; const LIST_ITEMS_CLASS = 'dx-list-items'; -const LIST_ITEM_CLASS = 'dx-list-item'; +export const LIST_ITEM_CLASS = 'dx-list-item'; const LIST_ITEM_SELECTOR = `.${LIST_ITEM_CLASS}`; const LIST_ITEM_ICON_CONTAINER_CLASS = 'dx-list-item-icon-container'; const LIST_ITEM_ICON_CLASS = 'dx-list-item-icon'; diff --git a/packages/devextreme/js/__internal/ui/popup/m_popup.ts b/packages/devextreme/js/__internal/ui/popup/m_popup.ts index e203ee762a41..f65687acfe05 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -683,6 +683,8 @@ class Popup< disabled, rtlEnabled, items, + focusStateEnabled: false, + allowKeyboardNavigation: false, useDefaultButtons: useDefaultToolbarButtons, useFlatButtons: useFlatToolbarButtons, integrationOptions, diff --git a/packages/devextreme/js/__internal/ui/toolbar/constants.ts b/packages/devextreme/js/__internal/ui/toolbar/constants.ts index 78195b8a9cd1..1d3df86f2a84 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/constants.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/constants.ts @@ -1 +1,3 @@ export const TOOLBAR_CLASS = 'dx-toolbar'; +export const TOOLBAR_FOCUS_MODE_CLASS = 'dx-toolbar-focus-mode'; +export const DROPDOWNMENU_LIST_FOCUS_MODE_CLASS = 'dx-dropdownmenu-list-focus-mode'; diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts new file mode 100644 index 000000000000..cac000e1d6b0 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts @@ -0,0 +1,575 @@ +import { keyboard } from '@js/common/core/events/short'; +import domAdapter from '@js/core/dom_adapter'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { DxEvent } from '@js/events'; +import { getPublicElement } from '@ts/core/m_element'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; + +import type ToolbarBase from '../toolbar.base'; +import { + applyItemTabIndex, + closeItemWidget, + closeOpenSubmenu, + getItemFocusTarget as defaultGetItemFocusTarget, + getPlainItemFocusTargets, + isItemDisabled, + isItemWidgetOpened, + isMenuTarget, + isTextInputTarget, +} from '../toolbar.utils'; +import type ToolbarMenuList from './toolbar.menu.list'; + +type Direction = 'horizontal' | 'vertical'; + +const HORIZONTAL_KEY_LOCATION: Record = { + ArrowRight: 'right', + ArrowLeft: 'left', + Home: 'first', + End: 'last', +}; + +const VERTICAL_KEY_LOCATION: Record = { + ArrowDown: 'down', + ArrowUp: 'up', + Home: 'first', + End: 'last', +}; + +type HostComponent = ToolbarBase | ToolbarMenuList; + +export interface RovingTabIndexNavigatorConfig { + component: HostComponent; + itemsSelector: string; + direction: Direction; + getItemFocusTarget?: ($item: dxElementWrapper) => dxElementWrapper | undefined; + onTabKey?: () => void; + onEscapeKey?: () => void; + isEnabled?: () => boolean; +} + +export interface FocusRestoreDescriptor { + index: number | undefined; + overflow: boolean; +} + +export class RovingTabIndexNavigator { + private readonly config: RovingTabIndexNavigatorConfig; + + private keyboardListenerId?: string; + + private captureHandler?: (e: KeyboardEvent) => void; + + private $prevActiveItem?: dxElementWrapper; + + constructor(config: RovingTabIndexNavigatorConfig) { + this.config = config; + } + + attach(): void { + this.detach(); + + const { component } = this.config; + + this.keyboardListenerId = keyboard.on( + component._keyboardEventBindingTarget(), + null, + (opts: KeyboardKeyDownEvent) => component._keyboardHandler(opts), + ); + + this.attachCaptureHandler(); + } + + detach(): void { + if (this.keyboardListenerId) { + keyboard.off(this.keyboardListenerId); + this.keyboardListenerId = undefined; + } + this.detachCaptureHandler(); + this.$prevActiveItem = undefined; + } + + private getFocusTarget($item: dxElementWrapper): dxElementWrapper | undefined { + return this.config.getItemFocusTarget + ? this.config.getItemFocusTarget($item) + : defaultGetItemFocusTarget($item); + } + + private getKeyToLocation(): Record { + return this.config.direction === 'horizontal' + ? HORIZONTAL_KEY_LOCATION + : VERTICAL_KEY_LOCATION; + } + + private attachCaptureHandler(): void { + const element = this.config.component.$element().get(0) as HTMLElement; + + this.captureHandler = (e: KeyboardEvent): void => { + const { target } = e; + if (!(target instanceof HTMLElement)) { + return; + } + + const isTextInput = isTextInputTarget(target); + const isMenu = isMenuTarget(target); + + if (e.key === 'Tab') { + this.config.onTabKey?.(); + return; + } + + if ((isTextInput || isMenu) && e.key !== 'Escape') { + return; + } + + if (e.key === 'Escape' && (isTextInput || isMenu)) { + this.handleEscapeInsideWidget(target, e, isMenu); + return; + } + + if (e.key === 'Escape') { + if (this.config.onEscapeKey) { + e.preventDefault(); + e.stopPropagation(); + this.config.onEscapeKey(); + } + return; + } + + const location = this.getKeyToLocation()[e.key]; + + if (!location) { + return; + } + + this.syncFocusedItem(target); + + const { focusedElement } = this.config.component.option(); + const $focused = $(focusedElement); + if ($focused.length && isItemWidgetOpened($focused)) { + return; + } + + if (this.moveInsidePlainItem(target, location, e)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.moveFocus(location); + this.focusPlainItemEdge(location); + }; + + element.addEventListener('keydown', this.captureHandler as EventListener, true); + } + + private detachCaptureHandler(): void { + if (this.captureHandler) { + const element = this.config.component.$element().get(0) as HTMLElement; + element.removeEventListener('keydown', this.captureHandler as EventListener, true); + this.captureHandler = undefined; + } + } + + private moveInsidePlainItem( + target: HTMLElement, + location: string, + e: KeyboardEvent, + ): boolean { + if (this.config.direction !== 'horizontal' || (location !== 'left' && location !== 'right')) { + return false; + } + + const { focusedElement } = this.config.component.option(); + const $focused = $(focusedElement); + const $item = $(target).closest(this.config.itemsSelector); + + if (!$focused.length || $focused.get(0) !== $item.get(0)) { + return false; + } + + const $targets = getPlainItemFocusTargets($focused); + if ($targets.length <= 1) { + return false; + } + + const targets = $targets.toArray() as HTMLElement[]; + const currentIndex = targets.findIndex(( + element, + ) => element === target || element.contains(target)); + if (currentIndex < 0) { + return false; + } + + const nextIndex = currentIndex + (location === 'right' ? 1 : -1); + if (nextIndex < 0 || nextIndex >= targets.length) { + return false; + } + + e.preventDefault(); + e.stopPropagation(); + + $targets.attr('tabIndex', -1); + $(targets[nextIndex]).attr('tabIndex', this.getItemTabIndex($focused)); + targets[nextIndex].focus(); + + return true; + } + + private focusPlainItemEdge(location: string): void { + if (this.config.direction !== 'horizontal' || (location !== 'left' && location !== 'right')) { + return; + } + + const { focusedElement } = this.config.component.option(); + const $focused = $(focusedElement); + const $targets = getPlainItemFocusTargets($focused); + + if ($targets.length <= 1) { + return; + } + + const targets = $targets.toArray() as HTMLElement[]; + const edgeTarget = location === 'left' ? targets[targets.length - 1] : targets[0]; + + $targets.attr('tabIndex', -1); + $(edgeTarget).attr('tabIndex', this.getItemTabIndex($focused)); + edgeTarget.focus(); + } + + private handleEscapeInsideWidget(target: HTMLElement, e: KeyboardEvent, isMenu: boolean): void { + if (isMenu && closeOpenSubmenu(target, e)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const $item = $(target).closest(this.config.itemsSelector); + if ($item.length && closeItemWidget($item)) { + return; + } + + if ($item.length) { + this.focusItemWidget($item); + } + } + + private syncFocusedItem(target: HTMLElement): void { + let $item = $(target).closest(this.config.itemsSelector); + + if (!$item.length) { + $item = $(target) + .find('[tabindex="0"]') + .closest(this.config.itemsSelector) + .first(); + } + + if ($item.length && defaultGetItemFocusTarget($item)?.length) { + this.config.component.option({ focusedElement: getPublicElement($item) }); + } + } + + moveFocus(location: string, e?: DxEvent): void { + this.config.component._moveFocus(location, e); + } + + focusInHandler( + component: HostComponent, + e: DxEvent, + ): void { + const $target = $(e.target as Element); + const $item = $target.closest(this.config.itemsSelector); + + if ($item.length && defaultGetItemFocusTarget($item)?.length) { + component.option({ focusedElement: getPublicElement($item) }); + } + } + + focusItemWidget($item: dxElementWrapper): void { + const $focusTarget = this.getFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + ($focusTarget.get(0) as HTMLElement).focus(); + } + + getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + return this.config.component._getAvailableItems($itemElements); + } + + getItemTabIndex($item: dxElementWrapper): number { + const itemData = this.config.component._getItemData($item) as + | { options?: { tabIndex?: number } } + | undefined; + return itemData?.options?.tabIndex ?? 0; + } + + updateRovingTabIndex($activeItem?: dxElementWrapper): void { + const { isEnabled } = this.config; + const enabled = isEnabled?.() ?? false; + if (!enabled) { + return; + } + + const $prev = this.$prevActiveItem; + const prev = $prev?.get(0); + const next = $activeItem?.get(0); + + if ($prev && prev && prev !== next && prev.isConnected) { + applyItemTabIndex($prev, -1); + } + + if ($activeItem?.length) { + applyItemTabIndex($activeItem, this.getItemTabIndex($activeItem)); + this.$prevActiveItem = $activeItem; + return; + } + + const $first = this.getAvailableItems().first(); + if ($first.length) { + applyItemTabIndex($first, this.getItemTabIndex($first)); + this.$prevActiveItem = $first; + } else { + this.$prevActiveItem = undefined; + } + } + + resetRovingTabIndex(itemsContainer: dxElementWrapper): void { + const { isEnabled } = this.config; + const enabled = isEnabled?.() ?? false; + if (!enabled) { + return; + } + + const $allItems = itemsContainer.find(this.config.itemsSelector); + $allItems.each((_index: number, item: Element): boolean => { + applyItemTabIndex($(item), -1); + return true; + }); + + this.$prevActiveItem = undefined; + + const { focusedElement } = this.config.component.option(); + const $focused = $(focusedElement); + const $available = this.getAvailableItems(); + const focusedEl = $focused.get(0); + const isFocusedAvailable = !!focusedEl && $available.toArray().includes(focusedEl); + const $newActive = isFocusedAvailable ? $focused : $available.first(); + + if ($newActive.length) { + applyItemTabIndex($newActive, this.getItemTabIndex($newActive)); + this.$prevActiveItem = $newActive; + } + } + + // NOTE: tri-state result consumed before a full re-render: + // - descriptor: DOM focus was on a toolbar item -> remember it for restore; + // - null: focus moved to a real element outside the toolbar -> drop pending state; + // - undefined: navigation disabled or focus on body/null -> keep pending state intact + // (a nested re-render may run after the item DOM was already cleaned). + captureFocusedItem(): FocusRestoreDescriptor | null | undefined { + const enabled = this.config.isEnabled?.() ?? false; + if (!enabled) { + return undefined; + } + + const root = this.config.component.$element().get(0) as HTMLElement | undefined; + const active = domAdapter.getActiveElement(root); + const insideToolbar = !!active && active !== root && !!root?.contains(active); + + if (!insideToolbar) { + // Focus on body/null (e.g. the focused item was removed mid re-render) keeps the + // pending state, so a nested re-render does not lose the original capture. Focus on + // any other real element means the user moved away -> drop the pending state. + const body = domAdapter.getBody(); + return active && active !== body ? null : undefined; + } + + const $item = $(active).closest(this.config.itemsSelector); + if (!$item.length) { + return null; + } + + const itemIndexKey = this.config.component._itemIndexKey(); + const index = $item.data(itemIndexKey) as unknown as number | undefined; + + return { + index: typeof index === 'number' ? index : undefined, + overflow: $item.hasClass('dx-dropdownmenu-button'), + }; + } + + // Returns a descriptor for $item only if it currently owns DOM focus — e.g. the focused + // item is being disabled in place (an incremental option('items[n].disabled', true), not a + // full re-render). The caller restores focus onto an adjacent enabled item afterwards. + captureItemIfFocused($item: dxElementWrapper): FocusRestoreDescriptor | undefined { + const enabled = this.config.isEnabled?.() ?? false; + if (!enabled || !$item?.length) { + return undefined; + } + + const root = this.config.component.$element().get(0) as HTMLElement | undefined; + const active = domAdapter.getActiveElement(root); + const item = $item.get(0); + if (!active || !item?.contains(active)) { + return undefined; + } + + const itemIndexKey = this.config.component._itemIndexKey(); + const index = $item.data(itemIndexKey) as unknown as number | undefined; + + return { + index: typeof index === 'number' ? index : undefined, + overflow: $item.hasClass('dx-dropdownmenu-button'), + }; + } + + restoreFocus(descriptor: FocusRestoreDescriptor): void { + const enabled = this.config.isEnabled?.() ?? false; + if (!enabled) { + return; + } + + const $available = this.getAvailableItems(); + if (!$available.length) { + return; + } + + const $target = this.resolveRestoreTarget($available, descriptor); + if (!$target?.length) { + return; + } + + // NOTE: updateRovingTabIndex moves the single tab stop from the reset default + // (first item) onto the restored target before focus, so there is never a moment + // with two tab stops, regardless of whether focusin fires synchronously. + this.updateRovingTabIndex($target); + this.focusItemWidget($target); + } + + private resolveRestoreTarget( + $available: dxElementWrapper, + descriptor: FocusRestoreDescriptor, + ): dxElementWrapper | undefined { + const { index, overflow } = descriptor; + + if (overflow) { + const $overflow = $available.filter('.dx-dropdownmenu-button'); + if ($overflow.length) { + return $overflow.first(); + } + } + + if (index !== undefined) { + const itemIndexKey = this.config.component._itemIndexKey(); + const available = $available.toArray(); + const getIndex = (el: Element): number | undefined => ( + $(el).data(itemIndexKey) as unknown as number | undefined + ); + + const exact = available.find((el) => getIndex(el) === index); + if (exact) { + return $(exact); + } + + const nearest = available.find((el) => { + const elIndex = getIndex(el); + return elIndex !== undefined && elIndex >= index; + }); + + return $(nearest ?? available[available.length - 1]); + } + + return $available.first(); + } +} + +export function enterKeyHandler( + component: HostComponent, + e: DxEvent, + callSuper: (e: DxEvent) => void, +): void { + const { focusStateEnabled, focusedElement } = component.option(); + + if (!focusStateEnabled) { + callSuper(e); + return; + } + + const target = e.target as HTMLElement; + if (isTextInputTarget(target) || isMenuTarget(target)) { + return; + } + + component._handleActivationAtNavLevel(e); + if (e.defaultPrevented) { + return; + } + + const $item = $(focusedElement); + if ($item.length) { + const $textEditor = $item.find('.dx-texteditor-input').first(); + if ($textEditor.length) { + e.preventDefault(); + ($textEditor.get(0) as HTMLElement).focus(); + return; + } + } + + callSuper(e); +} + +export function focusOutHandler( + component: HostComponent, + e: DxEvent, + callSuper: (e: DxEvent) => void, +): void { + const { relatedTarget } = e as DxEvent & { relatedTarget: Element }; + const target = e.target as Element; + + if (relatedTarget && component.$element().get(0)?.contains(relatedTarget)) { + return; + } + + if (relatedTarget && $(relatedTarget).closest('.dx-overlay-content').length) { + return; + } + + if (target && $(target).closest('.dx-overlay-content').length) { + return; + } + + callSuper(e); +} + +export function focusItemWidget( + component: HostComponent, + $item: dxElementWrapper, +): void { + if (component._navigator) { + component._navigator.focusItemWidget($item); + return; + } + const $focusTarget = component._getItemFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + ($focusTarget.get(0) as HTMLElement).focus(); +} + +export function getAvailableItems( + component: HostComponent, + $itemElements?: dxElementWrapper, +): dxElementWrapper { + const $visible = component._getVisibleItems($itemElements); + const { disabled } = component.option(); + const widgetDisabled = !!disabled; + const elements = Array.from($visible.toArray()).filter( + (item) => !isItemDisabled($(item), widgetDisabled) + && !!component._getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; +} diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts index a1b1b29f8fde..2bb4e3209f29 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts @@ -5,9 +5,24 @@ import $ from '@js/core/renderer'; import { each } from '@js/core/utils/iterator'; import type { DxEvent } from '@js/events'; import type { Item } from '@js/ui/toolbar'; +import { getPublicElement } from '@ts/core/m_element'; import type { ActionConfig } from '@ts/core/widget/component'; +import type { SupportedKeys } from '@ts/core/widget/widget'; import type { ItemRenderInfo, ItemTemplate } from '@ts/ui/collection/collection_widget.base'; import { ListBase } from '@ts/ui/list/list.base'; +import { + enterKeyHandler, + focusItemWidget, + focusOutHandler, + getAvailableItems, + RovingTabIndexNavigator, +} from '@ts/ui/toolbar/internal/keyboard.navigation'; +import { + activateMenu, + closeItemWidget, + getItemFocusTarget, + isItemWidgetOpened, +} from '@ts/ui/toolbar/toolbar.utils'; export const TOOLBAR_MENU_ACTION_CLASS = 'dx-toolbar-menu-action'; const TOOLBAR_HIDDEN_BUTTON_CLASS = 'dx-toolbar-hidden-button'; @@ -19,10 +34,30 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content'; type ActionableComponents = Extract; export default class ToolbarMenuList extends ListBase { + _navigator?: RovingTabIndexNavigator; + + _onEscapePress?: () => void; + + _onTabPress?: () => void; + protected _activeStateUnit(): string { return `.${TOOLBAR_MENU_ACTION_CLASS}:not(.${TOOLBAR_HIDDEN_BUTTON_GROUP_CLASS})`; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _toggleFocusClass(_isFocused: boolean, _$element?: dxElementWrapper): void { + // Intentionally empty: visual focus is managed by the inner widgets themselves + // not by dx-state-focused on the list item container. + } + + _refreshActiveDescendant(): void { + // roving tabIndex: real DOM focus moves to items, aria-activedescendant not needed + } + + _refreshItemId(): void { + // roving tabIndex: no synthetic id needed for aria-activedescendant + } + _initMarkup(): void { this._renderSections(); super._initMarkup(); @@ -43,10 +78,8 @@ export default class ToolbarMenuList extends ListBase { each(['before', 'center', 'after', 'menu'], (_, section) => { const sectionName = `_$${section}Section`; - if (!this[sectionName]) { - this[sectionName] = $('
') - .addClass(TOOLBAR_MENU_SECTION_CLASS); - } + this[sectionName] ??= $('
') + .addClass(TOOLBAR_MENU_SECTION_CLASS); this[sectionName].appendTo($container); }); @@ -130,6 +163,143 @@ export default class ToolbarMenuList extends ListBase { }; } + _supportedKeys(): SupportedKeys { + const keys = super._supportedKeys(); + + if (!this.option('focusStateEnabled')) { + return keys; + } + + delete keys.leftArrow; + delete keys.rightArrow; + delete keys.upArrow; + delete keys.downArrow; + delete keys.home; + delete keys.end; + + return keys; + } + + _attachKeyboardEvents(): void { + this._detachKeyboardEvents(); + + if (!this.option('focusStateEnabled')) { + super._attachKeyboardEvents(); + return; + } + + this._navigator = new RovingTabIndexNavigator({ + component: this, + itemsSelector: this._itemSelector(), + direction: 'vertical', + getItemFocusTarget: ($item): dxElementWrapper => this._getItemFocusTarget($item), + onEscapeKey: (): void => this._onEscapePress?.(), + onTabKey: (): void => this._onTabPress?.(), + isEnabled: (): boolean => !!this.option('focusStateEnabled'), + }); + this._navigator.attach(); + } + + _detachKeyboardEvents(): void { + this._navigator?.detach(); + this._navigator = undefined; + super._detachKeyboardEvents(); + } + + _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper { + return getItemFocusTarget($item) ?? ($item.hasClass(TOOLBAR_MENU_ACTION_CLASS) ? $item : $()); + } + + _enterKeyHandler(e: DxEvent): void { + enterKeyHandler(this, e, (evt) => super._enterKeyHandler(evt)); + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + + this._navigator?.updateRovingTabIndex($target); + } + + _focusOutHandler(e: DxEvent): void { + focusOutHandler(this, e, (evt) => super._focusOutHandler(evt)); + } + + _focusItemWidget($item: dxElementWrapper): void { + focusItemWidget(this, $item); + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + return getAvailableItems(this, $itemElements); + } + + _focusInHandler(e: DxEvent): void { + this._navigator?.focusInHandler(this, e); + } + + _resetRovingTabIndex(): void { + this._navigator?.resetRovingTabIndex(this.$element()); + } + + _handleActivationAtNavLevel(e: KeyboardEvent): void { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if (!$focused.length || isItemWidgetOpened($focused)) { + return; + } + + const $menu = $focused.find('.dx-menu').first(); + if ($menu.length) { + e.preventDefault(); + e.stopPropagation(); + activateMenu($menu); + } + } + + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + _moveFocus(location: string): boolean | undefined | void { + if (!this.option('focusStateEnabled')) { + return super._moveFocus(location); + } + + const { focusedElement: prevFocusedElement } = this.option(); + const $prev = $(prevFocusedElement); + if ($prev.length) { + closeItemWidget($prev); + } + + const result = super._moveFocus(location); + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + if ($focused.length) { + this._focusItemWidget($focused); + } + + return result; + } + + focusFirstItem(): void { + const $first = this._getAvailableItems().first(); + if ($first.length) { + this.option('focusedElement', getPublicElement($first)); + this._focusItemWidget($first); + } + } + + focusLastItem(): void { + const $last = this._getAvailableItems().last(); + if ($last.length) { + this.option('focusedElement', getPublicElement($last)); + this._focusItemWidget($last); + } + } + + _postProcessRenderItems(): void { + super._postProcessRenderItems(); + this._resetRovingTabIndex(); + } + _itemClickHandler( e: DxEvent, args?: Record, @@ -141,6 +311,8 @@ export default class ToolbarMenuList extends ListBase { } _clean(): void { + this._navigator?.detach(); + this._navigator = undefined; this._getSections().empty(); super._clean(); } diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts index b74b0d57e877..cc67f7107c37 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -19,19 +19,21 @@ import type { OptionChanged } from '@ts/core/widget/types'; import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; import Button from '@ts/ui/button/wrapper'; -import type { ListBase } from '@ts/ui/list/list.base'; import Popup from '@ts/ui/popup/m_popup'; +import { DROPDOWNMENU_LIST_FOCUS_MODE_CLASS } from '@ts/ui/toolbar/constants'; import ToolbarMenuList, { TOOLBAR_MENU_ACTION_CLASS } from '@ts/ui/toolbar/internal/toolbar.menu.list'; import { toggleItemFocusableElementTabIndex } from '@ts/ui/toolbar/toolbar.utils'; -const DROP_DOWN_MENU_CLASS = 'dx-dropdownmenu'; +export const DROP_DOWN_MENU_CLASS = 'dx-dropdownmenu'; const DROP_DOWN_MENU_POPUP_CLASS = 'dx-dropdownmenu-popup'; -const DROP_DOWN_MENU_POPUP_WRAPPER_CLASS = 'dx-dropdownmenu-popup-wrapper'; +export const DROP_DOWN_MENU_POPUP_WRAPPER_CLASS = 'dx-dropdownmenu-popup-wrapper'; const DROP_DOWN_MENU_LIST_CLASS = 'dx-dropdownmenu-list'; -const DROP_DOWN_MENU_BUTTON_CLASS = 'dx-dropdownmenu-button'; +export const DROP_DOWN_MENU_BUTTON_CLASS = 'dx-dropdownmenu-button'; const POPUP_BOUNDARY_VERTICAL_OFFSET = 10; const POPUP_VERTICAL_OFFSET = 3; +type OpenFocusTarget = 'first' | 'last' | null; + export interface DropDownMenuProperties extends WidgetProperties { opened?: boolean; container: string | Element | undefined; @@ -44,6 +46,7 @@ export interface DropDownMenuProperties extends WidgetProperties { onButtonClick?: (e: ClickEvent) => void; useInkRipple?: boolean; closeOnClick?: boolean; + listFocusStateEnabled?: boolean; } export default class DropDownMenu extends Widget { @@ -51,7 +54,7 @@ export default class DropDownMenu extends Widget { _popup?: Popup; - _list?: ListBase; + _list?: ToolbarMenuList; _$popup?: dxElementWrapper; @@ -61,6 +64,8 @@ export default class DropDownMenu extends Widget { _buttonClickAction?: (e: ClickEvent) => void; + _openFocusTarget: OpenFocusTarget = null; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _supportedKeys(): Record boolean | void> { let extension = {}; @@ -74,7 +79,7 @@ export default class DropDownMenu extends Widget { ...super._supportedKeys(), ...extension, tab(): void { - this._popup?.hide(); + this.option('opened', false); }, }; } @@ -90,6 +95,7 @@ export default class DropDownMenu extends Widget { opened: false, closeOnClick: true, useInkRipple: false, + listFocusStateEnabled: false, container: undefined, animation: { show: { type: 'fade', from: 0, to: 1 }, @@ -239,15 +245,18 @@ export default class DropDownMenu extends Widget { rtlEnabled, container, animation, + listFocusStateEnabled, } = this.option(); this._popup = this._createComponent(this._$popup, Popup, { + focusStateEnabled: !listFocusStateEnabled, onInitialized(e) { const { component } = e; // @ts-expect-error component.$wrapper() .addClass(DROP_DOWN_MENU_POPUP_WRAPPER_CLASS) - .addClass(DROP_DOWN_MENU_POPUP_CLASS); + .addClass(DROP_DOWN_MENU_POPUP_CLASS) + .toggleClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS, !!listFocusStateEnabled); }, deferRendering: false, preventScrollEvents: false, @@ -271,6 +280,22 @@ export default class DropDownMenu extends Widget { this.option('opened', value); } }, + onShown: () => { + if (this.option('listFocusStateEnabled')) { + if (this._openFocusTarget === 'last') { + this._list?.focusLastItem(); + } else { + this._list?.focusFirstItem(); + } + this._openFocusTarget = null; + } + }, + onHiding: () => { + const popupEl = this._popup?.$overlayContent().get(0); + if (popupEl?.contains(document.activeElement)) { + (this._button?.$element().get(0) as HTMLElement | undefined)?.focus(); + } + }, container, autoResizeEnabled: false, height: 'auto', @@ -306,6 +331,11 @@ export default class DropDownMenu extends Widget { }); } + openWithFocus(focusTarget: OpenFocusTarget = 'first'): void { + this._openFocusTarget = focusTarget; + this.option('opened', true); + } + _getMaxHeight(): number { const $element = this.$element(); @@ -338,8 +368,10 @@ export default class DropDownMenu extends Widget { const $content = $(contentElement); $content.addClass(DROP_DOWN_MENU_LIST_CLASS); - const { itemTemplate, onItemRendered } = this.option(); - + const { + itemTemplate, + onItemRendered, listFocusStateEnabled, focusStateEnabled, + } = this.option(); this._list = this._createComponent($content, ToolbarMenuList, { dataSource: this._getListDataSource(), pageLoadMode: 'scrollBottom', @@ -352,7 +384,7 @@ export default class DropDownMenu extends Widget { this._itemClickHandler(e); }, tabIndex: -1, - focusStateEnabled: false, + focusStateEnabled: listFocusStateEnabled ?? focusStateEnabled, activeStateEnabled: true, onItemRendered, _itemAttributes: { role: 'menuitem' }, @@ -363,6 +395,14 @@ export default class DropDownMenu extends Widget { } }, }); + + this._list._onEscapePress = (): void => { + this.option('opened', false); + }; + + this._list._onTabPress = (): void => { + this.option('opened', false); + }; } _popupKeyHandler(e: DxEvent): void { @@ -389,7 +429,7 @@ export default class DropDownMenu extends Widget { value: unknown, ): void { this._list?._itemOptionChanged(item, property, value); - toggleItemFocusableElementTabIndex(this._list, item); + this._updateFocusableItemsTabIndex(); } _getListDataSource(): DataSourceLike | Item[] { @@ -438,9 +478,13 @@ export default class DropDownMenu extends Widget { this._invalidate(); break; case 'focusStateEnabled': - this._list?.option(name, value); super._optionChanged(args); break; + case 'listFocusStateEnabled': + this._list?.option('focusStateEnabled', value); + this._popup?.option('focusStateEnabled', !value); + this._popup?.$wrapper()?.toggleClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS, !!value); + break; case 'onItemRendered': this._list?.option(name, value); break; @@ -467,8 +511,13 @@ export default class DropDownMenu extends Widget { } _updateFocusableItemsTabIndex(): void { - const { items = [] } = this.option(); - - items.forEach((item) => toggleItemFocusableElementTabIndex(this._list, item)); + if (this._list) { + if (this.option('listFocusStateEnabled')) { + this._list._resetRovingTabIndex(); + } else { + const { items = [] } = this.option(); + items.forEach((item) => toggleItemFocusableElementTabIndex(this._list, item)); + } + } } } diff --git a/packages/devextreme/js/__internal/ui/toolbar/strategy/toolbar.singleline.ts b/packages/devextreme/js/__internal/ui/toolbar/strategy/toolbar.singleline.ts index 6c4481db6cb1..6f359fc4b499 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/strategy/toolbar.singleline.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/strategy/toolbar.singleline.ts @@ -57,11 +57,13 @@ export class SingleLineStrategy { const { disabled, + allowKeyboardNavigation, menuContainer, } = this._toolbar.option(); this._menu = this._toolbar._createComponent($menu, DropDownMenu, { disabled, + listFocusStateEnabled: allowKeyboardNavigation, // eslint-disable-next-line @typescript-eslint/no-unsafe-return itemTemplate: () => menuItemTemplate, onItemClick: (e) => { itemClickAction(e); }, @@ -268,6 +270,9 @@ export class SingleLineStrategy { case 'disabled': this._menu?.option(name, value); break; + case 'allowKeyboardNavigation': + this._menu?.option('listFocusStateEnabled', value); + break; case 'overflowMenuVisible': this._menu?.option('opened', value); break; diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 878ba732e30c..a34e9ba2e442 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -1,5 +1,6 @@ import type { DefaultOptionsRule } from '@js/common'; import { fx } from '@js/common/core/animation'; +import { keyboard } from '@js/common/core/events/short'; import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; @@ -7,6 +8,7 @@ import { BindableTemplate } from '@js/core/templates/bindable_template'; import { each } from '@js/core/utils/iterator'; import { getHeight, getOuterWidth, getWidth } from '@js/core/utils/size'; import { isDefined, isPlainObject } from '@js/core/utils/type'; +import type { DxEvent } from '@js/events'; import { current, isMaterial, @@ -16,16 +18,32 @@ import { } from '@js/ui/themes'; import type { Item, Properties } from '@js/ui/toolbar'; import type { OptionChanged } from '@ts/core/widget/types'; +import type { SupportedKeys } from '@ts/core/widget/widget'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; -import { TOOLBAR_CLASS } from './constants'; +import { TOOLBAR_CLASS, TOOLBAR_FOCUS_MODE_CLASS } from './constants'; +import type { FocusRestoreDescriptor } from './internal/keyboard.navigation'; +import { + enterKeyHandler, + focusItemWidget, + focusOutHandler, + getAvailableItems, + RovingTabIndexNavigator, +} from './internal/keyboard.navigation'; +import { + activateMenu, + closeItemWidget, + getItemFocusTarget, + isItemWidgetOpened, +} from './toolbar.utils'; export const TOOLBAR_BEFORE_CLASS = 'dx-toolbar-before'; const TOOLBAR_CENTER_CLASS = 'dx-toolbar-center'; export const TOOLBAR_AFTER_CLASS = 'dx-toolbar-after'; const TOOLBAR_MINI_CLASS = 'dx-toolbar-mini'; -const TOOLBAR_ITEM_CLASS = 'dx-toolbar-item'; +export const TOOLBAR_ITEM_CLASS = 'dx-toolbar-item'; const TOOLBAR_LABEL_CLASS = 'dx-toolbar-label'; const TOOLBAR_BUTTON_CLASS = 'dx-toolbar-button'; const TOOLBAR_ITEMS_CONTAINER_CLASS = 'dx-toolbar-items-container'; @@ -49,6 +67,7 @@ export interface ToolbarBaseProperties< CollectionWidgetBaseProperties, keyof Properties & keyof CollectionWidgetBaseProperties > { + allowKeyboardNavigation: boolean; grouped: boolean; renderAs: 'topToolbar'; useFlatButtons: boolean; @@ -69,6 +88,10 @@ class ToolbarBase< _waitParentAnimationTimeout?: ReturnType; + _navigator?: RovingTabIndexNavigator; + + _pendingFocusDescriptor?: FocusRestoreDescriptor; + _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); } @@ -133,6 +156,9 @@ class ToolbarBase< grouped: false, useFlatButtons: false, useDefaultButtons: false, + focusStateEnabled: true, + allowKeyboardNavigation: true, + loopItemFocus: true, }; } @@ -150,6 +176,186 @@ class ToolbarBase< ]); } + _focusTarget(): dxElementWrapper { + return this.$element(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _toggleFocusClass(_isFocused: boolean, _$element?: dxElementWrapper): void { + // Intentionally empty: focus visual is managed via :focus-visible on inner elements, + // not by dx-state-focused on the toolbar root or item containers. + } + + _supportedKeys(): SupportedKeys { + const keys = super._supportedKeys(); + + if (!this.option('allowKeyboardNavigation')) { + return keys; + } + + delete keys.leftArrow; + delete keys.rightArrow; + delete keys.home; + delete keys.end; + + keys.upArrow = (e: DxEvent): void => this._handleOverflowOpenAtNavLevel(e); + keys.downArrow = (e: DxEvent): void => this._handleOverflowOpenAtNavLevel(e); + + return keys; + } + + _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | undefined { + return getItemFocusTarget($item); + } + + _enterKeyHandler(e: DxEvent): void { + enterKeyHandler(this._getContext(), e, (evt) => super._enterKeyHandler(evt)); + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + + this._navigator?.updateRovingTabIndex($target); + } + + _focusOutHandler(e: DxEvent): void { + focusOutHandler(this._getContext(), e, (evt) => super._focusOutHandler(evt)); + } + + _focusItemWidget($item: dxElementWrapper): void { + focusItemWidget(this._getContext(), $item); + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + return getAvailableItems(this._getContext(), $itemElements); + } + + _focusInHandler(e: DxEvent): void { + this._navigator?.focusInHandler(this._getContext(), e); + } + + _renderFocusTarget(): void { + this._focusTarget().removeAttr('tabIndex'); + } + + _refreshActiveDescendant(): void { + // roving tabIndex: real DOM focus moves to items, aria-activedescendant not needed + } + + _refreshItemId(): void { + // roving tabIndex: no synthetic id needed for aria-activedescendant + } + + _attachKeyboardEvents(): void { + this._detachKeyboardEvents(); + + if (!this.option('allowKeyboardNavigation')) { + this._keyboardListenerId = keyboard.on( + this._keyboardEventBindingTarget(), + null, + (opts: KeyboardKeyDownEvent) => this._keyboardHandler(opts), + ); + return; + } + + this._navigator = new RovingTabIndexNavigator({ + component: this._getContext(), + itemsSelector: `${this._itemSelector()}, .dx-dropdownmenu-button`, + direction: 'horizontal', + isEnabled: (): boolean => !!this.option('allowKeyboardNavigation'), + }); + this._navigator.attach(); + } + + _getContext(): ToolbarBase { + // @ts-expect-error ts-error + return this; + } + + _detachKeyboardEvents(): void { + this._navigator?.detach(); + this._navigator = undefined; + super._detachKeyboardEvents(); + } + + _isOverflowItem($item: dxElementWrapper): boolean { + return $item.hasClass('dx-dropdownmenu-button'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _openOverflowMenu(focusTarget: 'first' | 'last'): void { + // overridden in Toolbar + } + + _getVisibleItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $items = $itemElements ?? this._itemContainer().find(`${this._itemSelector()}, .dx-dropdownmenu-button`); + return $items.filter(':visible'); + } + + _resetRovingTabIndex(): void { + this._navigator?.resetRovingTabIndex(this._itemContainer()); + } + + _handleActivationAtNavLevel(e: KeyboardEvent): void { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if (!$focused.length || isItemWidgetOpened($focused)) { + return; + } + + if (this._isOverflowItem($focused)) { + e.preventDefault(); + e.stopPropagation(); + this._openOverflowMenu('first'); + return; + } + + const $menu = $focused.find('.dx-menu').first(); + if ($menu.length) { + e.preventDefault(); + e.stopPropagation(); + activateMenu($menu); + } + } + + _handleOverflowOpenAtNavLevel(e: KeyboardEvent): void { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if (!$focused.length || !this._isOverflowItem($focused)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this._openOverflowMenu(e.key === 'ArrowUp' ? 'last' : 'first'); + } + + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + _moveFocus(location: string, e?: DxEvent): boolean | undefined | void { + if (!this.option('allowKeyboardNavigation')) { + const { focusedElement } = this.option(); + return focusedElement ? super._moveFocus(location, e) : undefined; + } + + const { focusedElement: prevFocusedElement } = this.option(); + const $prev = $(prevFocusedElement); + if ($prev.length) { + closeItemWidget($prev); + } + + const result = super._moveFocus(location, e); + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + if ($focused.length) { + this._focusItemWidget($focused); + } + + return result; + } + _itemContainer(): dxElementWrapper { return this._$toolbarItemsContainer.find([ `.${TOOLBAR_BEFORE_CLASS}`, @@ -191,11 +397,41 @@ class ToolbarBase< _postProcessRenderItems(): void { this._arrangeItems(); + + this._updateFocusableItemsTabIndex(); + + const descriptor = this._pendingFocusDescriptor; + this._pendingFocusDescriptor = undefined; + if (descriptor) { + // NOTE: navigator exists only when allowKeyboardNavigation is enabled, so this + // restore is a no-op otherwise. It is recreated during the re-render, hence the + // descriptor (not the navigator) holds the pending state across the refresh. + this._navigator?.restoreFocus(descriptor); + } + } + + _updateFocusableItemsTabIndex(): void { + this._resetRovingTabIndex(); + } + + _invalidate(): void { + // NOTE: capture DOM focus before super resets focusedElement and cleans the item + // container, so it can be restored after the full re-render (roving tabIndex). + const captured = this._navigator?.captureFocusedItem(); + if (captured) { + this._pendingFocusDescriptor = captured; + } else if (captured === null) { + this._pendingFocusDescriptor = undefined; + } + + super._invalidate(); } _renderToolbar(): void { + const { allowKeyboardNavigation } = this.option(); this.$element() - .addClass(TOOLBAR_CLASS); + .addClass(TOOLBAR_CLASS) + .toggleClass(TOOLBAR_FOCUS_MODE_CLASS, !!allowKeyboardNavigation); this._$toolbarItemsContainer = $('
') .addClass(TOOLBAR_ITEMS_CONTAINER_CLASS) @@ -450,6 +686,8 @@ class ToolbarBase< } _clean(): void { + super._clean(); + this._$toolbarItemsContainer.children().empty(); this.$element().empty(); @@ -486,7 +724,7 @@ class ToolbarBase< } _optionChanged(args: OptionChanged): void { - const { name } = args; + const { name, value } = args; switch (name) { case 'width': @@ -501,6 +739,14 @@ class ToolbarBase< case 'compactMode': this._applyCompactMode(); break; + case 'allowKeyboardNavigation': + this.$element().toggleClass(TOOLBAR_FOCUS_MODE_CLASS, !!value); + if (!value) { + this.option('focusedElement', null); + } + super._optionChanged(args); + this._attachKeyboardEvents(); + break; case 'grouped': break; default: diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index ae02af7499e3..accb0fe92a8c 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -33,6 +33,12 @@ class Toolbar extends ToolbarBase { return multiline; } + _openOverflowMenu(focusTarget: 'first' | 'last'): void { + if (this._layoutStrategy instanceof SingleLineStrategy && this._layoutStrategy._menu) { + this._layoutStrategy._menu.openWithFocus(focusTarget); + } + } + _dimensionChanged(dimension?: 'height' | 'width'): void { if (dimension === 'height') { return; @@ -41,6 +47,8 @@ class Toolbar extends ToolbarBase { super._dimensionChanged(); this._layoutStrategy._dimensionChanged(); + + this._updateFocusableItemsTabIndex(); } _initMarkup(): void { @@ -141,14 +149,31 @@ class Toolbar extends ToolbarBase { value: unknown, prevValue: unknown, ): void { + // @ts-expect-error ts-error + const isDisabledChange = property === 'disabled' || property === 'options.disabled'; + // NOTE: capture before super disables the item in place and resets focusedElement, so + // when the disabled item was the focused one we can move DOM focus onto an adjacent + // enabled item — otherwise focus would be stranded on a now-disabled control. + const disablingFocusDescriptor = isDisabledChange && !!value && !this._isMenuItem(item) + ? this._navigator?.captureItemIfFocused(this._findItemElementByItem(item)) + : undefined; + if (!this._isMenuItem(item)) { super._itemOptionChanged(item, property, value, prevValue); } this._layoutStrategy._itemOptionChanged(item, property, value); - // @ts-expect-error ts-error - if (property === 'disabled' || property === 'options.disabled') { - toggleItemFocusableElementTabIndex(this, item); + if (isDisabledChange) { + if (this._isMenuItem(item)) { + toggleItemFocusableElementTabIndex(this, item); + } else if (this.option('allowKeyboardNavigation')) { + this._resetRovingTabIndex(); + if (disablingFocusDescriptor) { + this._navigator?.restoreFocus(disablingFocusDescriptor); + } + } else { + toggleItemFocusableElementTabIndex(this, item); + } } if (property === 'location') { @@ -157,7 +182,24 @@ class Toolbar extends ToolbarBase { } _updateFocusableItemsTabIndex(): void { - this._getToolbarItems().forEach((item) => toggleItemFocusableElementTabIndex(this, item)); + const menuItems: Item[] = []; + const toolbarItems: Item[] = []; + + this._getToolbarItems().forEach((item) => { + if (this._isMenuItem(item)) { + menuItems.push(item); + } else { + toolbarItems.push(item); + } + }); + + menuItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); + + if (this.option('allowKeyboardNavigation')) { + this._resetRovingTabIndex(); + } else { + toolbarItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); + } } _isMenuItem(itemData: Item): boolean { @@ -181,6 +223,7 @@ class Toolbar extends ToolbarBase { case 'multiline': this._invalidate(); break; + case 'allowKeyboardNavigation': case 'disabled': super._optionChanged(args); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index df6f8d97017f..2fcd9c8e948d 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -1,13 +1,55 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { Item } from '@js/ui/toolbar'; +import { getPublicElement } from '@ts/core/m_element'; import type Widget from '@ts/core/widget/widget'; import type { ListBase } from '@ts/ui/list/list.base'; import type Toolbar from './toolbar'; const BUTTON_GROUP_CLASS = 'dx-buttongroup'; -const TOOLBAR_ITEMS = ['dxAutocomplete', 'dxButton', 'dxCheckBox', 'dxDateBox', 'dxMenu', 'dxSelectBox', 'dxTabs', 'dxTextBox', 'dxButtonGroup', 'dxDropDownButton']; +const DROP_DOWN_MENU_BUTTON_CLASS = 'dx-dropdownmenu-button'; +const TOOLBAR_ITEMS = ['dxAutocomplete', 'dxButton', 'dxCheckBox', 'dxDateBox', 'dxDateRangeBox', 'dxMenu', 'dxSelectBox', 'dxSwitch', 'dxTabs', 'dxTextBox', 'dxButtonGroup', 'dxDropDownButton']; +const TOOLBAR_WIDGETS_SELECTOR = TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(','); +const NATIVE_FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]'; + +export function isTextInputTarget(target: HTMLElement): boolean { + const tagName = target.tagName.toLowerCase(); + + return (tagName === 'input' || tagName === 'textarea') + && $(target).closest('.dx-texteditor').length > 0; +} + +export function isMenuTarget(target: HTMLElement): boolean { + return $(target).closest('.dx-menu, .dx-menu-item').length > 0; +} + +export function activateMenu($menu: dxElementWrapper): void { + ($menu.get(0) as HTMLElement).focus(); +} + +export function closeOpenSubmenu(target: HTMLElement, e: Event): boolean { + const $menu = $(target).closest('.dx-menu'); + if (!$menu.length) { + return false; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $menu.data('dxMenu') as any; + if (!menuInstance?._visibleSubmenu) { + return false; + } + + e.preventDefault(); + e.stopPropagation(); + + const $anchor = $menu.find('.dx-menu-item-expanded').first(); + menuInstance._hideSubmenu(menuInstance._visibleSubmenu); + + if ($anchor.length) { + menuInstance.option('focusedElement', getPublicElement($anchor)); + } + return true; +} const getItemInstance = ($element: dxElementWrapper): Widget => { // @ts-expect-error ts-error @@ -19,6 +61,143 @@ const getItemInstance = ($element: dxElementWrapper): Widget => { return (widgetName && itemData[widgetName]) as Widget; }; +const getWidgetName = ($element: dxElementWrapper): string => { + // @ts-expect-error ts-error + const itemData = $element?.data(); + // @ts-expect-error ts-error + const dxComponents = itemData?.dxComponents; + return (dxComponents?.[0] ?? '') as string; +}; + +export function closeItemWidget($item: dxElementWrapper): boolean { + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); + + if (!$widgets.length) { + return false; + } + + const $widget = $widgets.first(); + const itemInstance = getItemInstance($widget); + + if (itemInstance && typeof (itemInstance as any).option === 'function') { // eslint-disable-line @typescript-eslint/no-explicit-any + const opened = (itemInstance as any).option('opened'); // eslint-disable-line @typescript-eslint/no-explicit-any + if (opened) { + (itemInstance as any).option('opened', false); // eslint-disable-line @typescript-eslint/no-explicit-any + return true; + } + } + + return false; +} + +export function isItemDisabled($item: dxElementWrapper, widgetDisabled: boolean): boolean { + if (widgetDisabled) { + return true; + } + if ($item.hasClass('dx-state-disabled')) { + return true; + } + const $widget = $item.find('.dx-widget').first(); + return $widget.length > 0 && $widget.hasClass('dx-state-disabled'); +} + +export function isItemWidgetOpened($item: dxElementWrapper): boolean { + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); + + if (!$widgets.length) { + return false; + } + + const $widget = $widgets.first(); + const itemInstance = getItemInstance($widget); + + if (itemInstance && typeof (itemInstance as any).option === 'function') { // eslint-disable-line @typescript-eslint/no-explicit-any + return !!(itemInstance as any).option('opened'); // eslint-disable-line @typescript-eslint/no-explicit-any + } + + return false; +} + +export function getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | undefined { + if ($item.hasClass(DROP_DOWN_MENU_BUTTON_CLASS)) { + return $item; + } + + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); + + if (!$widgets.length) { + const $nativeFocusable = $item.find(NATIVE_FOCUSABLE_SELECTOR).first(); + return $nativeFocusable.length ? $nativeFocusable : undefined; + } + + const $widget = $widgets.first(); + const itemInstance = getItemInstance($widget); + + if (!itemInstance) { + return undefined; + } + + const $base = itemInstance._focusTarget?.(); + const widgetName = getWidgetName($widget); + + if (widgetName === 'dxDropDownButton') return $base?.find(`.${BUTTON_GROUP_CLASS}`); + if ($widget.hasClass('dx-menu')) return $item; + if ($widget.hasClass('dx-texteditor')) return $(itemInstance.element()); + return $base ?? $(itemInstance.element()); +} + +export function getPlainItemFocusTargets($item: dxElementWrapper): dxElementWrapper { + if ($item.hasClass(DROP_DOWN_MENU_BUTTON_CLASS)) { + return $(); + } + + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); + if ($widgets.length) { + return $(); + } + + return $item.find(NATIVE_FOCUSABLE_SELECTOR); +} + +export function applyItemTabIndex($item: dxElementWrapper, tabIndex: number): void { + const $focusTarget = getItemFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + + const $plainTargets = getPlainItemFocusTargets($item); + if ($plainTargets.length > 1) { + $plainTargets.attr('tabIndex', -1); + } + + $focusTarget.attr('tabIndex', tabIndex); + + if ($focusTarget.hasClass('dx-texteditor')) { + $focusTarget.find('.dx-texteditor-input').attr('tabIndex', -1); + } + + const $menu = $item.find('.dx-menu'); + if ($menu.length) { + $menu.attr('tabIndex', -1); + $menu.find('[tabindex]').attr('tabIndex', -1); + } +} + +export function setItemWidgetFocusState($item: dxElementWrapper, isFocused: boolean): void { + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); + + if (!$widgets.length) { + return; + } + + const $widget = $widgets.first(); + const itemInstance = getItemInstance($widget); + + if (itemInstance && typeof itemInstance._toggleFocusClass === 'function') { + itemInstance._toggleFocusClass(isFocused, getItemFocusTarget($item)); + } +} + export function toggleItemFocusableElementTabIndex( context: Toolbar | ListBase | undefined, item: Item, diff --git a/packages/devextreme/js/ui/toolbar.d.ts b/packages/devextreme/js/ui/toolbar.d.ts index 3aa747d9ce96..fa784800ed05 100644 --- a/packages/devextreme/js/ui/toolbar.d.ts +++ b/packages/devextreme/js/ui/toolbar.d.ts @@ -110,6 +110,12 @@ export interface dxToolbarOptions< TItem extends ItemLike = any, TKey = any, > extends CollectionWidgetOptions, TItem, TKey> { + /** + * @docid + * @default true + * @public + */ + allowKeyboardNavigation?: boolean; /** * @docid * @type string | Array | Store | DataSource | DataSourceOptions | null diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownEditor.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownEditor.tests.js index fe32dd285fdf..6a861c3b25db 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownEditor.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/dropDownEditor.tests.js @@ -390,35 +390,42 @@ QUnit.module('dxDropDownEditor', testEnvironment, () => { QUnit.module('focus policy', () => { QUnit.testInActiveWindow('editor should save focus on button clicking', function(assert) { - const $dropDownEditor = $('#dropDownEditorLazy').dxDropDownEditor({ - applyValueMode: 'useButtons', - focusStateEnabled: true - }); + const clock = sinon.useFakeTimers(); - const instance = $dropDownEditor.dxDropDownEditor('instance'); + try { + const $dropDownEditor = $('#dropDownEditorLazy').dxDropDownEditor({ + applyValueMode: 'useButtons', + focusStateEnabled: true + }); - instance.open(); + const instance = $dropDownEditor.dxDropDownEditor('instance'); - const $buttons = instance._popup.$wrapper().find('.dx-button'); + instance.open(); - $.each($buttons, function(index, button) { - const $button = $(button); - const buttonInstance = $button.dxButton('instance'); - instance.focus(); - $button.focus(); + const $buttons = instance._popup.$wrapper().find('.dx-button'); - const pointer = pointerMock(button); + $.each($buttons, function(index, button) { + const $button = $(button); + const buttonInstance = $button.dxButton('instance'); + instance.focus(); + $button.focus(); - assert.ok(!$dropDownEditor.hasClass('dx-state-focused') || !buttonInstance.option('focusStateEnabled'), 'dropDownEditor lose focus after click on button, nested into overlay'); + const pointer = pointerMock(button); - pointer.click(); + assert.ok(!$dropDownEditor.hasClass('dx-state-focused') || !buttonInstance.option('focusStateEnabled'), 'dropDownEditor lose focus after click on button, nested into overlay'); - if(!instance.option('opened')) { - assert.ok($dropDownEditor.hasClass('dx-state-focused'), 'dropDownEditor obtained focus after popup button click with close action'); - } else { - instance.option('opened', false); - } - }); + pointer.click(); + clock.tick(0); + + if(!instance.option('opened')) { + assert.ok($dropDownEditor.hasClass('dx-state-focused'), 'dropDownEditor obtained focus after popup button click with close action'); + } else { + instance.option('opened', false); + } + }); + } finally { + clock.restore(); + } }); QUnit.testInActiveWindow('editor should save focus on clearbutton clicking, fieldTemplate is used', function(assert) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.htmlEditor/htmlEditorParts/aiDialog.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.htmlEditor/htmlEditorParts/aiDialog.tests.js index fe20110fd88c..9090b99d04d3 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.htmlEditor/htmlEditorParts/aiDialog.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.htmlEditor/htmlEditorParts/aiDialog.tests.js @@ -925,7 +925,7 @@ QUnit.module('AIDialog', () => { this.promise.then(() => { try { const $replaceButton = this.$element.find(`.${BUTTON_GROUP_CLASS}`); - keyboardMock($replaceButton).press('enter'); + $replaceButton.trigger($.Event('keydown', { key: 'Enter' })); assert.ok(true, 'There is no error'); } catch(e) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js index 9a9f285d81e5..d8d85094e661 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js @@ -385,6 +385,28 @@ QUnit.module('basic', { assert.equal($bottomToolbar.text(), 'bottom text', 'bottom toolbar has correct content'); }); + QUnit.test('popup toolbars have focusStateEnabled: false', function(assert) { + const $popup = $('#popup').dxPopup({ + visible: true, + showTitle: true, + title: 'Title', + toolbarItems: [{ shortcut: 'done' }, { shortcut: 'cancel' }], + }); + const instance = $popup.dxPopup('instance'); + + const $bottomToolbar = instance.$content().parent().find('.' + POPUP_BOTTOM_CLASS); + const bottomToolbarInstance = $bottomToolbar.dxToolbar('instance'); + + assert.strictEqual(bottomToolbarInstance.option('focusStateEnabled'), false, + 'bottom toolbar has focusStateEnabled: false'); + + const $topToolbar = instance.$content().parent().find('.' + POPUP_TITLE_CLASS); + const topToolbarInstance = $topToolbar.dxToolbar('instance'); + + assert.strictEqual(topToolbarInstance.option('focusStateEnabled'), false, + 'top toolbar has focusStateEnabled: false'); + }); + QUnit.test(`top toolbar has specific ${POPUP_HAS_CLOSE_BUTTON_CLASS} class`, function(assert) { $('#popup').dxPopup({ visible: true, showCloseButton: true, showTitle: true }); const $titleToolbar = $('.' + POPUP_TITLE_CLASS); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.disabled.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.disabled.tests.js index dc61645d85a5..471690b9c1e2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.disabled.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.disabled.tests.js @@ -56,319 +56,413 @@ const openDropDownMenuIfExist = (toolbar) => { } }; -['never', 'always'].forEach((locateInMenu) => { - [ - { widget: 'dxButton', focusableElementSelector: '.dx-button:not(.dx-dropdownmenu-button)' }, - { widget: 'dxTextBox', focusableElementSelector: '.dx-textbox .dx-texteditor-input' }, - { widget: 'dxSelectBox', focusableElementSelector: '.dx-selectbox .dx-texteditor-input' }, - { widget: 'dxDropDownButton', focusableElementSelector: '.dx-dropdownbutton .dx-buttongroup' }, +[true, false].forEach((focusStateEnabled) => { + ['never', 'always'].forEach((locateInMenu) => { + [ + { widget: 'dxButton', focusableElementSelector: '.dx-button:not(.dx-dropdownmenu-button)' }, + { widget: 'dxTextBox', focusableElementSelector: focusStateEnabled ? '.dx-textbox' : '.dx-textbox .dx-texteditor-input' }, + { widget: 'dxSelectBox', focusableElementSelector: focusStateEnabled ? '.dx-selectbox' : '.dx-selectbox .dx-texteditor-input' }, + { widget: 'dxDropDownButton', focusableElementSelector: '.dx-dropdownbutton .dx-buttongroup' }, // { widget: 'dxAutocomplete', focusableElementSelector: '.dx-autocomplete .dx-texteditor-input' }, // { widget: 'dxCheckBox', focusableElementSelector: '.dx-checkbox' }, // { widget: 'dxDateBox', focusableElementSelector: '.dx-datebox .dx-texteditor-input' }, // { widget: 'dxMenu', focusableElementSelector: '.dx-menu' }, // { widget: 'dxTabs', focusableElementSelector: '.dx-tabs' }, // { widget: 'dxButtonGroup', focusableElementSelector: '.dx-buttongroup' }, - ].forEach(({ widget, focusableElementSelector }) => { - QUnit.module(`Disabled state: locateInMenu: ${locateInMenu}, widget: ${widget}`, moduleConfig, () => { - const itemClickHandler = sinon.spy(); - const buttonClickHandler = sinon.spy(); - - const getExpectedDisabledState = (toolbarDisabled, itemDisabled, itemOptionsDisabled) => { - return [ - { toolbarDisabled: true, itemDisabled: true, itemOptionsDisabled: true, expectedDisabled: { toolbar: true, itemDisabled: true, itemOptionsDisabled: true } }, - { toolbarDisabled: true, itemDisabled: true, itemOptionsDisabled: false, expectedDisabled: { toolbar: true, itemDisabled: true, itemOptionsDisabled: false } }, - { toolbarDisabled: true, itemDisabled: true, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: true, itemDisabled: true, itemOptionsDisabled: undefined } }, - { toolbarDisabled: true, itemDisabled: false, itemOptionsDisabled: true, expectedDisabled: { toolbar: true, itemDisabled: false, itemOptionsDisabled: true } }, - { toolbarDisabled: true, itemDisabled: false, itemOptionsDisabled: false, expectedDisabled: { toolbar: true, itemDisabled: false, itemOptionsDisabled: false } }, - { toolbarDisabled: true, itemDisabled: false, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: true, itemDisabled: false, itemOptionsDisabled: undefined } }, - { toolbarDisabled: true, itemDisabled: undefined, itemOptionsDisabled: true, expectedDisabled: { toolbar: true, itemDisabled: undefined, itemOptionsDisabled: true } }, - { toolbarDisabled: true, itemDisabled: undefined, itemOptionsDisabled: false, expectedDisabled: { toolbar: true, itemDisabled: undefined, itemOptionsDisabled: false } }, - { toolbarDisabled: true, itemDisabled: undefined, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: true, itemDisabled: undefined, itemOptionsDisabled: undefined } }, - { toolbarDisabled: false, itemDisabled: true, itemOptionsDisabled: true, expectedDisabled: { toolbar: false, itemDisabled: true, itemOptionsDisabled: true } }, - { toolbarDisabled: false, itemDisabled: true, itemOptionsDisabled: false, expectedDisabled: { toolbar: false, itemDisabled: true, itemOptionsDisabled: false } }, - { toolbarDisabled: false, itemDisabled: true, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: false, itemDisabled: true, itemOptionsDisabled: undefined } }, - { toolbarDisabled: false, itemDisabled: false, itemOptionsDisabled: true, expectedDisabled: { toolbar: false, itemDisabled: false, itemOptionsDisabled: true } }, - { toolbarDisabled: false, itemDisabled: false, itemOptionsDisabled: false, expectedDisabled: { toolbar: false, itemDisabled: false, itemOptionsDisabled: false } }, - { toolbarDisabled: false, itemDisabled: false, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: false, itemDisabled: false, itemOptionsDisabled: undefined } }, - { toolbarDisabled: false, itemDisabled: undefined, itemOptionsDisabled: true, expectedDisabled: { toolbar: false, itemDisabled: undefined, itemOptionsDisabled: true } }, - { toolbarDisabled: false, itemDisabled: undefined, itemOptionsDisabled: false, expectedDisabled: { toolbar: false, itemDisabled: undefined, itemOptionsDisabled: false } }, - { toolbarDisabled: false, itemDisabled: undefined, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: false, itemDisabled: undefined, itemOptionsDisabled: undefined } }, - { toolbarDisabled: undefined, itemDisabled: true, itemOptionsDisabled: true, expectedDisabled: { toolbar: undefined, itemDisabled: true, itemOptionsDisabled: true } }, - { toolbarDisabled: undefined, itemDisabled: true, itemOptionsDisabled: false, expectedDisabled: { toolbar: undefined, itemDisabled: true, itemOptionsDisabled: false } }, - { toolbarDisabled: undefined, itemDisabled: true, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: undefined, itemDisabled: true, itemOptionsDisabled: undefined } }, - { toolbarDisabled: undefined, itemDisabled: false, itemOptionsDisabled: true, expectedDisabled: { toolbar: undefined, itemDisabled: false, itemOptionsDisabled: true } }, - { toolbarDisabled: undefined, itemDisabled: false, itemOptionsDisabled: false, expectedDisabled: { toolbar: undefined, itemDisabled: false, itemOptionsDisabled: false } }, - { toolbarDisabled: undefined, itemDisabled: false, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: undefined, itemDisabled: false, itemOptionsDisabled: undefined } }, - { toolbarDisabled: undefined, itemDisabled: undefined, itemOptionsDisabled: true, expectedDisabled: { toolbar: undefined, itemDisabled: undefined, itemOptionsDisabled: true } }, - { toolbarDisabled: undefined, itemDisabled: undefined, itemOptionsDisabled: false, expectedDisabled: { toolbar: undefined, itemDisabled: undefined, itemOptionsDisabled: false } }, - { toolbarDisabled: undefined, itemDisabled: undefined, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: undefined, itemDisabled: undefined, itemOptionsDisabled: undefined } }, - ].filter((config) => (config.toolbarDisabled === toolbarDisabled && config.itemDisabled === itemDisabled && config.itemOptionsDisabled === itemOptionsDisabled))[0].expectedDisabled; - }; - - const checkClickHandlers = ($item, toolbarDisabled, itemDisabled, itemOptionsDisabled) => { - itemClickHandler.resetHistory(); - buttonClickHandler.resetHistory(); - - eventsEngine.trigger($item, 'dxclick'); - - QUnit.assert.strictEqual(itemClickHandler.callCount, itemDisabled || toolbarDisabled ? 0 : 1, `onItemClick ${itemClickHandler.callCount}`); - QUnit.assert.strictEqual(buttonClickHandler.callCount, itemOptionsDisabled || itemDisabled || toolbarDisabled ? 0 : 1, `onButtonClick ${buttonClickHandler.callCount}`); - }; - - const checkFocusableElementTabIndex = (focusableElement, widgetName, expectedDisabled) => { - const expectedFocusableElementTabIndex = expectedDisabled.itemOptionsDisabled || expectedDisabled.itemDisabled || expectedDisabled.toolbar - ? -1 - : 0; - - QUnit.assert.strictEqual(focusableElement.tabIndex, expectedFocusableElementTabIndex, `${widgetName}.tabIndex`); - }; - - const checkDisabledState = (toolbar, widgetName, toolbarDisabled, itemDisabled, itemOptionsDisabled, focusableElementSelector) => { - const $element = $(toolbar.element()); - const expectedDisabled = getExpectedDisabledState(toolbarDisabled, itemDisabled, itemOptionsDisabled); - - QUnit.assert.strictEqual(toolbar.option('disabled'), expectedDisabled.toolbar, 'toolbar.disabled'); - QUnit.assert.strictEqual($element.hasClass('dx-state-disabled'), !!expectedDisabled.toolbar, 'toolbar disabled class'); - - const itemElementSelector = focusableElementSelector.split(' ')[0]; - const $item = getItemElement(toolbar, itemElementSelector); - - const $toolbarMenu = $element.find('.dx-dropdownmenu-button'); - if($toolbarMenu.length) { - QUnit.assert.strictEqual($toolbarMenu.hasClass('dx-state-disabled'), !!expectedDisabled.toolbar, 'menu button disabled class'); - } - - const $itemElement = $item.parent().parent(); - - QUnit.assert.strictEqual($itemElement.hasClass('dx-state-disabled'), !!expectedDisabled.itemDisabled, 'toolbar item disabled class'); - QUnit.assert.strictEqual(toolbar.option('items')[0].disabled, expectedDisabled.itemDisabled, 'item.disabled'); - - const itemDisabledOption = toolbar.option('items')[0].options && toolbar.option('items')[0].options.disabled; - QUnit.assert.strictEqual(itemDisabledOption, expectedDisabled.itemOptionsDisabled, 'item.options.disabled'); - - QUnit.assert.strictEqual($item.hasClass('dx-state-disabled'), !!expectedDisabled.itemOptionsDisabled, `${widgetName} disabled class`); - checkFocusableElementTabIndex(getItemElement(toolbar, focusableElementSelector).get(0), widgetName, expectedDisabled); - - if(widgetName === 'dxButton') { - checkClickHandlers($item, expectedDisabled.toolbar, expectedDisabled.itemDisabled, expectedDisabled.itemOptionsDisabled); - } - }; - - [true, false].forEach((isToolbarDisabled) => { - [true, false].forEach((isItemOptionsDisabled) => { - [true, false].forEach((isItemDisabled) => { - const initialTestConfig = `Toolbar.disabled=${isToolbarDisabled}, items[].disabled=${isItemDisabled}, items[].options.disabled=${isItemOptionsDisabled}`; - - const getInitialToolbarOptions = () => { - const initialToolbarOptions = { - items: [{ - location: 'after', - locateInMenu, - widget, - options: { - } - }] - }; + ].forEach(({ widget, focusableElementSelector }) => { + QUnit.module(`Disabled state: focusStateEnabled: ${focusStateEnabled}, locateInMenu: ${locateInMenu}, widget: ${widget}`, moduleConfig, () => { + const itemClickHandler = sinon.spy(); + const buttonClickHandler = sinon.spy(); + + const getExpectedDisabledState = (toolbarDisabled, itemDisabled, itemOptionsDisabled) => { + return [ + { toolbarDisabled: true, itemDisabled: true, itemOptionsDisabled: true, expectedDisabled: { toolbar: true, itemDisabled: true, itemOptionsDisabled: true } }, + { toolbarDisabled: true, itemDisabled: true, itemOptionsDisabled: false, expectedDisabled: { toolbar: true, itemDisabled: true, itemOptionsDisabled: false } }, + { toolbarDisabled: true, itemDisabled: true, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: true, itemDisabled: true, itemOptionsDisabled: undefined } }, + { toolbarDisabled: true, itemDisabled: false, itemOptionsDisabled: true, expectedDisabled: { toolbar: true, itemDisabled: false, itemOptionsDisabled: true } }, + { toolbarDisabled: true, itemDisabled: false, itemOptionsDisabled: false, expectedDisabled: { toolbar: true, itemDisabled: false, itemOptionsDisabled: false } }, + { toolbarDisabled: true, itemDisabled: false, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: true, itemDisabled: false, itemOptionsDisabled: undefined } }, + { toolbarDisabled: true, itemDisabled: undefined, itemOptionsDisabled: true, expectedDisabled: { toolbar: true, itemDisabled: undefined, itemOptionsDisabled: true } }, + { toolbarDisabled: true, itemDisabled: undefined, itemOptionsDisabled: false, expectedDisabled: { toolbar: true, itemDisabled: undefined, itemOptionsDisabled: false } }, + { toolbarDisabled: true, itemDisabled: undefined, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: true, itemDisabled: undefined, itemOptionsDisabled: undefined } }, + { toolbarDisabled: false, itemDisabled: true, itemOptionsDisabled: true, expectedDisabled: { toolbar: false, itemDisabled: true, itemOptionsDisabled: true } }, + { toolbarDisabled: false, itemDisabled: true, itemOptionsDisabled: false, expectedDisabled: { toolbar: false, itemDisabled: true, itemOptionsDisabled: false } }, + { toolbarDisabled: false, itemDisabled: true, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: false, itemDisabled: true, itemOptionsDisabled: undefined } }, + { toolbarDisabled: false, itemDisabled: false, itemOptionsDisabled: true, expectedDisabled: { toolbar: false, itemDisabled: false, itemOptionsDisabled: true } }, + { toolbarDisabled: false, itemDisabled: false, itemOptionsDisabled: false, expectedDisabled: { toolbar: false, itemDisabled: false, itemOptionsDisabled: false } }, + { toolbarDisabled: false, itemDisabled: false, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: false, itemDisabled: false, itemOptionsDisabled: undefined } }, + { toolbarDisabled: false, itemDisabled: undefined, itemOptionsDisabled: true, expectedDisabled: { toolbar: false, itemDisabled: undefined, itemOptionsDisabled: true } }, + { toolbarDisabled: false, itemDisabled: undefined, itemOptionsDisabled: false, expectedDisabled: { toolbar: false, itemDisabled: undefined, itemOptionsDisabled: false } }, + { toolbarDisabled: false, itemDisabled: undefined, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: false, itemDisabled: undefined, itemOptionsDisabled: undefined } }, + { toolbarDisabled: undefined, itemDisabled: true, itemOptionsDisabled: true, expectedDisabled: { toolbar: undefined, itemDisabled: true, itemOptionsDisabled: true } }, + { toolbarDisabled: undefined, itemDisabled: true, itemOptionsDisabled: false, expectedDisabled: { toolbar: undefined, itemDisabled: true, itemOptionsDisabled: false } }, + { toolbarDisabled: undefined, itemDisabled: true, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: undefined, itemDisabled: true, itemOptionsDisabled: undefined } }, + { toolbarDisabled: undefined, itemDisabled: false, itemOptionsDisabled: true, expectedDisabled: { toolbar: undefined, itemDisabled: false, itemOptionsDisabled: true } }, + { toolbarDisabled: undefined, itemDisabled: false, itemOptionsDisabled: false, expectedDisabled: { toolbar: undefined, itemDisabled: false, itemOptionsDisabled: false } }, + { toolbarDisabled: undefined, itemDisabled: false, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: undefined, itemDisabled: false, itemOptionsDisabled: undefined } }, + { toolbarDisabled: undefined, itemDisabled: undefined, itemOptionsDisabled: true, expectedDisabled: { toolbar: undefined, itemDisabled: undefined, itemOptionsDisabled: true } }, + { toolbarDisabled: undefined, itemDisabled: undefined, itemOptionsDisabled: false, expectedDisabled: { toolbar: undefined, itemDisabled: undefined, itemOptionsDisabled: false } }, + { toolbarDisabled: undefined, itemDisabled: undefined, itemOptionsDisabled: undefined, expectedDisabled: { toolbar: undefined, itemDisabled: undefined, itemOptionsDisabled: undefined } }, + ].filter((config) => (config.toolbarDisabled === toolbarDisabled && config.itemDisabled === itemDisabled && config.itemOptionsDisabled === itemOptionsDisabled))[0].expectedDisabled; + }; - if(widget === 'dxButton') { - initialToolbarOptions.onItemClick = itemClickHandler; - initialToolbarOptions.items[0].options.onClick = buttonClickHandler; - } + const checkClickHandlers = ($item, toolbarDisabled, itemDisabled, itemOptionsDisabled) => { + itemClickHandler.resetHistory(); + buttonClickHandler.resetHistory(); - initialToolbarOptions.disabled = isToolbarDisabled; - initialToolbarOptions.items[0].disabled = isItemDisabled; - initialToolbarOptions.items[0].options.disabled = isItemOptionsDisabled; + eventsEngine.trigger($item, 'dxclick'); - return initialToolbarOptions; - }; + QUnit.assert.strictEqual(itemClickHandler.callCount, itemDisabled || toolbarDisabled ? 0 : 1, `onItemClick ${itemClickHandler.callCount}`); + QUnit.assert.strictEqual(buttonClickHandler.callCount, itemOptionsDisabled || itemDisabled || toolbarDisabled ? 0 : 1, `onButtonClick ${buttonClickHandler.callCount}`); + }; - QUnit.test(`Nested widgets, ${initialTestConfig}`, function() { - this.createInstance(getInitialToolbarOptions()); + const checkFocusableElementTabIndex = (focusableElement, widgetName, expectedDisabled) => { + const expectedFocusableElementTabIndex = expectedDisabled.itemOptionsDisabled || expectedDisabled.itemDisabled || expectedDisabled.toolbar + ? -1 + : 0; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, isToolbarDisabled, isItemDisabled, isItemOptionsDisabled, focusableElementSelector); - }); + QUnit.assert.strictEqual(focusableElement.tabIndex, expectedFocusableElementTabIndex, `${widgetName}.tabIndex`); + }; - QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].options.disabled -> toolbar.disabled -> items[].disabled`, function() { - this.createInstance(getInitialToolbarOptions()); + const checkDisabledState = (toolbar, widgetName, toolbarDisabled, itemDisabled, itemOptionsDisabled, focusableElementSelector) => { + const $element = $(toolbar.element()); + const expectedDisabled = getExpectedDisabledState(toolbarDisabled, itemDisabled, itemOptionsDisabled); - let currentToolbarDisabledState = isToolbarDisabled; - let currentItemOptionsDisabledState = isItemOptionsDisabled; - let currentItemDisabledState = isItemDisabled; + QUnit.assert.strictEqual(toolbar.option('disabled'), expectedDisabled.toolbar, 'toolbar.disabled'); + QUnit.assert.strictEqual($element.hasClass('dx-state-disabled'), !!expectedDisabled.toolbar, 'toolbar disabled class'); - [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { - this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); - currentItemOptionsDisabledState = newItemOptionsDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + const itemElementSelector = focusableElementSelector.split(' ')[0]; + const $item = getItemElement(toolbar, itemElementSelector); - [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { - this.toolbar.option('disabled', newToolbarDisabled); - currentToolbarDisabledState = newToolbarDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + const $toolbarMenu = $element.find('.dx-dropdownmenu-button'); + if($toolbarMenu.length) { + QUnit.assert.strictEqual($toolbarMenu.hasClass('dx-state-disabled'), !!expectedDisabled.toolbar, 'menu button disabled class'); + } - [true, false, undefined].forEach((newItemDisabled) => { - this.toolbar.option('items[0].disabled', newItemDisabled); - currentItemDisabledState = newItemDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); - }); - }); - }); - }); + const $itemElement = $item.parent().parent(); - QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].options.disabled -> items[].disabled -> toolbar.disabled`, function() { - this.createInstance(getInitialToolbarOptions()); + QUnit.assert.strictEqual($itemElement.hasClass('dx-state-disabled'), !!expectedDisabled.itemDisabled, 'toolbar item disabled class'); + QUnit.assert.strictEqual(toolbar.option('items')[0].disabled, expectedDisabled.itemDisabled, 'item.disabled'); - let currentToolbarDisabledState = isToolbarDisabled; - let currentItemOptionsDisabledState = isItemOptionsDisabled; - let currentItemDisabledState = isItemDisabled; + const itemDisabledOption = toolbar.option('items')[0].options && toolbar.option('items')[0].options.disabled; + QUnit.assert.strictEqual(itemDisabledOption, expectedDisabled.itemOptionsDisabled, 'item.options.disabled'); - [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { - this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); - currentItemOptionsDisabledState = newItemOptionsDisabled; + QUnit.assert.strictEqual($item.hasClass('dx-state-disabled'), !!expectedDisabled.itemOptionsDisabled, `${widgetName} disabled class`); + checkFocusableElementTabIndex(getItemElement(toolbar, focusableElementSelector).get(0), widgetName, expectedDisabled); - [true, false, undefined].filter((value) => value !== isItemDisabled).forEach((newItemDisabled) => { - this.toolbar.option('items[0].disabled', newItemDisabled); - currentItemDisabledState = newItemDisabled; + if(widgetName === 'dxButton') { + checkClickHandlers($item, expectedDisabled.toolbar, expectedDisabled.itemDisabled, expectedDisabled.itemOptionsDisabled); + } + }; + + [true, false].forEach((isToolbarDisabled) => { + [true, false].forEach((isItemOptionsDisabled) => { + [true, false].forEach((isItemDisabled) => { + const initialTestConfig = `Toolbar.disabled=${isToolbarDisabled}, items[].disabled=${isItemDisabled}, items[].options.disabled=${isItemOptionsDisabled}`; + + const getInitialToolbarOptions = () => { + const initialToolbarOptions = { + focusStateEnabled, + items: [{ + location: 'after', + locateInMenu, + widget, + options: { + } + }] + }; + + if(widget === 'dxButton') { + initialToolbarOptions.onItemClick = itemClickHandler; + initialToolbarOptions.items[0].options.onClick = buttonClickHandler; + } + + initialToolbarOptions.disabled = isToolbarDisabled; + initialToolbarOptions.items[0].disabled = isItemDisabled; + initialToolbarOptions.items[0].options.disabled = isItemOptionsDisabled; + + return initialToolbarOptions; + }; + + QUnit.test(`Nested widgets, ${initialTestConfig}`, function() { + this.createInstance(getInitialToolbarOptions()); + + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, isToolbarDisabled, isItemDisabled, isItemOptionsDisabled, focusableElementSelector); + }); + + QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].options.disabled -> toolbar.disabled -> items[].disabled`, function() { + this.createInstance(getInitialToolbarOptions()); + + let currentToolbarDisabledState = isToolbarDisabled; + let currentItemOptionsDisabledState = isItemOptionsDisabled; + let currentItemDisabledState = isItemDisabled; + + [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { + this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); + currentItemOptionsDisabledState = newItemOptionsDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); - [true, false, undefined].forEach((newToolbarDisabled) => { + [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { this.toolbar.option('disabled', newToolbarDisabled); currentToolbarDisabledState = newToolbarDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + + [true, false, undefined].forEach((newItemDisabled) => { + this.toolbar.option('items[0].disabled', newItemDisabled); + currentItemDisabledState = newItemDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + }); }); }); }); - }); - QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: toolbar.disabled -> items[].options.disabled -> items[].disabled`, function() { - this.createInstance(getInitialToolbarOptions()); + QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].options.disabled -> items[].disabled -> toolbar.disabled`, function() { + this.createInstance(getInitialToolbarOptions()); - let currentToolbarDisabledState = isToolbarDisabled; - let currentItemOptionsDisabledState = isItemOptionsDisabled; - let currentItemDisabledState = isItemDisabled; - - [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { - this.toolbar.option('disabled', newToolbarDisabled); - currentToolbarDisabledState = newToolbarDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + let currentToolbarDisabledState = isToolbarDisabled; + let currentItemOptionsDisabledState = isItemOptionsDisabled; + let currentItemDisabledState = isItemDisabled; [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); currentItemOptionsDisabledState = newItemOptionsDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); - [true, false, undefined].forEach((newItemDisabled) => { + [true, false, undefined].filter((value) => value !== isItemDisabled).forEach((newItemDisabled) => { this.toolbar.option('items[0].disabled', newItemDisabled); currentItemDisabledState = newItemDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + + [true, false, undefined].forEach((newToolbarDisabled) => { + this.toolbar.option('disabled', newToolbarDisabled); + currentToolbarDisabledState = newToolbarDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + }); }); }); }); - }); - QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: toolbar.disabled -> items[].disabled -> items[].options.disabled`, function() { - this.createInstance(getInitialToolbarOptions()); + QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: toolbar.disabled -> items[].options.disabled -> items[].disabled`, function() { + this.createInstance(getInitialToolbarOptions()); - let currentToolbarDisabledState = isToolbarDisabled; - let currentItemOptionsDisabledState = isItemOptionsDisabled; - let currentItemDisabledState = isItemDisabled; + let currentToolbarDisabledState = isToolbarDisabled; + let currentItemOptionsDisabledState = isItemOptionsDisabled; + let currentItemDisabledState = isItemDisabled; - [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { - this.toolbar.option('disabled', newToolbarDisabled); - currentToolbarDisabledState = newToolbarDisabled; - - [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { - this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); - currentItemOptionsDisabledState = newItemOptionsDisabled; + [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { + this.toolbar.option('disabled', newToolbarDisabled); + currentToolbarDisabledState = newToolbarDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); - [true, false, undefined].forEach((newItemDisabled) => { - this.toolbar.option('items[0].disabled', newItemDisabled); - currentItemDisabledState = newItemDisabled; + [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { + this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); + currentItemOptionsDisabledState = newItemOptionsDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + + [true, false, undefined].forEach((newItemDisabled) => { + this.toolbar.option('items[0].disabled', newItemDisabled); + currentItemDisabledState = newItemDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + }); }); }); }); - }); - QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].disabled -> toolbar.disabled -> items[].options.disabled`, function() { - this.createInstance(getInitialToolbarOptions()); + QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: toolbar.disabled -> items[].disabled -> items[].options.disabled`, function() { + this.createInstance(getInitialToolbarOptions()); - let currentToolbarDisabledState = isToolbarDisabled; - let currentItemOptionsDisabledState = isItemOptionsDisabled; - let currentItemDisabledState = isItemDisabled; - - [true, false, undefined].filter((value) => value !== isItemDisabled).forEach((newItemDisabled) => { - this.toolbar.option('items[0].disabled', newItemDisabled); - currentItemDisabledState = newItemDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + let currentToolbarDisabledState = isToolbarDisabled; + let currentItemOptionsDisabledState = isItemOptionsDisabled; + let currentItemDisabledState = isItemDisabled; [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { this.toolbar.option('disabled', newToolbarDisabled); currentToolbarDisabledState = newToolbarDisabled; - openDropDownMenuIfExist(this.toolbar); - checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); - [true, false, undefined].forEach((newItemOptionsDisabled) => { + [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); currentItemOptionsDisabledState = newItemOptionsDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + + [true, false, undefined].forEach((newItemDisabled) => { + this.toolbar.option('items[0].disabled', newItemDisabled); + currentItemDisabledState = newItemDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + }); }); }); }); - }); - QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].disabled -> items[].options.disabled -> toolbar.disabled`, function() { - this.createInstance(getInitialToolbarOptions()); + QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].disabled -> toolbar.disabled -> items[].options.disabled`, function() { + this.createInstance(getInitialToolbarOptions()); - let currentToolbarDisabledState = isToolbarDisabled; - let currentItemOptionsDisabledState = isItemOptionsDisabled; - let currentItemDisabledState = isItemDisabled; + let currentToolbarDisabledState = isToolbarDisabled; + let currentItemOptionsDisabledState = isItemOptionsDisabled; + let currentItemDisabledState = isItemDisabled; - [true, false, undefined].filter((value) => value !== isItemDisabled).forEach((newItemDisabled) => { - this.toolbar.option('items[0].disabled', newItemDisabled); - currentItemDisabledState = newItemDisabled; - - [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { - this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); - currentItemOptionsDisabledState = newItemOptionsDisabled; + [true, false, undefined].filter((value) => value !== isItemDisabled).forEach((newItemDisabled) => { + this.toolbar.option('items[0].disabled', newItemDisabled); + currentItemDisabledState = newItemDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); - [true, false, undefined].forEach((newToolbarDisabled) => { + [true, false, undefined].filter((value) => value !== isToolbarDisabled).forEach((newToolbarDisabled) => { this.toolbar.option('disabled', newToolbarDisabled); currentToolbarDisabledState = newToolbarDisabled; openDropDownMenuIfExist(this.toolbar); checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + + [true, false, undefined].forEach((newItemOptionsDisabled) => { + this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); + currentItemOptionsDisabledState = newItemOptionsDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + }); + }); + }); + }); + + QUnit.test(`Nested widgets, ${initialTestConfig} -> change order: items[].disabled -> items[].options.disabled -> toolbar.disabled`, function() { + this.createInstance(getInitialToolbarOptions()); + + let currentToolbarDisabledState = isToolbarDisabled; + let currentItemOptionsDisabledState = isItemOptionsDisabled; + let currentItemDisabledState = isItemDisabled; + + [true, false, undefined].filter((value) => value !== isItemDisabled).forEach((newItemDisabled) => { + this.toolbar.option('items[0].disabled', newItemDisabled); + currentItemDisabledState = newItemDisabled; + + [true, false, undefined].filter((value) => value !== isItemOptionsDisabled).forEach((newItemOptionsDisabled) => { + this.toolbar.option('items[0].options.disabled', newItemOptionsDisabled); + currentItemOptionsDisabledState = newItemOptionsDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + + [true, false, undefined].forEach((newToolbarDisabled) => { + this.toolbar.option('disabled', newToolbarDisabled); + currentToolbarDisabledState = newToolbarDisabled; + openDropDownMenuIfExist(this.toolbar); + checkDisabledState(this.toolbar, widget, currentToolbarDisabledState, currentItemDisabledState, currentItemOptionsDisabledState, focusableElementSelector); + }); }); }); }); }); }); }); - }); - QUnit.test(`Restore default ${widget} tabIndex value on change toolbar.items[i].disabled, locateInMenu: ${locateInMenu}`, function(assert) { + QUnit.test(`Restore default ${widget} tabIndex value on change toolbar.items[i].disabled, locateInMenu: ${locateInMenu}`, function(assert) { + const initialToolbarOptions = { + focusStateEnabled, + items: [{ + location: 'before', + widget, + locateInMenu, + options: { + tabIndex: 2, + } + }] + }; + + this.createInstance(initialToolbarOptions); + openDropDownMenuIfExist(this.toolbar); + + const $item = getItemElement(this.toolbar, focusableElementSelector); + assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + + this.toolbar.option('items[0].disabled', true); + + assert.strictEqual($item.attr('tabIndex'), '-1', 'tabIndex'); + + this.toolbar.option('items[0].disabled', false); + + assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + }); + + QUnit.test(`Restore default ${widget} tabIndex value on change toolbar.disabled, locateInMenu: ${locateInMenu}`, function(assert) { + const initialToolbarOptions = { + focusStateEnabled, + items: [{ + location: 'before', + widget, + locateInMenu, + options: { + tabIndex: 2, + } + }] + }; + + this.createInstance(initialToolbarOptions); + openDropDownMenuIfExist(this.toolbar); + + const $item = getItemElement(this.toolbar, focusableElementSelector); + assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + + this.toolbar.option('disabled', true); + + assert.strictEqual($item.attr('tabIndex'), '-1', 'tabIndex'); + + this.toolbar.option('disabled', false); + + assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + }); + + QUnit.test(`Restore default ${widget} tabIndex value on change toolbar.disabled.items[i].options.disabled, locateInMenu: ${locateInMenu}`, function(assert) { + const initialToolbarOptions = { + focusStateEnabled, + items: [{ + location: 'before', + widget, + locateInMenu, + options: { + tabIndex: 2, + } + }] + }; + + this.createInstance(initialToolbarOptions); + openDropDownMenuIfExist(this.toolbar); + + let $item = getItemElement(this.toolbar, focusableElementSelector); + assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + + this.toolbar.option('items[0].options.disabled', true); + $item = getItemElement(this.toolbar, focusableElementSelector); + + assert.strictEqual($item.attr('tabIndex'), '-1', 'tabIndex'); + + this.toolbar.option('items[0].options.disabled', false); + $item = getItemElement(this.toolbar, focusableElementSelector); + + assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + }); + }); + }); + + QUnit.module(`Editor state: focusStateEnabled: ${focusStateEnabled}, locateInMenu: ${locateInMenu}`, moduleConfig, () => { + QUnit.test('Changing toolbar.items[i].options.disabled does not save the current value in selectbox', function(assert) { const initialToolbarOptions = { + focusStateEnabled, items: [{ location: 'before', - widget, + widget: 'dxSelectBox', + cssClass: 'my-test-selectbox', locateInMenu, options: { - tabIndex: 2, + items: ['item1', 'item2'], + value: 'item1', } }] }; @@ -376,26 +470,33 @@ const openDropDownMenuIfExist = (toolbar) => { this.createInstance(initialToolbarOptions); openDropDownMenuIfExist(this.toolbar); - const $item = getItemElement(this.toolbar, focusableElementSelector); - assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + let $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); + const selectBox = $selectBox.dxSelectBox('instance'); + selectBox.option('value', 'item2'); - this.toolbar.option('items[0].disabled', true); + assert.equal(selectBox.option('value'), 'item2', 'selectbox state is right'); - assert.strictEqual($item.attr('tabIndex'), '-1', 'tabIndex'); + this.toolbar.option('items[0].options.disabled', true); - this.toolbar.option('items[0].disabled', false); + $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); + const $selectBoxDisabledContainer = $selectBox.closest('.my-test-selectbox'); + assert.ok(!$selectBoxDisabledContainer.hasClass('dx-state-disabled'), 'button option changed'); - assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + const selectBoxDisabled = $selectBox.dxSelectBox('instance'); + assert.equal(selectBoxDisabled.option('value'), 'item1', 'selectbox state saved'); }); - QUnit.test(`Restore default ${widget} tabIndex value on change toolbar.disabled, locateInMenu: ${locateInMenu}`, function(assert) { + QUnit.test('Changing toolbar.disable saves the current value in selectbox', function(assert) { const initialToolbarOptions = { + focusStateEnabled, items: [{ location: 'before', - widget, + widget: 'dxSelectBox', + cssClass: 'my-test-selectbox', locateInMenu, options: { - tabIndex: 2, + items: ['item1', 'item2'], + value: 'item1', } }] }; @@ -403,26 +504,33 @@ const openDropDownMenuIfExist = (toolbar) => { this.createInstance(initialToolbarOptions); openDropDownMenuIfExist(this.toolbar); - const $item = getItemElement(this.toolbar, focusableElementSelector); - assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + let $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); + const selectBox = $selectBox.dxSelectBox('instance'); + selectBox.option('value', 'item2'); - this.toolbar.option('disabled', true); + assert.equal(selectBox.option('value'), 'item2', 'selectbox state is right'); - assert.strictEqual($item.attr('tabIndex'), '-1', 'tabIndex'); + this.toolbar.option('disabled', true); - this.toolbar.option('disabled', false); + $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); + const $selectBoxDisabledContainer = $selectBox.closest('.my-test-selectbox'); + assert.ok(!$selectBoxDisabledContainer.hasClass('dx-state-disabled'), 'button option changed'); - assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + const selectBoxDisabled = $selectBox.dxSelectBox('instance'); + assert.equal(selectBoxDisabled.option('value'), 'item2', 'selectbox state saved'); }); - QUnit.test(`Restore default ${widget} tabIndex value on change toolbar.disabled.items[i].options.disabled, locateInMenu: ${locateInMenu}`, function(assert) { + QUnit.test('Changing toolbar.items[i].disabled saves the current value in selectbox', function(assert) { const initialToolbarOptions = { + focusStateEnabled, items: [{ location: 'before', - widget, + widget: 'dxSelectBox', + cssClass: 'my-test-selectbox', locateInMenu, options: { - tabIndex: 2, + items: ['item1', 'item2'], + value: 'item1', } }] }; @@ -430,121 +538,22 @@ const openDropDownMenuIfExist = (toolbar) => { this.createInstance(initialToolbarOptions); openDropDownMenuIfExist(this.toolbar); - let $item = getItemElement(this.toolbar, focusableElementSelector); - assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + let $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); + const selectBox = $selectBox.dxSelectBox('instance'); + selectBox.option('value', 'item2'); - this.toolbar.option('items[0].options.disabled', true); - $item = getItemElement(this.toolbar, focusableElementSelector); + assert.equal(selectBox.option('value'), 'item2', 'selectbox state is right'); - assert.strictEqual($item.attr('tabIndex'), '-1', 'tabIndex'); + this.toolbar.option('items[0].disabled', true); - this.toolbar.option('items[0].options.disabled', false); - $item = getItemElement(this.toolbar, focusableElementSelector); + $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); + const $selectBoxDisabledContainer = $selectBox.closest('.my-test-selectbox'); + assert.ok($selectBoxDisabledContainer.hasClass('dx-state-disabled'), 'button option changed'); - assert.strictEqual($item.attr('tabIndex'), '2', 'tabIndex'); + const selectBoxDisabled = $selectBox.dxSelectBox('instance'); + assert.equal(selectBoxDisabled.option('value'), 'item2', 'selectbox state saved'); }); }); }); - - QUnit.module(`Editor state: locateInMenu: ${locateInMenu}`, moduleConfig, () => { - QUnit.test('Changing toolbar.items[i].options.disabled does not save the current value in selectbox', function(assert) { - const initialToolbarOptions = { - items: [{ - location: 'before', - widget: 'dxSelectBox', - cssClass: 'my-test-selectbox', - locateInMenu, - options: { - items: ['item1', 'item2'], - value: 'item1', - } - }] - }; - - this.createInstance(initialToolbarOptions); - openDropDownMenuIfExist(this.toolbar); - - let $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); - const selectBox = $selectBox.dxSelectBox('instance'); - selectBox.option('value', 'item2'); - - assert.equal(selectBox.option('value'), 'item2', 'selectbox state is right'); - - this.toolbar.option('items[0].options.disabled', true); - - $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); - const $selectBoxDisabledContainer = $selectBox.closest('.my-test-selectbox'); - assert.ok(!$selectBoxDisabledContainer.hasClass('dx-state-disabled'), 'button option changed'); - - const selectBoxDisabled = $selectBox.dxSelectBox('instance'); - assert.equal(selectBoxDisabled.option('value'), 'item1', 'selectbox state saved'); - }); - - QUnit.test('Changing toolbar.disable saves the current value in selectbox', function(assert) { - const initialToolbarOptions = { - items: [{ - location: 'before', - widget: 'dxSelectBox', - cssClass: 'my-test-selectbox', - locateInMenu, - options: { - items: ['item1', 'item2'], - value: 'item1', - } - }] - }; - - this.createInstance(initialToolbarOptions); - openDropDownMenuIfExist(this.toolbar); - - let $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); - const selectBox = $selectBox.dxSelectBox('instance'); - selectBox.option('value', 'item2'); - - assert.equal(selectBox.option('value'), 'item2', 'selectbox state is right'); - - this.toolbar.option('disabled', true); - - $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); - const $selectBoxDisabledContainer = $selectBox.closest('.my-test-selectbox'); - assert.ok(!$selectBoxDisabledContainer.hasClass('dx-state-disabled'), 'button option changed'); - - const selectBoxDisabled = $selectBox.dxSelectBox('instance'); - assert.equal(selectBoxDisabled.option('value'), 'item2', 'selectbox state saved'); - }); - - QUnit.test('Changing toolbar.items[i].disabled saves the current value in selectbox', function(assert) { - const initialToolbarOptions = { - items: [{ - location: 'before', - widget: 'dxSelectBox', - cssClass: 'my-test-selectbox', - locateInMenu, - options: { - items: ['item1', 'item2'], - value: 'item1', - } - }] - }; - - this.createInstance(initialToolbarOptions); - openDropDownMenuIfExist(this.toolbar); - - let $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); - const selectBox = $selectBox.dxSelectBox('instance'); - selectBox.option('value', 'item2'); - - assert.equal(selectBox.option('value'), 'item2', 'selectbox state is right'); - - this.toolbar.option('items[0].disabled', true); - - $selectBox = getItemElement(this.toolbar, '.dx-selectbox'); - const $selectBoxDisabledContainer = $selectBox.closest('.my-test-selectbox'); - assert.ok($selectBoxDisabledContainer.hasClass('dx-state-disabled'), 'button option changed'); - - const selectBoxDisabled = $selectBox.dxSelectBox('instance'); - assert.equal(selectBoxDisabled.option('value'), 'item2', 'selectbox state saved'); - }); - }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js new file mode 100644 index 000000000000..9908864b3a22 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -0,0 +1,5544 @@ +import $ from 'jquery'; +import fx from 'common/core/animation/fx'; +import { TOOLBAR_ITEM_CLASS } from '__internal/ui/toolbar/toolbar.base'; +import { + DROP_DOWN_MENU_BUTTON_CLASS, + DROP_DOWN_MENU_POPUP_WRAPPER_CLASS, +} from '__internal/ui/toolbar/internal/toolbar.menu'; +import { TOOLBAR_FOCUS_MODE_CLASS, DROPDOWNMENU_LIST_FOCUS_MODE_CLASS } from '__internal/ui/toolbar/constants'; +import { BUTTON_CLASS } from '__internal/ui/button/button'; +import { LIST_ITEM_CLASS } from '__internal/ui/list/list.base'; +import { + DISABLED_STATE_CLASS, +} from '__internal/core/widget/widget'; + +import 'ui/toolbar'; +import 'ui/button'; +import 'ui/select_box'; +import 'ui/drop_down_button'; +import 'ui/button_group'; +import 'ui/text_box'; +import 'ui/number_box'; +import 'ui/date_box'; +import 'ui/date_range_box'; +import 'ui/color_box'; +import 'ui/tag_box'; +import 'ui/autocomplete'; +import 'ui/switch'; +import 'ui/check_box'; +import 'ui/menu'; +import 'ui/tabs'; + +import 'fluent_blue_light.css!'; + +QUnit.testStart(function() { + const markup = ` + + +
+
+
+
+ `; + + $('#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(document.activeElement, $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(document.activeElement), + `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 = document.activeElement; + 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', document.activeElement); + this.clock.tick(50); + + const $item = toolbar._getAvailableItems().eq(1); + assert.ok($item.get(0).contains(document.activeElement), + `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', document.activeElement); + 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', document.activeElement); + 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', document.activeElement); + 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', document.activeElement); + 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(document.activeElement, $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(document.activeElement, $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(document.activeElement, $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( + document.activeElement === $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( + document.activeElement, + $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( + document.activeElement, + $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()); + assert.strictEqual( + document.activeElement, + $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( + document.activeElement === $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( + document.activeElement === $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( + document.activeElement, + $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( + document.activeElement, + $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( + document.activeElement === $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( + document.activeElement === $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( + document.activeElement === popupContent, + false, + 'Popup overlay content is NOT the active element', + ); + assert.strictEqual( + document.activeElement === $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( + document.activeElement === $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( + document.activeElement === $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( + document.activeElement === $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( + document.activeElement === $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(document.activeElement, $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( + document.activeElement, + $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(document.activeElement, $overflowBtn.get(0), + 'overflow button is focused before opening'); + + dispatchKeydown(document.activeElement, '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(document.activeElement, $menuListItem.get(0), + 'DOM focus is on the overflow list item wrapper, not inside dxMenu'); + + dispatchKeydown(document.activeElement, '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(document.activeElement, 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: () => $('