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..dd709b2d5f3c --- /dev/null +++ b/e2e/testcafe-devextreme/tests/navigation/toolbar/keyboard.ts @@ -0,0 +1,141 @@ +import { Selector, ClientFunction } from 'testcafe'; +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 isFocusInsideItem = ClientFunction((itemIndex: number) => { + const items = document.querySelectorAll( + '#toolbar .dx-toolbar-item', + ); + const item = items[itemIndex]; + return item ? item.contains(document.activeElement) : false; +}); + +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; + +toolbarWidgets.forEach(({ widget, options }) => { + test(`${widget}: Tab leaves and Shift+Tab returns focus`, async (t) => { + const externalBefore = Selector('#externalBefore'); + const externalAfter = Selector('#externalAfter'); + + await t.click(externalBefore); + await t + .expect(externalBefore.focused) + .ok('external before button should be focused'); + + await t.pressKey('tab'); + await t + .expect(isFocusInsideItem(0)) + .ok('first toolbar item should be focused after Tab'); + + await t.pressKey('right'); + await t + .expect(isFocusInsideItem(1)) + .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(isFocusInsideItem(1)) + .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/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/ui/drop_down_editor/m_drop_down_editor.ts b/packages/devextreme/js/__internal/ui/drop_down_editor/m_drop_down_editor.ts index 046c22d1169b..a62015596501 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/list/list.base.ts b/packages/devextreme/js/__internal/ui/list/list.base.ts index 7e851d78a5d4..16621d927787 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..5b476f99b321 100644 --- a/packages/devextreme/js/__internal/ui/popup/m_popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/m_popup.ts @@ -683,6 +683,7 @@ class Popup< disabled, rtlEnabled, items, + focusStateEnabled: false, useDefaultButtons: useDefaultToolbarButtons, useFlatButtons: useFlatToolbarButtons, integrationOptions, 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..3a7a3947cafd 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 @@ -1,13 +1,19 @@ import type { ToolbarItemComponent } from '@js/common'; +import { keyboard } from '@js/common/core/events/short'; import type { DataSourceOptions } from '@js/common/data'; import type { dxElementWrapper } from '@js/core/renderer'; 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 { + closeItemWidget, getItemFocusTarget, isItemWidgetOpened, setItemWidgetFocusState, +} 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 +25,22 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content'; type ActionableComponents = Extract; export default class ToolbarMenuList extends ListBase { + _captureKeydownHandler?: EventListener; + + _onEscapePress?: () => void; + + _keyboardListenerId?: string; + 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 setItemWidgetFocusState on inner widgets, + // not by dx-state-focused on the list item container. + } + _initMarkup(): void { this._renderSections(); super._initMarkup(); @@ -130,6 +148,378 @@ export default class ToolbarMenuList extends ListBase { }; } + _supportedKeys(): SupportedKeys { + const keys = super._supportedKeys(); + + delete keys.leftArrow; + delete keys.rightArrow; + delete keys.upArrow; + delete keys.downArrow; + delete keys.home; + delete keys.end; + + const originalEnter = keys.enter; + keys.enter = (e: DxEvent): void => { + const target = e.target as HTMLElement; + + if (this._isTextInputTarget(target) || this._isMenuTarget(target)) { + return; + } + + const { focusedElement } = this.option(); + 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; + } + + const $menu = $item.find('.dx-menu').first(); + if ($menu.length) { + e.preventDefault(); + const menuInstance = $menu.data('dxMenu'); + if (menuInstance) { + // @ts-expect-error ts-error + menuInstance.focus(); + } + return; + } + } + + originalEnter?.call(this, e); + }; + + return keys; + } + + _attachKeyboardEvents(): void { + this._detachKeyboardEvents(); + + const { focusStateEnabled } = this.option(); + + if (focusStateEnabled) { + this._keyboardListenerId = keyboard.on( + this._keyboardEventBindingTarget(), + null, + (opts) => this._keyboardHandler(opts), + ); + + this._attachCaptureKeyHandler(); + } + } + + _detachKeyboardEvents(): void { + if (this._keyboardListenerId) { + keyboard.off(this._keyboardListenerId); + this._keyboardListenerId = undefined; + } + + this._detachCaptureKeyHandler(); + } + + _attachCaptureKeyHandler(): void { + this._detachCaptureKeyHandler(); + + const element = this.$element().get(0) as HTMLElement; + + this._captureKeydownHandler = (evt: Event): void => { + const e = evt as KeyboardEvent; + const target = e.target as HTMLElement; + + const isTextInput = this._isTextInputTarget(target); + const isMenu = this._isMenuTarget(target); + + if ((isTextInput || isMenu) && e.key !== 'Escape') { + return; + } + + if (e.key === 'Escape' && (isTextInput || isMenu)) { + e.preventDefault(); + e.stopPropagation(); + + const $item = $(target).closest(this._itemSelector()); + if ($item.length && closeItemWidget($item)) { + return; + } + + if ($item.length) { + this._focusItemWidget($item); + } + + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this._onEscapePress?.(); + + return; + } + + const keyToLocation: Record = { + ArrowDown: 'down', + ArrowUp: 'up', + Home: 'first', + End: 'last', + }; + + const location = keyToLocation[e.key]; + + if (!location) { + return; + } + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + if ($focused.length && isItemWidgetOpened($focused)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this._moveFocus(location); + }; + + element.addEventListener('keydown', this._captureKeydownHandler, true); + } + + _detachCaptureKeyHandler(): void { + if (this._captureKeydownHandler) { + const element = this.$element().get(0) as HTMLElement; + element.removeEventListener('keydown', this._captureKeydownHandler, true); + this._captureKeydownHandler = undefined; + } + } + + _isTextInputTarget(target: HTMLElement): boolean { + const tagName = target.tagName.toLowerCase(); + + return (tagName === 'input' || tagName === 'textarea') + && $(target).closest('.dx-texteditor').length > 0; + } + + _isMenuTarget(target: HTMLElement): boolean { + return $(target).closest('.dx-menu').length > 0; + } + + _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper { + return getItemFocusTarget($item) ?? ($item.hasClass(TOOLBAR_MENU_ACTION_CLASS) ? $item : $()); + } + + _isItemDisabled($item: dxElementWrapper): boolean { + if (this.option('disabled')) { + return true; + } + + if ($item.hasClass('dx-state-disabled')) { + return true; + } + + const $widget = $item.find('.dx-widget').first(); + if ($widget.length && $widget.hasClass('dx-state-disabled')) { + return true; + } + + return false; + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $visible = this._getVisibleItems($itemElements); + const elements = Array.from($visible.toArray()).filter( + (item) => !this._isItemDisabled($(item)) && !!this._getItemFocusTarget($(item)).length, + ); + + return $(elements) as unknown as dxElementWrapper; + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + this._updateRovingTabIndex($target); + } + + _getItemTabIndex($item: dxElementWrapper): number { + const itemData = this._getItemData($item); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (itemData as Item)?.options?.tabIndex ?? 0; + } + + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + if (!this.option('focusStateEnabled')) { + return; + } + + const $allVisible = this._getVisibleItems(); + const $available = this._getAvailableItems($allVisible); + let hasActive = false; + + $allVisible.each((_index: number, item: Element): boolean => { + const $item = $(item); + const $focusTarget = this._getItemFocusTarget($item); + + if (!$focusTarget?.length) { + return true; + } + + if (this._isItemDisabled($item)) { + $focusTarget.attr('tabIndex', -1); + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + if ($input?.length) { + $input.attr('tabIndex', -1); + } + return true; + } + + const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0); + const activeTabIndex = this._getItemTabIndex($item); + const tabIndexValue = isActive ? activeTabIndex : -1; + $focusTarget.attr('tabIndex', tabIndexValue); + if (isActive) { + hasActive = true; + } + + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + + if ($input?.length) { + $input.attr('tabIndex', -1); + + const hasDropDown = $focusTarget.hasClass('dx-dropdowneditor'); + if (!hasDropDown && !$focusTarget.attr('role')) { + const label = $input.attr('aria-label') + ?? $input.attr('placeholder') + ?? ''; + // @ts-expect-error ts-error + $focusTarget.attr({ + role: 'textbox', + 'aria-readonly': 'true', + 'aria-label': label, + }); + } + } + + const $menu = $item.find('.dx-menu'); + if ($menu.length) { + $menu.attr('tabIndex', -1); + $menu.find('[tabindex]').attr('tabIndex', -1); + } + + return true; + }); + + if (!hasActive) { + const $first = $available.first(); + if ($first.length) { + const $firstTarget = this._getItemFocusTarget($first); + const firstTabIndex = this._getItemTabIndex($first); + $firstTarget?.attr('tabIndex', firstTabIndex); + + const $firstInput = $firstTarget?.hasClass('dx-texteditor') + ? $firstTarget.find('.dx-texteditor-input') + : undefined; + if ($firstInput?.length) { + $firstInput.attr('tabIndex', -1); + } + } + } + } + + _focusInHandler(e: DxEvent): void { + const $target = $(e.target as Element); + const $item = $target.closest(this._itemSelector()); + + if ($item.length && getItemFocusTarget($item)?.length) { + this.option('focusedElement', getPublicElement($item)); + } + } + + _focusItemWidget($item: dxElementWrapper): void { + const $focusTarget = this._getItemFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + + ($focusTarget.get(0) as HTMLElement).focus(); + setItemWidgetFocusState($item, true); + } + + _focusOutHandler(e: DxEvent): void { + const { relatedTarget } = e as DxEvent & { relatedTarget: Element }; + const target = e.target as Element; + + if (relatedTarget && this.$element().get(0)?.contains(relatedTarget)) { + return; + } + + if (relatedTarget && $(relatedTarget).closest('.dx-overlay-content').length) { + return; + } + + if (target && $(target).closest('.dx-overlay-content').length) { + return; + } + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + if ($focused.length) { + setItemWidgetFocusState($focused, false); + } + + super._focusOutHandler(e); + } + + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + _moveFocus(location: string): boolean | undefined | void { + const { focusedElement: prevFocusedElement } = this.option(); + const $prev = $(prevFocusedElement); + if ($prev.length) { + closeItemWidget($prev); + setItemWidgetFocusState($prev, false); + } + + 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(); + + if (this.option('focusStateEnabled')) { + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); + } + } + _itemClickHandler( e: DxEvent, args?: Record, @@ -141,6 +531,7 @@ export default class ToolbarMenuList extends ListBase { } _clean(): void { + this._detachCaptureKeyHandler(); 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..6861c7b3fcbc 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,20 @@ 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 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 +45,7 @@ export interface DropDownMenuProperties extends WidgetProperties { onButtonClick?: (e: ClickEvent) => void; useInkRipple?: boolean; closeOnClick?: boolean; + listFocusStateEnabled?: boolean; } export default class DropDownMenu extends Widget { @@ -51,7 +53,7 @@ export default class DropDownMenu extends Widget { _popup?: Popup; - _list?: ListBase; + _list?: ToolbarMenuList; _$popup?: dxElementWrapper; @@ -61,6 +63,10 @@ export default class DropDownMenu extends Widget { _buttonClickAction?: (e: ClickEvent) => void; + _openFocusTarget: OpenFocusTarget = null; + + _wasOpenedWithFocus = false; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _supportedKeys(): Record boolean | void> { let extension = {}; @@ -271,6 +277,23 @@ export default class DropDownMenu extends Widget { this.option('opened', value); } }, + onShown: () => { + if (this.option('listFocusStateEnabled')) { + if (this._openFocusTarget === 'last') { + this._list?.focusLastItem(); + } else if (this._openFocusTarget === 'first') { + this._list?.focusFirstItem(); + } + this._openFocusTarget = null; + } + }, + onHidden: () => { + if (this._wasOpenedWithFocus) { + this._wasOpenedWithFocus = false; + const buttonEl = this._button?.$element().get(0) as HTMLElement | undefined; + buttonEl?.focus(); + } + }, container, autoResizeEnabled: false, height: 'auto', @@ -306,6 +329,12 @@ export default class DropDownMenu extends Widget { }); } + openWithFocus(focusTarget: OpenFocusTarget = 'first'): void { + this._openFocusTarget = focusTarget; + this._wasOpenedWithFocus = true; + this.option('opened', true); + } + _getMaxHeight(): number { const $element = this.$element(); @@ -338,8 +367,7 @@ 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 } = this.option(); this._list = this._createComponent($content, ToolbarMenuList, { dataSource: this._getListDataSource(), pageLoadMode: 'scrollBottom', @@ -352,7 +380,7 @@ export default class DropDownMenu extends Widget { this._itemClickHandler(e); }, tabIndex: -1, - focusStateEnabled: false, + focusStateEnabled: listFocusStateEnabled, activeStateEnabled: true, onItemRendered, _itemAttributes: { role: 'menuitem' }, @@ -363,6 +391,10 @@ export default class DropDownMenu extends Widget { } }, }); + + this._list._onEscapePress = (): void => { + this.option('opened', false); + }; } _popupKeyHandler(e: DxEvent): void { @@ -389,7 +421,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 +470,11 @@ 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); + break; case 'onItemRendered': this._list?.option(name, value); break; @@ -467,8 +501,14 @@ 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')) { + const { focusedElement } = this._list.option(); + this._list._updateRovingTabIndex($(focusedElement)); + } 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..1214cbabc545 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, + focusStateEnabled, menuContainer, } = this._toolbar.option(); this._menu = this._toolbar._createComponent($menu, DropDownMenu, { disabled, + listFocusStateEnabled: focusStateEnabled, // eslint-disable-next-line @typescript-eslint/no-unsafe-return itemTemplate: () => menuItemTemplate, onItemClick: (e) => { itemClickAction(e); }, @@ -268,6 +270,8 @@ export class SingleLineStrategy { case 'disabled': this._menu?.option(name, value); break; + case 'focusStateEnabled': + this._menu?.option('listFocusStateEnabled', value); 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 26429ac5e56d..8ff88a30a1b8 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, @@ -15,17 +17,22 @@ import { waitWebFont, } from '@js/ui/themes'; import type { Item, Properties } from '@js/ui/toolbar'; +import { getPublicElement } from '@ts/core/m_element'; import type { OptionChanged } from '@ts/core/widget/types'; +import type { SupportedKeys } from '@ts/core/widget/widget'; 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 { + closeItemWidget, getItemFocusTarget, isItemWidgetOpened, setItemWidgetFocusState, +} 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'; @@ -69,6 +76,10 @@ class ToolbarBase< _waitParentAnimationTimeout?: ReturnType; + _keyboardListenerId?: string; + + _captureKeydownHandler?: EventListener; + _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); } @@ -133,6 +144,8 @@ class ToolbarBase< grouped: false, useFlatButtons: false, useDefaultButtons: false, + focusStateEnabled: true, + loopItemFocus: true, }; } @@ -150,6 +163,404 @@ class ToolbarBase< ]); } + _focusTarget(): dxElementWrapper { + return this.$element(); + } + + _supportedKeys(): SupportedKeys { + const keys = super._supportedKeys(); + + delete keys.leftArrow; + delete keys.rightArrow; + delete keys.upArrow; + delete keys.downArrow; + delete keys.home; + delete keys.end; + + const originalEnter = keys.enter; + keys.enter = (e: DxEvent): void => { + const target = e.target as HTMLElement; + + if (this._isTextInputTarget(target) || this._isMenuTarget(target)) { + return; + } + + const { focusedElement } = this.option(); + const $item = $(focusedElement); + + if ($item.length) { + if (this._isOverflowItem($item)) { + e.preventDefault(); + this._openOverflowMenu('first'); + return; + } + + const $textEditor = $item.find('.dx-texteditor-input').first(); + if ($textEditor.length) { + e.preventDefault(); + ($textEditor.get(0) as HTMLElement).focus(); + return; + } + + const $menu = $item.find('.dx-menu').first(); + if ($menu.length) { + e.preventDefault(); + setItemWidgetFocusState($item, false); + const menuInstance = $menu.data('dxMenu'); + if (menuInstance) { + // @ts-expect-error + menuInstance.focus(); + } + return; + } + } + + originalEnter?.call(this, e); + }; + + return keys; + } + + _renderFocusTarget(): void { + this._focusTarget().attr('tabIndex', -1); + } + + _attachKeyboardEvents(): void { + this._detachKeyboardEvents(); + + const { focusStateEnabled } = this.option(); + + if (focusStateEnabled) { + this._keyboardListenerId = keyboard.on( + this._keyboardEventBindingTarget(), + null, + (opts) => this._keyboardHandler(opts), + ); + + this._attachCaptureArrowHandler(); + } + } + + _detachKeyboardEvents(): void { + if (this._keyboardListenerId) { + keyboard.off(this._keyboardListenerId); + this._keyboardListenerId = undefined; + } + + this._detachCaptureArrowHandler(); + } + + _attachCaptureArrowHandler(): void { + this._detachCaptureArrowHandler(); + + const element = this.$element().get(0) as HTMLElement; + const { rtlEnabled } = this.option(); + + this._captureKeydownHandler = (evt: Event): void => { + const e = evt as KeyboardEvent; + const target = e.target as HTMLElement; + + const isTextInput = this._isTextInputTarget(target); + const isMenu = this._isMenuTarget(target); + + if ((isTextInput || isMenu) && e.key !== 'Escape') { + return; + } + + if (e.key === 'Escape' && (isTextInput || isMenu)) { + e.preventDefault(); + e.stopPropagation(); + + const $item = $(target).closest(`${this._itemSelector()}, .dx-dropdownmenu-button`); + if ($item.length && closeItemWidget($item)) { + return; + } + + if ($item.length) { + this._focusItemWidget($item); + } + + return; + } + + const keyToLocation: Record = { + ArrowRight: rtlEnabled ? 'left' : 'right', + ArrowLeft: rtlEnabled ? 'right' : 'left', + Home: 'first', + End: 'last', + }; + + const location = keyToLocation[e.key]; + + if (!location) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if ($focused.length && isItemWidgetOpened($focused)) { + return; + } + + if ($focused.length && this._isOverflowItem($focused)) { + e.preventDefault(); + e.stopPropagation(); + this._openOverflowMenu(e.key === 'ArrowUp' ? 'last' : 'first'); + } + } + + return; + } + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + if ($focused.length && isItemWidgetOpened($focused)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this._moveFocus(location); + }; + + element.addEventListener('keydown', this._captureKeydownHandler, true); + } + + _detachCaptureArrowHandler(): void { + if (this._captureKeydownHandler) { + const element = this.$element().get(0) as HTMLElement; + element.removeEventListener('keydown', this._captureKeydownHandler, true); + this._captureKeydownHandler = undefined; + } + } + + _isTextInputTarget(target: HTMLElement): boolean { + const tagName = target.tagName.toLowerCase(); + + return (tagName === 'input' || tagName === 'textarea') + && $(target).closest('.dx-texteditor').length > 0; + } + + _isMenuTarget(target: HTMLElement): boolean { + return $(target).closest('.dx-menu').length > 0 + && !$(target).hasClass('dx-toolbar-item'); + } + + _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'); + } + + _isItemDisabled($item: dxElementWrapper): boolean { + if (this.option('disabled')) { + return true; + } + + if ($item.hasClass('dx-state-disabled')) { + return true; + } + + const $widget = $item.find('.dx-widget').first(); + if ($widget.length && $widget.hasClass('dx-state-disabled')) { + return true; + } + + return false; + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $visible = this._getVisibleItems($itemElements); + const elements = Array.from($visible.toArray()).filter( + (item) => !this._isItemDisabled($(item)) && !!getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + this._updateRovingTabIndex($target); + } + + _getItemTabIndex($item: dxElementWrapper): number { + const itemData = this._getItemData($item); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (itemData as Item)?.options?.tabIndex ?? 0; + } + + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + if (!this.option('focusStateEnabled')) { + return; + } + + const $allItems = this._itemContainer().find(`${this._itemSelector()}, .dx-dropdownmenu-button`); + const $available = this._getAvailableItems(); + let hasActive = false; + + $allItems.each((_index: number, item: Element): boolean => { + const $item = $(item); + const $focusTarget = getItemFocusTarget($item); + + if (!$focusTarget?.length) { + return true; + } + + if (!$item.is(':visible') || this._isItemDisabled($item)) { + $focusTarget.attr('tabIndex', -1); + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + if ($input?.length) { + $input.attr('tabIndex', -1); + } + return true; + } + + const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0); + const activeTabIndex = this._getItemTabIndex($item); + const tabIndexValue = isActive ? activeTabIndex : -1; + $focusTarget.attr('tabIndex', tabIndexValue); + if (isActive) { + hasActive = true; + } + + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + + if ($input?.length) { + $input.attr('tabIndex', -1); + + const hasDropDown = $focusTarget.hasClass('dx-dropdowneditor'); + if (!hasDropDown && !$focusTarget.attr('role')) { + const label = $input.attr('aria-label') + ?? $input.attr('placeholder') + ?? ''; + // @ts-expect-error ts-error + $focusTarget.attr({ + role: 'textbox', + 'aria-readonly': 'true', + 'aria-label': label, + }); + } + } + + const $menu = $item.find('.dx-menu'); + if ($menu.length) { + $menu.attr('tabIndex', -1); + $menu.find('[tabindex]').attr('tabIndex', -1); + } + + return true; + }); + + if (!hasActive) { + const $first = $available.first(); + if ($first.length) { + const $firstTarget = getItemFocusTarget($first); + const firstTabIndex = this._getItemTabIndex($first); + $firstTarget?.attr('tabIndex', firstTabIndex); + + const $firstInput = $firstTarget?.hasClass('dx-texteditor') + ? $firstTarget.find('.dx-texteditor-input') + : undefined; + if ($firstInput?.length) { + $firstInput.attr('tabIndex', -1); + } + } + } + } + + _focusInHandler(e: DxEvent): void { + if (this._isFocusTarget(e.target)) { + super._focusInHandler(e); + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if ($focused.length) { + this._focusItemWidget($focused); + } else { + const $firstItem = this._getAvailableItems().first(); + if ($firstItem.length) { + this.option('focusedElement', getPublicElement($firstItem)); + this._focusItemWidget($firstItem); + } + } + } else { + const $target = $(e.target); + const $item = $target.closest(`${this._itemSelector()}, .dx-dropdownmenu-button`); + + if ($item.length && getItemFocusTarget($item)?.length) { + this.option('focusedElement', getPublicElement($item)); + } + } + } + + _focusItemWidget($item: dxElementWrapper): void { + const $focusTarget = getItemFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + + ($focusTarget.get(0) as HTMLElement).focus(); + setItemWidgetFocusState($item, true); + } + + _focusOutHandler(e: DxEvent): void { + const { relatedTarget } = e as DxEvent & { relatedTarget: Element }; + const target = e.target as Element; + + if (relatedTarget && this.$element().get(0).contains(relatedTarget)) { + return; + } + + if (relatedTarget && $(relatedTarget).closest('.dx-overlay-content').length) { + return; + } + + if (target && $(target).closest('.dx-overlay-content').length) { + return; + } + + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + if ($focused.length) { + setItemWidgetFocusState($focused, false); + } + + super._focusOutHandler(e); + } + + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + _moveFocus(location: string, e?: DxEvent): boolean | undefined | void { + const { focusedElement: prevFocusedElement } = this.option(); + const $prev = $(prevFocusedElement); + if ($prev.length) { + closeItemWidget($prev); + setItemWidgetFocusState($prev, false); + } + + 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,6 +602,17 @@ class ToolbarBase< _postProcessRenderItems(): void { this._arrangeItems(); + + this._updateFocusableItemsTabIndex(); + } + + _updateFocusableItemsTabIndex(): void { + if (!this.option('focusStateEnabled')) { + return; + } + + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); } _renderToolbar(): void { @@ -448,6 +870,8 @@ class ToolbarBase< _renderEmptyMessage(): void {} _clean(): void { + super._clean(); + this._$toolbarItemsContainer.children().empty(); this.$element().empty(); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index ae02af7499e3..d7e3be5c055f 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -1,5 +1,6 @@ import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; import type { Item } from '@js/ui/toolbar'; import type { OptionChanged } from '@ts/core/widget/types'; @@ -33,6 +34,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 +48,8 @@ class Toolbar extends ToolbarBase { super._dimensionChanged(); this._layoutStrategy._dimensionChanged(); + + this._updateFocusableItemsTabIndex(); } _initMarkup(): void { @@ -148,7 +157,14 @@ class Toolbar extends ToolbarBase { this._layoutStrategy._itemOptionChanged(item, property, value); // @ts-expect-error ts-error if (property === 'disabled' || property === 'options.disabled') { - toggleItemFocusableElementTabIndex(this, item); + if (this._isMenuItem(item)) { + toggleItemFocusableElementTabIndex(this, item); + } else if (this.option('focusStateEnabled')) { + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); + } else { + toggleItemFocusableElementTabIndex(this, item); + } } if (property === 'location') { @@ -157,7 +173,25 @@ 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('focusStateEnabled')) { + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); + } else { + toolbarItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); + } } _isMenuItem(itemData: Item): boolean { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index df6f8d97017f..58d142402956 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -7,7 +7,9 @@ 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 NATIVE_FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const getItemInstance = ($element: dxElementWrapper): Widget => { // @ts-expect-error ts-error @@ -19,6 +21,104 @@ const getItemInstance = ($element: dxElementWrapper): Widget => { return (widgetName && itemData[widgetName]) as Widget; }; +export function closeItemWidget($item: dxElementWrapper): boolean { + const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + + 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 isItemWidgetOpened($item: dxElementWrapper): boolean { + const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + + 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_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + + 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; + } + + let $focusTarget = itemInstance._focusTarget?.(); + + // @ts-expect-error ts-error + const itemData = $widget.data(); + // @ts-expect-error ts-error + const widgetName = (itemData?.dxComponents?.[0] ?? '') as string; + if (widgetName.toLowerCase().includes('dropdownbutton')) { + $focusTarget = $focusTarget?.find(`.${BUTTON_GROUP_CLASS}`); + } else if ($widget.hasClass('dx-texteditor')) { + $focusTarget = $(itemInstance.element()); + } else if ($widget.hasClass('dx-menu')) { + $focusTarget = $item; + } else { + $focusTarget = $focusTarget ?? $(itemInstance.element()); + } + + return $focusTarget; +} + +export function setItemWidgetFocusState($item: dxElementWrapper, isFocused: boolean): void { + const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + + if (!$widgets.length) { + return; + } + + const $widget = $widgets.first(); + const itemInstance = getItemInstance($widget); + + if (itemInstance && typeof itemInstance._toggleFocusClass === 'function') { + if ($widget.hasClass('dx-menu')) { + $item.toggleClass('dx-state-focused', isFocused); + } else if ($widget.hasClass('dx-texteditor')) { + // TODO: text editors have an editing mode activated by Enter; + // do not show dx-state-focused during roving-tabindex navigation + } else { + itemInstance._toggleFocusClass(isFocused, getItemFocusTarget($item)); + } + } +} + export function toggleItemFocusableElementTabIndex( context: Toolbar | ListBase | undefined, item: Item, @@ -48,6 +148,8 @@ export function toggleItemFocusableElementTabIndex( if (widget === 'dxDropDownButton') { $focusTarget = $focusTarget?.find(`.${BUTTON_GROUP_CLASS}`); + } else if (widget === 'dxMenu') { + $focusTarget = $item; } else { $focusTarget = $focusTarget ?? $(itemInstance.element()); } 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 40dec83cfb48..903a916ef2ba 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..67999424664f --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -0,0 +1,4180 @@ +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 { 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'); +}); + +QUnit.module('Enter/Exit: text input editors', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + const textEditorWidgets = [ + { + 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: ['Tag1', 'Tag2', 'Tag3'], inputAttr: { 'aria-label': 'Test' } }, + }, + ]; + + textEditorWidgets.forEach(({ widget, options }) => { + function findInput($item) { + return $item.find('.dx-texteditor-input').first(); + } + + QUnit.test(`${widget}: Enter focuses input`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $input = findInput($items.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 = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $input = findInput($items.eq(1)); + triggerKey($input.get(0), 'ArrowLeft'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + `ArrowLeft does not navigate toolbar while ${widget} input is focused`); + + triggerKey($input.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + `ArrowRight does not navigate toolbar while ${widget} input is focused`); + }); + + QUnit.test(`${widget}: Esc exits editing mode`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $input = findInput($items.eq(1)); + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + `Esc keeps toolbar focusedElement on ${widget} item`); + }); + + QUnit.test(`${widget}: arrows navigate toolbar after Esc`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $input = findInput($items.eq(1)); + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), + `ArrowRight navigates toolbar after Esc from ${widget}`); + }); + + QUnit.test(`${widget}: tabindex invariant after enter and exit`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $input = findInput($items.eq(1)); + triggerKey($input.get(0), 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + const $tabZero = this.$element.find('[tabindex="0"]').not('.dx-texteditor-input'); + assert.strictEqual($tabZero.length, 1, + `Exactly one non-input tabindex=0 after enter/exit/navigate cycle with ${widget}`); + }); + }); +}); + +QUnit.module('Enter/Exit: dropdown/popup editors (matrix)', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + const dropDownWidgets = [ + { + 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)); + }, + }, + ]; + + dropDownWidgets.forEach(({ widget, options, getInstance, getFocusTarget, prepareFocus }) => { + QUnit.test(`${widget}: Enter opens popup`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $targetItem = $items.eq(1); + toolbar.option('focusedElement', $targetItem.get(0)); + prepareFocus($targetItem); + + triggerKey(getFocusTarget($targetItem).get(0), 'Enter'); + this.clock.tick(300); + + const instance = getInstance($targetItem); + assert.strictEqual(instance.option('opened'), true, + `Enter opens ${widget} popup`); + }); + + QUnit.test(`${widget}: Space opens popup`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $targetItem = $items.eq(1); + toolbar.option('focusedElement', $targetItem.get(0)); + prepareFocus($targetItem); + + triggerKey(getFocusTarget($targetItem).get(0), ' '); + this.clock.tick(300); + + const instance = getInstance($targetItem); + assert.strictEqual(instance.option('opened'), true, + `Space opens ${widget} popup`); + }); + + QUnit.test(`${widget}: ArrowDown opens popup`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $targetItem = $items.eq(1); + toolbar.option('focusedElement', $targetItem.get(0)); + + triggerKey(getFocusTarget($targetItem).get(0), 'ArrowDown'); + this.clock.tick(300); + + const instance = getInstance($targetItem); + assert.strictEqual(instance.option('opened'), true, + `ArrowDown opens ${widget} popup`); + }); + + QUnit.test(`${widget}: arrows blocked while popup open`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $targetItem = $items.eq(1); + toolbar.option('focusedElement', $targetItem.get(0)); + prepareFocus($targetItem); + + triggerKey(getFocusTarget($targetItem).get(0), 'Enter'); + this.clock.tick(300); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $targetItem.get(0), + `ArrowRight does not navigate toolbar while ${widget} popup is open`); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $targetItem.get(0), + `ArrowLeft does not navigate toolbar while ${widget} popup is open`); + }); + + QUnit.test(`${widget}: Esc closes popup and keeps toolbar focus`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $targetItem = $items.eq(1); + toolbar.option('focusedElement', $targetItem.get(0)); + + const instance = getInstance($targetItem); + instance.option('opened', true); + this.clock.tick(300); + + triggerKey(getFocusTarget($targetItem).get(0), 'Escape'); + this.clock.tick(300); + + assert.strictEqual(instance.option('opened'), false, + `Esc closes ${widget} popup`); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $targetItem.get(0), + `Toolbar focus stays on ${widget} item after Esc`); + }); + }); +}); + +QUnit.module('Enter/Exit: toggle widgets', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + const toggleWidgets = [ + { + widget: 'dxSwitch', + options: { value: false, width: 70 }, + containerSelector: '.dx-switch', + getValueFn(instance) { return instance.option('value'); }, + toggledByEnter: true, + }, + { + widget: 'dxCheckBox', + options: { text: 'Check', value: false }, + containerSelector: '.dx-checkbox', + getValueFn(instance) { return instance.option('value'); }, + toggledByEnter: false, + }, + ]; + + toggleWidgets.forEach(({ widget, options, containerSelector, getValueFn, toggledByEnter }) => { + if(toggledByEnter) { + QUnit.test(`${widget}: Enter toggles value`, function(assert) { + this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }); + + const $widgetEl = this.$element.find(containerSelector); + const widgetInstance = $widgetEl[widget]('instance'); + + const valueBefore = getValueFn(widgetInstance); + + const $focusTarget = $widgetEl; + $focusTarget.get(0).focus(); + this.clock.tick(0); + + triggerKey($focusTarget.get(0), 'Enter'); + this.clock.tick(50); + + const valueAfter = getValueFn(widgetInstance); + assert.notStrictEqual(valueAfter, valueBefore, + `Enter toggles ${widget} value`); + }); + } else { + QUnit.test(`${widget}: Enter does not toggle value`, function(assert) { + this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }); + + const $widgetEl = this.$element.find(containerSelector); + const widgetInstance = $widgetEl[widget]('instance'); + + const valueBefore = getValueFn(widgetInstance); + + const $focusTarget = $widgetEl; + $focusTarget.get(0).focus(); + this.clock.tick(0); + + triggerKey($focusTarget.get(0), 'Enter'); + this.clock.tick(50); + + const valueAfter = getValueFn(widgetInstance); + assert.strictEqual(valueAfter, valueBefore, + `Enter does not toggle ${widget} value`); + }); + } + + QUnit.test(`${widget}: Space toggles value`, function(assert) { + this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }); + + const $widgetEl = this.$element.find(containerSelector); + const widgetInstance = $widgetEl[widget]('instance'); + + const valueBefore = getValueFn(widgetInstance); + + const $focusTarget = $widgetEl; + $focusTarget.get(0).focus(); + this.clock.tick(0); + + triggerKey($focusTarget.get(0), ' '); + this.clock.tick(50); + + const valueAfter = getValueFn(widgetInstance); + assert.notStrictEqual(valueAfter, valueBefore, + `Space toggles ${widget} value`); + }); + + QUnit.test(`${widget}: arrows navigate toolbar (no inner edit mode)`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), + `ArrowRight navigates toolbar from ${widget} (no inner edit mode)`); + }); + + QUnit.test(`${widget}: ArrowLeft navigates toolbar`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + `ArrowLeft navigates toolbar from ${widget} (no inner edit mode)`); + }); + }); +}); + +QUnit.module('Enter/Exit: collection widgets', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + const collectionWidgets = [ + { + widget: 'dxMenu', + options: { + items: [ + { text: 'File', items: [{ text: 'New' }, { text: 'Open' }] }, + { text: 'Edit', items: [{ text: 'Cut' }, { text: 'Copy' }] }, + ], + }, + containerSelector: '.dx-menu', + getInnerFocusableSelector: '.dx-menu-item', + }, + ]; + + collectionWidgets.forEach(({ widget, options, getInnerFocusableSelector }) => { + QUnit.test(`${widget}: Enter activates inner navigation`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $innerFocusable = $items.eq(1).find(getInnerFocusableSelector).first(); + assert.strictEqual( + $items.eq(1).get(0).contains(document.activeElement), + true, + `Enter activates inner navigation for ${widget} (focus is inside the widget)`, + ); + assert.ok($innerFocusable.length > 0, `${widget} has inner focusable elements`); + }); + + QUnit.test(`${widget}: arrows blocked in active mode`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const activeEl = document.activeElement; + triggerKey(activeEl, 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + `ArrowRight does not navigate toolbar while inside ${widget}`); + + triggerKey(activeEl, 'ArrowLeft'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + `ArrowLeft does not navigate toolbar while inside ${widget}`); + }); + + QUnit.test(`${widget}: Esc exits to toolbar mode`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey(document.activeElement, 'Escape'); + this.clock.tick(50); + + assert.strictEqual(document.activeElement, $items.eq(1).get(0), + `Esc returns focus to toolbar item containing ${widget}`); + }); + + QUnit.test(`${widget}: arrows navigate toolbar after Esc`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey(document.activeElement, 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), + `ArrowRight navigates toolbar after Esc from ${widget}`); + }); + + QUnit.test(`${widget}: tabindex invariant after enter and exit`, function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget, options }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + triggerKey(document.activeElement, 'Escape'); + this.clock.tick(50); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + const $tabZero = this.$element.find('[tabindex="0"]'); + assert.strictEqual($tabZero.length, 1, + `Exactly one tabindex=0 after enter/exit/navigate cycle with ${widget}`); + }); + }); +}); + +QUnit.module('Enter/Exit: dxTabs in toolbar', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + function createTabsToolbar($el) { + return $el.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxTabs', options: { items: [{ text: 'Home' }, { text: 'Insert' }, { text: 'Layout' }], selectedIndex: 0, width: 'auto' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + } + + QUnit.test('dxTabs: ArrowRight navigates toolbar to next item', function(assert) { + const toolbar = createTabsToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), + 'ArrowRight navigates toolbar away from dxTabs to next item'); + }); + + QUnit.test('dxTabs: ArrowLeft navigates toolbar to previous item', function(assert) { + const toolbar = createTabsToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(1).get(0)); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + this.clock.tick(0); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + 'ArrowLeft navigates toolbar away from dxTabs to previous item'); + }); + + QUnit.test('dxTabs: ArrowDown switches tabs (does not navigate toolbar)', function(assert) { + const toolbar = createTabsToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const tabs = $items.eq(1).find('.dx-tabs').dxTabs('instance'); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + const $tabsContainer = $items.eq(1).find('.dx-tabs'); + $tabsContainer.get(0).focus(); + this.clock.tick(50); + + const selectedBefore = tabs.option('selectedIndex'); + triggerKey(document.activeElement, 'ArrowDown'); + this.clock.tick(50); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowDown does not navigate toolbar'); + assert.strictEqual(tabs.option('selectedIndex'), selectedBefore + 1, + 'ArrowDown switches to next tab'); + }); + + QUnit.test('dxTabs: ArrowUp switches tabs (does not navigate toolbar)', function(assert) { + const toolbar = createTabsToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + const tabs = $items.eq(1).find('.dx-tabs').dxTabs('instance'); + + tabs.option('selectedIndex', 1); + toolbar.option('focusedElement', $items.eq(1).get(0)); + const $tabsContainer = $items.eq(1).find('.dx-tabs'); + $tabsContainer.get(0).focus(); + this.clock.tick(50); + + const selectedBefore = tabs.option('selectedIndex'); + triggerKey(document.activeElement, 'ArrowUp'); + this.clock.tick(50); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowUp does not navigate toolbar'); + assert.strictEqual(tabs.option('selectedIndex'), selectedBefore - 1, + 'ArrowUp switches to previous tab'); + }); +}); + +function dispatchKeydown(element, key, options = {}) { + element.dispatchEvent(new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options, + })); +} + +function getItemFocusTarget($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; + + if($item.find('.dx-menu').length) return $item; + + const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href]').first(); + if($native.length) return $native; + + return $item; +} + +const moduleConfig = { + beforeEach: function() { + fx.off = true; + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + }, + afterEach: function() { + fx.off = false; + this.clock.restore(); + const instance = this.$element.dxToolbar('instance'); + if(instance) { + instance.dispose(); + } + }, +}; + +QUnit.module('Core Navigation', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + } +}, function() { + function makeButtonItems(count) { + return Array.from({ length: count }, (_, i) => ({ + widget: 'dxButton', + options: { text: String.fromCharCode(65 + i) }, + })); + } + + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + QUnit.test('first available item is the roving tabindex anchor on init', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + 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 = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), 'focus moved to item[1]'); + assert.strictEqual(this.$element.find('[tabindex="0"]').length, 1, 'exactly one tabindex=0'); + }); + + QUnit.test('ArrowRight on last item wraps focus to first item', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.last().get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'focus wrapped to first item'); + }); + + QUnit.test('ArrowLeft on first item wraps focus to last item', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowLeft'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.last().get(0), 'focus wrapped to last item'); + }); + + QUnit.test('Home moves focus to the first item', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(2).get(0)); + triggerKey(this.$element.get(0), 'Home'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'focus moved to first item'); + }); + + QUnit.test('End moves focus to the last item', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'End'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.last().get(0), 'focus moved to last item'); + }); + + QUnit.test('disabled widget items (options.disabled) are skipped by ArrowRight', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B', disabled: true } }, + { widget: 'dxButton', options: { text: 'C' } }, + ] + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 2, 'only 2 available items (disabled filtered out)'); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowRight skips disabled item and moves to C'); + }); + + QUnit.test('disabled toolbar items (item.disabled) are skipped by ArrowRight', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', disabled: true, options: { text: 'B' } }, + { widget: 'dxButton', options: { text: 'C' } }, + ] + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 2, 'only 2 available items (disabled filtered out)'); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowRight skips item.disabled and moves to C'); + }); + + QUnit.test('disabled widget items are skipped by ArrowLeft', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B', disabled: true } }, + { widget: 'dxButton', options: { text: 'C' } }, + ] + }).dxToolbar('instance'); + + 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 skips disabled item and moves to A'); + }); + + QUnit.test('Home skips leading disabled items', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', disabled: true, options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B' } }, + { widget: 'dxButton', options: { text: 'C' } }, + ] + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.last().get(0)); + triggerKey(this.$element.get(0), 'Home'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + 'Home lands on first enabled item (B), skipping disabled A'); + }); + + QUnit.test('End skips trailing disabled items', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B' } }, + { widget: 'dxButton', disabled: true, options: { text: 'C' } }, + ] + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'End'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.last().get(0), + 'End lands on last enabled item (B), skipping disabled C'); + }); + + QUnit.test('multiple consecutive disabled items are all skipped', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', disabled: true, options: { text: 'B' } }, + { widget: 'dxButton', options: { text: 'C', disabled: true } }, + { widget: 'dxButton', options: { text: 'D' } }, + ] + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 2, 'only 2 available items'); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowRight skips two consecutive disabled items and lands on D'); + }); + + QUnit.test('disabled item never has tabindex=0', function(assert) { + this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B', disabled: true } }, + { widget: 'dxButton', options: { text: '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) { + this.$element.dxToolbar({ + disabled: true, + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B' } }, + ] + }); + + 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 sequential navigation', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(4) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + + triggerKey(this.$element.get(0), 'ArrowRight'); + assert.strictEqual(this.$element.find('[tabindex="0"]').length, 1, 'one tabindex=0 after first ArrowRight'); + + triggerKey(this.$element.get(0), 'ArrowRight'); + assert.strictEqual(this.$element.find('[tabindex="0"]').length, 1, 'one tabindex=0 after second ArrowRight'); + + triggerKey(this.$element.get(0), 'End'); + assert.strictEqual(this.$element.find('[tabindex="0"]').length, 1, 'one tabindex=0 after End'); + + triggerKey(this.$element.get(0), 'Home'); + assert.strictEqual(this.$element.find('[tabindex="0"]').length, 1, 'one tabindex=0 after Home'); + }); + + QUnit.test('ArrowRight: newly focused item gets tabindex=0; previously focused item gets tabindex=-1', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + 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('keyboard navigation: all non-focused items have tabindex=-1', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(0).get(0)); + triggerKey(this.$element.get(0), 'ArrowRight'); + + 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('focusing an item via pointer makes it the roving tabindex anchor', function(assert) { + const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const $items = toolbar._getAvailableItems(); + + $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + const $tabZeroElements = this.$element.find('[tabindex="0"]'); + assert.strictEqual($tabZeroElements.length, 1, 'exactly one tabindex=0 after pointer focus'); + assert.strictEqual( + $tabZeroElements.closest(`.${TOOLBAR_ITEM_CLASS}`).get(0), + $items.eq(1).get(0), + 'item[1] is now the anchor' + ); + assert.strictEqual( + $(toolbar.option('focusedElement')).get(0), + $items.eq(1).get(0), + 'focusedElement updated to item[1]' + ); + }); +}); + +QUnit.module('Widget interaction', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + } +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + 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', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#toolbar'); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + } +}, function() { + function triggerKey(element, key) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + } + + function create3ButtonToolbar($el) { + return $el.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'A' } }, + { widget: 'dxButton', options: { text: 'B' } }, + { widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + } + + QUnit.test('Mouse click on item[j] → tabindex=0 on that item; others tabindex=-1', function(assert) { + const toolbar = create3ButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + assert.strictEqual($items.eq(1).find('.dx-button').attr('tabindex'), '0', 'Clicked item has tabindex=0'); + assert.strictEqual($items.eq(0).find('.dx-button').attr('tabindex'), '-1', 'Previous item has tabindex=-1'); + assert.strictEqual($items.eq(2).find('.dx-button').attr('tabindex'), '-1', 'Next item has tabindex=-1'); + }); + + QUnit.test('Mouse click on item[j] → ArrowRight → moves to item[j+1]', function(assert) { + const toolbar = create3ButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + triggerKey(this.$element.get(0), 'ArrowRight'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), + 'ArrowRight from click-focused item moves to next item'); + }); + + QUnit.test('Mouse click on item[j] → ArrowLeft → moves to item[j-1]', function(assert) { + const toolbar = create3ButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + 'ArrowLeft from click-focused item moves to previous item'); + }); + + QUnit.test('Mouse click on TextBox input → arrows do not navigate toolbar', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxTextBox', options: { value: 'hello' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + $input.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + triggerKey($input.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowLeft does not navigate toolbar after clicking TextBox input'); + }); + + QUnit.test('Mouse click on TextBox → Esc → ArrowLeft navigates toolbar', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxTextBox', options: { value: 'hello' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + $input.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + 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 click-focused TextBox'); + }); + + QUnit.test('Mouse click on SelectBox input provokes focusedElement updates to SelectBox item', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxSelectBox', options: { items: ['A', 'B', 'C'], value: 'A' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $input = $items.eq(1).find('.dx-texteditor-input'); + + $input.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'focusedElement updated to SelectBox item after click on input'); + }); + + QUnit.test('Mouse click on DropDownButton should provoke anchor updates; Enter opens popup', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', options: { text: 'Prev' } }, + { widget: 'dxDropDownButton', options: { items: ['Option 1', 'Option 2'], text: 'Actions' } }, + { widget: 'dxButton', options: { text: 'Next' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $buttonGroup = $items.eq(1).find('.dx-buttongroup'); + const dropDownButton = this.$element.find('.dx-dropdownbutton').dxDropDownButton('instance'); + + $buttonGroup.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'focusedElement updated to DropDownButton item after click'); + + const bgInstance = $buttonGroup.dxButtonGroup('instance'); + const $firstItem = bgInstance._buttonsCollection._itemElements().eq(0); + bgInstance._buttonsCollection.option('focusedElement', $firstItem.get(0)); + triggerKey($buttonGroup.get(0), 'Enter'); + this.clock.tick(300); + + assert.strictEqual(dropDownButton.option('opened'), true, 'Enter opens DropDownButton popup after click-focus'); + }); + + QUnit.test('Mouse click on non-TextBox item → arrows navigate toolbar', function(assert) { + const toolbar = create3ButtonToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + + triggerKey(this.$element.get(0), 'ArrowLeft'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + 'ArrowLeft navigates toolbar after clicking non-TextBox item'); + }); +}); + +QUnit.module('Disabled items skip', moduleConfig, function() { + QUnit.test('ArrowRight skips disabled item', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', widget: 'dxButton', disabled: true, options: { text: 'Disabled' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $itemA = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(0); + const $itemC = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(1); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemA).get(0) })); + this.clock.tick(0); + + dispatchKeydown(getItemFocusTarget($itemA).get(0), 'ArrowRight'); + this.clock.tick(0); + + const { focusedElement } = toolbar.option(); + assert.strictEqual($(focusedElement).get(0), $itemC.get(0), 'ArrowRight skipped disabled item and landed on C'); + }); + + QUnit.test('ArrowLeft skips disabled item', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', widget: 'dxButton', disabled: true, options: { text: 'Disabled' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $itemA = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(0); + const $itemC = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(1); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemC).get(0) })); + this.clock.tick(0); + + dispatchKeydown(getItemFocusTarget($itemC).get(0), 'ArrowLeft'); + this.clock.tick(0); + + const { focusedElement } = toolbar.option(); + assert.strictEqual($(focusedElement).get(0), $itemA.get(0), 'ArrowLeft skipped disabled item and landed on A'); + }); + + QUnit.test('Home skips leading disabled items', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', disabled: true, options: { text: 'Disabled' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $itemB = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(0); + const $itemC = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(1); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemC).get(0) })); + this.clock.tick(0); + + dispatchKeydown(getItemFocusTarget($itemC).get(0), 'Home'); + this.clock.tick(0); + + const { focusedElement } = toolbar.option(); + assert.strictEqual($(focusedElement).get(0), $itemB.get(0), 'Home skipped leading disabled and landed on B'); + }); + + QUnit.test('End skips trailing disabled items', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, + { locateInMenu: 'never', widget: 'dxButton', disabled: true, options: { text: 'Disabled' } }, + ], + }).dxToolbar('instance'); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $itemA = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(0); + const $itemB = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(1); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemA).get(0) })); + this.clock.tick(0); + + dispatchKeydown(getItemFocusTarget($itemA).get(0), 'End'); + this.clock.tick(0); + + const { focusedElement } = toolbar.option(); + assert.strictEqual($(focusedElement).get(0), $itemB.get(0), 'End skipped trailing disabled and landed on B'); + }); + + QUnit.test('disabled item never has tabindex=0', function(assert) { + this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', widget: 'dxButton', disabled: true, options: { text: 'Disabled' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $disabledItem = $allItems.filter(`.${DISABLED_STATE_CLASS}`).first(); + const $itemA = $allItems.not(`.${DISABLED_STATE_CLASS}`).eq(0); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemA).get(0) })); + this.clock.tick(0); + dispatchKeydown(getItemFocusTarget($itemA).get(0), 'ArrowRight'); + this.clock.tick(0); + + assert.strictEqual( + parseInt(getItemFocusTarget($disabledItem).attr('tabindex'), 10) !== 0, true, + 'Disabled item focus target never has tabindex=0', + ); + }); +}); + +QUnit.module('Dynamic item removal', moduleConfig, function() { + QUnit.skip('after toolbar.option(items), active item retains tabindex=0', function(assert) { + const itemA = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }; + const itemB = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }; + const itemC = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }; + const itemD = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'D' } }; + + const toolbar = this.$element.dxToolbar({ + items: [itemA, itemB, itemC], + }).dxToolbar('instance'); + + const $itemsBefore = toolbar._getAvailableItems(); + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(1)).get(0) })); + this.clock.tick(0); + + toolbar.option('items', [itemA, itemB, itemC, itemD]); + this.clock.tick(0); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $newItemB = $allItems.toArray().reduce(($acc, el) => { + const $el = $(el); + return $el.find(`.${BUTTON_CLASS}`).text().trim() === 'B' ? $el : $acc; + }, $()); + + assert.strictEqual( + parseInt(getItemFocusTarget($newItemB).attr('tabindex'), 10), + 0, + 'B retains tabindex=0 after items update', + ); + }); + + QUnit.skip('inserting item before active does not shift focus', function(assert) { + const itemA = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }; + const itemB = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }; + const itemC = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }; + const itemNew = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'New' } }; + + const toolbar = this.$element.dxToolbar({ + items: [itemA, itemB, itemC], + }).dxToolbar('instance'); + + const $itemsBefore = toolbar._getAvailableItems(); + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(1)).get(0) })); + this.clock.tick(0); + + toolbar.option('items', [itemNew, itemA, itemB, itemC]); + this.clock.tick(0); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const findByText = (text) => $allItems.toArray().reduce(($acc, el) => { + const $el = $(el); + return $el.find(`.${BUTTON_CLASS}`).text().trim() === text ? $el : $acc; + }, $()); + + assert.strictEqual(parseInt(getItemFocusTarget(findByText('B')).attr('tabindex'), 10), 0, 'B retains tabindex=0'); + assert.strictEqual(parseInt(getItemFocusTarget(findByText('A')).attr('tabindex'), 10), -1, 'A has tabindex=-1'); + assert.strictEqual(parseInt(getItemFocusTarget(findByText('New')).attr('tabindex'), 10), -1, 'New has tabindex=-1'); + }); + + QUnit.skip('removing non-active item does not shift focus', function(assert) { + + const itemA = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }; + const itemB = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }; + const itemC = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }; + + const toolbar = this.$element.dxToolbar({ + items: [itemA, itemB, itemC], + }).dxToolbar('instance'); + + const $itemsBefore = toolbar._getAvailableItems(); + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(1)).get(0) })); + this.clock.tick(0); + + toolbar.option('items', [itemA, itemB]); + this.clock.tick(0); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $newItemB = $allItems.toArray().reduce(($acc, el) => { + const $el = $(el); + return $el.find(`.${BUTTON_CLASS}`).text().trim() === 'B' ? $el : $acc; + }, $()); + + assert.strictEqual( + parseInt(getItemFocusTarget($newItemB).attr('tabindex'), 10), + 0, + 'B retains tabindex=0 after removing non-active C', + ); + }); + + QUnit.skip('removing active item moves focus to previous item', function(assert) { + const itemA = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }; + const itemB = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }; + const itemC = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }; + + const toolbar = this.$element.dxToolbar({ + items: [itemA, itemB, itemC], + }).dxToolbar('instance'); + + const $itemsBefore = toolbar._getAvailableItems(); + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(1)).get(0) })); + this.clock.tick(0); + + toolbar.option('items', [itemA, itemC]); + this.clock.tick(0); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $newItemA = $allItems.toArray().reduce(($acc, el) => { + const $el = $(el); + return $el.find(`.${BUTTON_CLASS}`).text().trim() === 'A' ? $el : $acc; + }, $()); + + assert.strictEqual( + parseInt(getItemFocusTarget($newItemA).attr('tabindex'), 10), + 0, + 'Focus moved to previous item A after removing active B', + ); + }); + + QUnit.skip('removing first item moves focus to new first item', function(assert) { + const itemA = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }; + const itemB = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }; + const itemC = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }; + + const toolbar = this.$element.dxToolbar({ + items: [itemA, itemB, itemC], + }).dxToolbar('instance'); + + const $itemsBefore = toolbar._getAvailableItems(); + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(0)).get(0) })); + this.clock.tick(0); + + toolbar.option('items', [itemB, itemC]); + this.clock.tick(0); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $newItemB = $allItems.toArray().reduce(($acc, el) => { + const $el = $(el); + return $el.find(`.${BUTTON_CLASS}`).text().trim() === 'B' ? $el : $acc; + }, $()); + + assert.strictEqual( + parseInt(getItemFocusTarget($newItemB).attr('tabindex'), 10), + 0, + 'New first item B gets tabindex=0 after removing first item A', + ); + }); + + QUnit.skip('after removal, Arrow keys navigate from new active position', function(assert) { + const itemA = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }; + const itemB = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }; + const itemC = { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }; + + const toolbar = this.$element.dxToolbar({ + items: [itemA, itemB, itemC], + }).dxToolbar('instance'); + + const $itemsBefore = toolbar._getAvailableItems(); + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(1)).get(0) })); + this.clock.tick(0); + + toolbar.option('items', [itemA, itemC]); + this.clock.tick(0); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const findByText = (text) => $allItems.toArray().reduce(($acc, el) => { + const $el = $(el); + return $el.find(`.${BUTTON_CLASS}`).text().trim() === text ? $el : $acc; + }, $()); + + const $newItemA = findByText('A'); + const $newItemC = findByText('C'); + + dispatchKeydown(getItemFocusTarget($newItemA).get(0), 'ArrowRight'); + this.clock.tick(0); + + const { focusedElement } = toolbar.option(); + assert.strictEqual( + $(focusedElement).get(0), + $newItemC.get(0), + 'ArrowRight from A (new active after B removed) navigates to C', + ); + }); + + QUnit.skip('navigation order follows DOM order (before, before, after)', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', location: 'before', options: { text: 'B1' } }, + { locateInMenu: 'never', widget: 'dxButton', location: 'before', options: { text: 'B2' } }, + { locateInMenu: 'never', widget: 'dxButton', location: 'after', options: { text: 'A1' } }, + ], + }).dxToolbar('instance'); + + const $available = toolbar._getAvailableItems(); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($available.eq(0)).get(0) })); + this.clock.tick(0); + + dispatchKeydown(getItemFocusTarget($available.eq(0)).get(0), 'ArrowRight'); + this.clock.tick(0); + + const { focusedElement: afterFirst } = toolbar.option(); + assert.strictEqual( + $(afterFirst).get(0), + $available.eq(1).get(0), + 'ArrowRight moved to second item in DOM order', + ); + + dispatchKeydown(getItemFocusTarget($available.eq(1)).get(0), 'ArrowRight'); + this.clock.tick(0); + + const { focusedElement: afterSecond } = toolbar.option(); + assert.strictEqual( + $(afterSecond).get(0), + $available.eq(2).get(0), + 'ArrowRight moved to third item in DOM order', + ); + }); +}); + +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), + '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.skip('Tab inside menu closes popup and exits 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 $firstItem = list._getAvailableItems().first(); + dispatchKeydown(getItemFocusTarget($firstItem).get(0), 'Tab'); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu closed after 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), + '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.module('Template items', moduleConfig, function() { + 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: () => $('