From f0fca417981f9216dbf55d68df10c4de33cbc183 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 14 May 2026 14:10:22 +0400 Subject: [PATCH 01/47] Toolbar: support keyboard navigation according to APG W3C --- .../ui/toolbar/internal/toolbar.menu.list.ts | 328 ++++++++++++++++ .../ui/toolbar/internal/toolbar.menu.ts | 30 +- .../js/__internal/ui/toolbar/toolbar.base.ts | 364 ++++++++++++++++++ .../js/__internal/ui/toolbar/toolbar.ts | 6 + .../js/__internal/ui/toolbar/toolbar.utils.ts | 97 ++++- 5 files changed, 821 insertions(+), 4 deletions(-) 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..c05f3cc5fc70 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,17 @@ 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,6 +23,12 @@ 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})`; } @@ -130,6 +140,323 @@ 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; + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $visible = this._getVisibleItems($itemElements); + const elements = Array.from($visible.toArray()).filter( + (item) => !!getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + this._updateRovingTabIndex($target); + } + + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + const $items = this._getAvailableItems(); + let hasActive = false; + + $items.each((_index: number, item: Element): boolean => { + const $item = $(item); + const $focusTarget = getItemFocusTarget($item); + + if ($focusTarget?.length) { + const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0); + $focusTarget.attr('tabIndex', isActive ? 0 : -1); + if (isActive) { + hasActive = true; + } + + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + + if ($input?.length) { + if (!isActive) { + $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 = $items.first(); + if ($first.length) { + const $firstTarget = getItemFocusTarget($first); + $firstTarget?.attr('tabIndex', 0); + } + } + } + + _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 = 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(); + + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); + } + _itemClickHandler( e: DxEvent, args?: Record, @@ -141,6 +468,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..3e0d9e8fccc1 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -19,7 +19,6 @@ 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'; @@ -32,6 +31,8 @@ const DROP_DOWN_MENU_BUTTON_CLASS = 'dx-dropdownmenu-button'; const POPUP_BOUNDARY_VERTICAL_OFFSET = 10; const POPUP_VERTICAL_OFFSET = 3; +type OpenFocusTarget = 'first' | 'last'; + export interface DropDownMenuProperties extends WidgetProperties { opened?: boolean; container: string | Element | undefined; @@ -51,7 +52,7 @@ export default class DropDownMenu extends Widget { _popup?: Popup; - _list?: ListBase; + _list?: ToolbarMenuList; _$popup?: dxElementWrapper; @@ -61,6 +62,8 @@ export default class DropDownMenu extends Widget { _buttonClickAction?: (e: ClickEvent) => void; + _openFocusTarget: OpenFocusTarget = 'first'; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _supportedKeys(): Record boolean | void> { let extension = {}; @@ -271,6 +274,18 @@ export default class DropDownMenu extends Widget { this.option('opened', value); } }, + onShown: () => { + if (this._openFocusTarget === 'last') { + this._list?.focusLastItem(); + } else { + this._list?.focusFirstItem(); + } + this._openFocusTarget = 'first'; + }, + onHidden: () => { + const buttonEl = this._button?.$element().get(0) as HTMLElement | undefined; + buttonEl?.focus(); + }, container, autoResizeEnabled: false, height: 'auto', @@ -306,6 +321,11 @@ export default class DropDownMenu extends Widget { }); } + openWithFocus(focusTarget: OpenFocusTarget = 'first'): void { + this._openFocusTarget = focusTarget; + this.option('opened', true); + } + _getMaxHeight(): number { const $element = this.$element(); @@ -352,7 +372,7 @@ export default class DropDownMenu extends Widget { this._itemClickHandler(e); }, tabIndex: -1, - focusStateEnabled: false, + focusStateEnabled: true, activeStateEnabled: true, onItemRendered, _itemAttributes: { role: 'menuitem' }, @@ -363,6 +383,10 @@ export default class DropDownMenu extends Widget { } }, }); + + this._list._onEscapePress = (): void => { + this.option('opened', false); + }; } _popupKeyHandler(e: DxEvent): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 26429ac5e56d..49e6f318a5f5 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,11 +17,14 @@ 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'; @@ -69,6 +74,10 @@ class ToolbarBase< _waitParentAnimationTimeout?: ReturnType; + _keyboardListenerId?: string; + + _captureKeydownHandler?: EventListener; + _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); } @@ -133,6 +142,8 @@ class ToolbarBase< grouped: false, useFlatButtons: false, useDefaultButtons: false, + focusStateEnabled: true, + loopItemFocus: true, }; } @@ -150,6 +161,356 @@ 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(); + 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'); + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $visible = this._getVisibleItems($itemElements); + const elements = Array.from($visible.toArray()).filter( + (item) => !!getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + this._updateRovingTabIndex($target); + } + + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + const $items = this._getAvailableItems(); + let hasActive = false; + + $items.each((_index: number, item: Element): boolean => { + const $item = $(item); + const $focusTarget = getItemFocusTarget($item); + + if ($focusTarget?.length) { + const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0); + $focusTarget.attr('tabIndex', isActive ? 0 : -1); + if (isActive) { + hasActive = true; + } + + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + + if ($input?.length) { + if (!isActive) { + $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 = $items.first(); + if ($first.length) { + const $firstTarget = getItemFocusTarget($first); + $firstTarget?.attr('tabIndex', 0); + } + } + } + + _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 +552,9 @@ class ToolbarBase< _postProcessRenderItems(): void { this._arrangeItems(); + + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); } _renderToolbar(): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index ae02af7499e3..611b74e1c34e 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -33,6 +33,12 @@ class Toolbar extends ToolbarBase { return multiline; } + _openOverflowMenu(focusTarget: 'first' | 'last'): void { + if (this._layoutStrategy instanceof SingleLineStrategy && this._layoutStrategy._menu) { + this._layoutStrategy._menu.openWithFocus(focusTarget); + } + } + _dimensionChanged(dimension?: 'height' | 'width'): void { if (dimension === 'height') { return; diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index df6f8d97017f..0047be7ee64f 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,97 @@ 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 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') { + itemInstance._toggleFocusClass(isFocused); + } +} + +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 toggleItemFocusableElementTabIndex( context: Toolbar | ListBase | undefined, item: Item, @@ -48,6 +141,8 @@ export function toggleItemFocusableElementTabIndex( if (widget === 'dxDropDownButton') { $focusTarget = $focusTarget?.find(`.${BUTTON_GROUP_CLASS}`); + } else if (widget === 'dxMenu') { + $focusTarget = $item; } else { $focusTarget = $focusTarget ?? $(itemInstance.element()); } From e926260b1ab76c307902bdf27bf7d4cd02fa8cf2 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 14 May 2026 14:10:22 +0400 Subject: [PATCH 02/47] Toolbar: support keyboard navigation according to APG W3C --- .../ui/toolbar/internal/toolbar.menu.list.ts | 330 ++++++++++++++++ .../ui/toolbar/internal/toolbar.menu.ts | 30 +- .../js/__internal/ui/toolbar/toolbar.base.ts | 366 ++++++++++++++++++ .../js/__internal/ui/toolbar/toolbar.ts | 6 + .../js/__internal/ui/toolbar/toolbar.utils.ts | 97 ++++- .../tests/DevExpress.ui/dialog.tests.js | 2 +- 6 files changed, 826 insertions(+), 5 deletions(-) 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..2a48f4d60ead 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,6 +25,12 @@ 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})`; } @@ -130,6 +142,323 @@ 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; + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $visible = this._getVisibleItems($itemElements); + const elements = Array.from($visible.toArray()).filter( + (item) => !!getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + this._updateRovingTabIndex($target); + } + + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + const $items = this._getAvailableItems(); + let hasActive = false; + + $items.each((_index: number, item: Element): boolean => { + const $item = $(item); + const $focusTarget = getItemFocusTarget($item); + + if ($focusTarget?.length) { + const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0); + $focusTarget.attr('tabIndex', isActive ? 0 : -1); + if (isActive) { + hasActive = true; + } + + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + + if ($input?.length) { + if (!isActive) { + $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 = $items.first(); + if ($first.length) { + const $firstTarget = getItemFocusTarget($first); + $firstTarget?.attr('tabIndex', 0); + } + } + } + + _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 = 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(); + + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); + } + _itemClickHandler( e: DxEvent, args?: Record, @@ -141,6 +470,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..3e0d9e8fccc1 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -19,7 +19,6 @@ 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'; @@ -32,6 +31,8 @@ const DROP_DOWN_MENU_BUTTON_CLASS = 'dx-dropdownmenu-button'; const POPUP_BOUNDARY_VERTICAL_OFFSET = 10; const POPUP_VERTICAL_OFFSET = 3; +type OpenFocusTarget = 'first' | 'last'; + export interface DropDownMenuProperties extends WidgetProperties { opened?: boolean; container: string | Element | undefined; @@ -51,7 +52,7 @@ export default class DropDownMenu extends Widget { _popup?: Popup; - _list?: ListBase; + _list?: ToolbarMenuList; _$popup?: dxElementWrapper; @@ -61,6 +62,8 @@ export default class DropDownMenu extends Widget { _buttonClickAction?: (e: ClickEvent) => void; + _openFocusTarget: OpenFocusTarget = 'first'; + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _supportedKeys(): Record boolean | void> { let extension = {}; @@ -271,6 +274,18 @@ export default class DropDownMenu extends Widget { this.option('opened', value); } }, + onShown: () => { + if (this._openFocusTarget === 'last') { + this._list?.focusLastItem(); + } else { + this._list?.focusFirstItem(); + } + this._openFocusTarget = 'first'; + }, + onHidden: () => { + const buttonEl = this._button?.$element().get(0) as HTMLElement | undefined; + buttonEl?.focus(); + }, container, autoResizeEnabled: false, height: 'auto', @@ -306,6 +321,11 @@ export default class DropDownMenu extends Widget { }); } + openWithFocus(focusTarget: OpenFocusTarget = 'first'): void { + this._openFocusTarget = focusTarget; + this.option('opened', true); + } + _getMaxHeight(): number { const $element = this.$element(); @@ -352,7 +372,7 @@ export default class DropDownMenu extends Widget { this._itemClickHandler(e); }, tabIndex: -1, - focusStateEnabled: false, + focusStateEnabled: true, activeStateEnabled: true, onItemRendered, _itemAttributes: { role: 'menuitem' }, @@ -363,6 +383,10 @@ export default class DropDownMenu extends Widget { } }, }); + + this._list._onEscapePress = (): void => { + this.option('opened', false); + }; } _popupKeyHandler(e: DxEvent): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 26429ac5e56d..b4586e5ea4fb 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,11 +17,16 @@ 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'; @@ -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,354 @@ 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(); + 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'); + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + const $visible = this._getVisibleItems($itemElements); + const elements = Array.from($visible.toArray()).filter( + (item) => !!getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; + } + + _setFocusedItem($target: dxElementWrapper): void { + super._setFocusedItem($target); + this._updateRovingTabIndex($target); + } + + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + const $items = this._getAvailableItems(); + let hasActive = false; + + $items.each((_index: number, item: Element): boolean => { + const $item = $(item); + const $focusTarget = getItemFocusTarget($item); + + if ($focusTarget?.length) { + const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0); + $focusTarget.attr('tabIndex', isActive ? 0 : -1); + if (isActive) { + hasActive = true; + } + + const $input = $focusTarget.hasClass('dx-texteditor') + ? $focusTarget.find('.dx-texteditor-input') + : undefined; + + if ($input?.length) { + if (!isActive) { + $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 = $items.first(); + if ($first.length) { + const $firstTarget = getItemFocusTarget($first); + $firstTarget?.attr('tabIndex', 0); + } + } + } + + _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 +552,9 @@ class ToolbarBase< _postProcessRenderItems(): void { this._arrangeItems(); + + const { focusedElement } = this.option(); + this._updateRovingTabIndex($(focusedElement)); } _renderToolbar(): void { @@ -448,6 +812,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..611b74e1c34e 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -33,6 +33,12 @@ class Toolbar extends ToolbarBase { return multiline; } + _openOverflowMenu(focusTarget: 'first' | 'last'): void { + if (this._layoutStrategy instanceof SingleLineStrategy && this._layoutStrategy._menu) { + this._layoutStrategy._menu.openWithFocus(focusTarget); + } + } + _dimensionChanged(dimension?: 'height' | 'width'): void { if (dimension === 'height') { return; diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index df6f8d97017f..0047be7ee64f 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,97 @@ 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 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') { + itemInstance._toggleFocusClass(isFocused); + } +} + +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 toggleItemFocusableElementTabIndex( context: Toolbar | ListBase | undefined, item: Item, @@ -48,6 +141,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/dialog.tests.js b/packages/devextreme/testing/tests/DevExpress.ui/dialog.tests.js index b01ed9147575..9d60a9d3dc52 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui/dialog.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui/dialog.tests.js @@ -169,7 +169,7 @@ module('dialog', { testInActiveWindow('first button in dialog obtained focus on shown', function(assert) { alert('Sample message', 'Alert'); - assert.equal($('.dx-dialog-wrapper').find('.dx-state-focused').length, 1, 'button obtained focus'); + assert.equal($('.dx-dialog-wrapper').find(`.${DIALOG_BUTTON_CLASS}.dx-state-focused`).length, 1, 'button obtained focus'); }); test('dialog content', function(assert) { From e3ff633a4e8a97b587c07b276310dafc7c1c58ce Mon Sep 17 00:00:00 2001 From: dmlvr Date: Fri, 15 May 2026 15:42:46 +0300 Subject: [PATCH 03/47] update imports for classnames --- packages/devextreme/js/__internal/core/widget/widget.ts | 2 +- packages/devextreme/js/__internal/ui/list/list.base.ts | 2 +- .../js/__internal/ui/toolbar/internal/toolbar.menu.ts | 6 +++--- .../devextreme/js/__internal/ui/toolbar/toolbar.base.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/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/toolbar/internal/toolbar.menu.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts index 3e0d9e8fccc1..09b426fc51da 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -23,11 +23,11 @@ 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; diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 49e6f318a5f5..0652db007f3c 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -30,7 +30,7 @@ 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'; From b98de3d2c568415a02321a56b6d6624c429cb825 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Fri, 15 May 2026 16:33:57 +0300 Subject: [PATCH 04/47] add testfile --- .../toolbar.kbn.tests.js | 1400 +++++++++++++++++ 1 file changed, 1400 insertions(+) create mode 100644 packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js 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..c953d9c640c5 --- /dev/null +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -0,0 +1,1400 @@ +import $ from 'jquery'; +import fx from 'common/core/animation/fx'; +import { getItemFocusTarget } from '__internal/ui/toolbar/toolbar.utils'; +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 { TEXTEDITOR_INPUT_CLASS } from '__internal/ui/text_box/m_text_editor.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 'fluent_blue_light.css!'; + +QUnit.testStart(function() { + const markup = ` + + +
+
+
+
+ `; + + $('#qunit-fixture').html(markup); + $('#widthRootStyle').css('width', '300px'); +}); + +function dispatchKeydown(element, key, options = {}) { + element.dispatchEvent(new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options, + })); +} + +function focusToolbar($toolbar) { + $toolbar.trigger($.Event('focusin', { target: $toolbar.get(0) })); +} + +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('Disabled items skip', moduleConfig, function() { + // BUG: _getAvailableItems() in f0fca41 does NOT filter out disabled items. + // getItemFocusTarget() returns the widget root div for disabled items (identical to enabled). + // The roving tabindex mechanism therefore CAN land on a disabled toolbar item. + // All AC-1.5.1 tests are SKIPPED; they document unimplemented contract behavior. + + QUnit.skip('ArrowRight skips disabled item', function(assert) { + // BUG: ArrowRight can land on a disabled item because _getAvailableItems() + // includes disabled items. AC-1.5.1 is not implemented in f0fca41. + + 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.skip('ArrowLeft skips disabled item', function(assert) { + // BUG: Same root cause as AC-1.5.1.2. + + 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.skip('Home skips leading disabled items', function(assert) { + // BUG: Home can land on a leading disabled item. AC-1.5.1 not implemented in f0fca41. + + 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.skip('End skips trailing disabled items', function(assert) { + // BUG: End can land on a trailing disabled item. AC-1.5.1 not implemented in f0fca41. + + 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.skip('disabled item never has tabindex=0', function(assert) { + // BUG: Roving tabindex does not exclude disabled items in f0fca41. + // After navigation a disabled item can receive tabindex=0. + + 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) { + // NOT IMPLEMENTED in f0fca41: no active-item data-reference tracking. + // After re-render, _postProcessRenderItems uses stale focusedElement (old DOM element) + // → no match → first item always gets tabindex=0. + + 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) { + // NOT IMPLEMENTED in f0fca41: same root cause as 1.5.2.1. + + 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) { + // NOT IMPLEMENTED in f0fca41: after removal first item always gets tabindex=0. + + 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) { + // The _getAvailableItems / _getVisibleItems uses DOM traversal via + // _itemContainer().find(...) which is DOM-order. This likely works, + // but verifying requires a stable active-item state. Skipped with 1.5.2 suite. + + 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('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); + if($firstFocusTarget && $firstFocusTarget.length) { + 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()); + if($firstFocusTarget && $firstFocusTarget.length) { + 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')); + if($currentListFocus.length) { + const $focusTarget = getItemFocusTarget($currentListFocus); + if($focusTarget && $focusTarget.length) { + dispatchKeydown($focusTarget.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); + + if($focusTarget && $focusTarget.length) { + dispatchKeydown($focusTarget.get(0), 'Escape'); + } else { + dispatchKeydown(list.$element().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) { + // BUG (RC-6): Tab from inside overflow popup moves focus outside toolbar + // but the popup does NOT close. Known issue per compliance report. + // The popup remains open after Tab navigation. + + 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(); + const $focusTarget = getItemFocusTarget($firstItem); + dispatchKeydown($focusTarget.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) { + // BUG (RC-2): Multiple tabindex=0 elements exist due to inner widget inputs + // (SelectBox, TextBox etc.) retaining their own default tabindex=0. + // The overflow button does have tabindex=0, but uniqueness is not guaranteed. + + 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(); + 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( + 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); + if($focusTarget && $focusTarget.length) { + assert.strictEqual( + document.activeElement, + $focusTarget.get(0), + 'First menu item is focused after ArrowDown', + ); + } + }); +}); + +QUnit.module('Template items (pending)', moduleConfig, function() { + QUnit.skip('template item with focusable content is in roving tabindex sequence', function(assert) { + // NOT IMPLEMENTED: getItemFocusTarget does not recognize .dx-item-content as a focus host. + + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', template: () => $('').appendTo(document.body); + + try { + menu.openWithFocus('first'); + this.clock.tick(0); + + $outside.get(0).focus(); + this.clock.tick(0); + assert.strictEqual(document.activeElement, $outside.get(0), 'Focus moved outside popup'); + + menu.option('opened', false); + this.clock.tick(0); + + assert.strictEqual(menu.option('opened'), false, 'Menu is closed'); + assert.notStrictEqual( + document.activeElement, + $overflowBtn.get(0), + 'Focus is NOT moved to overflow button when it was already outside the popup', + ); + } finally { + $outside.remove(); + } + }); + }); QUnit.module('Template items', moduleConfig, function() { @@ -3317,7 +3484,7 @@ QUnit.module('Template items', moduleConfig, function() { 'second inner button has tabindex=-1 before activation'); }); - QUnit.test('template with multiple focusable: ArrowRight/Left inside activated mode do NOT navigate toolbar', function(assert) { + QUnit.skip('template with multiple focusable: ArrowRight/Left inside activated mode do NOT navigate toolbar', function(assert) { // NOT IMPLEMENTED: no inner-focus mode for templates yet. const toolbar = this.$element.dxToolbar({ @@ -3626,36 +3793,6 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(countMinusOne, 1, 'other items have tabindex=-1'); }); - QUnit.test('ARIA attributes set on non-dropdown texteditor wrapper', function(assert) { - this.$element.dxToolbar({ - items: [ - { widget: 'dxTextBox', options: { value: 'hello', inputAttr: { 'aria-label': 'Search' } } }, - ], - }); - - const $textEditor = this.$element.find('.dx-texteditor').first(); - - assert.strictEqual($textEditor.attr('role'), 'textbox', - 'texteditor wrapper has role=textbox'); - assert.strictEqual($textEditor.attr('aria-readonly'), 'true', - 'texteditor wrapper has aria-readonly=true'); - assert.strictEqual($textEditor.attr('aria-label'), 'Search', - 'texteditor wrapper has aria-label from input'); - }); - - QUnit.test('ARIA attributes NOT set on dropdown editor wrapper', function(assert) { - this.$element.dxToolbar({ - items: [ - { widget: 'dxSelectBox', options: { items: ['A', 'B'] } }, - ], - }); - - const $dropdownEditor = this.$element.find('.dx-dropdowneditor').first(); - - assert.strictEqual($dropdownEditor.attr('role'), undefined, - 'dropdown editor wrapper does not get role=textbox'); - }); - QUnit.test('focusOut to overlay content does not reset focus state', function(assert) { const toolbar = this.$element.dxToolbar({ items: [ diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js index 9832a99c23d4..a371f8d0ac8a 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js @@ -577,7 +577,7 @@ QUnit.module('widget sizing render', moduleConfig, () => { beforeEach: function() { this.instance.option({ items: [1, 2, 3], - focusStateEnabled: true, + listFocusStateEnabled: true, opened: true }); @@ -587,10 +587,10 @@ QUnit.module('widget sizing render', moduleConfig, () => { QUnit.test('list focusStateEnabled option', function(assert) { assert.expect(3); - this.instance.option({ focusStateEnabled: false }); + this.instance.option({ listFocusStateEnabled: false }); assert.ok(!this.overflowMenu.list().option('focusStateEnabled')); - this.instance.option('focusStateEnabled', true); + this.instance.option('listFocusStateEnabled', true); assert.ok(this.overflowMenu.list().option('focusStateEnabled')); const $listItemContainer = this.overflowMenu.$list().find(`.${SCROLLVIEW_CONTENT_CLASS}`); @@ -618,15 +618,20 @@ QUnit.module('widget sizing render', moduleConfig, () => { this.keyboard.keyDown('enter'); assert.ok(this.overflowMenu.popup().option('visible')); + const list = this.overflowMenu.list(); const $items = this.overflowMenu.$items(); - assert.ok($items.eq(0).attr('id'), 'first item is active'); + $items.eq(0).get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + const $focused0 = $(list.option('focusedElement')); + assert.ok($focused0.get(0) === $items.eq(0).get(0), 'first item is active'); $items.eq(0).get(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); - assert.ok($items.eq(1).attr('id'), 'second item is active'); + const $focused1 = $(list.option('focusedElement')); + assert.ok($focused1.get(0) === $items.eq(1).get(0), 'second item is active'); $items.eq(1).get(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })); - assert.ok($items.eq(0).attr('id'), 'third item is active'); + const $focused2 = $(list.option('focusedElement')); + assert.ok($focused2.get(0) === $items.eq(0).get(0), 'first item is active again'); }); QUnit.test('hide popup on press tab', function(assert) { @@ -762,9 +767,11 @@ QUnit.module('widget sizing render', moduleConfig, () => { this.keyboard.keyDown('enter'); const $firstItem = this.overflowMenu.$items().eq(0); + $firstItem.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); $firstItem.trigger($.Event('keydown', { key: 'Enter' })); this.keyboard.keyDown('enter'); + this.overflowMenu.$items().eq(0).get(0).dispatchEvent(new Event('focusin', { bubbles: true })); this.overflowMenu.$items().eq(0).trigger($.Event('keydown', { key: ' ' })); assert.equal(itemClicked, 2, 'item was clicked twice'); @@ -773,7 +780,7 @@ QUnit.module('widget sizing render', moduleConfig, () => { QUnit.test('No exceptions on tab key pressing when popup is not opened', function(assert) { assert.expect(0); - this.instance.option({ focusStateEnabled: true }); + this.instance.option({ listFocusStateEnabled: true }); const keyboard = keyboardMock(this.$element); From 3cf14f8046015e711dffd8d48217c793b0e14b26 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 20 May 2026 21:52:26 +0400 Subject: [PATCH 27/47] fix ts --- .../js/__internal/ui/toolbar/internal/toolbar.menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bed7f52b7baf..fb7f7433d572 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -45,7 +45,7 @@ export interface DropDownMenuProperties extends WidgetProperties { onButtonClick?: (e: ClickEvent) => void; useInkRipple?: boolean; closeOnClick?: boolean; - listFocusStateEnabled: boolean; + listFocusStateEnabled?: boolean; } export default class DropDownMenu extends Widget { From be2517e53756df85e9f1d411f421bb21c43142ec Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 20 May 2026 22:33:05 +0400 Subject: [PATCH 28/47] turn on fallback mode in components --- .../grids/grid_core/header_panel/m_header_panel.ts | 1 + .../devextreme/js/__internal/scheduler/header/header.ts | 2 ++ .../js/__internal/ui/chat/message_box/chat_text_area.ts | 1 + .../js/__internal/ui/diagram/ui.diagram.toolbar.ts | 1 + .../ui/file_manager/ui.file_manager.toolbar.ts | 4 ++-- .../js/__internal/ui/html_editor/modules/m_toolbar.ts | 1 + .../devextreme/js/__internal/ui/toolbar/toolbar.utils.ts | 9 +-------- .../tests/DevExpress.ui.widgets/toolbar.menu.tests.js | 6 +++--- 8 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts index a3a06d9bcde8..2ed9e8c493a2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts @@ -133,6 +133,7 @@ export class HeaderPanel extends ColumnsView { const options: { toolbarOptions: ToolbarProperties } = { toolbarOptions: { items: sortedToolbarItems, + focusStateEnabled: false, visible: userToolbarOptions?.visible, disabled: userToolbarOptions?.disabled, onItemRendered(e) { diff --git a/packages/devextreme/js/__internal/scheduler/header/header.ts b/packages/devextreme/js/__internal/scheduler/header/header.ts index 6da327f2d94f..d1a794e5e1a5 100644 --- a/packages/devextreme/js/__internal/scheduler/header/header.ts +++ b/packages/devextreme/js/__internal/scheduler/header/header.ts @@ -171,6 +171,8 @@ export class SchedulerHeader extends Widget { return { ...toolbar, + // @ts-expect-error ts-error + focusStateEnabled: false, items: parsedItems, }; } diff --git a/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts b/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts index 70003779b204..0b208c392f4f 100644 --- a/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts +++ b/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts @@ -278,6 +278,7 @@ class ChatTextArea extends TextArea { const toolbarOptions = { items: toolbarItems, + focusStateEnabled: false, }; this._$toolbar = $('
') diff --git a/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts b/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts index 7a83562515b8..28107e2a6c96 100644 --- a/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts +++ b/packages/devextreme/js/__internal/ui/diagram/ui.diagram.toolbar.ts @@ -326,6 +326,7 @@ class DiagramToolbar extends DiagramPanel { this._prepareToolbarItems(afterCommands, 'after', this._executeCommand), ); this._toolbarInstance = this._createComponent($toolbar, Toolbar, { + focusStateEnabled: false, dataSource, }); } diff --git a/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts b/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts index 757380ae0cf6..0b6171570ab2 100644 --- a/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts +++ b/packages/devextreme/js/__internal/ui/file_manager/ui.file_manager.toolbar.ts @@ -166,7 +166,6 @@ class FileManagerToolbar extends Widget { _isRefreshVisibleInFileToolbar?: boolean; - // eslint-disable-next-line no-restricted-globals _refreshItemTextTimeout?: ReturnType; _init(): void { @@ -224,6 +223,7 @@ class FileManagerToolbar extends Widget { const $toolbar = $('
').appendTo(this.$element()); const toolbar = this._createComponent($toolbar, Toolbar, { items: toolbarItems, + focusStateEnabled: false, visible: !hidden, onItemClick: (args) => this._raiseItemClicked(args), }) as Toolbar & { compactMode?: boolean }; @@ -732,7 +732,7 @@ class FileManagerToolbar extends Widget { isDeferredUpdate, text, showText, - // eslint-disable-next-line no-restricted-globals + ): ReturnType | undefined { const options = { showText, diff --git a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts index 0647c4f58e5c..fa92e74e6a07 100644 --- a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts +++ b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts @@ -209,6 +209,7 @@ if (Quill) { get toolbarConfig() { return { dataSource: this._prepareToolbarItems(), + focusStateEnabled: false, disabled: this.isInteractionDisabled, menuContainer: this._$toolbarContainer, multiline: this.isMultilineMode(), diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index 58d142402956..38c959cd0151 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -108,14 +108,7 @@ export function setItemWidgetFocusState($item: dxElementWrapper, isFocused: bool 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)); - } + itemInstance._toggleFocusClass(isFocused, getItemFocusTarget($item)); } } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js index a371f8d0ac8a..3e1975d16dbf 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js @@ -623,15 +623,15 @@ QUnit.module('widget sizing render', moduleConfig, () => { $items.eq(0).get(0).dispatchEvent(new Event('focusin', { bubbles: true })); const $focused0 = $(list.option('focusedElement')); - assert.ok($focused0.get(0) === $items.eq(0).get(0), 'first item is active'); + assert.strictEqual($focused0.get(0), $items.eq(0).get(0), 'first item is active'); $items.eq(0).get(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); const $focused1 = $(list.option('focusedElement')); - assert.ok($focused1.get(0) === $items.eq(1).get(0), 'second item is active'); + assert.strictEqual($focused1.get(0), $items.eq(1).get(0), 'second item is active'); $items.eq(1).get(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true })); const $focused2 = $(list.option('focusedElement')); - assert.ok($focused2.get(0) === $items.eq(0).get(0), 'first item is active again'); + assert.strictEqual($focused2.get(0), $items.eq(0).get(0), 'first item is active again'); }); QUnit.test('hide popup on press tab', function(assert) { From 680b105329b82ea5f34ca6623bbf90b91931ff26 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 21 May 2026 10:18:16 +0400 Subject: [PATCH 29/47] updates --- .../{_toolbar.scss => toolbar/_index.scss} | 2 +- .../scss/widgets/base/toolbar/_mixins.scss | 32 +++ .../scss/widgets/fluent/toolbar/_index.scss | 21 +- .../scss/widgets/fluent/toolbar/_mixins.scss | 6 - .../ui/toolbar/internal/toolbar.menu.list.ts | 69 +++-- .../js/__internal/ui/toolbar/toolbar.base.ts | 81 ++++-- .../js/__internal/ui/toolbar/toolbar.utils.ts | 19 +- .../toolbar.kbn.tests.js | 243 ++++++++++++++---- 8 files changed, 354 insertions(+), 119 deletions(-) rename packages/devextreme-scss/scss/widgets/base/{_toolbar.scss => toolbar/_index.scss} (99%) create mode 100644 packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss diff --git a/packages/devextreme-scss/scss/widgets/base/_toolbar.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_index.scss similarity index 99% rename from packages/devextreme-scss/scss/widgets/base/_toolbar.scss rename to packages/devextreme-scss/scss/widgets/base/toolbar/_index.scss index 6ad17a55dcc7..dc986ce20148 100644 --- a/packages/devextreme-scss/scss/widgets/base/_toolbar.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_index.scss @@ -1,4 +1,4 @@ -@use "./mixins" as *; +@use "../mixins" as *; // adduse diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss new file mode 100644 index 000000000000..e34ced7d62a0 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -0,0 +1,32 @@ +@mixin dx-toolbar-focus-outline( + $accent-color, +) { + .dx-toolbar { + .dx-toolbar-item { + [tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: 4px; + } + } + + .dx-toolbar-menu-container { + [tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: 4px; + } + } + } + + .dx-dropdownmenu-list { + .dx-list-item { + [tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: 4px; + } + } + } +} + diff --git a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss index ffb85c623400..c5de2cc26268 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss @@ -12,6 +12,7 @@ @use "../checkBox/sizes" as *; @use "mixins" as *; @use "../../base/toolbar"; +@use "../../base/toolbar/mixins" as *; // adduse @use "../dropDownMenu"; @@ -32,18 +33,6 @@ height: $fluent-toolbar-height; } } - - .dx-toolbar-item { - [tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } - - .dx-toolbar-menu-container { - [tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } } .dx-toolbar-after { @@ -193,10 +182,4 @@ } } -.dx-dropdownmenu-list { - .dx-list-item { - :is(.dx-texteditor, .dx-checkbox, .dx-tabs, .dx-switch)[tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } -} +@include dx-toolbar-focus-outline($base-accent); diff --git a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss index 613c7483e5de..ec869eeff6cd 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_mixins.scss @@ -60,9 +60,3 @@ padding: 0; } } - -@mixin dx-toolbar-focus-outline() { - outline: 2px solid $base-accent; - outline-offset: 1px; - border-radius: 4px; -} 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 8806703df898..ea1baa79bdaf 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 @@ -12,7 +12,7 @@ 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, + closeItemWidget, getItemFocusTarget, isItemWidgetOpened, } from '@ts/ui/toolbar/toolbar.utils'; export const TOOLBAR_MENU_ACTION_CLASS = 'dx-toolbar-menu-action'; @@ -33,13 +33,15 @@ export default class ToolbarMenuList extends ListBase { _keyboardListenerId?: string; + _menuActivating = false; + 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, + // Intentionally empty: visual focus is managed by the inner widgets themselves // not by dx-state-focused on the list item container. } @@ -182,11 +184,7 @@ export default class ToolbarMenuList extends ListBase { 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(); - } + this._activateMenu($menu); return; } } @@ -312,7 +310,18 @@ export default class ToolbarMenuList extends ListBase { } _isMenuTarget(target: HTMLElement): boolean { - return $(target).closest('.dx-menu').length > 0; + if ($(target).closest('.dx-menu-item').length > 0) { + return true; + } + + const $menu = $(target).closest('.dx-menu'); + if (!$menu.length) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $menu.data('dxMenu') as any; + return !!menuInstance?.option?.('focusedElement'); } _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper { @@ -402,7 +411,6 @@ export default class ToolbarMenuList extends ListBase { const $menu = $item.find('.dx-menu'); if ($menu.length) { - $menu.attr('tabIndex', -1); $menu.find('[tabindex]').attr('tabIndex', -1); } @@ -432,6 +440,15 @@ export default class ToolbarMenuList extends ListBase { if ($item.length && getItemFocusTarget($item)?.length) { this.option('focusedElement', getPublicElement($item)); + + if ($target.hasClass('dx-menu') && !this._menuActivating) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $target.data('dxMenu') as any; + menuInstance?._detachFocusEvents?.(); + menuInstance?._detachKeyboardEvents?.(); + menuInstance?.option?.('focusedElement', null); + $target.removeClass('dx-state-focused'); + } } } @@ -441,8 +458,33 @@ export default class ToolbarMenuList extends ListBase { return; } + if ($focusTarget.hasClass('dx-menu')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $focusTarget.data('dxMenu') as any; + menuInstance?._detachFocusEvents?.(); + menuInstance?._detachKeyboardEvents?.(); + menuInstance?.option?.('focusedElement', null); + $focusTarget.removeClass('dx-state-focused'); + } + ($focusTarget.get(0) as HTMLElement).focus(); - setItemWidgetFocusState($item, true); + } + + _activateMenu($menu: dxElementWrapper): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $menu.data('dxMenu') as any; + if (!menuInstance) { + return; + } + + this._menuActivating = true; + try { + menuInstance._attachFocusEvents(); + menuInstance._attachKeyboardEvents(); + menuInstance.focus(); + } finally { + this._menuActivating = false; + } } _focusOutHandler(e: DxEvent): void { @@ -461,12 +503,6 @@ export default class ToolbarMenuList extends ListBase { return; } - const { focusedElement } = this.option(); - const $focused = $(focusedElement); - if ($focused.length) { - setItemWidgetFocusState($focused, false); - } - super._focusOutHandler(e); } @@ -476,7 +512,6 @@ export default class ToolbarMenuList extends ListBase { const $prev = $(prevFocusedElement); if ($prev.length) { closeItemWidget($prev); - setItemWidgetFocusState($prev, false); } const result = super._moveFocus(location); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 73f333cd4ef4..dad2e3c8e308 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -25,7 +25,7 @@ import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/c import { TOOLBAR_CLASS } from './constants'; import { - closeItemWidget, getItemFocusTarget, isItemWidgetOpened, setItemWidgetFocusState, + closeItemWidget, getItemFocusTarget, isItemWidgetOpened, } from './toolbar.utils'; export const TOOLBAR_BEFORE_CLASS = 'dx-toolbar-before'; @@ -80,6 +80,8 @@ class ToolbarBase< _captureKeydownHandler?: EventListener; + _menuActivating = false; + _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); } @@ -205,12 +207,7 @@ class ToolbarBase< 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(); - } + this._activateMenu($menu); return; } } @@ -354,8 +351,21 @@ class ToolbarBase< } _isMenuTarget(target: HTMLElement): boolean { - return $(target).closest('.dx-menu').length > 0 - && !$(target).hasClass('dx-toolbar-item'); + if ($(target).closest('.dx-menu-item').length > 0) { + return true; + } + + // After Enter, DOM focus is on the menu's internal container (not on a + // .dx-menu-item itself). Detect "menu is in active mode" via its + // focusedElement option set by CollectionWidget activation. + const $menu = $(target).closest('.dx-menu'); + if (!$menu.length) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $menu.data('dxMenu') as any; + return !!menuInstance?.option?.('focusedElement'); } _isOverflowItem($item: dxElementWrapper): boolean { @@ -455,7 +465,6 @@ class ToolbarBase< const $menu = $item.find('.dx-menu'); if ($menu.length) { - $menu.attr('tabIndex', -1); $menu.find('[tabindex]').attr('tabIndex', -1); } @@ -505,6 +514,20 @@ class ToolbarBase< if ($item.length && getItemFocusTarget($item)?.length) { this.option('focusedElement', getPublicElement($item)); + + // If focus landed on .dx-menu root externally (Tab from outside), the + // menu's own _focusInHandler already auto-activated. Detach to bring + // it back to silent nav level — symmetric with texteditor. Skip when + // we are intentionally activating (Enter) — focusin from _activateMenu + // bubbles here and must not undo activation. + if ($target.hasClass('dx-menu') && !this._menuActivating) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $target.data('dxMenu') as any; + menuInstance?._detachFocusEvents?.(); + menuInstance?._detachKeyboardEvents?.(); + menuInstance?.option?.('focusedElement', null); + $target.removeClass('dx-state-focused'); + } } } } @@ -515,8 +538,37 @@ class ToolbarBase< return; } + if ($focusTarget.hasClass('dx-menu')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $focusTarget.data('dxMenu') as any; + // Detach menu's focus + keyboard handlers so focus on .dx-menu root is + // "silent" — symmetric with texteditor whose handlers are on inner input. + // Direct detach avoids the option-change side effect of stripping tabIndex. + menuInstance?._detachFocusEvents?.(); + menuInstance?._detachKeyboardEvents?.(); + menuInstance?.option?.('focusedElement', null); + $focusTarget.removeClass('dx-state-focused'); + } + ($focusTarget.get(0) as HTMLElement).focus(); - setItemWidgetFocusState($item, true); + } + + _activateMenu($menu: dxElementWrapper): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $menu.data('dxMenu') as any; + if (!menuInstance) { + return; + } + + this._menuActivating = true; + try { + // Re-attach handlers detached at nav level, then focus to activate. + menuInstance._attachFocusEvents(); + menuInstance._attachKeyboardEvents(); + menuInstance.focus(); + } finally { + this._menuActivating = false; + } } _focusOutHandler(e: DxEvent): void { @@ -535,12 +587,6 @@ class ToolbarBase< return; } - const { focusedElement } = this.option(); - const $focused = $(focusedElement); - if ($focused.length) { - setItemWidgetFocusState($focused, false); - } - super._focusOutHandler(e); } @@ -550,7 +596,6 @@ class ToolbarBase< const $prev = $(prevFocusedElement); if ($prev.length) { closeItemWidget($prev); - setItemWidgetFocusState($prev, false); } const result = super._moveFocus(location, e); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index 38c959cd0151..801cb4542bf4 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -21,6 +21,14 @@ const getItemInstance = ($element: dxElementWrapper): Widget => { return (widgetName && itemData[widgetName]) as Widget; }; +const getWidgetName = ($element: dxElementWrapper): string => { + // @ts-expect-error ts-error + const itemData = $element?.data(); + // @ts-expect-error ts-error + const dxComponents = itemData?.dxComponents; + return (dxComponents?.[0] ?? '') as string; +}; + export function closeItemWidget($item: dxElementWrapper): boolean { const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); @@ -80,16 +88,11 @@ export function getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | 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; + const widgetName = getWidgetName($widget); if (widgetName.toLowerCase().includes('dropdownbutton')) { $focusTarget = $focusTarget?.find(`.${BUTTON_GROUP_CLASS}`); - } else if ($widget.hasClass('dx-texteditor')) { + } else if ($widget.hasClass('dx-texteditor') || $widget.hasClass('dx-menu')) { $focusTarget = $(itemInstance.element()); - } else if ($widget.hasClass('dx-menu')) { - $focusTarget = $item; } else { $focusTarget = $focusTarget ?? $(itemInstance.element()); } @@ -141,8 +144,6 @@ 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/toolbar.kbn.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js index fca8d5de5c00..397f15f2bc4f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -235,6 +235,45 @@ QUnit.module('Enter/Exit: text input editors', { assert.strictEqual($tabZero.length, 1, `Exactly one non-input tabindex=0 after enter/exit/navigate cycle with ${widget}`); }); + + QUnit.test(`${widget}: editor does not get dx-state-focused on toolbar navigation (before Enter)`, 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)); + toolbar._focusItemWidget($items.eq(1)); + this.clock.tick(0); + + const $editor = $items.eq(1).find('.dx-texteditor').first(); + assert.strictEqual($editor.hasClass('dx-state-focused'), false, + `${widget} root element does not have dx-state-focused during toolbar navigation`); + }); + + QUnit.test(`${widget}: editor gets dx-state-focused after Enter`, 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 $editor = $items.eq(1).find('.dx-texteditor').first(); + assert.strictEqual($editor.hasClass('dx-state-focused'), true, + `${widget} root element has dx-state-focused after Enter`); + }); }); }); @@ -661,8 +700,8 @@ QUnit.module('Enter/Exit: collection widgets', { 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}`); + assert.ok($items.eq(1).get(0).contains(document.activeElement), + `Esc returns focus inside the ${widget} toolbar item (on widget root, not on inner element)`); }); QUnit.test(`${widget}: arrows navigate toolbar after Esc`, function(assert) { @@ -837,7 +876,8 @@ function getItemFocusTarget($item) { const $buttonGroup = $item.find('.dx-buttongroup').first(); if($buttonGroup.length) return $buttonGroup; - if($item.find('.dx-menu').length) return $item; + const $menu = $item.find('.dx-menu').first(); + if($menu.length) return $menu; const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href]').first(); if($native.length) return $native; @@ -2925,6 +2965,55 @@ QUnit.module('Overflow menu', moduleConfig, function() { } }); + QUnit.test('ArrowDown on dxMenu inside overflow list navigates list, does not activate menu', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { + locateInMenu: 'always', + widget: 'dxMenu', + options: { + items: [ + { text: 'File', items: [{ text: 'New' }, { text: 'Open' }] }, + ], + }, + }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'After Menu' } }, + ], + }).dxToolbar('instance'); + const $overflowBtn = this.$element.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + const menu = toolbar._layoutStrategy._menu; + + $overflowBtn.trigger('dxclick'); + this.clock.tick(0); + assert.strictEqual(menu.option('opened'), true, 'overflow popup opened'); + + const $listItems = menu._list._getAvailableItems(); + // Navigate to the list item that contains dxMenu (assume it's at index 0 after Visible button is excluded from menu) + const $menuListItem = $listItems.toArray().map((el) => $(el)).find(($i) => $i.find('.dx-menu').length > 0); + assert.ok($menuListItem, 'found a list item containing dxMenu'); + + menu._list.option('focusedElement', $menuListItem.get(0)); + menu._list._focusItemWidget($menuListItem); + this.clock.tick(0); + + const $menuRoot = $menuListItem.find('.dx-menu').first(); + const menuInstance = $menuRoot.dxMenu('instance'); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu is at list nav level — internal focusedElement is null'); + + dispatchKeydown($menuRoot.get(0), 'ArrowDown'); + this.clock.tick(0); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu did NOT activate on ArrowDown — its keyboard handler did not process the key'); + + const newFocused = $(menu._list.option('focusedElement')).get(0); + assert.notStrictEqual(newFocused, $menuListItem.get(0), + 'list moved to the next item on ArrowDown (instead of menu reacting)'); + }); + }); QUnit.module('Template items', moduleConfig, function() { @@ -4020,7 +4109,7 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { 'second ArrowRight skips past dxMenu to Next button'); }); - QUnit.test('dxMenu item gets dx-state-focused when toolbar focuses it', function(assert) { + QUnit.test('dxMenu root does NOT get dx-state-focused on toolbar navigation', function(assert) { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4028,8 +4117,8 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { toolbar._focusItemWidget($items.eq(1)); this.clock.tick(0); - assert.ok($items.eq(1).hasClass('dx-state-focused'), - 'toolbar item wrapper has dx-state-focused'); + assert.strictEqual($items.eq(1).find('.dx-menu').first().hasClass('dx-state-focused'), false, + '.dx-menu root does NOT have dx-state-focused during toolbar navigation (before Enter)'); }); QUnit.test('Enter activates menu — focus moves inside .dx-menu', function(assert) { @@ -4046,24 +4135,6 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { ); }); - QUnit.test('Enter removes dx-state-focused from toolbar item wrapper', function(assert) { - const toolbar = createMenuToolbar(this.$element); - const $items = toolbar._getAvailableItems(); - - toolbar.option('focusedElement', $items.eq(1).get(0)); - toolbar._focusItemWidget($items.eq(1)); - this.clock.tick(0); - - assert.ok($items.eq(1).hasClass('dx-state-focused'), - 'item has dx-state-focused before Enter'); - - dispatchKeydown(this.$element.get(0), 'Enter'); - this.clock.tick(50); - - assert.notOk($items.eq(1).hasClass('dx-state-focused'), - 'item lost dx-state-focused after Enter'); - }); - QUnit.test('first menu-item gets dx-state-focused after Enter', function(assert) { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4121,7 +4192,7 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { 'menu focusedElement moved back to first root item'); }); - QUnit.test('Escape exits menu — focus returns to toolbar item wrapper', function(assert) { + QUnit.test('Escape exits menu — focus returns to .dx-menu root', function(assert) { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4132,29 +4203,11 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); - assert.strictEqual(document.activeElement, $items.eq(1).get(0), - 'focus returned to toolbar item wrapper after Escape'); - }); - - QUnit.test('Escape restores dx-state-focused on toolbar item wrapper', function(assert) { - const toolbar = createMenuToolbar(this.$element); - const $items = toolbar._getAvailableItems(); - - toolbar.option('focusedElement', $items.eq(1).get(0)); - toolbar._focusItemWidget($items.eq(1)); - this.clock.tick(0); - - dispatchKeydown(this.$element.get(0), 'Enter'); - this.clock.tick(50); - - assert.notOk($items.eq(1).hasClass('dx-state-focused'), - 'item lost dx-state-focused after Enter'); - - dispatchKeydown(document.activeElement, 'Escape'); - this.clock.tick(50); - - assert.ok($items.eq(1).hasClass('dx-state-focused'), - 'item regained dx-state-focused after Escape'); + const $menuRoot = $items.eq(1).find('.dx-menu').first(); + assert.strictEqual(document.activeElement, $menuRoot.get(0), + 'focus returned to .dx-menu root after Escape'); + assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, + '.dx-menu root does NOT have dx-state-focused after Escape (back to toolbar nav level)'); }); QUnit.test('menu-item dx-state-focused removed after Escape', function(assert) { @@ -4227,8 +4280,100 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { assert.strictEqual($tabZero.length, 1, 'exactly one tabindex=0 after enter/exit/navigate cycle'); }); -}); + QUnit.test('tabindex=0 is on .dx-menu root, not on .dx-toolbar-item wrapper', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + toolbar._updateRovingTabIndex($items.eq(1)); + this.clock.tick(0); + + assert.strictEqual($items.eq(1).find('.dx-menu').first().attr('tabindex'), '0', + '.dx-menu root is the tab stop (tabindex=0)'); + assert.notStrictEqual($items.eq(1).attr('tabindex'), '0', + '.dx-toolbar-item wrapper is NOT the tab stop'); + }); + + QUnit.test('dxMenu does not get dx-state-focused on toolbar navigation (before Enter)', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + toolbar._focusItemWidget($items.eq(1)); + this.clock.tick(0); + + const $menuRoot = $items.eq(1).find('.dx-menu').first(); + assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, + '.dx-menu root does not have dx-state-focused during toolbar navigation'); + + const $menuItems = $menuRoot.find('.dx-menu-item'); + const anyMenuItemFocused = $menuItems.toArray().some( + (el) => $(el).hasClass('dx-state-focused') + ); + assert.strictEqual(anyMenuItemFocused, false, + 'no .dx-menu-item is activated/highlighted during toolbar navigation (silent like texteditor)'); + + const menuInstance = $menuRoot.dxMenu('instance'); + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'menu internal focusedElement is null (not auto-activated)'); + }); + + QUnit.test('dxMenu\'s own keyboard handler does not process keys at toolbar nav level (symmetric with texteditor)', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + toolbar._focusItemWidget($items.eq(1)); + this.clock.tick(0); + + const $menuRoot = $items.eq(1).find('.dx-menu').first(); + const menuInstance = $menuRoot.dxMenu('instance'); + + let menuHandlerCalled = false; + const originalHandler = menuInstance._keyboardHandler.bind(menuInstance); + menuInstance._keyboardHandler = function(opts) { + menuHandlerCalled = true; + return originalHandler(opts); + }; + + try { + ['ArrowDown', 'ArrowUp', 'Enter', ' ', 'a', 'F1', 'PageDown'].forEach(function(key) { + // Ensure menu is at toolbar nav level (inactive) before each key: + // _activateMenu from a previous iteration may have set focusedElement. + menuInstance.option('focusedElement', null); + + menuHandlerCalled = false; + dispatchKeydown($menuRoot.get(0), key); + this.clock.tick(0); + + assert.strictEqual(menuHandlerCalled, false, + `menu's keyboard handler not invoked for "${key}" at toolbar nav level`); + }, this); + } finally { + menuInstance._keyboardHandler = originalHandler; + } + }); + + QUnit.test('Tab landing directly on .dx-menu root does not auto-activate menu (toolbar resets to nav level)', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + this.clock.tick(0); + + const $menuRoot = $items.eq(1).find('.dx-menu').first(); + const menuInstance = $menuRoot.dxMenu('instance'); + + $menuRoot.get(0).focus(); + this.clock.tick(0); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'menu is reset to nav level — focusedElement cleared by toolbar _focusInHandler'); + assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, + '.dx-menu root does not have dx-state-focused after Tab in'); + }); +}); QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { function makeOverflowToolbar($el) { return $el.dxToolbar({ From 0f2455715be4507d621b40e19f36305613a6299a Mon Sep 17 00:00:00 2001 From: pharret31 Date: Thu, 21 May 2026 14:18:37 +0200 Subject: [PATCH 30/47] use focus outline only in new kbn mode --- .../scss/widgets/base/toolbar/_mixins.scss | 16 ++- .../js/__internal/ui/toolbar/constants.ts | 1 + .../ui/toolbar/internal/toolbar.menu.ts | 5 +- .../js/__internal/ui/toolbar/toolbar.base.ts | 12 +- .../toolbar.kbn.tests.js | 128 ++++++++++++++++++ 5 files changed, 151 insertions(+), 11 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index e34ced7d62a0..9596fcdc5dd0 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -1,7 +1,7 @@ @mixin dx-toolbar-focus-outline( $accent-color, ) { - .dx-toolbar { + .dx-toolbar.dx-toolbar-focus-state-enabled { .dx-toolbar-item { [tabindex="0"]:focus-visible { outline: 2px solid $accent-color; @@ -19,12 +19,14 @@ } } - .dx-dropdownmenu-list { - .dx-list-item { - [tabindex="0"]:focus-visible { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: 4px; + .dx-dropdownmenu-popup-wrapper.dx-toolbar-focus-state-enabled { + .dx-dropdownmenu-list { + .dx-list-item { + [tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: 4px; + } } } } diff --git a/packages/devextreme/js/__internal/ui/toolbar/constants.ts b/packages/devextreme/js/__internal/ui/toolbar/constants.ts index 78195b8a9cd1..8dd1e0d1746c 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/constants.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/constants.ts @@ -1 +1,2 @@ export const TOOLBAR_CLASS = 'dx-toolbar'; +export const TOOLBAR_FOCUS_STATE_ENABLED_CLASS = 'dx-toolbar-focus-state-enabled'; 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 fb7f7433d572..79c5474370e3 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -20,6 +20,7 @@ import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; import Button from '@ts/ui/button/wrapper'; import Popup from '@ts/ui/popup/m_popup'; +import { TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from '@ts/ui/toolbar/constants'; import ToolbarMenuList, { TOOLBAR_MENU_ACTION_CLASS } from '@ts/ui/toolbar/internal/toolbar.menu.list'; import { toggleItemFocusableElementTabIndex } from '@ts/ui/toolbar/toolbar.utils'; @@ -254,7 +255,8 @@ export default class DropDownMenu extends Widget { // @ts-expect-error component.$wrapper() .addClass(DROP_DOWN_MENU_POPUP_WRAPPER_CLASS) - .addClass(DROP_DOWN_MENU_POPUP_CLASS); + .addClass(DROP_DOWN_MENU_POPUP_CLASS) + .toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!listFocusStateEnabled); }, deferRendering: false, preventScrollEvents: false, @@ -481,6 +483,7 @@ export default class DropDownMenu extends Widget { case 'listFocusStateEnabled': this._list?.option('focusStateEnabled', value); this._popup?.option('focusStateEnabled', !value); + this._popup?.$wrapper()?.toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!value); break; case 'onItemRendered': this._list?.option(name, value); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index dad2e3c8e308..3efc6c9a7021 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -23,7 +23,7 @@ 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 { TOOLBAR_CLASS, TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from './constants'; import { closeItemWidget, getItemFocusTarget, isItemWidgetOpened, } from './toolbar.utils'; @@ -664,8 +664,10 @@ class ToolbarBase< } _renderToolbar(): void { + const { focusStateEnabled } = this.option(); this.$element() - .addClass(TOOLBAR_CLASS); + .addClass(TOOLBAR_CLASS) + .toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!focusStateEnabled); this._$toolbarItemsContainer = $('
') .addClass(TOOLBAR_ITEMS_CONTAINER_CLASS) @@ -956,7 +958,7 @@ class ToolbarBase< } _optionChanged(args: OptionChanged): void { - const { name } = args; + const { name, value } = args; switch (name) { case 'width': @@ -971,6 +973,10 @@ class ToolbarBase< case 'compactMode': this._applyCompactMode(); break; + case 'focusStateEnabled': + this.$element().toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!value); + super._optionChanged(args); + break; case 'grouped': break; default: 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 index 397f15f2bc4f..93bd5c64c5f2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -5,6 +5,7 @@ import { DROP_DOWN_MENU_BUTTON_CLASS, DROP_DOWN_MENU_POPUP_WRAPPER_CLASS, } from '__internal/ui/toolbar/internal/toolbar.menu'; +import { TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from '__internal/ui/toolbar/constants'; import { BUTTON_CLASS } from '__internal/ui/button/button'; import { LIST_ITEM_CLASS } from '__internal/ui/list/list.base'; import { @@ -3925,6 +3926,133 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(event.defaultPrevented, false, 'Tab keydown is not prevented by toolbar'); }); + + QUnit.test('focusStateEnabled:true (default) — toolbar element has dx-toolbar-focus-state-enabled class', function(assert) { + this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + ], + }); + + assert.ok( + this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'toolbar has dx-toolbar-focus-state-enabled class when focusStateEnabled:true' + ); + }); + + QUnit.test('focusStateEnabled:false — toolbar element does NOT have dx-toolbar-focus-state-enabled class', function(assert) { + this.$element.dxToolbar({ + focusStateEnabled: false, + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + ], + }); + + assert.notOk( + this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'toolbar does not have dx-toolbar-focus-state-enabled class when focusStateEnabled:false' + ); + }); + + QUnit.test('changing focusStateEnabled at runtime toggles dx-toolbar-focus-state-enabled class', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: true, + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + ], + }).dxToolbar('instance'); + + assert.ok( + this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'class is present when focusStateEnabled:true' + ); + + toolbar.option('focusStateEnabled', false); + + assert.notOk( + this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'class is removed after setting focusStateEnabled:false' + ); + + toolbar.option('focusStateEnabled', true); + + assert.ok( + this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'class is re-added after setting focusStateEnabled:true' + ); + }); + + QUnit.test('focusStateEnabled:true — overflow popup wrapper has dx-toolbar-focus-state-enabled class', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: true, + items: [ + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.option('opened', true); + this.clock.tick(0); + + const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); + assert.ok( + $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'popup wrapper has dx-toolbar-focus-state-enabled class when focusStateEnabled:true' + ); + }); + + QUnit.test('focusStateEnabled:false — overflow popup wrapper does NOT have dx-toolbar-focus-state-enabled class', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + items: [ + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.option('opened', true); + this.clock.tick(0); + + const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); + assert.notOk( + $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'popup wrapper does not have dx-toolbar-focus-state-enabled class when focusStateEnabled:false' + ); + }); + + QUnit.test('changing focusStateEnabled at runtime toggles dx-toolbar-focus-state-enabled on popup wrapper', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: true, + items: [ + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.option('opened', true); + this.clock.tick(0); + + const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); + + assert.ok( + $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'popup wrapper has class when focusStateEnabled:true' + ); + + toolbar.option('focusStateEnabled', false); + + assert.notOk( + $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'popup wrapper loses class after setting focusStateEnabled:false' + ); + + toolbar.option('focusStateEnabled', true); + + assert.ok( + $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'popup wrapper regains class after setting focusStateEnabled:true' + ); + }); }); QUnit.module('Non-focusable service items', moduleConfig, function() { From df9d87f6582372703798853ed7961542fc1db3db Mon Sep 17 00:00:00 2001 From: pharret31 Date: Thu, 21 May 2026 14:43:27 +0200 Subject: [PATCH 31/47] update focusStateEnabled at runtime --- .../js/__internal/ui/toolbar/toolbar.ts | 1 + .../toolbar.kbn.tests.js | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index d7e3be5c055f..c72637774314 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -215,6 +215,7 @@ class Toolbar extends ToolbarBase { case 'multiline': this._invalidate(); break; + case 'focusStateEnabled': case 'disabled': super._optionChanged(args); 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 index 93bd5c64c5f2..7a45d2642c7e 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -3816,6 +3816,57 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { 'ArrowRight does not move focus after focusStateEnabled changed to false'); }); + QUnit.test('focusStateEnabled:true→false — items reset to natural tabindex (all 0)', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: true, + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + const $firstFocusTarget = getItemFocusTarget($items.first()); + this.$element.trigger($.Event('focusin', { target: $firstFocusTarget.get(0) })); + this.clock.tick(0); + + const tabIndicesBefore = $items.toArray().map(item => getItemFocusTarget($(item)).attr('tabindex')); + assert.strictEqual(tabIndicesBefore[0], '0', 'First item has tabindex=0 (roving)'); + assert.strictEqual(tabIndicesBefore[1], '-1', 'Second item has tabindex=-1 (roving)'); + + toolbar.option('focusStateEnabled', false); + + const tabIndicesAfter = $items.toArray().map(item => getItemFocusTarget($(item)).attr('tabindex')); + assert.strictEqual(tabIndicesAfter[0], '0', 'First item has natural tabindex=0 after focusStateEnabled:false'); + assert.strictEqual(tabIndicesAfter[1], '0', 'Second item has natural tabindex=0 after focusStateEnabled:false'); + assert.strictEqual(tabIndicesAfter[2], '0', 'Third item has natural tabindex=0 after focusStateEnabled:false'); + }); + + QUnit.test('focusStateEnabled:false→true — roving tabindex is applied (only first item at 0)', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + + const tabIndicesBefore = $items.toArray().map(item => getItemFocusTarget($(item)).attr('tabindex')); + assert.strictEqual(tabIndicesBefore[0], '0', 'All items start at natural tabindex=0'); + assert.strictEqual(tabIndicesBefore[1], '0', 'All items start at natural tabindex=0'); + + toolbar.option('focusStateEnabled', true); + + const tabIndicesAfter = $items.toArray().map(item => getItemFocusTarget($(item)).attr('tabindex')); + assert.strictEqual(tabIndicesAfter[0], '0', 'First item gets tabindex=0 from roving tabindex'); + assert.strictEqual(tabIndicesAfter[1], '-1', 'Second item gets tabindex=-1 from roving tabindex'); + assert.strictEqual(tabIndicesAfter[2], '-1', 'Third item gets tabindex=-1 from roving tabindex'); + }); + QUnit.test('focusStateEnabled:false — overflow menu items use toggleItemFocusableElementTabIndex (not roving)', function(assert) { const toolbar = this.$element.dxToolbar({ focusStateEnabled: false, From 59abdac38d77067f7243279b7ae04c77e9be9ab4 Mon Sep 17 00:00:00 2001 From: pharret31 Date: Thu, 21 May 2026 17:51:07 +0200 Subject: [PATCH 32/47] fix scss --- .../scss/widgets/base/toolbar/_mixins.scss | 7 +++--- .../scss/widgets/fluent/toolbar/_index.scss | 2 +- .../scss/widgets/generic/toolbar/_index.scss | 21 ++--------------- .../scss/widgets/generic/toolbar/_mixins.scss | 6 ----- .../scss/widgets/material/toolbar/_index.scss | 23 ++----------------- .../widgets/material/toolbar/_mixins.scss | 6 ----- 6 files changed, 9 insertions(+), 56 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index 9596fcdc5dd0..7a4b7080bd9e 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -1,12 +1,13 @@ @mixin dx-toolbar-focus-outline( $accent-color, + $border-radius, ) { .dx-toolbar.dx-toolbar-focus-state-enabled { .dx-toolbar-item { [tabindex="0"]:focus-visible { outline: 2px solid $accent-color; outline-offset: 1px; - border-radius: 4px; + border-radius: $border-radius; } } @@ -14,7 +15,7 @@ [tabindex="0"]:focus-visible { outline: 2px solid $accent-color; outline-offset: 1px; - border-radius: 4px; + border-radius: $border-radius; } } } @@ -25,7 +26,7 @@ [tabindex="0"]:focus-visible { outline: 2px solid $accent-color; outline-offset: 1px; - border-radius: 4px; + border-radius: $border-radius; } } } diff --git a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss index c5de2cc26268..4c44cb2fce84 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/toolbar/_index.scss @@ -182,4 +182,4 @@ } } -@include dx-toolbar-focus-outline($base-accent); +@include dx-toolbar-focus-outline($base-accent, $fluent-base-border-radius); diff --git a/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss index 127ad22bd109..0946be17dcce 100644 --- a/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/toolbar/_index.scss @@ -10,6 +10,7 @@ @use "../button/sizes" as *; @use "mixins" as *; @use "../../base/toolbar"; +@use "../../base/toolbar/mixins" as *; // adduse @use "../dropDownMenu"; @@ -30,18 +31,6 @@ min-width: auto; } } - - .dx-toolbar-item { - [tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } - - .dx-toolbar-menu-container { - [tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } } .dx-toolbar-after { @@ -112,10 +101,4 @@ } } -.dx-dropdownmenu-list { - .dx-list-item { - :is(.dx-texteditor, .dx-checkbox, .dx-tabs, .dx-switch)[tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } -} +@include dx-toolbar-focus-outline($base-accent, $base-border-radius); diff --git a/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss index 193d5303cff7..0f0443dbe2ad 100644 --- a/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/generic/toolbar/_mixins.scss @@ -58,9 +58,3 @@ padding: 0; } } - -@mixin dx-toolbar-focus-outline() { - outline: 2px solid $base-accent; - outline-offset: 1px; - border-radius: 4px; -} diff --git a/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss b/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss index a527c3873bcf..7358c45b77f9 100644 --- a/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/toolbar/_index.scss @@ -12,6 +12,7 @@ @use "../checkBox/sizes" as *; @use "mixins" as *; @use "../../base/toolbar"; +@use "../../base/toolbar/mixins" as *; // adduse @use "../dropDownMenu"; @@ -32,18 +33,6 @@ height: $material-toolbar-height; } } - - .dx-toolbar-item { - [tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } - - .dx-toolbar-menu-container { - [tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } } .dx-toolbar-after { @@ -219,12 +208,4 @@ padding: 4px; } -.dx-dropdownmenu-list { - .dx-list-item { - :is(.dx-texteditor, .dx-checkbox, .dx-tabs, .dx-switch)[tabindex="0"]:focus-visible { - @include dx-toolbar-focus-outline(); - } - } -} - - +@include dx-toolbar-focus-outline($base-accent, $material-base-border-radius); diff --git a/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss index 92c156ec651e..7e2faf16ac9a 100644 --- a/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/material/toolbar/_mixins.scss @@ -60,9 +60,3 @@ padding: 0; } } - -@mixin dx-toolbar-focus-outline() { - outline: 2px solid $base-accent; - outline-offset: 1px; - border-radius: 4px; -} From c374f7af6413890cb1ccfe624083a3000ec79a4f Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 22 May 2026 10:16:00 +0400 Subject: [PATCH 33/47] refactor menu implementation --- .../scss/widgets/base/toolbar/_mixins.scss | 40 +- .../ui/toolbar/internal/toolbar.menu.list.ts | 92 ++-- .../js/__internal/ui/toolbar/toolbar.base.ts | 162 +++---- .../js/__internal/ui/toolbar/toolbar.utils.ts | 4 +- .../toolbar.kbn.tests.js | 452 ++++++++++++++---- .../toolbar.menu.tests.js | 1 + 6 files changed, 496 insertions(+), 255 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index 7a4b7080bd9e..f4d80cda26f9 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -2,21 +2,27 @@ $accent-color, $border-radius, ) { - .dx-toolbar.dx-toolbar-focus-state-enabled { - .dx-toolbar-item { - [tabindex="0"]:focus-visible { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } + .dx-toolbar-item.dx-state-focused { + [tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; } + } - .dx-toolbar-menu-container { - [tabindex="0"]:focus-visible { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } + .dx-toolbar-menu-container { + [tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; + } + } + + .dx-toolbar-item[tabindex="0"].dx-state-focused:has(.dx-menu:not(.dx-state-focused)) { + .dx-menu { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; } } @@ -29,6 +35,14 @@ border-radius: $border-radius; } } + + .dx-list-item[tabindex="0"]:has(.dx-menu:not(.dx-state-focused)) { + .dx-menu { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; + } + } } } } 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 ea1baa79bdaf..8e2f0a1cda7f 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 @@ -33,8 +33,6 @@ export default class ToolbarMenuList extends ListBase { _keyboardListenerId?: string; - _menuActivating = false; - protected _activeStateUnit(): string { return `.${TOOLBAR_MENU_ACTION_CLASS}:not(.${TOOLBAR_HIDDEN_BUTTON_GROUP_CLASS})`; } @@ -172,7 +170,6 @@ export default class ToolbarMenuList extends ListBase { const { focusedElement } = this.option(); const $item = $(focusedElement); - if ($item.length) { const $textEditor = $item.find('.dx-texteditor-input').first(); if ($textEditor.length) { @@ -180,13 +177,6 @@ export default class ToolbarMenuList extends ListBase { ($textEditor.get(0) as HTMLElement).focus(); return; } - - const $menu = $item.find('.dx-menu').first(); - if ($menu.length) { - e.preventDefault(); - this._activateMenu($menu); - return; - } } originalEnter?.call(this, e); @@ -237,6 +227,10 @@ export default class ToolbarMenuList extends ListBase { } if (e.key === 'Escape' && (isTextInput || isMenu)) { + if (isMenu && this._closeOpenSubmenu(target, e)) { + return; + } + e.preventDefault(); e.stopPropagation(); @@ -266,6 +260,11 @@ export default class ToolbarMenuList extends ListBase { return; } + if (e.key === 'Enter' || e.key === ' ') { + this._handleActivationAtNavLevel(e); + return; + } + const keyToLocation: Record = { ArrowDown: 'down', ArrowUp: 'up', @@ -310,18 +309,7 @@ export default class ToolbarMenuList extends ListBase { } _isMenuTarget(target: HTMLElement): boolean { - if ($(target).closest('.dx-menu-item').length > 0) { - return true; - } - - const $menu = $(target).closest('.dx-menu'); - if (!$menu.length) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $menu.data('dxMenu') as any; - return !!menuInstance?.option?.('focusedElement'); + return $(target).closest('.dx-menu, .dx-menu-item').length > 0; } _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper { @@ -411,6 +399,7 @@ export default class ToolbarMenuList extends ListBase { const $menu = $item.find('.dx-menu'); if ($menu.length) { + $menu.attr('tabIndex', -1); $menu.find('[tabindex]').attr('tabIndex', -1); } @@ -440,15 +429,6 @@ export default class ToolbarMenuList extends ListBase { if ($item.length && getItemFocusTarget($item)?.length) { this.option('focusedElement', getPublicElement($item)); - - if ($target.hasClass('dx-menu') && !this._menuActivating) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $target.data('dxMenu') as any; - menuInstance?._detachFocusEvents?.(); - menuInstance?._detachKeyboardEvents?.(); - menuInstance?.option?.('focusedElement', null); - $target.removeClass('dx-state-focused'); - } } } @@ -457,34 +437,50 @@ export default class ToolbarMenuList extends ListBase { if (!$focusTarget?.length) { return; } + ($focusTarget.get(0) as HTMLElement).focus(); + } - if ($focusTarget.hasClass('dx-menu')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $focusTarget.data('dxMenu') as any; - menuInstance?._detachFocusEvents?.(); - menuInstance?._detachKeyboardEvents?.(); - menuInstance?.option?.('focusedElement', null); - $focusTarget.removeClass('dx-state-focused'); + _handleActivationAtNavLevel(e: KeyboardEvent): void { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if (!$focused.length || isItemWidgetOpened($focused)) { + return; } - ($focusTarget.get(0) as HTMLElement).focus(); + const $menu = $focused.find('.dx-menu').first(); + if ($menu.length) { + e.preventDefault(); + e.stopPropagation(); + this._activateMenu($menu); + } } _activateMenu($menu: dxElementWrapper): void { + ($menu.get(0) as HTMLElement).focus(); + } + + _closeOpenSubmenu(target: HTMLElement, e: Event): boolean { + const $menu = $(target).closest('.dx-menu'); + if (!$menu.length) { + return false; + } // eslint-disable-next-line @typescript-eslint/no-explicit-any const menuInstance = $menu.data('dxMenu') as any; - if (!menuInstance) { - return; + if (!menuInstance?._visibleSubmenu) { + return false; } - this._menuActivating = true; - try { - menuInstance._attachFocusEvents(); - menuInstance._attachKeyboardEvents(); - menuInstance.focus(); - } finally { - this._menuActivating = false; + e.preventDefault(); + e.stopPropagation(); + + const $anchor = $menu.find('.dx-menu-item-expanded').first(); + menuInstance._hideSubmenu(menuInstance._visibleSubmenu); + + if ($anchor.length) { + menuInstance.option('focusedElement', getPublicElement($anchor)); } + return true; } _focusOutHandler(e: DxEvent): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 3efc6c9a7021..f487e07b2f18 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -80,8 +80,6 @@ class ToolbarBase< _captureKeydownHandler?: EventListener; - _menuActivating = false; - _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); } @@ -189,27 +187,13 @@ class ToolbarBase< 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(); - this._activateMenu($menu); - return; - } } originalEnter?.call(this, e); @@ -251,7 +235,6 @@ class ToolbarBase< this._detachCaptureArrowHandler(); const element = this.$element().get(0) as HTMLElement; - const { rtlEnabled } = this.option(); this._captureKeydownHandler = (evt: Event): void => { const e = evt as KeyboardEvent; @@ -265,10 +248,14 @@ class ToolbarBase< } if (e.key === 'Escape' && (isTextInput || isMenu)) { + if (isMenu && this._closeOpenSubmenu(target, e)) { + return; + } + + const $item = $(target).closest(`${this._itemSelector()}, .dx-dropdownmenu-button`); e.preventDefault(); e.stopPropagation(); - const $item = $(target).closest(`${this._itemSelector()}, .dx-dropdownmenu-button`); if ($item.length && closeItemWidget($item)) { return; } @@ -281,8 +268,8 @@ class ToolbarBase< } const keyToLocation: Record = { - ArrowRight: rtlEnabled ? 'left' : 'right', - ArrowLeft: rtlEnabled ? 'right' : 'left', + ArrowRight: 'right', + ArrowLeft: 'left', Home: 'first', End: 'last', }; @@ -291,32 +278,10 @@ class ToolbarBase< if (!location) { if (e.key === 'Enter' || e.key === ' ') { - const { focusedElement } = this.option(); - const $focused = $(focusedElement); - - if ($focused.length && this._isOverflowItem($focused)) { - e.preventDefault(); - e.stopPropagation(); - this._openOverflowMenu('first'); - } - return; + this._handleActivationAtNavLevel(e); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + this._handleOverflowOpenAtNavLevel(e); } - - 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; } @@ -351,21 +316,7 @@ class ToolbarBase< } _isMenuTarget(target: HTMLElement): boolean { - if ($(target).closest('.dx-menu-item').length > 0) { - return true; - } - - // After Enter, DOM focus is on the menu's internal container (not on a - // .dx-menu-item itself). Detect "menu is in active mode" via its - // focusedElement option set by CollectionWidget activation. - const $menu = $(target).closest('.dx-menu'); - if (!$menu.length) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $menu.data('dxMenu') as any; - return !!menuInstance?.option?.('focusedElement'); + return $(target).closest('.dx-menu, .dx-menu-item').length > 0; } _isOverflowItem($item: dxElementWrapper): boolean { @@ -465,6 +416,7 @@ class ToolbarBase< const $menu = $item.find('.dx-menu'); if ($menu.length) { + $menu.attr('tabIndex', -1); $menu.find('[tabindex]').attr('tabIndex', -1); } @@ -514,20 +466,6 @@ class ToolbarBase< if ($item.length && getItemFocusTarget($item)?.length) { this.option('focusedElement', getPublicElement($item)); - - // If focus landed on .dx-menu root externally (Tab from outside), the - // menu's own _focusInHandler already auto-activated. Detach to bring - // it back to silent nav level — symmetric with texteditor. Skip when - // we are intentionally activating (Enter) — focusin from _activateMenu - // bubbles here and must not undo activation. - if ($target.hasClass('dx-menu') && !this._menuActivating) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $target.data('dxMenu') as any; - menuInstance?._detachFocusEvents?.(); - menuInstance?._detachKeyboardEvents?.(); - menuInstance?.option?.('focusedElement', null); - $target.removeClass('dx-state-focused'); - } } } } @@ -537,38 +475,70 @@ class ToolbarBase< if (!$focusTarget?.length) { return; } + ($focusTarget.get(0) as HTMLElement).focus(); + } - if ($focusTarget.hasClass('dx-menu')) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $focusTarget.data('dxMenu') as any; - // Detach menu's focus + keyboard handlers so focus on .dx-menu root is - // "silent" — symmetric with texteditor whose handlers are on inner input. - // Direct detach avoids the option-change side effect of stripping tabIndex. - menuInstance?._detachFocusEvents?.(); - menuInstance?._detachKeyboardEvents?.(); - menuInstance?.option?.('focusedElement', null); - $focusTarget.removeClass('dx-state-focused'); + _handleActivationAtNavLevel(e: KeyboardEvent): void { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if (!$focused.length || isItemWidgetOpened($focused)) { + return; } - ($focusTarget.get(0) as HTMLElement).focus(); + if (this._isOverflowItem($focused)) { + e.preventDefault(); + e.stopPropagation(); + this._openOverflowMenu('first'); + return; + } + + const $menu = $focused.find('.dx-menu').first(); + if ($menu.length) { + e.preventDefault(); + e.stopPropagation(); + this._activateMenu($menu); + } } - _activateMenu($menu: dxElementWrapper): void { + _handleOverflowOpenAtNavLevel(e: KeyboardEvent): void { + const { focusedElement } = this.option(); + const $focused = $(focusedElement); + + if (!$focused.length || !this._isOverflowItem($focused)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this._openOverflowMenu(e.key === 'ArrowUp' ? 'last' : 'first'); + } + + _closeOpenSubmenu(target: HTMLElement, e: Event): boolean { + const $menu = $(target).closest('.dx-menu'); + if (!$menu.length) { + return false; + } // eslint-disable-next-line @typescript-eslint/no-explicit-any const menuInstance = $menu.data('dxMenu') as any; - if (!menuInstance) { - return; + if (!menuInstance?._visibleSubmenu) { + return false; } - this._menuActivating = true; - try { - // Re-attach handlers detached at nav level, then focus to activate. - menuInstance._attachFocusEvents(); - menuInstance._attachKeyboardEvents(); - menuInstance.focus(); - } finally { - this._menuActivating = false; + e.preventDefault(); + e.stopPropagation(); + + const $anchor = $menu.find('.dx-menu-item-expanded').first(); + menuInstance._hideSubmenu(menuInstance._visibleSubmenu); + + if ($anchor.length) { + menuInstance.option('focusedElement', getPublicElement($anchor)); } + return true; + } + + _activateMenu($menu: dxElementWrapper): void { + ($menu.get(0) as HTMLElement).focus(); } _focusOutHandler(e: DxEvent): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index 801cb4542bf4..0d5a545c2f3d 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -91,7 +91,9 @@ export function getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | const widgetName = getWidgetName($widget); if (widgetName.toLowerCase().includes('dropdownbutton')) { $focusTarget = $focusTarget?.find(`.${BUTTON_GROUP_CLASS}`); - } else if ($widget.hasClass('dx-texteditor') || $widget.hasClass('dx-menu')) { + } else if ($widget.hasClass('dx-menu')) { + $focusTarget = $item; + } else if ($widget.hasClass('dx-texteditor')) { $focusTarget = $(itemInstance.element()); } else { $focusTarget = $focusTarget ?? $(itemInstance.element()); 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 index 7a45d2642c7e..39a21937e730 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -3654,68 +3654,6 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(focusBefore, focusAfter, 'focusedElement unchanged when focusStateEnabled:false'); }); - QUnit.test('RTL — ArrowRight navigates to next item in DOM order', function(assert) { - const toolbar = this.$element.dxToolbar({ - rtlEnabled: true, - items: [ - { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, - { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, - { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, - ], - }).dxToolbar('instance'); - - const $items = toolbar._getAvailableItems(); - const $itemB = $items.eq(1); - const $itemBFocusTarget = getItemFocusTarget($itemB); - this.$element.trigger($.Event('focusin', { target: $itemBFocusTarget.get(0) })); - this.clock.tick(0); - - const indexBefore = toolbar._getAvailableItems().toArray().indexOf( - $(toolbar.option('focusedElement')).get(0), - ); - assert.strictEqual(indexBefore, 1, 'Starting at item B (index 1)'); - - dispatchKeydown($itemBFocusTarget.get(0), 'ArrowRight'); - this.clock.tick(0); - - const indexAfter = toolbar._getAvailableItems().toArray().indexOf( - $(toolbar.option('focusedElement')).get(0), - ); - - assert.strictEqual(indexAfter > indexBefore, true, 'RTL: ArrowRight moved to item with higher DOM index (toward C)'); - }); - - QUnit.test('RTL — ArrowLeft navigates to previous item in DOM order', function(assert) { - const toolbar = this.$element.dxToolbar({ - rtlEnabled: true, - items: [ - { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, - { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, - { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, - ], - }).dxToolbar('instance'); - - const $items = toolbar._getAvailableItems(); - const $itemB = $items.eq(1); - const $itemBFocusTarget = getItemFocusTarget($itemB); - this.$element.trigger($.Event('focusin', { target: $itemBFocusTarget.get(0) })); - this.clock.tick(0); - - const indexBefore = toolbar._getAvailableItems().toArray().indexOf( - $(toolbar.option('focusedElement')).get(0), - ); - assert.strictEqual(indexBefore, 1, 'Starting at item B (index 1)'); - - dispatchKeydown($itemBFocusTarget.get(0), 'ArrowLeft'); - this.clock.tick(0); - - const indexAfter = toolbar._getAvailableItems().toArray().indexOf( - $(toolbar.option('focusedElement')).get(0), - ); - - assert.strictEqual(indexAfter < indexBefore, true, 'RTL: ArrowLeft moved to item with lower DOM index (toward A)'); - }); - QUnit.test('focusStateEnabled:false — roving tabindex is not applied', function(assert) { this.$element.dxToolbar({ focusStateEnabled: false, @@ -4371,7 +4309,7 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { 'menu focusedElement moved back to first root item'); }); - QUnit.test('Escape exits menu — focus returns to .dx-menu root', function(assert) { + QUnit.test('Escape exits menu — focus returns to .dx-toolbar-item (nav level)', function(assert) { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4382,9 +4320,9 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); + assert.strictEqual(document.activeElement, $items.eq(1).get(0), + 'focus returned to .dx-toolbar-item after Escape (nav-level focus target)'); const $menuRoot = $items.eq(1).find('.dx-menu').first(); - assert.strictEqual(document.activeElement, $menuRoot.get(0), - 'focus returned to .dx-menu root after Escape'); assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, '.dx-menu root does NOT have dx-state-focused after Escape (back to toolbar nav level)'); }); @@ -4460,7 +4398,7 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { 'exactly one tabindex=0 after enter/exit/navigate cycle'); }); - QUnit.test('tabindex=0 is on .dx-menu root, not on .dx-toolbar-item wrapper', function(assert) { + QUnit.test('tabindex=0 is on .dx-toolbar-item wrapper, not on .dx-menu root', function(assert) { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4468,10 +4406,10 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { toolbar._updateRovingTabIndex($items.eq(1)); this.clock.tick(0); - assert.strictEqual($items.eq(1).find('.dx-menu').first().attr('tabindex'), '0', - '.dx-menu root is the tab stop (tabindex=0)'); - assert.notStrictEqual($items.eq(1).attr('tabindex'), '0', - '.dx-toolbar-item wrapper is NOT the tab stop'); + assert.strictEqual($items.eq(1).attr('tabindex'), '0', + '.dx-toolbar-item is the Tab stop (tabindex=0)'); + assert.strictEqual($items.eq(1).find('.dx-menu').first().attr('tabindex'), '-1', + '.dx-menu root is NOT a Tab stop (tabindex=-1, programmatic focus only)'); }); QUnit.test('dxMenu does not get dx-state-focused on toolbar navigation (before Enter)', function(assert) { @@ -4498,7 +4436,11 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { 'menu internal focusedElement is null (not auto-activated)'); }); - QUnit.test('dxMenu\'s own keyboard handler does not process keys at toolbar nav level (symmetric with texteditor)', function(assert) { + QUnit.test('Non-activation keys at toolbar nav level do not activate dxMenu', function(assert) { + // dxMenu's keyboard handler is always attached; the toolbar prevents + // observable effects at nav level by intercepting activation keys + // (Arrow/Enter/Space) in its capture phase. Other keys reach dxMenu + // but it has no behavior for them (they are not in _supportedKeys). const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4509,50 +4451,366 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { const $menuRoot = $items.eq(1).find('.dx-menu').first(); const menuInstance = $menuRoot.dxMenu('instance'); - let menuHandlerCalled = false; - const originalHandler = menuInstance._keyboardHandler.bind(menuInstance); - menuInstance._keyboardHandler = function(opts) { - menuHandlerCalled = true; - return originalHandler(opts); - }; + ['a', 'F1', 'PageDown', 'PageUp', 'Tab'].forEach(function(key) { + dispatchKeydown($menuRoot.get(0), key); + this.clock.tick(0); - try { - ['ArrowDown', 'ArrowUp', 'Enter', ' ', 'a', 'F1', 'PageDown'].forEach(function(key) { - // Ensure menu is at toolbar nav level (inactive) before each key: - // _activateMenu from a previous iteration may have set focusedElement. - menuInstance.option('focusedElement', null); + assert.strictEqual(menuInstance.option('focusedElement'), null, + `dxMenu focusedElement is still null after "${key}" at nav level`); + assert.strictEqual($menuRoot.find('.dx-state-focused').length, 0, + `no .dx-menu-item is highlighted after "${key}" at nav level`); + }, this); + }); - menuHandlerCalled = false; - dispatchKeydown($menuRoot.get(0), key); - this.clock.tick(0); + QUnit.test('Tab landing on .dx-toolbar-item does not auto-activate dxMenu', function(assert) { + // The nav-level Tab stop is the .dx-toolbar-item wrapper (tabindex=0), + // not the .dx-menu root (tabindex=-1). So Tab lands on the wrapper and + // dxMenu's CollectionWidget-inherited auto-activation never fires. + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); - assert.strictEqual(menuHandlerCalled, false, - `menu's keyboard handler not invoked for "${key}" at toolbar nav level`); - }, this); - } finally { - menuInstance._keyboardHandler = originalHandler; - } + toolbar.option('focusedElement', $items.eq(1).get(0)); + toolbar._updateRovingTabIndex($items.eq(1)); + this.clock.tick(0); + + $items.eq(1).get(0).focus(); + this.clock.tick(0); + + const $menuRoot = $items.eq(1).find('.dx-menu').first(); + const menuInstance = $menuRoot.dxMenu('instance'); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'menu does not auto-activate when Tab lands on .dx-toolbar-item'); + assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, + '.dx-menu root does not have dx-state-focused'); }); - QUnit.test('Tab landing directly on .dx-menu root does not auto-activate menu (toolbar resets to nav level)', function(assert) { + QUnit.test('Escape with open submenu closes submenu first; second Escape exits to toolbar nav', function(assert) { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); toolbar.option('focusedElement', $items.eq(1).get(0)); - this.clock.tick(0); + dispatchKeydown(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + // Open submenu via ArrowDown + dispatchKeydown(document.activeElement, 'ArrowDown'); + this.clock.tick(300); + + const $expanded = $items.eq(1).find('.dx-menu-item-expanded'); + assert.ok($expanded.length > 0, 'submenu is open after ArrowDown'); + + // First Escape — must close submenu, NOT exit to nav level + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); const $menuRoot = $items.eq(1).find('.dx-menu').first(); const menuInstance = $menuRoot.dxMenu('instance'); - $menuRoot.get(0).focus(); + assert.ok($items.eq(1).get(0).contains(document.activeElement), + 'focus is still inside dxMenu toolbar item after first Escape'); + assert.strictEqual($items.eq(1).find('.dx-menu-item-expanded').length, 0, + 'submenu is closed after first Escape'); + assert.notStrictEqual(menuInstance.option('focusedElement'), null, + 'menu is still active (focusedElement set) after first Escape'); + + // Second Escape — exits to toolbar nav level + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); + + assert.strictEqual(document.activeElement, $items.eq(1).get(0), + 'focus returned to .dx-toolbar-item after second Escape (nav-level focus target)'); + assert.strictEqual($menuRoot.find('.dx-state-focused').length, 0, + 'no menu-item is visually focused after exiting to nav level'); + assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, + '.dx-menu root does not have dx-state-focused at nav level'); + }); + + QUnit.test('ArrowDown at nav level does NOT activate dxMenu (menu is already visible)', function(assert) { + // dxMenu is a visible horizontal menubar inside the toolbar item, not + // a popup menu button. ArrowDown does not "open" anything, so it is + // not an activation key here — activation requires Enter/Space. + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + dispatchKeydown(this.$element.get(0), 'ArrowDown'); + this.clock.tick(50); + + const $menu = $items.eq(1).find('.dx-menu').first(); + const menuInstance = $menu.dxMenu('instance'); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu is NOT activated by ArrowDown — focusedElement stays null'); + assert.strictEqual($menu.find('.dx-state-focused').length, 0, + 'no .dx-menu-item is highlighted after ArrowDown at nav level'); + }); + + QUnit.test('ArrowUp at nav level does NOT activate dxMenu', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + toolbar.option('focusedElement', $items.eq(1).get(0)); + dispatchKeydown(this.$element.get(0), 'ArrowUp'); + this.clock.tick(50); + + const $menu = $items.eq(1).find('.dx-menu').first(); + const menuInstance = $menu.dxMenu('instance'); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu is NOT activated by ArrowUp — focusedElement stays null'); + assert.strictEqual($menu.find('.dx-state-focused').length, 0, + 'no .dx-menu-item is highlighted after ArrowUp at nav level'); + }); + + QUnit.test('Re-activating dxMenu restores previously focused item (menu remembers position)', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + // First activation — focuses first item (default). + toolbar.option('focusedElement', $items.eq(1).get(0)); + dispatchKeydown(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + // Navigate to second root item. + dispatchKeydown(document.activeElement, 'ArrowRight'); + this.clock.tick(50); + + const $menu = $items.eq(1).find('.dx-menu').first(); + const $menuItems = $menu.find('.dx-menu-item'); + + // Exit to nav level. + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); + + // Re-activate. dxMenu should restore the second item, not jump to first. + dispatchKeydown(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + assert.ok($menuItems.eq(1).hasClass('dx-state-focused'), + 'second menu-item (the last focused one) is restored on re-activation'); + assert.notOk($menuItems.eq(0).hasClass('dx-state-focused'), + 'first menu-item is NOT focused (would be a regression to old behavior)'); + }); + + QUnit.test('ArrowDown after opening submenu navigates within submenu (does not re-activate root)', function(assert) { + const toolbar = createMenuToolbar(this.$element); + const $items = toolbar._getAvailableItems(); + + // Activate dxMenu — focusedElement = first root item ("File"). + toolbar.option('focusedElement', $items.eq(1).get(0)); + dispatchKeydown(this.$element.get(0), 'Enter'); + this.clock.tick(50); + + const $menu = $items.eq(1).find('.dx-menu').first(); + + // Move right to second root item ("Edit"). + dispatchKeydown(document.activeElement, 'ArrowRight'); + this.clock.tick(50); + + // ArrowDown on "Edit" opens its submenu. dxMenu resets focusedElement + // to null internally at this point (it tracks state via _visibleSubmenu). + dispatchKeydown(document.activeElement, 'ArrowDown'); + this.clock.tick(300); + + const $expandedBefore = $menu.find('.dx-menu-item-expanded'); + assert.strictEqual($expandedBefore.length, 1, 'submenu open on the second root item'); + const expandedElement = $expandedBefore.get(0); + + // Next ArrowDown — must navigate within the submenu via dxMenu's own + // handler. Regression guard: previously the toolbar saw focusedElement=null + // and treated this as nav-level activation, jumping focus to the first + // root item ("File") and closing the open submenu. + dispatchKeydown(document.activeElement, 'ArrowDown'); + this.clock.tick(50); + + const $expandedAfter = $menu.find('.dx-menu-item-expanded'); + assert.strictEqual($expandedAfter.length, 1, 'submenu still open after second ArrowDown'); + assert.strictEqual($expandedAfter.get(0), expandedElement, + 'submenu is still on the second root item (not jumped to the first)'); + }); +}); + +// Same set of Enter/Exit scenarios as for dxMenu inside a toolbar item, +// applied to dxMenu nested in an overflow popup list item. +QUnit.module('Enter/Exit: dxMenu inside overflow list', moduleConfig, function() { + const menuItems = [ + { text: 'File', items: [{ text: 'New' }, { text: 'Open' }] }, + { text: 'Edit', items: [{ text: 'Cut' }, { text: 'Copy' }] }, + ]; + + // Returns { list, $menuListItem, $menuRoot, menuInstance } with the + // overflow popup opened and the list item containing dxMenu pre-focused + // at list nav level. + function setupOverflowWithMenu($el, clock) { + const toolbar = $el.dxToolbar({ + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { locateInMenu: 'always', widget: 'dxMenu', options: { items: menuItems } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'After' } }, + ], + }).dxToolbar('instance'); + + const $overflowBtn = $el.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); + $overflowBtn.trigger('dxclick'); + clock.tick(0); + + const menu = toolbar._layoutStrategy._menu; + const list = menu._list; + const $menuListItem = list._getAvailableItems() + .toArray() + .map((el) => $(el)) + .find(($i) => $i.find('.dx-menu').length > 0); + + list.option('focusedElement', $menuListItem.get(0)); + list._focusItemWidget($menuListItem); + clock.tick(0); + + const $menuRoot = $menuListItem.find('.dx-menu').first(); + const menuInstance = $menuRoot.dxMenu('instance'); + + return { list, $menuListItem, $menuRoot, menuInstance }; + } + + QUnit.test('Enter activates dxMenu — focus moves into .dx-menu, first item highlighted', function(assert) { + const { $menuListItem, $menuRoot, menuInstance } = setupOverflowWithMenu(this.$element, this.clock); + + dispatchKeydown(document.activeElement, 'Enter'); + this.clock.tick(50); + + assert.ok($menuListItem.get(0).contains(document.activeElement), + 'focus is inside the list item that hosts dxMenu'); + const $firstMenuItem = $menuRoot.find('.dx-menu-item').first(); + assert.ok($firstMenuItem.hasClass('dx-state-focused'), + 'first .dx-menu-item has dx-state-focused after Enter'); + assert.strictEqual($(menuInstance.option('focusedElement')).get(0), $firstMenuItem.get(0), + 'dxMenu focusedElement is on the first item'); + }); + + QUnit.test('ArrowDown at list nav level navigates list — does NOT activate dxMenu', function(assert) { + const { list, $menuListItem, $menuRoot, menuInstance } = setupOverflowWithMenu(this.$element, this.clock); + + dispatchKeydown($menuRoot.get(0), 'ArrowDown'); this.clock.tick(0); assert.strictEqual(menuInstance.option('focusedElement'), null, - 'menu is reset to nav level — focusedElement cleared by toolbar _focusInHandler'); - assert.strictEqual($menuRoot.hasClass('dx-state-focused'), false, - '.dx-menu root does not have dx-state-focused after Tab in'); + 'dxMenu is NOT activated by ArrowDown — focusedElement stays null'); + assert.notStrictEqual($(list.option('focusedElement')).get(0), $menuListItem.get(0), + 'list moved focus to the next list item'); + }); + + QUnit.test('ArrowUp at list nav level navigates list — does NOT activate dxMenu', function(assert) { + const { list, $menuListItem, $menuRoot, menuInstance } = setupOverflowWithMenu(this.$element, this.clock); + + dispatchKeydown($menuRoot.get(0), 'ArrowUp'); + this.clock.tick(0); + + assert.strictEqual(menuInstance.option('focusedElement'), null, + 'dxMenu is NOT activated by ArrowUp — focusedElement stays null'); + assert.notStrictEqual($(list.option('focusedElement')).get(0), $menuListItem.get(0), + 'list moved focus on ArrowUp'); + }); + + QUnit.test('ArrowRight inside menu navigates between root items (not list)', function(assert) { + const { list, $menuListItem, $menuRoot, menuInstance } = setupOverflowWithMenu(this.$element, this.clock); + + dispatchKeydown(document.activeElement, 'Enter'); + this.clock.tick(50); + + dispatchKeydown(document.activeElement, 'ArrowRight'); + this.clock.tick(0); + + const $menuItems = $menuRoot.find('.dx-menu-item'); + assert.strictEqual($(menuInstance.option('focusedElement')).get(0), $menuItems.eq(1).get(0), + 'menu focusedElement moved to second root item'); + assert.strictEqual($(list.option('focusedElement')).get(0), $menuListItem.get(0), + 'list focus stays on the dxMenu list item'); + }); + + QUnit.test('Escape exits dxMenu — focus returns to list-item wrapper (nav level)', function(assert) { + const { $menuListItem, $menuRoot } = setupOverflowWithMenu(this.$element, this.clock); + + dispatchKeydown(document.activeElement, 'Enter'); + this.clock.tick(50); + + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); + + assert.strictEqual(document.activeElement, $menuListItem.get(0), + 'focus returned to the list-item wrapper after Escape'); + assert.strictEqual($menuRoot.find('.dx-state-focused').length, 0, + 'no menu-item is visually focused after exiting to list nav level'); + }); + + QUnit.test('Escape with open submenu closes submenu first; second Escape exits to list nav', function(assert) { + const { $menuListItem, $menuRoot } = setupOverflowWithMenu(this.$element, this.clock); + + dispatchKeydown(document.activeElement, 'Enter'); + this.clock.tick(50); + + // Open submenu via ArrowDown (now at active level — dxMenu owns this key) + dispatchKeydown(document.activeElement, 'ArrowDown'); + this.clock.tick(300); + + assert.ok($menuRoot.find('.dx-menu-item-expanded').length > 0, + 'submenu is open after ArrowDown'); + + // First Escape — closes submenu, stays at active level + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); + + assert.strictEqual($menuRoot.find('.dx-menu-item-expanded').length, 0, + 'submenu is closed after first Escape'); + assert.ok($menuListItem.get(0).contains(document.activeElement), + 'focus is still inside dxMenu list item after first Escape'); + + // Second Escape — exits to list nav + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); + + assert.strictEqual(document.activeElement, $menuListItem.get(0), + 'focus returned to list-item wrapper after second Escape'); + assert.strictEqual($menuRoot.find('.dx-state-focused').length, 0, + 'no menu-item is visually focused after exit'); + }); + + QUnit.test('Re-activating dxMenu restores previously focused item', function(assert) { + const { $menuRoot } = setupOverflowWithMenu(this.$element, this.clock); + + // First activation + dispatchKeydown(document.activeElement, 'Enter'); + this.clock.tick(50); + + // Navigate to second root item + dispatchKeydown(document.activeElement, 'ArrowRight'); + this.clock.tick(50); + + const $menuItems = $menuRoot.find('.dx-menu-item'); + + // Exit to list nav + dispatchKeydown(document.activeElement, 'Escape'); + this.clock.tick(50); + + // Re-activate + dispatchKeydown(document.activeElement, 'Enter'); + this.clock.tick(50); + + assert.ok($menuItems.eq(1).hasClass('dx-state-focused'), + 'second menu-item (the last focused one) is restored on re-activation'); + assert.notOk($menuItems.eq(0).hasClass('dx-state-focused'), + 'first menu-item is NOT focused (would be a regression)'); + }); + + QUnit.test('tabindex=0 is on the list-item wrapper, not on .dx-menu root', function(assert) { + const { $menuListItem, $menuRoot } = setupOverflowWithMenu(this.$element, this.clock); + + assert.strictEqual($menuListItem.attr('tabindex'), '0', + 'list-item is the Tab stop (tabindex=0)'); + assert.strictEqual($menuRoot.attr('tabindex'), '-1', + '.dx-menu root is NOT a Tab stop (tabindex=-1)'); }); }); + QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { function makeOverflowToolbar($el) { return $el.dxToolbar({ diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js index 3e1975d16dbf..5cdc6fc8308b 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js @@ -652,6 +652,7 @@ QUnit.module('widget sizing render', moduleConfig, () => { beforeEach: function() { this.instance.option({ opened: false, + focusStateEnabled: false, items: [{ template: () => $('
').dxCheckBox({ value: false }) }], }); }, From 2a6e6c37ff699151bd0886b4fdb8ccbb734b6331 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Sun, 24 May 2026 23:22:15 +0400 Subject: [PATCH 34/47] refactor --- .../scss/widgets/base/toolbar/_mixins.scss | 41 +- .../internal/roving.tabindex.navigator.ts | 249 ++ .../ui/toolbar/internal/toolbar.menu.list.ts | 331 +-- .../ui/toolbar/internal/toolbar.menu.ts | 3 +- .../js/__internal/ui/toolbar/toolbar.base.ts | 320 +-- .../js/__internal/ui/toolbar/toolbar.ts | 7 +- .../js/__internal/ui/toolbar/toolbar.utils.ts | 83 +- .../toolbar.kbn.tests.js | 2444 ++++++++--------- 8 files changed, 1609 insertions(+), 1869 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index f4d80cda26f9..0b8c8b8ab04d 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -2,24 +2,14 @@ $accent-color, $border-radius, ) { - .dx-toolbar-item.dx-state-focused { - [tabindex="0"]:focus-visible { + .dx-toolbar.dx-toolbar-focus-state-enabled { + [tabindex="0"]:focus-visible:not(.dx-toolbar-item) { outline: 2px solid $accent-color; outline-offset: 1px; border-radius: $border-radius; } - } - - .dx-toolbar-menu-container { - [tabindex="0"]:focus-visible { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } - } - .dx-toolbar-item[tabindex="0"].dx-state-focused:has(.dx-menu:not(.dx-state-focused)) { - .dx-menu { + .dx-toolbar-item[tabindex="0"]:focus-visible .dx-menu { outline: 2px solid $accent-color; outline-offset: 1px; border-radius: $border-radius; @@ -28,22 +18,21 @@ .dx-dropdownmenu-popup-wrapper.dx-toolbar-focus-state-enabled { .dx-dropdownmenu-list { - .dx-list-item { - [tabindex="0"]:focus-visible { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } + .dx-list-item:has([tabindex="0"]:focus-visible) { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; } - .dx-list-item[tabindex="0"]:has(.dx-menu:not(.dx-state-focused)) { - .dx-menu { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } + .dx-list-item[tabindex="0"]:focus-visible { + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; } } } -} + .dx-dropdownmenu-popup-wrapper .dx-toolbar-menu-section { + margin-inline: 4px; + } +} diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts new file mode 100644 index 000000000000..15aa9ae813ef --- /dev/null +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts @@ -0,0 +1,249 @@ +import { keyboard } from '@js/common/core/events/short'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { DxEvent } from '@js/events'; +import type { Item } from '@js/ui/toolbar'; + +import { + applyItemTabIndex, + closeItemWidget, + closeOpenSubmenu, + getItemFocusTarget as defaultGetItemFocusTarget, + isItemWidgetOpened, + isMenuTarget, + isTextInputTarget, +} from '../toolbar.utils'; + +type Direction = 'horizontal' | 'vertical'; + +const HORIZONTAL_KEY_LOCATION: Record = { + ArrowRight: 'right', + ArrowLeft: 'left', + Home: 'first', + End: 'last', +}; + +const VERTICAL_KEY_LOCATION: Record = { + ArrowDown: 'down', + ArrowUp: 'up', + Home: 'first', + End: 'last', +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type HostWidget = any; + +export interface RovingTabIndexNavigatorConfig { + widget: HostWidget; + itemsSelector: string; + direction: Direction; + getItemFocusTarget?: ($item: dxElementWrapper) => dxElementWrapper | undefined; + onTabKey?: () => void; + onEscapeKey?: () => void; +} + +export class RovingTabIndexNavigator { + private readonly config: RovingTabIndexNavigatorConfig; + + private keyboardListenerId?: string; + + private captureHandler?: EventListener; + + private $prevActiveItem?: dxElementWrapper; + + constructor(config: RovingTabIndexNavigatorConfig) { + this.config = config; + } + + attach(): void { + this.detach(); + + const { widget } = this.config; + + this.keyboardListenerId = keyboard.on( + widget._keyboardEventBindingTarget(), + null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (opts: any) => widget._keyboardHandler(opts), + ); + + this.attachCaptureHandler(); + } + + detach(): void { + if (this.keyboardListenerId) { + keyboard.off(this.keyboardListenerId); + this.keyboardListenerId = undefined; + } + this.detachCaptureHandler(); + this.$prevActiveItem = undefined; + } + + private getFocusTarget($item: dxElementWrapper): dxElementWrapper | undefined { + return this.config.getItemFocusTarget + ? this.config.getItemFocusTarget($item) + : defaultGetItemFocusTarget($item); + } + + private getKeyToLocation(): Record { + return this.config.direction === 'horizontal' + ? HORIZONTAL_KEY_LOCATION + : VERTICAL_KEY_LOCATION; + } + + private attachCaptureHandler(): void { + const element = this.config.widget.$element().get(0) as HTMLElement; + + this.captureHandler = (evt: Event): void => { + const e = evt as KeyboardEvent; + const target = e.target as HTMLElement; + + const isTextInput = isTextInputTarget(target); + const isMenu = isMenuTarget(target); + + if ((isTextInput || isMenu) && e.key !== 'Escape') { + return; + } + + if (e.key === 'Escape' && (isTextInput || isMenu)) { + this.handleEscapeInsideWidget(target, e, isMenu); + return; + } + + if (e.key === 'Escape') { + if (this.config.onEscapeKey) { + e.preventDefault(); + e.stopPropagation(); + this.config.onEscapeKey(); + } + return; + } + + if (e.key === 'Tab') { + this.config.onTabKey?.(); + return; + } + + const location = this.getKeyToLocation()[e.key]; + + if (!location) { + return; + } + + const { focusedElement } = this.config.widget.option(); + const $focused = $(focusedElement); + if ($focused.length && isItemWidgetOpened($focused)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + this.moveFocus(location); + }; + + element.addEventListener('keydown', this.captureHandler, true); + } + + private detachCaptureHandler(): void { + if (this.captureHandler) { + const element = this.config.widget.$element().get(0) as HTMLElement; + element.removeEventListener('keydown', this.captureHandler, true); + this.captureHandler = undefined; + } + } + + private handleEscapeInsideWidget(target: HTMLElement, e: KeyboardEvent, isMenu: boolean): void { + if (isMenu && closeOpenSubmenu(target, e)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const $item = $(target).closest(this.config.itemsSelector); + if ($item.length && closeItemWidget($item)) { + return; + } + + if ($item.length) { + this.focusItemWidget($item); + } + } + + moveFocus(location: string, e?: DxEvent): void { + this.config.widget._moveFocus(location, e); + } + + focusItemWidget($item: dxElementWrapper): void { + const $focusTarget = this.getFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + ($focusTarget.get(0) as HTMLElement).focus(); + } + + getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.config.widget._getAvailableItems($itemElements); + } + + getItemTabIndex($item: dxElementWrapper): number { + const itemData = this.config.widget._getItemData($item); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (itemData as Item)?.options?.tabIndex ?? 0; + } + + updateRovingTabIndex($activeItem?: dxElementWrapper): void { + if (!this.config.widget.option('focusStateEnabled')) { + return; + } + + const prev = this.$prevActiveItem?.get(0); + const next = $activeItem?.get(0); + + if (prev && prev !== next && prev.isConnected) { + applyItemTabIndex(this.$prevActiveItem as dxElementWrapper, -1); + } + + if ($activeItem?.length) { + applyItemTabIndex($activeItem, this.getItemTabIndex($activeItem)); + this.$prevActiveItem = $activeItem; + return; + } + + const $first = this.getAvailableItems().first(); + if ($first.length) { + applyItemTabIndex($first, this.getItemTabIndex($first)); + this.$prevActiveItem = $first; + } else { + this.$prevActiveItem = undefined; + } + } + + resetRovingTabIndex(itemsContainer: dxElementWrapper): void { + if (!this.config.widget.option('focusStateEnabled')) { + return; + } + + const $allItems = itemsContainer.find(this.config.itemsSelector); + $allItems.each((_index: number, item: Element): boolean => { + applyItemTabIndex($(item), -1); + return true; + }); + + this.$prevActiveItem = undefined; + + const { focusedElement } = this.config.widget.option(); + const $focused = $(focusedElement); + const $available = this.getAvailableItems(); + const focusedEl = $focused.get(0); + const isFocusedAvailable = !!focusedEl && $available.toArray().includes(focusedEl); + const $newActive = isFocusedAvailable ? $focused : $available.first(); + + if ($newActive.length) { + applyItemTabIndex($newActive, this.getItemTabIndex($newActive)); + this.$prevActiveItem = $newActive; + } + } +} 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 8e2f0a1cda7f..5f1f0b8c95c5 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,5 +1,4 @@ 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'; @@ -11,8 +10,15 @@ 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 { RovingTabIndexNavigator } from '@ts/ui/toolbar/internal/roving.tabindex.navigator'; import { - closeItemWidget, getItemFocusTarget, isItemWidgetOpened, + activateMenu, + closeItemWidget, + getItemFocusTarget, + isItemDisabled, + isItemWidgetOpened, + isMenuTarget, + isTextInputTarget, } from '@ts/ui/toolbar/toolbar.utils'; export const TOOLBAR_MENU_ACTION_CLASS = 'dx-toolbar-menu-action'; @@ -25,13 +31,11 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content'; type ActionableComponents = Extract; export default class ToolbarMenuList extends ListBase { - _captureKeydownHandler?: EventListener; - _onEscapePress?: () => void; _onTabPress?: () => void; - _keyboardListenerId?: string; + _navigator?: RovingTabIndexNavigator; protected _activeStateUnit(): string { return `.${TOOLBAR_MENU_ACTION_CLASS}:not(.${TOOLBAR_HIDDEN_BUTTON_GROUP_CLASS})`; @@ -153,6 +157,10 @@ export default class ToolbarMenuList extends ListBase { _supportedKeys(): SupportedKeys { const keys = super._supportedKeys(); + if (!this.option('focusStateEnabled')) { + return keys; + } + delete keys.leftArrow; delete keys.rightArrow; delete keys.upArrow; @@ -160,183 +168,74 @@ export default class ToolbarMenuList extends ListBase { 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; - } - } - - 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(); + _enterKeyHandler(e: DxEvent): void { + if (!this.option('focusStateEnabled')) { + super._enterKeyHandler(e); + return; } - } - _detachKeyboardEvents(): void { - if (this._keyboardListenerId) { - keyboard.off(this._keyboardListenerId); - this._keyboardListenerId = undefined; + const target = e.target as HTMLElement; + if (isTextInputTarget(target) || isMenuTarget(target)) { + return; } - 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)) { - if (isMenu && this._closeOpenSubmenu(target, e)) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - const $item = $(target).closest(this._itemSelector()); - if ($item.length && closeItemWidget($item)) { - return; - } - - if ($item.length) { - this._focusItemWidget($item); - } - - return; - } + this._handleActivationAtNavLevel(e); + if (e.defaultPrevented) { + return; + } - if (e.key === 'Escape') { + const { focusedElement } = this.option(); + const $item = $(focusedElement); + if ($item.length) { + const $textEditor = $item.find('.dx-texteditor-input').first(); + if ($textEditor.length) { e.preventDefault(); - e.stopPropagation(); - this._onEscapePress?.(); - - return; - } - - if (e.key === 'Tab') { - this._onTabPress?.(); - - return; - } - - if (e.key === 'Enter' || e.key === ' ') { - this._handleActivationAtNavLevel(e); + ($textEditor.get(0) as HTMLElement).focus(); 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); + super._enterKeyHandler(e); } - _detachCaptureKeyHandler(): void { - if (this._captureKeydownHandler) { - const element = this.$element().get(0) as HTMLElement; - element.removeEventListener('keydown', this._captureKeydownHandler, true); - this._captureKeydownHandler = undefined; - } - } + _attachKeyboardEvents(): void { + this._detachKeyboardEvents(); - _isTextInputTarget(target: HTMLElement): boolean { - const tagName = target.tagName.toLowerCase(); + if (!this.option('focusStateEnabled')) { + super._attachKeyboardEvents(); + return; + } - return (tagName === 'input' || tagName === 'textarea') - && $(target).closest('.dx-texteditor').length > 0; + this._navigator = new RovingTabIndexNavigator({ + widget: this, + itemsSelector: this._itemSelector(), + direction: 'vertical', + getItemFocusTarget: ($item): dxElementWrapper => this._getItemFocusTarget($item), + onEscapeKey: (): void => this._onEscapePress?.(), + onTabKey: (): void => this._onTabPress?.(), + }); + this._navigator.attach(); } - _isMenuTarget(target: HTMLElement): boolean { - return $(target).closest('.dx-menu, .dx-menu-item').length > 0; + _detachKeyboardEvents(): void { + this._navigator?.detach(); + this._navigator = undefined; + super._detachKeyboardEvents(); } _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 widgetDisabled = !!this.option('disabled'); const elements = Array.from($visible.toArray()).filter( - (item) => !this._isItemDisabled($(item)) && !!this._getItemFocusTarget($(item)).length, + (item) => !isItemDisabled($(item), widgetDisabled) + && !!this._getItemFocusTarget($(item)).length, ); return $(elements) as unknown as dxElementWrapper; @@ -347,80 +246,12 @@ export default class ToolbarMenuList extends ListBase { 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 $menu = $item.find('.dx-menu'); - if ($menu.length) { - $menu.attr('tabIndex', -1); - $menu.find('[tabindex]').attr('tabIndex', -1); - } - - return true; - }); + this._navigator?.updateRovingTabIndex($activeItem); + } - 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); - } - } - } + _resetRovingTabIndex(): void { + this._navigator?.resetRovingTabIndex(this.$element()); } _focusInHandler(e: DxEvent): void { @@ -433,6 +264,10 @@ export default class ToolbarMenuList extends ListBase { } _focusItemWidget($item: dxElementWrapper): void { + if (this._navigator) { + this._navigator.focusItemWidget($item); + return; + } const $focusTarget = this._getItemFocusTarget($item); if (!$focusTarget?.length) { return; @@ -452,37 +287,10 @@ export default class ToolbarMenuList extends ListBase { if ($menu.length) { e.preventDefault(); e.stopPropagation(); - this._activateMenu($menu); + activateMenu($menu); } } - _activateMenu($menu: dxElementWrapper): void { - ($menu.get(0) as HTMLElement).focus(); - } - - _closeOpenSubmenu(target: HTMLElement, e: Event): boolean { - const $menu = $(target).closest('.dx-menu'); - if (!$menu.length) { - return false; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $menu.data('dxMenu') as any; - if (!menuInstance?._visibleSubmenu) { - return false; - } - - e.preventDefault(); - e.stopPropagation(); - - const $anchor = $menu.find('.dx-menu-item-expanded').first(); - menuInstance._hideSubmenu(menuInstance._visibleSubmenu); - - if ($anchor.length) { - menuInstance.option('focusedElement', getPublicElement($anchor)); - } - return true; - } - _focusOutHandler(e: DxEvent): void { const { relatedTarget } = e as DxEvent & { relatedTarget: Element }; const target = e.target as Element; @@ -504,6 +312,10 @@ export default class ToolbarMenuList extends ListBase { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _moveFocus(location: string): boolean | undefined | void { + if (!this.option('focusStateEnabled')) { + return super._moveFocus(location); + } + const { focusedElement: prevFocusedElement } = this.option(); const $prev = $(prevFocusedElement); if ($prev.length) { @@ -539,11 +351,7 @@ export default class ToolbarMenuList extends ListBase { _postProcessRenderItems(): void { super._postProcessRenderItems(); - - if (this.option('focusStateEnabled')) { - const { focusedElement } = this.option(); - this._updateRovingTabIndex($(focusedElement)); - } + this._resetRovingTabIndex(); } _itemClickHandler( @@ -557,7 +365,8 @@ export default class ToolbarMenuList extends ListBase { } _clean(): void { - this._detachCaptureKeyHandler(); + this._navigator?.detach(); + this._navigator = undefined; this._getSections().empty(); super._clean(); } diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts index 79c5474370e3..09f1f3a204f4 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -513,8 +513,7 @@ export default class DropDownMenu extends Widget { _updateFocusableItemsTabIndex(): void { if (this._list) { if (this.option('listFocusStateEnabled')) { - const { focusedElement } = this._list.option(); - this._list._updateRovingTabIndex($(focusedElement)); + this._list._resetRovingTabIndex(); } else { const { items = [] } = this.option(); items.forEach((item) => toggleItemFocusableElementTabIndex(this._list, item)); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index f487e07b2f18..9bd52f1a97af 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -1,6 +1,5 @@ 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'; @@ -24,8 +23,15 @@ import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; import { TOOLBAR_CLASS, TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from './constants'; +import { RovingTabIndexNavigator } from './internal/roving.tabindex.navigator'; import { - closeItemWidget, getItemFocusTarget, isItemWidgetOpened, + activateMenu, + closeItemWidget, + getItemFocusTarget, + isItemDisabled, + isItemWidgetOpened, + isMenuTarget, + isTextInputTarget, } from './toolbar.utils'; export const TOOLBAR_BEFORE_CLASS = 'dx-toolbar-before'; @@ -76,9 +82,7 @@ class ToolbarBase< _waitParentAnimationTimeout?: ReturnType; - _keyboardListenerId?: string; - - _captureKeydownHandler?: EventListener; + _navigator?: RovingTabIndexNavigator; _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); @@ -170,153 +174,75 @@ class ToolbarBase< _supportedKeys(): SupportedKeys { const keys = super._supportedKeys(); + if (!this.option('focusStateEnabled')) { + return keys; + } + delete keys.leftArrow; delete keys.rightArrow; - delete keys.upArrow; - delete keys.downArrow; delete keys.home; delete keys.end; - 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; - } - } - - originalEnter?.call(this, e); - }; + keys.upArrow = (e: DxEvent): void => this._handleOverflowOpenAtNavLevel(e); + keys.downArrow = (e: DxEvent): void => this._handleOverflowOpenAtNavLevel(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(); + _enterKeyHandler(e: DxEvent): void { + if (!this.option('focusStateEnabled')) { + super._enterKeyHandler(e); + return; } - } - _detachKeyboardEvents(): void { - if (this._keyboardListenerId) { - keyboard.off(this._keyboardListenerId); - this._keyboardListenerId = undefined; + const target = e.target as HTMLElement; + if (isTextInputTarget(target) || isMenuTarget(target)) { + return; } - this._detachCaptureArrowHandler(); - } - - _attachCaptureArrowHandler(): void { - this._detachCaptureArrowHandler(); - - 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)) { - if (isMenu && this._closeOpenSubmenu(target, e)) { - return; - } + this._handleActivationAtNavLevel(e); + if (e.defaultPrevented) { + return; + } - const $item = $(target).closest(`${this._itemSelector()}, .dx-dropdownmenu-button`); + const { focusedElement } = this.option(); + const $item = $(focusedElement); + if ($item.length) { + const $textEditor = $item.find('.dx-texteditor-input').first(); + if ($textEditor.length) { e.preventDefault(); - e.stopPropagation(); - - if ($item.length && closeItemWidget($item)) { - return; - } - - if ($item.length) { - this._focusItemWidget($item); - } - + ($textEditor.get(0) as HTMLElement).focus(); return; } + } - const keyToLocation: Record = { - ArrowRight: 'right', - ArrowLeft: 'left', - Home: 'first', - End: 'last', - }; - - const location = keyToLocation[e.key]; - - if (!location) { - if (e.key === 'Enter' || e.key === ' ') { - this._handleActivationAtNavLevel(e); - } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - this._handleOverflowOpenAtNavLevel(e); - } - 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); + super._enterKeyHandler(e); } - _detachCaptureArrowHandler(): void { - if (this._captureKeydownHandler) { - const element = this.$element().get(0) as HTMLElement; - element.removeEventListener('keydown', this._captureKeydownHandler, true); - this._captureKeydownHandler = undefined; - } + _renderFocusTarget(): void { + this._focusTarget().attr('tabIndex', -1); } - _isTextInputTarget(target: HTMLElement): boolean { - const tagName = target.tagName.toLowerCase(); + _attachKeyboardEvents(): void { + this._detachKeyboardEvents(); - return (tagName === 'input' || tagName === 'textarea') - && $(target).closest('.dx-texteditor').length > 0; + if (!this.option('focusStateEnabled')) { + super._attachKeyboardEvents(); + return; + } + + this._navigator = new RovingTabIndexNavigator({ + widget: this, + itemsSelector: `${this._itemSelector()}, .dx-dropdownmenu-button`, + direction: 'horizontal', + }); + this._navigator.attach(); } - _isMenuTarget(target: HTMLElement): boolean { - return $(target).closest('.dx-menu, .dx-menu-item').length > 0; + _detachKeyboardEvents(): void { + this._navigator?.detach(); + this._navigator = undefined; + super._detachKeyboardEvents(); } _isOverflowItem($item: dxElementWrapper): boolean { @@ -333,27 +259,11 @@ class ToolbarBase< 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 widgetDisabled = !!this.option('disabled'); const elements = Array.from($visible.toArray()).filter( - (item) => !this._isItemDisabled($(item)) && !!getItemFocusTarget($(item))?.length, + (item) => !isItemDisabled($(item), widgetDisabled) && !!getItemFocusTarget($(item))?.length, ); return $(elements) as unknown as dxElementWrapper; @@ -364,80 +274,12 @@ class ToolbarBase< 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 $menu = $item.find('.dx-menu'); - if ($menu.length) { - $menu.attr('tabIndex', -1); - $menu.find('[tabindex]').attr('tabIndex', -1); - } - - return true; - }); + this._navigator?.updateRovingTabIndex($activeItem); + } - 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); - } - } - } + _resetRovingTabIndex(): void { + this._navigator?.resetRovingTabIndex(this._itemContainer()); } _focusInHandler(e: DxEvent): void { @@ -471,6 +313,10 @@ class ToolbarBase< } _focusItemWidget($item: dxElementWrapper): void { + if (this._navigator) { + this._navigator.focusItemWidget($item); + return; + } const $focusTarget = getItemFocusTarget($item); if (!$focusTarget?.length) { return; @@ -497,7 +343,7 @@ class ToolbarBase< if ($menu.length) { e.preventDefault(); e.stopPropagation(); - this._activateMenu($menu); + activateMenu($menu); } } @@ -514,33 +360,6 @@ class ToolbarBase< this._openOverflowMenu(e.key === 'ArrowUp' ? 'last' : 'first'); } - _closeOpenSubmenu(target: HTMLElement, e: Event): boolean { - const $menu = $(target).closest('.dx-menu'); - if (!$menu.length) { - return false; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuInstance = $menu.data('dxMenu') as any; - if (!menuInstance?._visibleSubmenu) { - return false; - } - - e.preventDefault(); - e.stopPropagation(); - - const $anchor = $menu.find('.dx-menu-item-expanded').first(); - menuInstance._hideSubmenu(menuInstance._visibleSubmenu); - - if ($anchor.length) { - menuInstance.option('focusedElement', getPublicElement($anchor)); - } - return true; - } - - _activateMenu($menu: dxElementWrapper): void { - ($menu.get(0) as HTMLElement).focus(); - } - _focusOutHandler(e: DxEvent): void { const { relatedTarget } = e as DxEvent & { relatedTarget: Element }; const target = e.target as Element; @@ -562,6 +381,10 @@ class ToolbarBase< // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _moveFocus(location: string, e?: DxEvent): boolean | undefined | void { + if (!this.option('focusStateEnabled')) { + return super._moveFocus(location, e); + } + const { focusedElement: prevFocusedElement } = this.option(); const $prev = $(prevFocusedElement); if ($prev.length) { @@ -625,12 +448,7 @@ class ToolbarBase< } _updateFocusableItemsTabIndex(): void { - if (!this.option('focusStateEnabled')) { - return; - } - - const { focusedElement } = this.option(); - this._updateRovingTabIndex($(focusedElement)); + this._resetRovingTabIndex(); } _renderToolbar(): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index c72637774314..4950e376826e 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -1,6 +1,5 @@ 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'; @@ -160,8 +159,7 @@ class Toolbar extends ToolbarBase { if (this._isMenuItem(item)) { toggleItemFocusableElementTabIndex(this, item); } else if (this.option('focusStateEnabled')) { - const { focusedElement } = this.option(); - this._updateRovingTabIndex($(focusedElement)); + this._resetRovingTabIndex(); } else { toggleItemFocusableElementTabIndex(this, item); } @@ -187,8 +185,7 @@ class Toolbar extends ToolbarBase { menuItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); if (this.option('focusStateEnabled')) { - const { focusedElement } = this.option(); - this._updateRovingTabIndex($(focusedElement)); + this._resetRovingTabIndex(); } else { toolbarItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); } diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index 0d5a545c2f3d..38f9ccf8f2ba 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -1,6 +1,7 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { Item } from '@js/ui/toolbar'; +import { getPublicElement } from '@ts/core/m_element'; import type Widget from '@ts/core/widget/widget'; import type { ListBase } from '@ts/ui/list/list.base'; @@ -11,6 +12,44 @@ 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"])'; +export function isTextInputTarget(target: HTMLElement): boolean { + const tagName = target.tagName.toLowerCase(); + + return (tagName === 'input' || tagName === 'textarea') + && $(target).closest('.dx-texteditor').length > 0; +} + +export function isMenuTarget(target: HTMLElement): boolean { + return $(target).closest('.dx-menu, .dx-menu-item').length > 0; +} + +export function activateMenu($menu: dxElementWrapper): void { + ($menu.get(0) as HTMLElement).focus(); +} + +export function closeOpenSubmenu(target: HTMLElement, e: Event): boolean { + const $menu = $(target).closest('.dx-menu'); + if (!$menu.length) { + return false; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const menuInstance = $menu.data('dxMenu') as any; + if (!menuInstance?._visibleSubmenu) { + return false; + } + + e.preventDefault(); + e.stopPropagation(); + + const $anchor = $menu.find('.dx-menu-item-expanded').first(); + menuInstance._hideSubmenu(menuInstance._visibleSubmenu); + + if ($anchor.length) { + menuInstance.option('focusedElement', getPublicElement($anchor)); + } + return true; +} + const getItemInstance = ($element: dxElementWrapper): Widget => { // @ts-expect-error ts-error const itemData = $element?.data(); @@ -50,6 +89,17 @@ export function closeItemWidget($item: dxElementWrapper): boolean { return false; } +export function isItemDisabled($item: dxElementWrapper, widgetDisabled: boolean): boolean { + if (widgetDisabled) { + return true; + } + if ($item.hasClass('dx-state-disabled')) { + return true; + } + const $widget = $item.find('.dx-widget').first(); + return $widget.length > 0 && $widget.hasClass('dx-state-disabled'); +} + export function isItemWidgetOpened($item: dxElementWrapper): boolean { const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); @@ -86,20 +136,31 @@ export function getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | return undefined; } - let $focusTarget = itemInstance._focusTarget?.(); - + const $base = itemInstance._focusTarget?.(); const widgetName = getWidgetName($widget); - if (widgetName.toLowerCase().includes('dropdownbutton')) { - $focusTarget = $focusTarget?.find(`.${BUTTON_GROUP_CLASS}`); - } else if ($widget.hasClass('dx-menu')) { - $focusTarget = $item; - } else if ($widget.hasClass('dx-texteditor')) { - $focusTarget = $(itemInstance.element()); - } else { - $focusTarget = $focusTarget ?? $(itemInstance.element()); + + if (widgetName === 'dxDropDownButton') return $base?.find(`.${BUTTON_GROUP_CLASS}`); + if ($widget.hasClass('dx-menu')) return $item; + if ($widget.hasClass('dx-texteditor')) return $(itemInstance.element()); + return $base ?? $(itemInstance.element()); +} + +export function applyItemTabIndex($item: dxElementWrapper, tabIndex: number): void { + const $focusTarget = getItemFocusTarget($item); + if (!$focusTarget?.length) { + return; } + $focusTarget.attr('tabIndex', tabIndex); - return $focusTarget; + if ($focusTarget.hasClass('dx-texteditor')) { + $focusTarget.find('.dx-texteditor-input').attr('tabIndex', -1); + } + + const $menu = $item.find('.dx-menu'); + if ($menu.length) { + $menu.attr('tabIndex', -1); + $menu.find('[tabindex]').attr('tabIndex', -1); + } } export function setItemWidgetFocusState($item: dxElementWrapper, isFocused: boolean): void { 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 index 39a21937e730..8b0bdb7bfee1 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -38,7 +38,6 @@ QUnit.testStart(function() { width: 100px; } .dx-list-item { - /* NOTE: to avoid decimal values in geometry */ line-height: 1; } @@ -53,251 +52,237 @@ QUnit.testStart(function() { $('#widthRootStyle').css('width', '300px'); }); -QUnit.module('Enter/Exit: text input editors', { + +const TOOLBAR_SELECTOR = '#toolbar'; + +const buttonItem = (text, extra = {}) => ({ + widget: 'dxButton', + locateInMenu: 'never', + ...extra, + options: { text, ...(extra.options || {}) }, +}); + +const editorItem = (widget, options = {}, extra = {}) => ({ + widget, + locateInMenu: 'never', + ...extra, + options, +}); + +const labelItem = (text) => ({ text, locateInMenu: 'never' }); + +const overflowButtonItem = (text, extra = {}) => ({ + widget: 'dxButton', + locateInMenu: 'always', + ...extra, + options: { text, ...(extra.options || {}) }, +}); + +const createToolbar = (items, options = {}, selector = TOOLBAR_SELECTOR) => + $(selector).dxToolbar({ items, ...options }).dxToolbar('instance'); + +const press = (key, target, modifiers = {}) => { + const el = target instanceof Element + ? target + : (target && target.get ? target.get(0) : $(TOOLBAR_SELECTOR).get(0)); + el.dispatchEvent(new KeyboardEvent('keydown', { + key, bubbles: true, cancelable: true, ...modifiers, + })); +}; + +const focusItemAt = (toolbar, index) => { + const $items = toolbar._getAvailableItems(); + const $item = $items.eq(index); + toolbar.option('focusedElement', $item.get(0)); + toolbar._focusItemWidget($item); + return $item; +}; + +const findFocusTarget = ($item) => { + const $dropDownButton = $item.find('.dx-dropdownbutton').first(); + if($dropDownButton.length) { + return $item.find('.dx-buttongroup').first(); + } + const $button = $item.find('.dx-button').first(); + if($button.length) return $button; + const $textEditor = $item.find('.dx-texteditor').first(); + if($textEditor.length) return $textEditor; + const $buttonGroup = $item.find('.dx-buttongroup').first(); + if($buttonGroup.length) return $buttonGroup; + const $menu = $item.find('.dx-menu').first(); + if($menu.length) return $menu; + const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href]').first(); + if($native.length) return $native; + return $item; +}; + +const findInput = ($item) => $item.find('.dx-texteditor-input').first(); +const getActiveItem = (toolbar) => $(toolbar.option('focusedElement')); + +const assertFocusedItemAt = (assert, toolbar, expectedIndex, message) => { + const $items = toolbar._getAvailableItems(); + assert.strictEqual( + getActiveItem(toolbar).get(0), + $items.eq(expectedIndex).get(0), + message || `focusedElement is item #${expectedIndex}`, + ); +}; + +const assertOneTabStop = (assert, $toolbar, message) => { + const $stops = $toolbar.find('[tabindex="0"]').not('.dx-texteditor-input'); + assert.strictEqual($stops.length, 1, message || 'exactly one tab stop in toolbar'); +}; + +const assertActiveTabIndex = (assert, $item, expected, message) => { + const actual = parseInt(findFocusTarget($item).attr('tabindex'), 10); + assert.strictEqual(actual, expected, message || `active item tabindex=${expected}`); +}; + +const EDITOR_FIXTURES = { + textInput: [ + { widget: 'dxTextBox', options: { value: 'hello', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxNumberBox', options: { value: 42, inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxAutocomplete', options: { items: ['Item 1', 'Item 2'], inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxSelectBox', options: { items: ['A', 'B', 'C'], value: 'A', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateBox', options: { type: 'date', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateRangeBox', options: { startDateInputAttr: { 'aria-label': 'Start' }, endDateInputAttr: { 'aria-label': 'End' } } }, + { widget: 'dxColorBox', options: { value: '#ff0000', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxTagBox', options: { items: ['x', 'y', 'z'], inputAttr: { 'aria-label': 'Test' } } }, + ], + popup: [ + { widget: 'dxSelectBox', options: { items: ['A', 'B', 'C'], inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateBox', options: { type: 'date', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxColorBox', options: { value: '#0080ff', inputAttr: { 'aria-label': 'Test' } } }, + { widget: 'dxDateRangeBox', options: { startDateInputAttr: { 'aria-label': 'Start' }, endDateInputAttr: { 'aria-label': 'End' } } }, + { widget: 'dxTagBox', options: { items: ['x', 'y', 'z'], inputAttr: { 'aria-label': 'Test' } } }, + ], + toggle: [ + { widget: 'dxSwitch', options: { value: false } }, + { widget: 'dxCheckBox', options: { value: false } }, + ], + collection: [ + { widget: 'dxButtonGroup', options: { items: [{ text: 'A' }, { text: 'B' }, { text: 'C' }] } }, + ], +}; + +const moduleConfig = { beforeEach: function() { - this.clock = sinon.useFakeTimers(); - this.$element = $('#toolbar'); fx.off = true; + this.clock = sinon.useFakeTimers(); + this.$element = $(TOOLBAR_SELECTOR); }, afterEach: function() { - this.clock.restore(); fx.off = false; + this.clock.restore(); 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.module('Enter/Exit: text input editors', moduleConfig, function() { + const setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); + EDITOR_FIXTURES.textInput.forEach(({ widget, options }) => { QUnit.test(`${widget}: Enter focuses input`, function(assert) { - const toolbar = 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)); + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - const $input = findInput($items.eq(1)); + const $input = findInput(toolbar._getAvailableItems().eq(1)); assert.strictEqual(document.activeElement, $input.get(0), `Enter focuses ${widget} input`); }); QUnit.test(`${widget}: arrows blocked while input focused`, function(assert) { - const toolbar = 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)); + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('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); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('ArrowLeft', $input.get(0)); + press('ArrowRight', $input.get(0)); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - `ArrowRight does not navigate toolbar while ${widget} input is focused`); + assertFocusedItemAt(assert, toolbar, 1, + `arrows do 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)); + QUnit.test(`${widget}: Esc keeps focusedElement on the editor item`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - - const $input = findInput($items.eq(1)); - triggerKey($input.get(0), 'Escape'); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('Escape', $input.get(0)); this.clock.tick(50); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - `Esc keeps toolbar focusedElement on ${widget} item`); + assertFocusedItemAt(assert, toolbar, 1, + `Esc keeps 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)); + QUnit.test(`${widget}: arrows navigate toolbar after Esc exits the editor`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - - const $input = findInput($items.eq(1)); - triggerKey($input.get(0), 'Escape'); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('Escape', $input.get(0)); this.clock.tick(50); + press('ArrowRight'); - 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}`); + assertFocusedItemAt(assert, toolbar, 2, + `ArrowRight navigates 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)); + QUnit.test(`${widget}: enter→exit→arrow cycle preserves single tab stop`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - - const $input = findInput($items.eq(1)); - triggerKey($input.get(0), 'Escape'); + const $input = findInput(toolbar._getAvailableItems().eq(1)); + press('Escape', $input.get(0)); this.clock.tick(50); + press('ArrowRight'); - 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}`); + assertOneTabStop(assert, this.$element, + `single tab stop preserved through ${widget} enter/exit/navigate cycle`); }); - QUnit.test(`${widget}: editor does not get dx-state-focused on toolbar navigation (before Enter)`, 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)); - toolbar._focusItemWidget($items.eq(1)); - this.clock.tick(0); + QUnit.test(`${widget}: editor stays unfocused during plain toolbar navigation`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - const $editor = $items.eq(1).find('.dx-texteditor').first(); - assert.strictEqual($editor.hasClass('dx-state-focused'), false, - `${widget} root element does not have dx-state-focused during toolbar navigation`); + const $editor = toolbar._getAvailableItems().eq(1).find('.dx-texteditor').first(); + assert.notOk($editor.hasClass('dx-state-focused'), + `${widget} root has no dx-state-focused before Enter`); }); QUnit.test(`${widget}: editor gets dx-state-focused after Enter`, function(assert) { - const toolbar = 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)); + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - const $editor = $items.eq(1).find('.dx-texteditor').first(); - assert.strictEqual($editor.hasClass('dx-state-focused'), true, - `${widget} root element has dx-state-focused after Enter`); + const $editor = toolbar._getAvailableItems().eq(1).find('.dx-texteditor').first(); + assert.ok($editor.hasClass('dx-state-focused'), + `${widget} root has dx-state-focused after Enter`); }); }); }); -QUnit.module('Enter/Exit: dropdown/popup editors (matrix)', { - 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 = [ +QUnit.module('Enter/Exit: dropdown/popup editors (matrix)', moduleConfig, function() { + const POPUP_WIDGETS = [ { widget: 'dxDropDownButton', options: { items: ['Option 1', 'Option 2'], text: 'Actions' }, @@ -315,307 +300,142 @@ QUnit.module('Enter/Exit: dropdown/popup editors (matrix)', { }, ]; - 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`); - }); + const setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); - 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'); + POPUP_WIDGETS.forEach(({ widget, options, getInstance, getFocusTarget, prepareFocus }) => { + const focusInner = (toolbar) => { + const $item = focusItemAt(toolbar, 1); + prepareFocus($item); + return $item; + }; - const $items = toolbar._getAvailableItems(); - const $targetItem = $items.eq(1); - toolbar.option('focusedElement', $targetItem.get(0)); + ['Enter', ' ', 'ArrowDown'].forEach((key) => { + const label = key === ' ' ? 'Space' : key; + QUnit.test(`${widget}: ${label} opens popup`, function(assert) { + const toolbar = setupSandwich(widget, options); + const $item = key === 'ArrowDown' ? focusItemAt(toolbar, 1) : focusInner(toolbar); - triggerKey(getFocusTarget($targetItem).get(0), 'ArrowDown'); - this.clock.tick(300); + press(key, getFocusTarget($item).get(0)); + this.clock.tick(300); - const instance = getInstance($targetItem); - assert.strictEqual(instance.option('opened'), true, - `ArrowDown opens ${widget} popup`); + assert.strictEqual(getInstance($item).option('opened'), true, + `${label} 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); + QUnit.test(`${widget}: arrows blocked while popup is open`, function(assert) { + const toolbar = setupSandwich(widget, options); + const $item = focusInner(toolbar); - triggerKey(getFocusTarget($targetItem).get(0), 'Enter'); + press('Enter', getFocusTarget($item).get(0)); 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); + press('ArrowRight'); + press('ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $targetItem.get(0), - `ArrowLeft does not navigate toolbar while ${widget} popup is open`); + assertFocusedItemAt(assert, toolbar, 1, + `arrows do not navigate toolbar while ${widget} popup is open`); }); QUnit.test(`${widget}: Esc closes popup and keeps toolbar focus`, function(assert) { - const toolbar = 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); + const toolbar = setupSandwich(widget, options); + const $item = focusItemAt(toolbar, 1); + const instance = getInstance($item); instance.option('opened', true); this.clock.tick(300); - triggerKey(getFocusTarget($targetItem).get(0), 'Escape'); + press('Escape', getFocusTarget($item).get(0)); 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`); + assertFocusedItemAt(assert, toolbar, 1, + `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 = [ +QUnit.module('Enter/Exit: toggle widgets', moduleConfig, function() { + const TOGGLES = [ { 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 setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); - const $widgetEl = this.$element.find(containerSelector); - const widgetInstance = $widgetEl[widget]('instance'); - - const valueBefore = getValueFn(widgetInstance); + TOGGLES.forEach(({ widget, options, containerSelector, toggledByEnter }) => { + const buildAndFocusInner = (toolbar) => { + const $widgetEl = toolbar.$element().find(containerSelector); + const widgetInstance = $widgetEl[widget]('instance'); + $widgetEl.get(0).focus(); + return { $widgetEl, widgetInstance }; + }; - const $focusTarget = $widgetEl; - $focusTarget.get(0).focus(); - this.clock.tick(0); + const enterLabel = toggledByEnter ? 'toggles' : 'does not toggle'; + QUnit.test(`${widget}: Enter ${enterLabel} value`, function(assert) { + const toolbar = setupSandwich(widget, options); + const { $widgetEl, widgetInstance } = buildAndFocusInner(toolbar); + const valueBefore = widgetInstance.option('value'); - triggerKey($focusTarget.get(0), 'Enter'); - this.clock.tick(50); + press('Enter', $widgetEl.get(0)); + 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`); - }); - } + const valueAfter = widgetInstance.option('value'); + if(toggledByEnter) { + assert.notStrictEqual(valueAfter, valueBefore, `Enter toggles ${widget} value`); + } else { + assert.strictEqual(valueAfter, valueBefore, `Enter does not toggle ${widget} value`); + } + }); QUnit.test(`${widget}: Space toggles value`, function(assert) { - 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); + const toolbar = setupSandwich(widget, options); + const { $widgetEl, widgetInstance } = buildAndFocusInner(toolbar); + const valueBefore = widgetInstance.option('value'); - triggerKey($focusTarget.get(0), ' '); + press(' ', $widgetEl.get(0)); this.clock.tick(50); - const valueAfter = getValueFn(widgetInstance); - assert.notStrictEqual(valueAfter, valueBefore, + assert.notStrictEqual(widgetInstance.option('value'), 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)); + QUnit.test(`${widget}: ArrowRight navigates toolbar (no inner edit mode)`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'ArrowRight'); - this.clock.tick(0); + press('ArrowRight'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), - `ArrowRight navigates toolbar from ${widget} (no inner edit mode)`); + assertFocusedItemAt(assert, toolbar, 2, + `ArrowRight navigates 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)); + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'ArrowLeft'); - this.clock.tick(0); + press('ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), - `ArrowLeft navigates toolbar from ${widget} (no inner edit mode)`); + assertFocusedItemAt(assert, toolbar, 0, + `ArrowLeft navigates 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 = [ +QUnit.module('Enter/Exit: collection widgets', moduleConfig, function() { + const COLLECTIONS = [ { widget: 'dxMenu', options: { @@ -624,308 +444,161 @@ QUnit.module('Enter/Exit: collection widgets', { { text: 'Edit', items: [{ text: 'Cut' }, { text: 'Copy' }] }, ], }, - containerSelector: '.dx-menu', - getInnerFocusableSelector: '.dx-menu-item', + innerFocusableSelector: '.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 setupSandwich = (widget, options) => + createToolbar([buttonItem('Prev'), editorItem(widget, options), buttonItem('Next')]); - const $items = toolbar._getAvailableItems(); - toolbar.option('focusedElement', $items.eq(1).get(0)); + COLLECTIONS.forEach(({ widget, options, innerFocusableSelector }) => { + QUnit.test(`${widget}: Enter activates inner navigation`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('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`); + const $item = toolbar._getAvailableItems().eq(1); + assert.ok($item.get(0).contains(document.activeElement), + `Enter places DOM focus inside ${widget}`); + assert.ok($item.find(innerFocusableSelector).length > 0, + `${widget} has inner focusable elements`); }); - QUnit.test(`${widget}: arrows 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)); + QUnit.test(`${widget}: arrows do not navigate toolbar while inner mode is active`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); const activeEl = document.activeElement; - triggerKey(activeEl, 'ArrowRight'); - this.clock.tick(0); + press('ArrowRight', activeEl); + press('ArrowLeft', activeEl); - 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}`); + assertFocusedItemAt(assert, toolbar, 1, + `arrows do 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)); + QUnit.test(`${widget}: Esc returns focus to the toolbar item`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - - triggerKey(document.activeElement, 'Escape'); + press('Escape', document.activeElement); this.clock.tick(50); - assert.ok($items.eq(1).get(0).contains(document.activeElement), - `Esc returns focus inside the ${widget} toolbar item (on widget root, not on inner element)`); + const $item = toolbar._getAvailableItems().eq(1); + assert.ok($item.get(0).contains(document.activeElement), + `Esc keeps DOM focus inside the ${widget} toolbar item`); }); QUnit.test(`${widget}: arrows navigate toolbar after Esc`, function(assert) { - const toolbar = 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)); + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - - triggerKey(document.activeElement, 'Escape'); + press('Escape', document.activeElement); this.clock.tick(50); + press('ArrowRight'); - triggerKey(this.$element.get(0), 'ArrowRight'); - this.clock.tick(0); - - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), + assertFocusedItemAt(assert, toolbar, 2, `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)); + QUnit.test(`${widget}: enter/exit cycle preserves single tab stop`, function(assert) { + const toolbar = setupSandwich(widget, options); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'Enter'); + press('Enter'); this.clock.tick(50); - - triggerKey(document.activeElement, 'Escape'); + press('Escape', document.activeElement); this.clock.tick(50); - - triggerKey(this.$element.get(0), 'ArrowRight'); - this.clock.tick(0); + press('ArrowRight'); const $tabZero = this.$element.find('[tabindex="0"]'); assert.strictEqual($tabZero.length, 1, - `Exactly one tabindex=0 after enter/exit/navigate cycle with ${widget}`); + `single tab stop preserved through ${widget} enter/exit/navigate cycle`); }); }); }); -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 })); - } +QUnit.module('Enter/Exit: dxTabs in toolbar', moduleConfig, function() { + const tabsItem = editorItem('dxTabs', { + items: [{ text: 'Home' }, { text: 'Insert' }, { text: 'Layout' }], + selectedIndex: 0, + width: 'auto', + }); + const setupTabsToolbar = () => createToolbar([buttonItem('Prev'), tabsItem, buttonItem('Next')]); - 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'); - } + const focusTabsContainer = (toolbar, clock) => { + focusItemAt(toolbar, 1); + const $tabs = toolbar._getAvailableItems().eq(1).find('.dx-tabs'); + $tabs.get(0).focus(); + clock.tick(50); + return $tabs.dxTabs('instance'); + }; - QUnit.test('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)); + QUnit.test('ArrowRight on tabs moves toolbar focus to next item', function(assert) { + const toolbar = setupTabsToolbar(); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'ArrowRight'); - this.clock.tick(0); + press('ArrowRight'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(2).get(0), - 'ArrowRight navigates toolbar away from dxTabs to next item'); + assertFocusedItemAt(assert, toolbar, 2, + 'ArrowRight navigates toolbar away from dxTabs'); }); - 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)); + QUnit.test('ArrowLeft on tabs moves toolbar focus to previous item', function(assert) { + const toolbar = setupTabsToolbar(); + focusItemAt(toolbar, 1); - triggerKey(this.$element.get(0), 'ArrowLeft'); - this.clock.tick(0); + press('ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), - 'ArrowLeft navigates toolbar away from dxTabs to previous item'); + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft navigates toolbar away from dxTabs'); }); - 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); - + QUnit.test('ArrowDown on focused tabs switches tabs and does not move toolbar focus', function(assert) { + const toolbar = setupTabsToolbar(); + const tabs = focusTabsContainer(toolbar, this.clock); const selectedBefore = tabs.option('selectedIndex'); - triggerKey(document.activeElement, 'ArrowDown'); + + press('ArrowDown', document.activeElement); this.clock.tick(50); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - 'ArrowDown does not navigate toolbar'); + assertFocusedItemAt(assert, toolbar, 1, 'ArrowDown keeps toolbar focus on tabs item'); assert.strictEqual(tabs.option('selectedIndex'), selectedBefore + 1, - 'ArrowDown switches to next tab'); + 'ArrowDown selects the 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'); - + QUnit.test('ArrowUp on focused tabs switches tabs and does not move toolbar focus', function(assert) { + const toolbar = setupTabsToolbar(); + const tabs = focusTabsContainer(toolbar, this.clock); tabs.option('selectedIndex', 1); - 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'); + + press('ArrowUp', document.activeElement); this.clock.tick(50); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - 'ArrowUp does not navigate toolbar'); + assertFocusedItemAt(assert, toolbar, 1, 'ArrowUp keeps toolbar focus on tabs item'); assert.strictEqual(tabs.option('selectedIndex'), selectedBefore - 1, - 'ArrowUp switches to previous tab'); + 'ArrowUp selects the 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; - - const $menu = $item.find('.dx-menu').first(); - if($menu.length) return $menu; - - 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) }, - })); - } +const dispatchKeydown = (element, key, options = {}) => press(key, element, options); +const getItemFocusTarget = findFocusTarget; - function triggerKey(element, key) { - element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); - } +QUnit.module('Core Navigation', moduleConfig, function() { + const makeButtonItems = (count) => + Array.from({ length: count }, (_, i) => buttonItem(String.fromCharCode(65 + i))); QUnit.test('first available item is the roving tabindex anchor on init', function(assert) { - const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const toolbar = createToolbar(makeButtonItems(3)); const $available = toolbar._getAvailableItems(); const $tabZeroElements = this.$element.find('[tabindex="0"]'); @@ -933,178 +606,153 @@ QUnit.module('Core Navigation', { assert.strictEqual( $tabZeroElements.closest(`.${TOOLBAR_ITEM_CLASS}`).get(0), $available.eq(0).get(0), - 'the anchor belongs to the first available item' + '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(); + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 0); - toolbar.option('focusedElement', $items.eq(0).get(0)); - triggerKey(this.$element.get(0), 'ArrowRight'); + press('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'); + assertFocusedItemAt(assert, toolbar, 1, 'focus moved to item[1]'); + assertOneTabStop(assert, this.$element); }); QUnit.test('ArrowRight on last item wraps focus to first item', function(assert) { - const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); - const $items = toolbar._getAvailableItems(); + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 2); - toolbar.option('focusedElement', $items.last().get(0)); - triggerKey(this.$element.get(0), 'ArrowRight'); + press('ArrowRight'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'focus wrapped to first item'); + assertFocusedItemAt(assert, toolbar, 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(); + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 0); - toolbar.option('focusedElement', $items.eq(0).get(0)); - triggerKey(this.$element.get(0), 'ArrowLeft'); + press('ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.last().get(0), 'focus wrapped to last item'); + assertFocusedItemAt(assert, toolbar, 2, '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(); + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 2); - toolbar.option('focusedElement', $items.eq(2).get(0)); - triggerKey(this.$element.get(0), 'Home'); + press('Home'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), 'focus moved to first item'); + assertFocusedItemAt(assert, toolbar, 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(); + const toolbar = createToolbar(makeButtonItems(3)); + focusItemAt(toolbar, 0); - toolbar.option('focusedElement', $items.eq(0).get(0)); - triggerKey(this.$element.get(0), 'End'); + press('End'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.last().get(0), 'focus moved to last item'); + assertFocusedItemAt(assert, toolbar, 2, 'focus moved to last item'); }); - QUnit.test('disabled widget items (options.disabled) are skipped by ArrowRight', function(assert) { - const toolbar = this.$element.dxToolbar({ + const disabledScenarios = [ + { + name: 'options.disabled', 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({ + buttonItem('A'), + buttonItem('B', { options: { disabled: true } }), + buttonItem('C'), + ], + }, + { + name: 'item.disabled (item-level flag)', items: [ - { widget: 'dxButton', options: { text: 'A' } }, - { widget: 'dxButton', disabled: true, options: { text: 'B' } }, - { widget: 'dxButton', options: { text: 'C' } }, - ] - }).dxToolbar('instance'); + buttonItem('A'), + buttonItem('B', { disabled: true }), + buttonItem('C'), + ], + }, + ]; - const $items = toolbar._getAvailableItems(); - assert.strictEqual($items.length, 2, 'only 2 available items (disabled filtered out)'); + disabledScenarios.forEach(({ name, items }) => { + QUnit.test(`ArrowRight skips disabled item (${name})`, function(assert) { + const toolbar = createToolbar(items); + const $items = toolbar._getAvailableItems(); + assert.strictEqual($items.length, 2, 'only 2 available items (disabled filtered out)'); - toolbar.option('focusedElement', $items.eq(0).get(0)); - triggerKey(this.$element.get(0), 'ArrowRight'); + focusItemAt(toolbar, 0); + press('ArrowRight'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - 'ArrowRight skips item.disabled and moves to C'); + assertFocusedItemAt(assert, toolbar, 1, + `ArrowRight skips disabled (${name}) and lands on next enabled item`); + }); }); - 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'); + QUnit.test('ArrowLeft skips a disabled item between two enabled items', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B', { options: { disabled: true } }), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); - const $items = toolbar._getAvailableItems(); - toolbar.option('focusedElement', $items.eq(1).get(0)); - triggerKey(this.$element.get(0), 'ArrowLeft'); + press('ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), - 'ArrowLeft skips disabled item and moves to A'); + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft skips disabled item and lands on 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 toolbar = createToolbar([ + buttonItem('A', { disabled: true }), + buttonItem('B'), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); - const $items = toolbar._getAvailableItems(); - toolbar.option('focusedElement', $items.last().get(0)); - triggerKey(this.$element.get(0), 'Home'); + press('Home'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), - 'Home lands on first enabled item (B), skipping disabled A'); + assertFocusedItemAt(assert, toolbar, 0, + 'Home lands on first enabled item, skipping disabled leader'); }); 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 toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B'), + buttonItem('C', { disabled: true }), + ]); + focusItemAt(toolbar, 0); - const $items = toolbar._getAvailableItems(); - toolbar.option('focusedElement', $items.eq(0).get(0)); - triggerKey(this.$element.get(0), 'End'); + press('End'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.last().get(0), - 'End lands on last enabled item (B), skipping disabled C'); + assertFocusedItemAt(assert, toolbar, 1, + 'End lands on last enabled item, skipping disabled trailer'); }); QUnit.test('multiple consecutive disabled items are all skipped', function(assert) { - const toolbar = 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), + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B', { disabled: true }), + buttonItem('C', { options: { disabled: true } }), + buttonItem('D'), + ]); + assert.strictEqual(toolbar._getAvailableItems().length, 2, 'only 2 available items'); + + focusItemAt(toolbar, 0); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 1, 'ArrowRight skips two consecutive disabled items and lands on D'); }); QUnit.test('disabled item never has tabindex=0', function(assert) { - this.$element.dxToolbar({ - items: [ - { widget: 'dxButton', options: { text: 'A' } }, - { widget: 'dxButton', options: { text: 'B', disabled: true } }, - { widget: 'dxButton', options: { text: 'C' } }, - ] - }); + createToolbar([ + buttonItem('A'), + buttonItem('B', { options: { disabled: true } }), + buttonItem('C'), + ]); const $disabledButton = this.$element.find('.dx-button.dx-state-disabled'); assert.strictEqual($disabledButton.attr('tabindex'), '-1', @@ -1112,13 +760,7 @@ QUnit.module('Core Navigation', { }); 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' } }, - ] - }); + createToolbar([buttonItem('A'), buttonItem('B')], { disabled: true }); const $buttons = this.$element.find('.dx-button'); $buttons.each(function() { @@ -1127,86 +769,48 @@ QUnit.module('Core Navigation', { }); }); - 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'); + QUnit.test('exactly one tabindex=0 is maintained after a sequence of navigation keys', function(assert) { + const toolbar = createToolbar(makeButtonItems(4)); + focusItemAt(toolbar, 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'); + ['ArrowRight', 'ArrowRight', 'End', 'Home'].forEach((key) => { + press(key); + assertOneTabStop(assert, this.$element, `one tab stop after ${key}`); + }); }); - QUnit.test('keyboard navigation: all non-focused items have tabindex=-1', function(assert) { - const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + QUnit.test('ArrowRight transfers tabindex=0 from previous to newly focused item', function(assert) { + const toolbar = createToolbar(makeButtonItems(3)); const $items = toolbar._getAvailableItems(); + focusItemAt(toolbar, 0); - toolbar.option('focusedElement', $items.eq(0).get(0)); - triggerKey(this.$element.get(0), 'ArrowRight'); + press('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)'); + assertActiveTabIndex(assert, $items.eq(1), 0, 'item[1] is now the stop'); + assertActiveTabIndex(assert, $items.eq(0), -1, 'item[0] released the stop'); + assertActiveTabIndex(assert, $items.eq(2), -1, 'item[2] remained at -1'); }); QUnit.test('focusing an item via pointer makes it the roving tabindex anchor', function(assert) { - const toolbar = this.$element.dxToolbar({ items: makeButtonItems(3) }).dxToolbar('instance'); + const toolbar = createToolbar(makeButtonItems(3)); 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' - ); + assertOneTabStop(assert, this.$element); + const $tabZero = this.$element.find('[tabindex="0"]'); assert.strictEqual( - $(toolbar.option('focusedElement')).get(0), + $tabZero.closest(`.${TOOLBAR_ITEM_CLASS}`).get(0), $items.eq(1).get(0), - 'focusedElement updated to item[1]' + 'item[1] is now the anchor', ); + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement updated to item[1] after pointer focus'); }); }); -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.module('Widget interaction', moduleConfig, function() { + const triggerKey = (element, key) => press(key, element); QUnit.test('Enter on dxButton fires click', function(assert) { let clicked = false; @@ -1685,496 +1289,197 @@ QUnit.module('Widget interaction', { }); }); -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 })); - } +QUnit.module('Mouse and keyboard sync', moduleConfig, function() { + const threeButtons = () => [buttonItem('A'), buttonItem('B'), buttonItem('C')]; - function create3ButtonToolbar($el) { - return $el.dxToolbar({ - items: [ - { widget: 'dxButton', options: { text: 'A' } }, - { widget: 'dxButton', options: { text: 'B' } }, - { widget: 'dxButton', options: { text: 'C' } }, - ], - }).dxToolbar('instance'); - } + const focusInner = ($el) => $el.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); - QUnit.test('Mouse click on item[j] → tabindex=0 on that item; others tabindex=-1', function(assert) { - const toolbar = create3ButtonToolbar(this.$element); + QUnit.test('focusin on item[j] sets it as the roving anchor (others release the stop)', function(assert) { + const toolbar = createToolbar(threeButtons()); const $items = toolbar._getAvailableItems(); - $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + focusInner($items.eq(1).find('.dx-button')); - 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'); + assert.strictEqual($items.eq(1).find('.dx-button').attr('tabindex'), '0', + 'focused item has tabindex=0'); + assert.strictEqual($items.eq(0).find('.dx-button').attr('tabindex'), '-1', + 'previous item released the stop'); + assert.strictEqual($items.eq(2).find('.dx-button').attr('tabindex'), '-1', + 'next item released the stop'); }); - QUnit.test('Mouse click on item[j] → ArrowRight → moves to item[j+1]', function(assert) { - const toolbar = create3ButtonToolbar(this.$element); + QUnit.test('focusin on item[j], then ArrowRight moves to item[j+1]', function(assert) { + const toolbar = createToolbar(threeButtons()); const $items = toolbar._getAvailableItems(); - $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + focusInner($items.eq(1).find('.dx-button')); + press('ArrowRight'); - 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'); + assertFocusedItemAt(assert, toolbar, 2, + 'ArrowRight from click-focused item moves to next'); }); - QUnit.test('Mouse click on item[j] → ArrowLeft → moves to item[j-1]', function(assert) { - const toolbar = create3ButtonToolbar(this.$element); + QUnit.test('focusin on item[j], then ArrowLeft moves to item[j-1]', function(assert) { + const toolbar = createToolbar(threeButtons()); const $items = toolbar._getAvailableItems(); - $items.eq(1).find('.dx-button').get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + focusInner($items.eq(1).find('.dx-button')); + press('ArrowLeft'); - 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'); + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft from click-focused item moves to previous'); }); - 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'); - + QUnit.test('focusin on TextBox input keeps focusedElement on its item; arrows do not navigate', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxTextBox', { value: 'hello' }), + buttonItem('Next'), + ]); const $items = toolbar._getAvailableItems(); const $input = $items.eq(1).find('.dx-texteditor-input'); - $input.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + focusInner($input); + press('ArrowLeft', $input.get(0)); - triggerKey($input.get(0), 'ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + assertFocusedItemAt(assert, toolbar, 1, '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'); - + QUnit.test('focusin on TextBox → Esc → ArrowLeft navigates toolbar', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxTextBox', { value: 'hello' }), + buttonItem('Next'), + ]); const $items = toolbar._getAvailableItems(); const $input = $items.eq(1).find('.dx-texteditor-input'); - $input.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); - - triggerKey($input.get(0), 'Escape'); + focusInner($input); + press('Escape', $input.get(0)); this.clock.tick(50); + press('ArrowLeft'); - triggerKey(this.$element.get(0), 'ArrowLeft'); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(0).get(0), + assertFocusedItemAt(assert, toolbar, 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'); - + QUnit.test('focusin on SelectBox input promotes its item to be focusedElement', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxSelectBox', { items: ['A', 'B', 'C'], value: 'A' }), + buttonItem('Next'), + ]); const $items = toolbar._getAvailableItems(); const $input = $items.eq(1).find('.dx-texteditor-input'); - $input.get(0).dispatchEvent(new Event('focusin', { bubbles: true })); + focusInner($input); - assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - 'focusedElement updated to SelectBox item after click on input'); + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement promoted to SelectBox item'); }); - 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'); - + QUnit.test('focusin on DropDownButton item promotes it and Enter opens its popup', function(assert) { + const toolbar = createToolbar([ + buttonItem('Prev'), + editorItem('dxDropDownButton', { items: ['Option 1', 'Option 2'], text: 'Actions' }), + buttonItem('Next'), + ]); const $items = toolbar._getAvailableItems(); const $buttonGroup = $items.eq(1).find('.dx-buttongroup'); const dropDownButton = this.$element.find('.dx-dropdownbutton').dxDropDownButton('instance'); - $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'); + focusInner($buttonGroup); + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement promoted to DropDownButton item'); const bgInstance = $buttonGroup.dxButtonGroup('instance'); const $firstItem = bgInstance._buttonsCollection._itemElements().eq(0); bgInstance._buttonsCollection.option('focusedElement', $firstItem.get(0)); - triggerKey($buttonGroup.get(0), 'Enter'); + press('Enter', $buttonGroup.get(0)); 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', - ); + assert.strictEqual(dropDownButton.option('opened'), true, + 'Enter opens DropDownButton popup after click-focus'); }); }); -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) { +QUnit.module('Disabled items skip (focusin-driven)', moduleConfig, function() { - 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 triadWithMiddleDisabled = () => [ + buttonItem('A'), + buttonItem('Disabled', { disabled: true }), + buttonItem('C'), + ]; - const $itemsBefore = toolbar._getAvailableItems(); - this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($itemsBefore.eq(1)).get(0) })); - this.clock.tick(0); + const triggerFocusinOn = ($item, clock) => { + $(TOOLBAR_SELECTOR).trigger($.Event('focusin', { target: findFocusTarget($item).get(0) })); + clock.tick(0); + }; - toolbar.option('items', [itemA, itemB]); - this.clock.tick(0); + QUnit.test('ArrowRight skips disabled middle item (focusin-driven)', function(assert) { + const toolbar = createToolbar(triadWithMiddleDisabled()); + const $available = toolbar._getAvailableItems(); - 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; - }, $()); + triggerFocusinOn($available.eq(0), this.clock); + press('ArrowRight', findFocusTarget($available.eq(0)).get(0)); - assert.strictEqual( - parseInt(getItemFocusTarget($newItemB).attr('tabindex'), 10), - 0, - 'B retains tabindex=0 after removing non-active C', - ); + assertFocusedItemAt(assert, toolbar, 1, + 'ArrowRight skipped disabled item and landed on 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); + QUnit.test('ArrowLeft skips disabled middle item (focusin-driven)', function(assert) { + const toolbar = createToolbar(triadWithMiddleDisabled()); + const $available = toolbar._getAvailableItems(); - 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; - }, $()); + triggerFocusinOn($available.eq(1), this.clock); + press('ArrowLeft', findFocusTarget($available.eq(1)).get(0)); - assert.strictEqual( - parseInt(getItemFocusTarget($newItemA).attr('tabindex'), 10), - 0, - 'Focus moved to previous item A after removing active B', - ); + assertFocusedItemAt(assert, toolbar, 0, + 'ArrowLeft skipped disabled item and landed on A'); }); - 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); + QUnit.test('Home skips leading disabled items (focusin-driven)', function(assert) { + const toolbar = createToolbar([ + buttonItem('Disabled', { disabled: true }), + buttonItem('B'), + buttonItem('C'), + ]); + const $available = toolbar._getAvailableItems(); - 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; - }, $()); + triggerFocusinOn($available.eq(1), this.clock); + press('Home', findFocusTarget($available.eq(1)).get(0)); - assert.strictEqual( - parseInt(getItemFocusTarget($newItemB).attr('tabindex'), 10), - 0, - 'New first item B gets tabindex=0 after removing first item A', - ); + assertFocusedItemAt(assert, toolbar, 0, + 'Home landed on first enabled item (B), skipping leading disabled'); }); - 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'); + QUnit.test('End skips trailing disabled items (focusin-driven)', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B'), + buttonItem('Disabled', { disabled: true }), + ]); + const $available = toolbar._getAvailableItems(); - dispatchKeydown(getItemFocusTarget($newItemA).get(0), 'ArrowRight'); - this.clock.tick(0); + triggerFocusinOn($available.eq(0), this.clock); + press('End', findFocusTarget($available.eq(0)).get(0)); - const { focusedElement } = toolbar.option(); - assert.strictEqual( - $(focusedElement).get(0), - $newItemC.get(0), - 'ArrowRight from A (new active after B removed) navigates to C', - ); + assertFocusedItemAt(assert, toolbar, 1, + 'End landed on last enabled item (B), skipping trailing disabled'); }); - 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'); - + QUnit.test('disabled item never receives tabindex=0 even after navigation', function(assert) { + const toolbar = createToolbar(triadWithMiddleDisabled()); const $available = toolbar._getAvailableItems(); + const $disabled = this.$element.find(`.${TOOLBAR_ITEM_CLASS}.${DISABLED_STATE_CLASS}`).first(); - 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); + triggerFocusinOn($available.eq(0), this.clock); + press('ArrowRight', findFocusTarget($available.eq(0)).get(0)); - const { focusedElement: afterSecond } = toolbar.option(); - assert.strictEqual( - $(afterSecond).get(0), - $available.eq(2).get(0), - 'ArrowRight moved to third item in DOM order', - ); + const tabIndexOnDisabled = parseInt(findFocusTarget($disabled).attr('tabindex'), 10); + assert.notStrictEqual(tabIndexOnDisabled, 0, + 'disabled item focus target never has tabindex=0'); }); }); @@ -2990,7 +2295,6 @@ QUnit.module('Overflow menu', moduleConfig, function() { assert.strictEqual(menu.option('opened'), true, 'overflow popup opened'); const $listItems = menu._list._getAvailableItems(); - // Navigate to the list item that contains dxMenu (assume it's at index 0 after Visible button is excluded from menu) const $menuListItem = $listItems.toArray().map((el) => $(el)).find(($i) => $i.find('.dx-menu').length > 0); assert.ok($menuListItem, 'found a list item containing dxMenu'); @@ -3299,7 +2603,6 @@ QUnit.module('Template items', moduleConfig, function() { ); }); - // --- Template with (link) --- QUnit.test('template with is included in _getAvailableItems', function(assert) { const toolbar = this.$element.dxToolbar({ @@ -3406,7 +2709,6 @@ QUnit.module('Template items', moduleConfig, function() { 'ArrowLeft from link template navigates to previous toolbar item'); }); - // --- Template with DX-widget --- QUnit.test('template with dxButton widget: included in _getAvailableItems', function(assert) { const toolbar = this.$element.dxToolbar({ @@ -3463,7 +2765,6 @@ QUnit.module('Template items', moduleConfig, function() { 'ArrowRight from template dxButton navigates to next toolbar item'); }); - // --- Template with multiple focusable elements (one stop) --- QUnit.test('template with multiple focusable elements: item is one stop in _getAvailableItems', function(assert) { const toolbar = this.$element.dxToolbar({ @@ -3546,8 +2847,6 @@ QUnit.module('Template items', moduleConfig, function() { }); QUnit.skip('template with multiple focusable: inner elements have tabindex=-1 before activation', function(assert) { - // NOT IMPLEMENTED: toolbar does not manage inner element tabindexes for templates. - // Only the first native focusable (focus target) gets tabindex from roving mechanism. this.$element.dxToolbar({ items: [ @@ -3575,7 +2874,6 @@ QUnit.module('Template items', moduleConfig, function() { }); QUnit.skip('template with multiple focusable: ArrowRight/Left inside activated mode do NOT navigate toolbar', function(assert) { - // NOT IMPLEMENTED: no inner-focus mode for templates yet. const toolbar = this.$element.dxToolbar({ items: [ @@ -3597,11 +2895,9 @@ QUnit.module('Template items', moduleConfig, function() { this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($templateItem).get(0) })); this.clock.tick(0); - // Activate inner mode via Enter dispatchKeydown(getItemFocusTarget($templateItem).get(0), 'Enter'); this.clock.tick(0); - // Now ArrowRight should NOT navigate toolbar dispatchKeydown(document.activeElement, 'ArrowRight'); this.clock.tick(0); @@ -4437,10 +3733,6 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { }); QUnit.test('Non-activation keys at toolbar nav level do not activate dxMenu', function(assert) { - // dxMenu's keyboard handler is always attached; the toolbar prevents - // observable effects at nav level by intercepting activation keys - // (Arrow/Enter/Space) in its capture phase. Other keys reach dxMenu - // but it has no behavior for them (they are not in _supportedKeys). const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4463,9 +3755,6 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { }); QUnit.test('Tab landing on .dx-toolbar-item does not auto-activate dxMenu', function(assert) { - // The nav-level Tab stop is the .dx-toolbar-item wrapper (tabindex=0), - // not the .dx-menu root (tabindex=-1). So Tab lands on the wrapper and - // dxMenu's CollectionWidget-inherited auto-activation never fires. const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4493,14 +3782,12 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { dispatchKeydown(this.$element.get(0), 'Enter'); this.clock.tick(50); - // Open submenu via ArrowDown dispatchKeydown(document.activeElement, 'ArrowDown'); this.clock.tick(300); const $expanded = $items.eq(1).find('.dx-menu-item-expanded'); assert.ok($expanded.length > 0, 'submenu is open after ArrowDown'); - // First Escape — must close submenu, NOT exit to nav level dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); @@ -4514,7 +3801,6 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { assert.notStrictEqual(menuInstance.option('focusedElement'), null, 'menu is still active (focusedElement set) after first Escape'); - // Second Escape — exits to toolbar nav level dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); @@ -4527,9 +3813,6 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { }); QUnit.test('ArrowDown at nav level does NOT activate dxMenu (menu is already visible)', function(assert) { - // dxMenu is a visible horizontal menubar inside the toolbar item, not - // a popup menu button. ArrowDown does not "open" anything, so it is - // not an activation key here — activation requires Enter/Space. const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); @@ -4567,23 +3850,19 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); - // First activation — focuses first item (default). toolbar.option('focusedElement', $items.eq(1).get(0)); dispatchKeydown(this.$element.get(0), 'Enter'); this.clock.tick(50); - // Navigate to second root item. dispatchKeydown(document.activeElement, 'ArrowRight'); this.clock.tick(50); const $menu = $items.eq(1).find('.dx-menu').first(); const $menuItems = $menu.find('.dx-menu-item'); - // Exit to nav level. dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); - // Re-activate. dxMenu should restore the second item, not jump to first. dispatchKeydown(this.$element.get(0), 'Enter'); this.clock.tick(50); @@ -4597,19 +3876,15 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); - // Activate dxMenu — focusedElement = first root item ("File"). toolbar.option('focusedElement', $items.eq(1).get(0)); dispatchKeydown(this.$element.get(0), 'Enter'); this.clock.tick(50); const $menu = $items.eq(1).find('.dx-menu').first(); - // Move right to second root item ("Edit"). dispatchKeydown(document.activeElement, 'ArrowRight'); this.clock.tick(50); - // ArrowDown on "Edit" opens its submenu. dxMenu resets focusedElement - // to null internally at this point (it tracks state via _visibleSubmenu). dispatchKeydown(document.activeElement, 'ArrowDown'); this.clock.tick(300); @@ -4617,10 +3892,6 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { assert.strictEqual($expandedBefore.length, 1, 'submenu open on the second root item'); const expandedElement = $expandedBefore.get(0); - // Next ArrowDown — must navigate within the submenu via dxMenu's own - // handler. Regression guard: previously the toolbar saw focusedElement=null - // and treated this as nav-level activation, jumping focus to the first - // root item ("File") and closing the open submenu. dispatchKeydown(document.activeElement, 'ArrowDown'); this.clock.tick(50); @@ -4631,17 +3902,12 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { }); }); -// Same set of Enter/Exit scenarios as for dxMenu inside a toolbar item, -// applied to dxMenu nested in an overflow popup list item. QUnit.module('Enter/Exit: dxMenu inside overflow list', moduleConfig, function() { const menuItems = [ { text: 'File', items: [{ text: 'New' }, { text: 'Open' }] }, { text: 'Edit', items: [{ text: 'Cut' }, { text: 'Copy' }] }, ]; - // Returns { list, $menuListItem, $menuRoot, menuInstance } with the - // overflow popup opened and the list item containing dxMenu pre-focused - // at list nav level. function setupOverflowWithMenu($el, clock) { const toolbar = $el.dxToolbar({ items: [ @@ -4748,14 +4014,12 @@ QUnit.module('Enter/Exit: dxMenu inside overflow list', moduleConfig, function() dispatchKeydown(document.activeElement, 'Enter'); this.clock.tick(50); - // Open submenu via ArrowDown (now at active level — dxMenu owns this key) dispatchKeydown(document.activeElement, 'ArrowDown'); this.clock.tick(300); assert.ok($menuRoot.find('.dx-menu-item-expanded').length > 0, 'submenu is open after ArrowDown'); - // First Escape — closes submenu, stays at active level dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); @@ -4764,7 +4028,6 @@ QUnit.module('Enter/Exit: dxMenu inside overflow list', moduleConfig, function() assert.ok($menuListItem.get(0).contains(document.activeElement), 'focus is still inside dxMenu list item after first Escape'); - // Second Escape — exits to list nav dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); @@ -4777,21 +4040,17 @@ QUnit.module('Enter/Exit: dxMenu inside overflow list', moduleConfig, function() QUnit.test('Re-activating dxMenu restores previously focused item', function(assert) { const { $menuRoot } = setupOverflowWithMenu(this.$element, this.clock); - // First activation dispatchKeydown(document.activeElement, 'Enter'); this.clock.tick(50); - // Navigate to second root item dispatchKeydown(document.activeElement, 'ArrowRight'); this.clock.tick(50); const $menuItems = $menuRoot.find('.dx-menu-item'); - // Exit to list nav dispatchKeydown(document.activeElement, 'Escape'); this.clock.tick(50); - // Re-activate dispatchKeydown(document.activeElement, 'Enter'); this.clock.tick(50); @@ -4897,3 +4156,562 @@ QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { }); }); + +QUnit.module('Arrow navigation — RTL', moduleConfig, function() { + QUnit.test('ArrowLeft navigates to the visually-right item when rtlEnabled', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B'), buttonItem('C')], + { rtlEnabled: true }, + ); + focusItemAt(toolbar, 0); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 1, 'ArrowLeft navigates forward in RTL'); + }); + + QUnit.test('ArrowRight navigates to the visually-left item when rtlEnabled', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B'), buttonItem('C')], + { rtlEnabled: true }, + ); + focusItemAt(toolbar, 2); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 1, 'ArrowRight navigates backward in RTL'); + }); + + QUnit.test('Home/End remain absolute regardless of RTL', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B'), buttonItem('C')], + { rtlEnabled: true }, + ); + focusItemAt(toolbar, 1); + + press('Home'); + assertFocusedItemAt(assert, toolbar, 0, 'Home goes to first item in DOM order'); + + press('End'); + assertFocusedItemAt(assert, toolbar, 2, 'End goes to last item in DOM order'); + }); + + QUnit.test('RTL toggled at runtime flips the arrow semantics', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + press('ArrowRight'); + assertFocusedItemAt(assert, toolbar, 1, 'ArrowRight goes forward in LTR'); + + toolbar.option('rtlEnabled', true); + focusItemAt(toolbar, 0); + + press('ArrowLeft'); + assertFocusedItemAt(assert, toolbar, 1, 'ArrowLeft goes forward after switching to RTL'); + }); +}); + +QUnit.module('Arrow navigation — loopItemFocus', moduleConfig, function() { + QUnit.test('default loops at the right edge (ArrowRight on last → first)', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 2); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 0, 'ArrowRight at last loops to first'); + }); + + QUnit.test('default loops at the left edge (ArrowLeft on first → last)', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 2, 'ArrowLeft at first loops to last'); + }); + + QUnit.test('loopItemFocus:false keeps focus on last item when pressing ArrowRight', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B'), buttonItem('C')], + { loopItemFocus: false }, + ); + focusItemAt(toolbar, 2); + + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 2, 'ArrowRight at last stays put when loopItemFocus:false'); + }); + + QUnit.test('loopItemFocus:false keeps focus on first item when pressing ArrowLeft', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B'), buttonItem('C')], + { loopItemFocus: false }, + ); + focusItemAt(toolbar, 0); + + press('ArrowLeft'); + + assertFocusedItemAt(assert, toolbar, 0, 'ArrowLeft at first stays put when loopItemFocus:false'); + }); + + QUnit.test('Home/End work identically regardless of loopItemFocus', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B'), buttonItem('C')], + { loopItemFocus: false }, + ); + focusItemAt(toolbar, 1); + + press('End'); + assertFocusedItemAt(assert, toolbar, 2, 'End jumps to last'); + + press('Home'); + assertFocusedItemAt(assert, toolbar, 0, 'Home jumps to first'); + }); +}); + +QUnit.module('Item-level tabIndex option', moduleConfig, function() { + QUnit.test('active item receives custom tabIndex from item.options.tabIndex', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B', { options: { tabIndex: 5 } }), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); + + const $items = toolbar._getAvailableItems(); + assertActiveTabIndex(assert, $items.eq(1), 5, + 'item with options.tabIndex=5 keeps that value while active'); + }); + + QUnit.test('inactive item with custom tabIndex falls back to -1', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + buttonItem('B', { options: { tabIndex: 5 } }), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); + + press('ArrowRight'); + + const $items = toolbar._getAvailableItems(); + assertActiveTabIndex(assert, $items.eq(1), -1, + 'previously active item with custom tabIndex returns to -1 when inactive'); + assertActiveTabIndex(assert, $items.eq(2), 0, + 'newly active item gets default tabIndex=0'); + }); + + QUnit.test('exactly one tab stop is maintained when using custom item tabIndex', function(assert) { + const toolbar = createToolbar([ + buttonItem('A', { options: { tabIndex: 3 } }), + buttonItem('B', { options: { tabIndex: 5 } }), + buttonItem('C'), + ]); + focusItemAt(toolbar, 0); + + const $tabStops = this.$element.find('[tabindex]:not([tabindex="-1"])').not('.dx-texteditor-input'); + assert.strictEqual($tabStops.length, 1, 'exactly one positive-tabindex stop exists'); + }); +}); + +QUnit.module('Roving tabindex — incremental vs reset paths', moduleConfig, function() { + QUnit.test('arrow navigation uses _updateRovingTabIndex (incremental)', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + const updateSpy = sinon.spy(toolbar, '_updateRovingTabIndex'); + const resetSpy = sinon.spy(toolbar, '_resetRovingTabIndex'); + + press('ArrowRight'); + + assert.ok(updateSpy.called, '_updateRovingTabIndex is called on arrow navigation'); + assert.notOk(resetSpy.called, '_resetRovingTabIndex is NOT called on arrow navigation'); + + updateSpy.restore(); + resetSpy.restore(); + }); + + QUnit.test('item disabled option change triggers _resetRovingTabIndex (full pass)', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + const resetSpy = sinon.spy(toolbar, '_resetRovingTabIndex'); + + const items = toolbar.option('items').slice(); + items[1] = { ...items[1], disabled: true }; + toolbar.option('items', items); + this.clock.tick(0); + + assert.ok(resetSpy.called, '_resetRovingTabIndex was called after disabled change'); + + resetSpy.restore(); + }); + + QUnit.test('items option change triggers _resetRovingTabIndex', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B')]); + focusItemAt(toolbar, 0); + + const resetSpy = sinon.spy(toolbar, '_resetRovingTabIndex'); + + toolbar.option('items', [buttonItem('X'), buttonItem('Y'), buttonItem('Z')]); + this.clock.tick(0); + + assert.ok(resetSpy.called, '_resetRovingTabIndex was called after items option change'); + + resetSpy.restore(); + }); + + QUnit.test('after _resetRovingTabIndex, exactly one item has tabindex=0', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + toolbar._resetRovingTabIndex(); + + assertOneTabStop(assert, this.$element, + 'one tab stop after explicit reset (focused item retains the stop)'); + }); + + QUnit.test('after _updateRovingTabIndex on new active item, only prev/next change', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + const $items = toolbar._getAvailableItems(); + const initialFirst = parseInt(findFocusTarget($items.eq(0)).attr('tabindex'), 10); + + press('ArrowRight'); + + const afterFirst = parseInt(findFocusTarget($items.eq(0)).attr('tabindex'), 10); + const afterSecond = parseInt(findFocusTarget($items.eq(1)).attr('tabindex'), 10); + const afterThird = parseInt(findFocusTarget($items.eq(2)).attr('tabindex'), 10); + + assert.strictEqual(initialFirst, 0, 'first started as the tab stop'); + assert.strictEqual(afterFirst, -1, 'first lost the stop'); + assert.strictEqual(afterSecond, 0, 'second became the stop'); + assert.strictEqual(afterThird, -1, 'third remained untouched at -1'); + }); +}); + +QUnit.module('Tab key — toolbar does not intercept', moduleConfig, function() { + QUnit.test('Tab does not invoke roving navigation', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + const focusBefore = toolbar.option('focusedElement'); + press('Tab'); + this.clock.tick(0); + + assert.strictEqual(toolbar.option('focusedElement'), focusBefore, + 'Tab leaves focusedElement unchanged (browser handles natively)'); + }); + + QUnit.test('Shift+Tab does not invoke roving navigation', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + const focusBefore = toolbar.option('focusedElement'); + press('Tab', undefined, { shiftKey: true }); + this.clock.tick(0); + + assert.strictEqual(toolbar.option('focusedElement'), focusBefore, + 'Shift+Tab leaves focusedElement unchanged'); + }); +}); + +QUnit.module('focusStateEnabled — runtime toggle', moduleConfig, function() { + QUnit.test('toggling false removes the focus-state-enabled marker class', function(assert) { + const toolbar = createToolbar( + [buttonItem('A'), buttonItem('B')], + { focusStateEnabled: true }, + ); + assert.ok(this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'marker class present when focusStateEnabled:true'); + + toolbar.option('focusStateEnabled', false); + + assert.notOk(this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + 'marker class removed after toggling to false'); + }); + + QUnit.test('toggling false detaches arrow navigation', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + toolbar.option('focusStateEnabled', false); + + const focusBefore = toolbar.option('focusedElement'); + press('ArrowRight'); + this.clock.tick(0); + + assert.strictEqual(toolbar.option('focusedElement'), focusBefore, + 'ArrowRight is ignored when focusStateEnabled becomes false'); + }); + + QUnit.test('toggling back to true re-enables arrow navigation', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + toolbar.option('focusStateEnabled', false); + toolbar.option('focusStateEnabled', true); + + focusItemAt(toolbar, 0); + press('ArrowRight'); + + assertFocusedItemAt(assert, toolbar, 1, 'navigation works again after re-enabling'); + }); + + QUnit.test('toggling to false clears dx-state-focused on the active widget', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B')]); + focusItemAt(toolbar, 0); + this.clock.tick(0); + + toolbar.option('focusStateEnabled', false); + this.clock.tick(0); + + const $focused = this.$element.find('.dx-state-focused'); + assert.strictEqual($focused.length, 0, + 'no dx-state-focused after focusStateEnabled becomes false'); + }); +}); + +QUnit.module('Item collection updates — navigator stability', moduleConfig, function() { + QUnit.test('replacing all items after focus does not throw', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + try { + toolbar.option('items', [buttonItem('X'), buttonItem('Y')]); + this.clock.tick(0); + assert.ok(true, 'no error when items replaced after focus'); + } catch(e) { + assert.notOk(true, `unexpected error: ${e.message}`); + } + }); + + QUnit.test('extending items keeps a single tab stop', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + toolbar.option('items', [buttonItem('A'), buttonItem('B'), buttonItem('C'), buttonItem('D')]); + this.clock.tick(0); + + assertOneTabStop(assert, this.$element, + 'one tab stop preserved after items extended'); + }); + + QUnit.test('removing focused item leaves a single tab stop on a remaining item', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + toolbar.option('items', [buttonItem('A'), buttonItem('C')]); + this.clock.tick(0); + + assertOneTabStop(assert, this.$element, + 'one tab stop after focused item removal'); + }); + + QUnit.test('emptying items removes all tab stops without throwing', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 0); + + try { + toolbar.option('items', []); + this.clock.tick(0); + const $stops = this.$element.find('[tabindex="0"]').not('.dx-texteditor-input'); + assert.strictEqual($stops.length, 0, 'no tab stops when items list is empty'); + } catch(e) { + assert.notOk(true, `unexpected error: ${e.message}`); + } + }); +}); + +QUnit.module('Escape semantics (consolidated)', moduleConfig, function() { + QUnit.test('Escape on text-editor input closes editor and keeps focusedElement on item', function(assert) { + const toolbar = createToolbar([ + buttonItem('A'), + editorItem('dxTextBox', { value: 'hello', inputAttr: { 'aria-label': 't' } }), + buttonItem('C'), + ]); + focusItemAt(toolbar, 1); + press('Enter'); + this.clock.tick(50); + + const $items = toolbar._getAvailableItems(); + const $input = findInput($items.eq(1)); + press('Escape', $input.get(0)); + this.clock.tick(50); + + assertFocusedItemAt(assert, toolbar, 1, + 'focusedElement remains on the editor item after Escape'); + assert.notStrictEqual(document.activeElement, $input.get(0), + 'input has lost DOM focus after Escape'); + }); + + QUnit.test('Escape on a plain button item is a no-op (no error, focus unchanged)', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + try { + press('Escape'); + this.clock.tick(0); + assertFocusedItemAt(assert, toolbar, 1, 'focus unchanged on plain item Escape'); + } catch(e) { + assert.notOk(true, `unexpected error: ${e.message}`); + } + }); +}); + +QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleConfig, function() { + QUnit.test('_supportedKeys preserves base arrow/home/end handlers at focusStateEnabled:false', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + items: [buttonItem('A'), buttonItem('B')], + }).dxToolbar('instance'); + + const keys = toolbar._supportedKeys(); + assert.strictEqual(typeof keys.leftArrow, 'function', 'leftArrow inherited from CollectionWidget'); + assert.strictEqual(typeof keys.rightArrow, 'function', 'rightArrow inherited from CollectionWidget'); + assert.strictEqual(typeof keys.home, 'function', 'home inherited from CollectionWidget'); + assert.strictEqual(typeof keys.end, 'function', 'end inherited from CollectionWidget'); + }); + + QUnit.test('_supportedKeys deletes arrow/home/end handlers at focusStateEnabled:true (navigator owns)', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: true, + items: [buttonItem('A'), buttonItem('B')], + }).dxToolbar('instance'); + + const keys = toolbar._supportedKeys(); + assert.strictEqual(keys.leftArrow, undefined, 'leftArrow removed (handled by RovingTabIndexNavigator capture)'); + assert.strictEqual(keys.rightArrow, undefined, 'rightArrow removed'); + assert.strictEqual(keys.home, undefined, 'home removed'); + assert.strictEqual(keys.end, undefined, 'end removed'); + }); + + QUnit.test('onKeyboardHandled fires on arrow key when focusStateEnabled:false (super attachment preserved)', function(assert) { + const calls = []; + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + onKeyboardHandled: (opts) => { calls.push(opts.keyName); }, + items: [buttonItem('A'), buttonItem('B')], + }).dxToolbar('instance'); + + const focusTarget = toolbar._focusTarget().get(0); + dispatchKeydown(focusTarget, 'ArrowRight'); + + assert.ok(calls.length > 0, 'onKeyboardHandled was invoked at least once'); + assert.strictEqual(calls[0], 'rightArrow', 'callback received the rightArrow key'); + }); + + QUnit.test('navigator is not created at focusStateEnabled:false', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + items: [buttonItem('A'), buttonItem('B')], + }).dxToolbar('instance'); + + assert.strictEqual(toolbar._navigator, undefined, + 'no RovingTabIndexNavigator instance when focusStateEnabled:false'); + }); + + QUnit.test('navigator IS created at focusStateEnabled:true', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: true, + items: [buttonItem('A'), buttonItem('B')], + }).dxToolbar('instance'); + + assert.ok(toolbar._navigator, 'RovingTabIndexNavigator instance present at focusStateEnabled:true'); + }); + + QUnit.test('_moveFocus at focusStateEnabled:false delegates to super and moves focusedElement', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + items: [buttonItem('A'), buttonItem('B'), buttonItem('C')], + }).dxToolbar('instance'); + + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(0).get(0)); + toolbar._moveFocus('right'); + + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'super._moveFocus moves focusedElement to next item at focusStateEnabled:false'); + }); + + QUnit.test('toolbar.menu.list also delegates _supportedKeys to super at focusStateEnabled:false', function(assert) { + const toolbar = this.$element.dxToolbar({ + focusStateEnabled: false, + items: [ + { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, + { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, + ], + }).dxToolbar('instance'); + + const menu = toolbar._layoutStrategy._menu; + menu.option('opened', true); + this.clock.tick(0); + + const keys = menu._list._supportedKeys(); + assert.strictEqual(typeof keys.upArrow, 'function', 'menu list inherits upArrow from ListBase at focusStateEnabled:false'); + assert.strictEqual(typeof keys.downArrow, 'function', 'menu list inherits downArrow from ListBase'); + }); +}); + +QUnit.module('Audit cleanup — utilities and delegation', moduleConfig, function() { + QUnit.test('isItemDisabled util returns true when widgetDisabled flag is set', function(assert) { + const toolbar = this.$element.dxToolbar({ + disabled: true, + items: [buttonItem('A')], + }).dxToolbar('instance'); + + const $available = toolbar._getAvailableItems(); + assert.strictEqual($available.length, 0, + 'all items filtered out when widget disabled (isItemDisabled returns true)'); + }); + + QUnit.test('isItemDisabled util returns true for items with dx-state-disabled class', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + buttonItem('A'), + { widget: 'dxButton', locateInMenu: 'never', disabled: true, options: { text: 'B' } }, + buttonItem('C'), + ], + }).dxToolbar('instance'); + + const $available = toolbar._getAvailableItems(); + assert.strictEqual($available.length, 2, + 'disabled item excluded from available items (isItemDisabled detects dx-state-disabled)'); + }); + + QUnit.test('navigator.getAvailableItems delegates to widget._getAvailableItems', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + buttonItem('A'), + { widget: 'dxButton', locateInMenu: 'never', disabled: true, options: { text: 'B' } }, + buttonItem('C'), + ], + }).dxToolbar('instance'); + + const navigatorResult = toolbar._navigator.getAvailableItems().toArray(); + const widgetResult = toolbar._getAvailableItems().toArray(); + + assert.deepEqual(navigatorResult, widgetResult, + 'navigator returns same set as widget._getAvailableItems (delegation)'); + }); + + QUnit.test('menu list navigator.getAvailableItems uses menu-list-specific focus target logic', 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; + menu.option('opened', true); + this.clock.tick(0); + + const navigatorResult = menu._list._navigator.getAvailableItems().toArray(); + const listResult = menu._list._getAvailableItems().toArray(); + + assert.deepEqual(navigatorResult, listResult, + 'menu list navigator picks up TOOLBAR_MENU_ACTION_CLASS items via widget._getAvailableItems'); + assert.strictEqual(navigatorResult.length, 2, 'both menu action items are available'); + }); +}); + From ba9faf179ecab3f807b9cf0857ff29077bec4b3f Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Mon, 25 May 2026 12:15:13 +0400 Subject: [PATCH 35/47] refactor --- .../internal/roving.tabindex.navigator.ts | 66 +++++++++++-------- .../ui/toolbar/internal/toolbar.menu.list.ts | 2 +- .../js/__internal/ui/toolbar/toolbar.base.ts | 61 +++++++++-------- .../toolbar.kbn.tests.js | 45 +++++++------ .../toolbar.menu.tests.js | 2 +- 5 files changed, 98 insertions(+), 78 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts index 15aa9ae813ef..3878e1a6ddc6 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts @@ -3,6 +3,7 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { DxEvent } from '@js/events'; import type { Item } from '@js/ui/toolbar'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import { applyItemTabIndex, @@ -30,11 +31,21 @@ const VERTICAL_KEY_LOCATION: Record = { End: 'last', }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type HostWidget = any; +interface HostComponent { + $element: () => dxElementWrapper; + _keyboardEventBindingTarget: () => dxElementWrapper; + _keyboardHandler: (opts: KeyboardKeyDownEvent) => boolean; + option: () => { + focusedElement?: Element | dxElementWrapper | null; + focusStateEnabled?: boolean; + }; + _moveFocus: (location: string, e?: DxEvent) => void; + _getAvailableItems: ($itemElements?: dxElementWrapper) => dxElementWrapper; + _getItemData: ($item: dxElementWrapper) => Item; +} export interface RovingTabIndexNavigatorConfig { - widget: HostWidget; + component: HostComponent; itemsSelector: string; direction: Direction; getItemFocusTarget?: ($item: dxElementWrapper) => dxElementWrapper | undefined; @@ -47,7 +58,7 @@ export class RovingTabIndexNavigator { private keyboardListenerId?: string; - private captureHandler?: EventListener; + private captureHandler?: (e: KeyboardEvent) => void; private $prevActiveItem?: dxElementWrapper; @@ -58,13 +69,12 @@ export class RovingTabIndexNavigator { attach(): void { this.detach(); - const { widget } = this.config; + const { component } = this.config; this.keyboardListenerId = keyboard.on( - widget._keyboardEventBindingTarget(), + component._keyboardEventBindingTarget(), null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (opts: any) => widget._keyboardHandler(opts), + (opts: KeyboardKeyDownEvent) => component._keyboardHandler(opts), ); this.attachCaptureHandler(); @@ -92,11 +102,13 @@ export class RovingTabIndexNavigator { } private attachCaptureHandler(): void { - const element = this.config.widget.$element().get(0) as HTMLElement; + const element = this.config.component.$element().get(0) as HTMLElement; - this.captureHandler = (evt: Event): void => { - const e = evt as KeyboardEvent; - const target = e.target as HTMLElement; + this.captureHandler = (e: KeyboardEvent): void => { + const { target } = e; + if (!(target instanceof HTMLElement)) { + return; + } const isTextInput = isTextInputTarget(target); const isMenu = isMenuTarget(target); @@ -130,7 +142,7 @@ export class RovingTabIndexNavigator { return; } - const { focusedElement } = this.config.widget.option(); + const { focusedElement } = this.config.component.option(); const $focused = $(focusedElement); if ($focused.length && isItemWidgetOpened($focused)) { return; @@ -142,13 +154,13 @@ export class RovingTabIndexNavigator { this.moveFocus(location); }; - element.addEventListener('keydown', this.captureHandler, true); + element.addEventListener('keydown', this.captureHandler as EventListener, true); } private detachCaptureHandler(): void { if (this.captureHandler) { - const element = this.config.widget.$element().get(0) as HTMLElement; - element.removeEventListener('keydown', this.captureHandler, true); + const element = this.config.component.$element().get(0) as HTMLElement; + element.removeEventListener('keydown', this.captureHandler as EventListener, true); this.captureHandler = undefined; } } @@ -172,7 +184,7 @@ export class RovingTabIndexNavigator { } moveFocus(location: string, e?: DxEvent): void { - this.config.widget._moveFocus(location, e); + this.config.component._moveFocus(location, e); } focusItemWidget($item: dxElementWrapper): void { @@ -184,26 +196,26 @@ export class RovingTabIndexNavigator { } getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.config.widget._getAvailableItems($itemElements); + return this.config.component._getAvailableItems($itemElements); } getItemTabIndex($item: dxElementWrapper): number { - const itemData = this.config.widget._getItemData($item); + const itemData = this.config.component._getItemData($item); // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return (itemData as Item)?.options?.tabIndex ?? 0; + return itemData?.options?.tabIndex ?? 0; } updateRovingTabIndex($activeItem?: dxElementWrapper): void { - if (!this.config.widget.option('focusStateEnabled')) { + if (!this.config.component.option().focusStateEnabled) { return; } - const prev = this.$prevActiveItem?.get(0); + const $prev = this.$prevActiveItem; + const prev = $prev?.get(0); const next = $activeItem?.get(0); - if (prev && prev !== next && prev.isConnected) { - applyItemTabIndex(this.$prevActiveItem as dxElementWrapper, -1); + if ($prev && prev && prev !== next && prev.isConnected) { + applyItemTabIndex($prev, -1); } if ($activeItem?.length) { @@ -222,7 +234,7 @@ export class RovingTabIndexNavigator { } resetRovingTabIndex(itemsContainer: dxElementWrapper): void { - if (!this.config.widget.option('focusStateEnabled')) { + if (!this.config.component.option().focusStateEnabled) { return; } @@ -234,7 +246,7 @@ export class RovingTabIndexNavigator { this.$prevActiveItem = undefined; - const { focusedElement } = this.config.widget.option(); + const { focusedElement } = this.config.component.option(); const $focused = $(focusedElement); const $available = this.getAvailableItems(); const focusedEl = $focused.get(0); 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 5f1f0b8c95c5..af01f3577de1 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 @@ -210,7 +210,7 @@ export default class ToolbarMenuList extends ListBase { } this._navigator = new RovingTabIndexNavigator({ - widget: this, + component: this, itemsSelector: this._itemSelector(), direction: 'vertical', getItemFocusTarget: ($item): dxElementWrapper => this._getItemFocusTarget($item), diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 9bd52f1a97af..3ba3b5906e9a 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'; @@ -19,6 +20,7 @@ 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 type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; @@ -171,6 +173,12 @@ class ToolbarBase< return this.$element(); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _toggleFocusClass(_isFocused: boolean, _$element?: dxElementWrapper): void { + // Intentionally empty: focus visual is managed via :focus-visible on inner elements, + // not by dx-state-focused on the toolbar root or item containers. + } + _supportedKeys(): SupportedKeys { const keys = super._supportedKeys(); @@ -220,19 +228,31 @@ class ToolbarBase< } _renderFocusTarget(): void { - this._focusTarget().attr('tabIndex', -1); + this._focusTarget().removeAttr('tabIndex'); + } + + _refreshActiveDescendant(): void { + // roving tabIndex: real DOM focus moves to items, aria-activedescendant not needed + } + + _refreshItemId(): void { + // roving tabIndex: no synthetic id needed for aria-activedescendant } _attachKeyboardEvents(): void { this._detachKeyboardEvents(); if (!this.option('focusStateEnabled')) { - super._attachKeyboardEvents(); + this._keyboardListenerId = keyboard.on( + this._keyboardEventBindingTarget(), + null, + (opts: KeyboardKeyDownEvent) => this._keyboardHandler(opts), + ); return; } this._navigator = new RovingTabIndexNavigator({ - widget: this, + component: this, itemsSelector: `${this._itemSelector()}, .dx-dropdownmenu-button`, direction: 'horizontal', }); @@ -283,32 +303,11 @@ class ToolbarBase< } _focusInHandler(e: DxEvent): void { - if (this._isFocusTarget(e.target)) { - const { focusedElement } = this.option(); - const $focused = $(focusedElement); - - if ($focused.length && isItemWidgetOpened($focused)) { - return; - } - - super._focusInHandler(e); - - 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`); + const $target = $(e.target); + const $item = $target.closest(`${this._itemSelector()}, .dx-dropdownmenu-button`); - if ($item.length && getItemFocusTarget($item)?.length) { - this.option('focusedElement', getPublicElement($item)); - } + if ($item.length && getItemFocusTarget($item)?.length) { + this.option('focusedElement', getPublicElement($item)); } } @@ -382,7 +381,8 @@ class ToolbarBase< // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _moveFocus(location: string, e?: DxEvent): boolean | undefined | void { if (!this.option('focusStateEnabled')) { - return super._moveFocus(location, e); + const { focusedElement } = this.option(); + return focusedElement ? super._moveFocus(location, e) : undefined; } const { focusedElement: prevFocusedElement } = this.option(); @@ -763,6 +763,9 @@ class ToolbarBase< break; case 'focusStateEnabled': this.$element().toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!value); + if (!value) { + this.option('focusedElement', null); + } super._optionChanged(args); break; case 'grouped': 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 index 8b0bdb7bfee1..2688e42bfadc 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -4083,7 +4083,7 @@ QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { const getOverflowBtn = ($el) => $el.find(`.${DROP_DOWN_MENU_BUTTON_CLASS}`); - QUnit.test('overflow button gets dx-state-focused when focused via keyboard', function(assert) { + QUnit.test('overflow button is focused when navigated to via keyboard', function(assert) { const toolbar = makeOverflowToolbar(this.$element); const $overflowBtn = getOverflowBtn(this.$element); @@ -4091,11 +4091,11 @@ QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { toolbar._focusItemWidget($overflowBtn); this.clock.tick(0); - assert.ok($overflowBtn.hasClass('dx-state-focused'), - 'overflow button has dx-state-focused'); + assert.strictEqual(document.activeElement, $overflowBtn.get(0), + 'overflow button is the active element'); }); - QUnit.test('overflow button retains dx-state-focused after Escape closes popup', function(assert) { + QUnit.test('overflow button retains focus after Escape closes popup', function(assert) { const toolbar = makeOverflowToolbar(this.$element); const $overflowBtn = getOverflowBtn(this.$element); const menu = toolbar._layoutStrategy._menu; @@ -4114,11 +4114,11 @@ QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { this.clock.tick(0); assert.notOk(menu.option('opened'), 'popup closed after Escape'); - assert.ok($overflowBtn.hasClass('dx-state-focused'), - 'overflow button retains dx-state-focused after popup closes'); + assert.strictEqual(document.activeElement, $overflowBtn.get(0), + 'overflow button retains focus after popup closes'); }); - QUnit.test('ArrowRight from visible button navigates to overflow button with dx-state-focused', function(assert) { + QUnit.test('ArrowRight from visible button navigates focus to overflow button', function(assert) { const toolbar = makeOverflowToolbar(this.$element); const $items = toolbar._getAvailableItems(); const $overflowBtn = getOverflowBtn(this.$element); @@ -4132,8 +4132,8 @@ QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { assert.strictEqual($(toolbar.option('focusedElement')).get(0), $overflowBtn.get(0), 'focusedElement is the overflow button'); - assert.ok($overflowBtn.hasClass('dx-state-focused'), - 'overflow button has dx-state-focused after navigation'); + assert.strictEqual(document.activeElement, $overflowBtn.get(0), + 'overflow button is focused after navigation'); }); QUnit.test('previous button loses dx-state-focused when focus moves to overflow button', function(assert) { @@ -4456,17 +4456,21 @@ QUnit.module('focusStateEnabled — runtime toggle', moduleConfig, function() { assertFocusedItemAt(assert, toolbar, 1, 'navigation works again after re-enabling'); }); - QUnit.test('toggling to false clears dx-state-focused on the active widget', function(assert) { + QUnit.test('toolbar item containers never get dx-state-focused regardless of focusStateEnabled', function(assert) { const toolbar = createToolbar([buttonItem('A'), buttonItem('B')]); focusItemAt(toolbar, 0); this.clock.tick(0); + assert.strictEqual(this.$element.find('.dx-toolbar-item.dx-state-focused').length, 0, + 'item container has no dx-state-focused while focused'); + assert.strictEqual(this.$element.filter('.dx-state-focused').length, 0, + 'toolbar root has no dx-state-focused while focused'); + toolbar.option('focusStateEnabled', false); this.clock.tick(0); - const $focused = this.$element.find('.dx-state-focused'); - assert.strictEqual($focused.length, 0, - 'no dx-state-focused after focusStateEnabled becomes false'); + assert.strictEqual(this.$element.find('.dx-toolbar-item.dx-state-focused').length, 0, + 'item container still has no dx-state-focused after focusStateEnabled becomes false'); }); }); @@ -4584,19 +4588,20 @@ QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleCo assert.strictEqual(keys.end, undefined, 'end removed'); }); - QUnit.test('onKeyboardHandled fires on arrow key when focusStateEnabled:false (super attachment preserved)', function(assert) { - const calls = []; + QUnit.test('ArrowRight delegates to base CollectionWidget when focusStateEnabled:false (super attachment preserved)', function(assert) { const toolbar = this.$element.dxToolbar({ focusStateEnabled: false, - onKeyboardHandled: (opts) => { calls.push(opts.keyName); }, items: [buttonItem('A'), buttonItem('B')], }).dxToolbar('instance'); - const focusTarget = toolbar._focusTarget().get(0); - dispatchKeydown(focusTarget, 'ArrowRight'); + const $items = toolbar._getAvailableItems(); + toolbar.option('focusedElement', $items.eq(0).get(0)); + + dispatchKeydown($items.eq(0).get(0), 'ArrowRight'); + this.clock.tick(0); - assert.ok(calls.length > 0, 'onKeyboardHandled was invoked at least once'); - assert.strictEqual(calls[0], 'rightArrow', 'callback received the rightArrow key'); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), + 'ArrowRight moves focus via base CollectionWidget handler (super attachment preserved)'); }); QUnit.test('navigator is not created at focusStateEnabled:false', function(assert) { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js index 5cdc6fc8308b..205f960c49bb 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.menu.tests.js @@ -652,7 +652,7 @@ QUnit.module('widget sizing render', moduleConfig, () => { beforeEach: function() { this.instance.option({ opened: false, - focusStateEnabled: false, + listFocusStateEnabled: false, items: [{ template: () => $('
').dxCheckBox({ value: false }) }], }); }, From d860094003228e8984f0179698627b3bfd44dd85 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 28 May 2026 01:46:53 +0400 Subject: [PATCH 36/47] fix grids scenarios --- .../grid_core/header_panel/m_header_panel.ts | 2 +- .../ui/html_editor/modules/m_toolbar.ts | 2 +- .../js/__internal/ui/toolbar/toolbar.utils.ts | 2 +- .../toolbar.kbn.tests.js | 64 +++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts index 2ed9e8c493a2..6255c7d5d99f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts @@ -133,7 +133,7 @@ export class HeaderPanel extends ColumnsView { const options: { toolbarOptions: ToolbarProperties } = { toolbarOptions: { items: sortedToolbarItems, - focusStateEnabled: false, + focusStateEnabled: true, visible: userToolbarOptions?.visible, disabled: userToolbarOptions?.disabled, onItemRendered(e) { diff --git a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts index fa92e74e6a07..fe192bb2abbd 100644 --- a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts +++ b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts @@ -209,7 +209,7 @@ if (Quill) { get toolbarConfig() { return { dataSource: this._prepareToolbarItems(), - focusStateEnabled: false, + focusStateEnabled: true, disabled: this.isInteractionDisabled, menuContainer: this._$toolbarContainer, multiline: this.isMultilineMode(), diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index 38f9ccf8f2ba..bbfd7770aaa1 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -10,7 +10,7 @@ import type Toolbar from './toolbar'; const BUTTON_GROUP_CLASS = 'dx-buttongroup'; 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 NATIVE_FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]'; export function isTextInputTarget(target: HTMLElement): boolean { const tagName = target.tagName.toLowerCase(); 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 index 2688e42bfadc..8bf9d0ee84b8 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -2432,6 +2432,70 @@ QUnit.module('Template items', moduleConfig, function() { assert.strictEqual($(focusedElement).get(0), $itemC.get(0), 'ArrowRight from template item moves focus to C'); }); + QUnit.test('template item with [tabindex] div (not native button) is in roving tabindex sequence', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', template: () => $('
').text('Group item') }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $available = toolbar._getAvailableItems(); + assert.strictEqual($available.length, 3, 'Template item with [tabindex] div is in navigation sequence'); + }); + + QUnit.test('ArrowRight navigates to template item with [tabindex] div content', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, + { locateInMenu: 'never', template: () => $('
').text('Group item') }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'C' } }, + ], + }).dxToolbar('instance'); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $itemA = $allItems.eq(0); + const $templateItem = $allItems.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), $templateItem.get(0), 'focusedElement is the template item container'); + + const $focusTarget = getItemFocusTarget($templateItem); + assert.strictEqual(parseInt($focusTarget.attr('tabindex'), 10), 0, 'Template div has tabindex=0'); + }); + + QUnit.test('navigation round-trip: leave and return to [tabindex] div template item', function(assert) { + const toolbar = this.$element.dxToolbar({ + items: [ + { locateInMenu: 'never', template: () => $('
').text('Group item') }, + { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, + ], + }).dxToolbar('instance'); + + const $allItems = this.$element.find(`.${TOOLBAR_ITEM_CLASS}`); + const $templateItem = $allItems.eq(0); + const $itemB = $allItems.eq(1); + + this.$element.trigger($.Event('focusin', { target: getItemFocusTarget($templateItem).get(0) })); + this.clock.tick(0); + + dispatchKeydown(getItemFocusTarget($templateItem).get(0), 'ArrowRight'); + this.clock.tick(0); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $itemB.get(0), 'moved to B'); + + dispatchKeydown(getItemFocusTarget($itemB).get(0), 'ArrowLeft'); + this.clock.tick(0); + assert.strictEqual($(toolbar.option('focusedElement')).get(0), $templateItem.get(0), 'returned to template item'); + assert.strictEqual(parseInt(getItemFocusTarget($templateItem).attr('tabindex'), 10), 0, 'template div tabindex restored to 0'); + }); + QUnit.skip('Enter on template container: _insideActiveItem===true; focus moves to first focusable', function(assert) { this.$element.dxToolbar({ items: [ From a4dc10f1acb9241b32802c2b74be154ff06e83b0 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 28 May 2026 03:07:03 +0400 Subject: [PATCH 37/47] fix styles --- .../_index.scss} | 0 .../widgets/base/dropDownMenu/_mixins.scss | 22 +++++++++++++++++++ .../scss/widgets/base/toolbar/_mixins.scss | 20 ----------------- .../widgets/fluent/dropDownMenu/_index.scss | 5 +++++ .../widgets/generic/dropDownMenu/_index.scss | 9 ++++++++ .../widgets/generic/dropDownMenu/_sizes.scss | 1 + .../widgets/material/dropDownMenu/_index.scss | 6 +++++ .../widgets/material/dropDownMenu/_sizes.scss | 1 + 8 files changed, 44 insertions(+), 20 deletions(-) rename packages/devextreme-scss/scss/widgets/base/{_dropDownMenu.scss => dropDownMenu/_index.scss} (100%) create mode 100644 packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss diff --git a/packages/devextreme-scss/scss/widgets/base/_dropDownMenu.scss b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_index.scss similarity index 100% rename from packages/devextreme-scss/scss/widgets/base/_dropDownMenu.scss rename to packages/devextreme-scss/scss/widgets/base/dropDownMenu/_index.scss diff --git a/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss new file mode 100644 index 000000000000..d249802a848d --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss @@ -0,0 +1,22 @@ +@mixin dx-dropdownmenu-focus-outline( + $accent-color, + $border-radius, + $section-horizontal-margin, +) { + .dx-dropdownmenu-popup-wrapper.dx-toolbar-focus-state-enabled { + .dx-dropdownmenu-list { + .dx-list-item:has([tabindex="0"]:focus-visible), + .dx-list-item[tabindex="0"]:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid $accent-color; + outline-offset: 1px; + border-radius: $border-radius; + } + } + + .dx-toolbar-menu-section { + margin-inline: $section-horizontal-margin; + } + } +} diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index 0b8c8b8ab04d..1590809b54bd 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -15,24 +15,4 @@ border-radius: $border-radius; } } - - .dx-dropdownmenu-popup-wrapper.dx-toolbar-focus-state-enabled { - .dx-dropdownmenu-list { - .dx-list-item:has([tabindex="0"]:focus-visible) { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } - - .dx-list-item[tabindex="0"]:focus-visible { - outline: 2px solid $accent-color; - outline-offset: 1px; - border-radius: $border-radius; - } - } - } - - .dx-dropdownmenu-popup-wrapper .dx-toolbar-menu-section { - margin-inline: 4px; - } } diff --git a/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss b/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss index b762c89e5c5e..2572b0b74486 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/dropDownMenu/_index.scss @@ -1,8 +1,11 @@ @use "sizes" as *; @use "../sizes" as *; +@use "colors" as *; +@use "../colors" as *; @use "../common/mixins" as *; @use "../common/sizes" as *; @use "../../base/dropDownMenu"; +@use "../../base/dropDownMenu/mixins" as *; // adduse @@ -18,3 +21,5 @@ box-shadow: $fluent-base-dropdown-widgets-shadow; } } + +@include dx-dropdownmenu-focus-outline($base-accent, $base-border-radius, $fluent-dropdownmenu-section-horizontal-margin); diff --git a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss index e2a4af5e110a..240330f4862e 100644 --- a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss +++ b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_index.scss @@ -1 +1,10 @@ +@use "sizes" as *; +@use "../sizes" as *; +@use "colors" as *; +@use "../colors" as *; @use "../../base/dropDownMenu"; +@use "../../base/dropDownMenu/mixins" as *; + +// adduse + +@include dx-dropdownmenu-focus-outline($base-accent, $base-border-radius, $generic-dropdownmenu-section-horizontal-margin); diff --git a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss index 43b104010f9e..ff60f2dca0eb 100644 --- a/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/generic/dropDownMenu/_sizes.scss @@ -2,3 +2,4 @@ // adduse +$generic-dropdownmenu-section-horizontal-margin: 4px !default; diff --git a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss index 5d9758c7333f..a21ee2606da8 100644 --- a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss +++ b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_index.scss @@ -1,5 +1,9 @@ +@use "sizes" as *; @use "../sizes" as *; +@use "colors" as *; +@use "../colors" as *; @use "../../base/dropDownMenu"; +@use "../../base/dropDownMenu/mixins" as *; // adduse @@ -8,3 +12,5 @@ box-shadow: $material-base-dropdown-widgets-shadow; } } + +@include dx-dropdownmenu-focus-outline($base-accent, $base-border-radius, $material-dropdownmenu-section-horizontal-margin); diff --git a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss index 43b104010f9e..ff5de9a2df49 100644 --- a/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/material/dropDownMenu/_sizes.scss @@ -2,3 +2,4 @@ // adduse +$material-dropdownmenu-section-horizontal-margin: 4px !default; From 5374701c0686bc7b8b8ca9d091a47e56a33213d5 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Thu, 28 May 2026 09:27:53 +0300 Subject: [PATCH 38/47] refactoring --- .../devextreme/js/__internal/ui/toolbar/toolbar.utils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts index 38f9ccf8f2ba..04ed0ba09c22 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.utils.ts @@ -10,6 +10,7 @@ import type Toolbar from './toolbar'; const BUTTON_GROUP_CLASS = 'dx-buttongroup'; const DROP_DOWN_MENU_BUTTON_CLASS = 'dx-dropdownmenu-button'; const TOOLBAR_ITEMS = ['dxAutocomplete', 'dxButton', 'dxCheckBox', 'dxDateBox', 'dxDateRangeBox', 'dxMenu', 'dxSelectBox', 'dxSwitch', 'dxTabs', 'dxTextBox', 'dxButtonGroup', 'dxDropDownButton']; +const TOOLBAR_WIDGETS_SELECTOR = TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(','); const NATIVE_FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; export function isTextInputTarget(target: HTMLElement): boolean { @@ -69,7 +70,7 @@ const getWidgetName = ($element: dxElementWrapper): string => { }; export function closeItemWidget($item: dxElementWrapper): boolean { - const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); if (!$widgets.length) { return false; @@ -101,7 +102,7 @@ export function isItemDisabled($item: dxElementWrapper, widgetDisabled: boolean) } export function isItemWidgetOpened($item: dxElementWrapper): boolean { - const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); if (!$widgets.length) { return false; @@ -122,7 +123,7 @@ export function getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | return $item; } - const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); if (!$widgets.length) { const $nativeFocusable = $item.find(NATIVE_FOCUSABLE_SELECTOR).first(); @@ -164,7 +165,7 @@ export function applyItemTabIndex($item: dxElementWrapper, tabIndex: number): vo } export function setItemWidgetFocusState($item: dxElementWrapper, isFocused: boolean): void { - const $widgets = $item.find(TOOLBAR_ITEMS.map((w) => w.toLowerCase().replace('dx', '.dx-')).join(',')); + const $widgets = $item.find(TOOLBAR_WIDGETS_SELECTOR); if (!$widgets.length) { return; From 8d7900fd2730242caecda52a63cce173ef06bdf8 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 29 May 2026 12:25:32 +0400 Subject: [PATCH 39/47] avoid of duplication --- ...ex.navigator.ts => keyboard.navigation.ts} | 139 ++++++++++++++++-- .../ui/toolbar/internal/toolbar.menu.list.ts | 116 ++++----------- .../js/__internal/ui/toolbar/toolbar.base.ts | 126 +++++----------- 3 files changed, 195 insertions(+), 186 deletions(-) rename packages/devextreme/js/__internal/ui/toolbar/internal/{roving.tabindex.navigator.ts => keyboard.navigation.ts} (67%) diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts similarity index 67% rename from packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts rename to packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts index 3878e1a6ddc6..41b945e6ea47 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/roving.tabindex.navigator.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts @@ -2,18 +2,21 @@ import { keyboard } from '@js/common/core/events/short'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { DxEvent } from '@js/events'; -import type { Item } from '@js/ui/toolbar'; +import { getPublicElement } from '@ts/core/m_element'; import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; +import type Toolbar from '../toolbar'; import { applyItemTabIndex, closeItemWidget, closeOpenSubmenu, getItemFocusTarget as defaultGetItemFocusTarget, + isItemDisabled, isItemWidgetOpened, isMenuTarget, isTextInputTarget, } from '../toolbar.utils'; +import type ToolbarMenuList from './toolbar.menu.list'; type Direction = 'horizontal' | 'vertical'; @@ -31,18 +34,7 @@ const VERTICAL_KEY_LOCATION: Record = { End: 'last', }; -interface HostComponent { - $element: () => dxElementWrapper; - _keyboardEventBindingTarget: () => dxElementWrapper; - _keyboardHandler: (opts: KeyboardKeyDownEvent) => boolean; - option: () => { - focusedElement?: Element | dxElementWrapper | null; - focusStateEnabled?: boolean; - }; - _moveFocus: (location: string, e?: DxEvent) => void; - _getAvailableItems: ($itemElements?: dxElementWrapper) => dxElementWrapper; - _getItemData: ($item: dxElementWrapper) => Item; -} +type HostComponent = Toolbar | ToolbarMenuList; export interface RovingTabIndexNavigatorConfig { component: HostComponent; @@ -200,8 +192,9 @@ export class RovingTabIndexNavigator { } getItemTabIndex($item: dxElementWrapper): number { - const itemData = this.config.component._getItemData($item); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const itemData = this.config.component._getItemData($item) as + | { options?: { tabIndex?: number } } + | undefined; return itemData?.options?.tabIndex ?? 0; } @@ -259,3 +252,119 @@ export class RovingTabIndexNavigator { } } } + +export function enterKeyHandler( + component: HostComponent, + e: DxEvent, + callSuper: (e: DxEvent) => void, +): void { + const { focusStateEnabled, focusedElement } = component.option(); + + if (!focusStateEnabled) { + callSuper(e); + return; + } + + const target = e.target as HTMLElement; + if (isTextInputTarget(target) || isMenuTarget(target)) { + return; + } + + component._handleActivationAtNavLevel(e); + if (e.defaultPrevented) { + return; + } + + const $item = $(focusedElement); + if ($item.length) { + const $textEditor = $item.find('.dx-texteditor-input').first(); + if ($textEditor.length) { + e.preventDefault(); + ($textEditor.get(0) as HTMLElement).focus(); + return; + } + } + + callSuper(e); +} + +export function updateRovingTabIndex( + component: HostComponent, + $activeItem?: dxElementWrapper, +): void { + component._navigator?.updateRovingTabIndex($activeItem); +} + +export function setFocusedItem( + component: HostComponent, + $target: dxElementWrapper, + callSuper: ($target: dxElementWrapper) => void, +): void { + callSuper($target); + updateRovingTabIndex(component, $target); +} + +export function focusOutHandler( + component: HostComponent, + e: DxEvent, + callSuper: (e: DxEvent) => void, +): void { + const { relatedTarget } = e as DxEvent & { relatedTarget: Element }; + const target = e.target as Element; + + if (relatedTarget && component.$element().get(0)?.contains(relatedTarget)) { + return; + } + + if (relatedTarget && $(relatedTarget).closest('.dx-overlay-content').length) { + return; + } + + if (target && $(target).closest('.dx-overlay-content').length) { + return; + } + + callSuper(e); +} + +export function focusItemWidget( + component: HostComponent, + $item: dxElementWrapper, +): void { + if (component._navigator) { + component._navigator.focusItemWidget($item); + return; + } + const $focusTarget = component._getItemFocusTarget($item); + if (!$focusTarget?.length) { + return; + } + ($focusTarget.get(0) as HTMLElement).focus(); +} + +export function getAvailableItems( + component: HostComponent, + $itemElements?: dxElementWrapper, +): dxElementWrapper { + const $visible = component._getVisibleItems($itemElements); + const { disabled } = component.option(); + const widgetDisabled = !!disabled; + const elements = Array.from($visible.toArray()).filter( + (item) => !isItemDisabled($(item), widgetDisabled) + && !!component._getItemFocusTarget($(item))?.length, + ); + + return $(elements) as unknown as dxElementWrapper; +} + +export function focusInHandler( + component: HostComponent, + e: DxEvent, +): void { + const $target = $(e.target as Element); + const $item = $target.closest(component._getKeyboardNavItemSelector()); + + if ($item.length && defaultGetItemFocusTarget($item)?.length) { + component.option({ focusedElement: getPublicElement($item) }); + } +} 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 af01f3577de1..65c9fe897aed 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 @@ -10,15 +10,21 @@ 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 { RovingTabIndexNavigator } from '@ts/ui/toolbar/internal/roving.tabindex.navigator'; +import { + enterKeyHandler, + focusInHandler, + focusItemWidget, + focusOutHandler, + getAvailableItems, + RovingTabIndexNavigator, + setFocusedItem, + updateRovingTabIndex, +} from '@ts/ui/toolbar/internal/keyboard.navigation'; import { activateMenu, closeItemWidget, getItemFocusTarget, - isItemDisabled, isItemWidgetOpened, - isMenuTarget, - isTextInputTarget, } from '@ts/ui/toolbar/toolbar.utils'; export const TOOLBAR_MENU_ACTION_CLASS = 'dx-toolbar-menu-action'; @@ -31,12 +37,12 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content'; type ActionableComponents = Extract; export default class ToolbarMenuList extends ListBase { + _navigator?: RovingTabIndexNavigator; + _onEscapePress?: () => void; _onTabPress?: () => void; - _navigator?: RovingTabIndexNavigator; - protected _activeStateUnit(): string { return `.${TOOLBAR_MENU_ACTION_CLASS}:not(.${TOOLBAR_HIDDEN_BUTTON_GROUP_CLASS})`; } @@ -171,36 +177,6 @@ export default class ToolbarMenuList extends ListBase { return keys; } - _enterKeyHandler(e: DxEvent): void { - if (!this.option('focusStateEnabled')) { - super._enterKeyHandler(e); - return; - } - - const target = e.target as HTMLElement; - if (isTextInputTarget(target) || isMenuTarget(target)) { - return; - } - - this._handleActivationAtNavLevel(e); - if (e.defaultPrevented) { - 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; - } - } - - super._enterKeyHandler(e); - } - _attachKeyboardEvents(): void { this._detachKeyboardEvents(); @@ -230,49 +206,40 @@ export default class ToolbarMenuList extends ListBase { return getItemFocusTarget($item) ?? ($item.hasClass(TOOLBAR_MENU_ACTION_CLASS) ? $item : $()); } - _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { - const $visible = this._getVisibleItems($itemElements); - const widgetDisabled = !!this.option('disabled'); - const elements = Array.from($visible.toArray()).filter( - (item) => !isItemDisabled($(item), widgetDisabled) - && !!this._getItemFocusTarget($(item)).length, - ); + _getKeyboardNavItemSelector(): string { + return this._itemSelector(); + } - return $(elements) as unknown as dxElementWrapper; + _enterKeyHandler(e: DxEvent): void { + enterKeyHandler(this, e, (evt) => super._enterKeyHandler(evt)); } _setFocusedItem($target: dxElementWrapper): void { - super._setFocusedItem($target); - this._updateRovingTabIndex($target); + setFocusedItem(this, $target, ($t) => super._setFocusedItem($t)); } _updateRovingTabIndex($activeItem?: dxElementWrapper): void { - this._navigator?.updateRovingTabIndex($activeItem); + updateRovingTabIndex(this, $activeItem); } - _resetRovingTabIndex(): void { - this._navigator?.resetRovingTabIndex(this.$element()); + _focusOutHandler(e: DxEvent): void { + focusOutHandler(this, e, (evt) => super._focusOutHandler(evt)); } - _focusInHandler(e: DxEvent): void { - const $target = $(e.target as Element); - const $item = $target.closest(this._itemSelector()); + _focusItemWidget($item: dxElementWrapper): void { + focusItemWidget(this, $item); + } - if ($item.length && getItemFocusTarget($item)?.length) { - this.option('focusedElement', getPublicElement($item)); - } + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + return getAvailableItems(this, $itemElements); } - _focusItemWidget($item: dxElementWrapper): void { - if (this._navigator) { - this._navigator.focusItemWidget($item); - return; - } - const $focusTarget = this._getItemFocusTarget($item); - if (!$focusTarget?.length) { - return; - } - ($focusTarget.get(0) as HTMLElement).focus(); + _focusInHandler(e: DxEvent): void { + focusInHandler(this, e); + } + + _resetRovingTabIndex(): void { + this._navigator?.resetRovingTabIndex(this.$element()); } _handleActivationAtNavLevel(e: KeyboardEvent): void { @@ -291,25 +258,6 @@ export default class ToolbarMenuList extends ListBase { } } - _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; - } - - super._focusOutHandler(e); - } - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _moveFocus(location: string): boolean | undefined | void { if (!this.option('focusStateEnabled')) { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 3ba3b5906e9a..44972ad92b88 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -17,7 +17,6 @@ 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 type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; @@ -25,15 +24,21 @@ import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; import { TOOLBAR_CLASS, TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from './constants'; -import { RovingTabIndexNavigator } from './internal/roving.tabindex.navigator'; +import { + enterKeyHandler, + focusInHandler, + focusItemWidget, + focusOutHandler, + getAvailableItems, + RovingTabIndexNavigator, + setFocusedItem, + updateRovingTabIndex, +} from './internal/keyboard.navigation'; import { activateMenu, closeItemWidget, getItemFocusTarget, - isItemDisabled, isItemWidgetOpened, - isMenuTarget, - isTextInputTarget, } from './toolbar.utils'; export const TOOLBAR_BEFORE_CLASS = 'dx-toolbar-before'; @@ -197,34 +202,40 @@ class ToolbarBase< return keys; } + _getKeyboardNavItemSelector(): string { + return `${this._itemSelector()}, .dx-dropdownmenu-button`; + } + + _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | undefined { + return getItemFocusTarget($item); + } + _enterKeyHandler(e: DxEvent): void { - if (!this.option('focusStateEnabled')) { - super._enterKeyHandler(e); - return; - } + enterKeyHandler(this, e, (evt) => super._enterKeyHandler(evt)); + } - const target = e.target as HTMLElement; - if (isTextInputTarget(target) || isMenuTarget(target)) { - return; - } + _setFocusedItem($target: dxElementWrapper): void { + setFocusedItem(this, $target, ($t) => super._setFocusedItem($t)); + } - this._handleActivationAtNavLevel(e); - if (e.defaultPrevented) { - return; - } + _updateRovingTabIndex($activeItem?: dxElementWrapper): void { + updateRovingTabIndex(this, $activeItem); + } - 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; - } - } + _focusOutHandler(e: DxEvent): void { + focusOutHandler(this, e, (evt) => super._focusOutHandler(evt)); + } + + _focusItemWidget($item: dxElementWrapper): void { + focusItemWidget(this, $item); + } + + _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { + return getAvailableItems(this, $itemElements); + } - super._enterKeyHandler(e); + _focusInHandler(e: DxEvent): void { + focusInHandler(this, e); } _renderFocusTarget(): void { @@ -279,50 +290,10 @@ class ToolbarBase< return $items.filter(':visible'); } - _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { - const $visible = this._getVisibleItems($itemElements); - const widgetDisabled = !!this.option('disabled'); - const elements = Array.from($visible.toArray()).filter( - (item) => !isItemDisabled($(item), widgetDisabled) && !!getItemFocusTarget($(item))?.length, - ); - - return $(elements) as unknown as dxElementWrapper; - } - - _setFocusedItem($target: dxElementWrapper): void { - super._setFocusedItem($target); - this._updateRovingTabIndex($target); - } - - _updateRovingTabIndex($activeItem?: dxElementWrapper): void { - this._navigator?.updateRovingTabIndex($activeItem); - } - _resetRovingTabIndex(): void { this._navigator?.resetRovingTabIndex(this._itemContainer()); } - _focusInHandler(e: DxEvent): void { - 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 { - if (this._navigator) { - this._navigator.focusItemWidget($item); - return; - } - const $focusTarget = getItemFocusTarget($item); - if (!$focusTarget?.length) { - return; - } - ($focusTarget.get(0) as HTMLElement).focus(); - } - _handleActivationAtNavLevel(e: KeyboardEvent): void { const { focusedElement } = this.option(); const $focused = $(focusedElement); @@ -359,25 +330,6 @@ class ToolbarBase< this._openOverflowMenu(e.key === 'ArrowUp' ? 'last' : 'first'); } - _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; - } - - super._focusOutHandler(e); - } - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _moveFocus(location: string, e?: DxEvent): boolean | undefined | void { if (!this.option('focusStateEnabled')) { From ebf7a109410171693a4b086852b2e4a574b5a193 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Fri, 29 May 2026 13:31:23 +0400 Subject: [PATCH 40/47] refactor --- .../toolbar/internal/keyboard.navigation.ts | 44 ++++-------- .../ui/toolbar/internal/toolbar.menu.list.ts | 29 ++++---- .../js/__internal/ui/toolbar/toolbar.base.ts | 30 ++++----- .../toolbar.kbn.tests.js | 67 ++++++++++++++----- 4 files changed, 90 insertions(+), 80 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts index 41b945e6ea47..b23e184c96a3 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts @@ -5,7 +5,7 @@ import type { DxEvent } from '@js/events'; import { getPublicElement } from '@ts/core/m_element'; import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; -import type Toolbar from '../toolbar'; +import type ToolbarBase from '../toolbar.base'; import { applyItemTabIndex, closeItemWidget, @@ -34,7 +34,7 @@ const VERTICAL_KEY_LOCATION: Record = { End: 'last', }; -type HostComponent = Toolbar | ToolbarMenuList; +type HostComponent = ToolbarBase | ToolbarMenuList; export interface RovingTabIndexNavigatorConfig { component: HostComponent; @@ -179,6 +179,18 @@ export class RovingTabIndexNavigator { this.config.component._moveFocus(location, e); } + focusInHandler( + component: HostComponent, + e: DxEvent, + ): void { + const $target = $(e.target as Element); + const $item = $target.closest(this.config.itemsSelector); + + if ($item.length && defaultGetItemFocusTarget($item)?.length) { + component.option({ focusedElement: getPublicElement($item) }); + } + } + focusItemWidget($item: dxElementWrapper): void { const $focusTarget = this.getFocusTarget($item); if (!$focusTarget?.length) { @@ -288,22 +300,6 @@ export function enterKeyHandler( callSuper(e); } -export function updateRovingTabIndex( - component: HostComponent, - $activeItem?: dxElementWrapper, -): void { - component._navigator?.updateRovingTabIndex($activeItem); -} - -export function setFocusedItem( - component: HostComponent, - $target: dxElementWrapper, - callSuper: ($target: dxElementWrapper) => void, -): void { - callSuper($target); - updateRovingTabIndex(component, $target); -} - export function focusOutHandler( component: HostComponent, e: DxEvent, @@ -356,15 +352,3 @@ export function getAvailableItems( return $(elements) as unknown as dxElementWrapper; } - -export function focusInHandler( - component: HostComponent, - e: DxEvent, -): void { - const $target = $(e.target as Element); - const $item = $target.closest(component._getKeyboardNavItemSelector()); - - if ($item.length && defaultGetItemFocusTarget($item)?.length) { - component.option({ focusedElement: getPublicElement($item) }); - } -} 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 65c9fe897aed..d35e9c288a8a 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 @@ -12,13 +12,10 @@ import type { ItemRenderInfo, ItemTemplate } from '@ts/ui/collection/collection_ import { ListBase } from '@ts/ui/list/list.base'; import { enterKeyHandler, - focusInHandler, focusItemWidget, focusOutHandler, getAvailableItems, RovingTabIndexNavigator, - setFocusedItem, - updateRovingTabIndex, } from '@ts/ui/toolbar/internal/keyboard.navigation'; import { activateMenu, @@ -53,6 +50,14 @@ export default class ToolbarMenuList extends ListBase { // not by dx-state-focused on the list item container. } + _refreshActiveDescendant(): void { + // roving tabIndex: real DOM focus moves to items, aria-activedescendant not needed + } + + _refreshItemId(): void { + // roving tabIndex: no synthetic id needed for aria-activedescendant + } + _initMarkup(): void { this._renderSections(); super._initMarkup(); @@ -73,10 +78,8 @@ export default class ToolbarMenuList extends ListBase { each(['before', 'center', 'after', 'menu'], (_, section) => { const sectionName = `_$${section}Section`; - if (!this[sectionName]) { - this[sectionName] = $('
') - .addClass(TOOLBAR_MENU_SECTION_CLASS); - } + this[sectionName] ??= $('
') + .addClass(TOOLBAR_MENU_SECTION_CLASS); this[sectionName].appendTo($container); }); @@ -206,20 +209,14 @@ export default class ToolbarMenuList extends ListBase { return getItemFocusTarget($item) ?? ($item.hasClass(TOOLBAR_MENU_ACTION_CLASS) ? $item : $()); } - _getKeyboardNavItemSelector(): string { - return this._itemSelector(); - } - _enterKeyHandler(e: DxEvent): void { enterKeyHandler(this, e, (evt) => super._enterKeyHandler(evt)); } _setFocusedItem($target: dxElementWrapper): void { - setFocusedItem(this, $target, ($t) => super._setFocusedItem($t)); - } + super._setFocusedItem($target); - _updateRovingTabIndex($activeItem?: dxElementWrapper): void { - updateRovingTabIndex(this, $activeItem); + this._navigator?.updateRovingTabIndex($target); } _focusOutHandler(e: DxEvent): void { @@ -235,7 +232,7 @@ export default class ToolbarMenuList extends ListBase { } _focusInHandler(e: DxEvent): void { - focusInHandler(this, e); + this._navigator?.focusInHandler(this, e); } _resetRovingTabIndex(): void { diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 44972ad92b88..2eaf7e12626a 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -26,13 +26,10 @@ import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/c import { TOOLBAR_CLASS, TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from './constants'; import { enterKeyHandler, - focusInHandler, focusItemWidget, focusOutHandler, getAvailableItems, RovingTabIndexNavigator, - setFocusedItem, - updateRovingTabIndex, } from './internal/keyboard.navigation'; import { activateMenu, @@ -202,40 +199,34 @@ class ToolbarBase< return keys; } - _getKeyboardNavItemSelector(): string { - return `${this._itemSelector()}, .dx-dropdownmenu-button`; - } - _getItemFocusTarget($item: dxElementWrapper): dxElementWrapper | undefined { return getItemFocusTarget($item); } _enterKeyHandler(e: DxEvent): void { - enterKeyHandler(this, e, (evt) => super._enterKeyHandler(evt)); + enterKeyHandler(this._getContext(), e, (evt) => super._enterKeyHandler(evt)); } _setFocusedItem($target: dxElementWrapper): void { - setFocusedItem(this, $target, ($t) => super._setFocusedItem($t)); - } + super._setFocusedItem($target); - _updateRovingTabIndex($activeItem?: dxElementWrapper): void { - updateRovingTabIndex(this, $activeItem); + this._navigator?.updateRovingTabIndex($target); } _focusOutHandler(e: DxEvent): void { - focusOutHandler(this, e, (evt) => super._focusOutHandler(evt)); + focusOutHandler(this._getContext(), e, (evt) => super._focusOutHandler(evt)); } _focusItemWidget($item: dxElementWrapper): void { - focusItemWidget(this, $item); + focusItemWidget(this._getContext(), $item); } _getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper { - return getAvailableItems(this, $itemElements); + return getAvailableItems(this._getContext(), $itemElements); } _focusInHandler(e: DxEvent): void { - focusInHandler(this, e); + this._navigator?.focusInHandler(this._getContext(), e); } _renderFocusTarget(): void { @@ -263,13 +254,18 @@ class ToolbarBase< } this._navigator = new RovingTabIndexNavigator({ - component: this, + component: this._getContext(), itemsSelector: `${this._itemSelector()}, .dx-dropdownmenu-button`, direction: 'horizontal', }); this._navigator.attach(); } + _getContext(): ToolbarBase { + // @ts-expect-error ts-error + return this; + } + _detachKeyboardEvents(): void { this._navigator?.detach(); this._navigator = undefined; 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 index 8bf9d0ee84b8..d2a9d2095b72 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -111,7 +111,7 @@ const findFocusTarget = ($item) => { if($buttonGroup.length) return $buttonGroup; const $menu = $item.find('.dx-menu').first(); if($menu.length) return $menu; - const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href]').first(); + const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href], [tabindex]').first(); if($native.length) return $native; return $item; }; @@ -3762,8 +3762,8 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); - toolbar.option('focusedElement', $items.eq(1).get(0)); - toolbar._updateRovingTabIndex($items.eq(1)); + toolbar.option('focusedElement', $items.eq(0).get(0)); + dispatchKeydown(this.$element.get(0), 'ArrowRight'); this.clock.tick(0); assert.strictEqual($items.eq(1).attr('tabindex'), '0', @@ -3822,8 +3822,8 @@ QUnit.module('Enter/Exit: dxMenu (APG Menu Button)', moduleConfig, function() { const toolbar = createMenuToolbar(this.$element); const $items = toolbar._getAvailableItems(); - toolbar.option('focusedElement', $items.eq(1).get(0)); - toolbar._updateRovingTabIndex($items.eq(1)); + toolbar.option('focusedElement', $items.eq(0).get(0)); + dispatchKeydown(this.$element.get(0), 'ArrowRight'); this.clock.tick(0); $items.eq(1).get(0).focus(); @@ -4218,6 +4218,36 @@ QUnit.module('Overflow menu: visual focus states', moduleConfig, function() { assert.notOk($button.hasClass('dx-state-focused'), 'visible button lost dx-state-focused after focus moved'); }); + + QUnit.test('overflow list does not set aria-activedescendant on its container when item is focused', function(assert) { + const toolbar = makeOverflowToolbar(this.$element); + const menu = toolbar._layoutStrategy._menu; + + menu.openWithFocus('first'); + this.clock.tick(0); + + const list = menu._list; + const $listEl = list.$element(); + + assert.strictEqual($listEl.attr('aria-activedescendant'), undefined, + 'list container has no aria-activedescendant (roving tabindex owns focus)'); + }); + + QUnit.test('overflow list items do not receive a synthetic id attribute when focused', 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(); + + $items.each(function() { + assert.strictEqual($(this).attr('id'), undefined, + 'list item has no synthetic id (roving tabindex owns focus)'); + }); + }); }); @@ -4378,20 +4408,23 @@ QUnit.module('Item-level tabIndex option', moduleConfig, function() { }); QUnit.module('Roving tabindex — incremental vs reset paths', moduleConfig, function() { - QUnit.test('arrow navigation uses _updateRovingTabIndex (incremental)', function(assert) { - const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); - focusItemAt(toolbar, 0); - - const updateSpy = sinon.spy(toolbar, '_updateRovingTabIndex'); - const resetSpy = sinon.spy(toolbar, '_resetRovingTabIndex'); + QUnit.test('arrow navigation is incremental: unrelated items are not affected', function(assert) { + // Use 4 items so we can distinguish the two changed items from the untouched ones + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C'), buttonItem('D')]); + focusItemAt(toolbar, 1); // start on B - press('ArrowRight'); + const $items = toolbar._getAvailableItems(); - assert.ok(updateSpy.called, '_updateRovingTabIndex is called on arrow navigation'); - assert.notOk(resetSpy.called, '_resetRovingTabIndex is NOT called on arrow navigation'); + press('ArrowRight'); // only B and C should change - updateSpy.restore(); - resetSpy.restore(); + assert.strictEqual(parseInt(findFocusTarget($items.eq(0)).attr('tabindex'), 10), -1, + 'A is still -1 (was not re-scanned)'); + assert.strictEqual(parseInt(findFocusTarget($items.eq(1)).attr('tabindex'), 10), -1, + 'B lost tabindex=0'); + assert.strictEqual(parseInt(findFocusTarget($items.eq(2)).attr('tabindex'), 10), 0, + 'C gained tabindex=0'); + assert.strictEqual(parseInt(findFocusTarget($items.eq(3)).attr('tabindex'), 10), -1, + 'D is still -1 (was not re-scanned)'); }); QUnit.test('item disabled option change triggers _resetRovingTabIndex (full pass)', function(assert) { @@ -4434,7 +4467,7 @@ QUnit.module('Roving tabindex — incremental vs reset paths', moduleConfig, fun 'one tab stop after explicit reset (focused item retains the stop)'); }); - QUnit.test('after _updateRovingTabIndex on new active item, only prev/next change', function(assert) { + QUnit.test('ArrowRight: only the previously active and newly active items change tabindex', function(assert) { const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); focusItemAt(toolbar, 0); From fb5f5ad9e6589df79f2b9a758c9eaef43c1f4240 Mon Sep 17 00:00:00 2001 From: pharret31 Date: Thu, 28 May 2026 14:29:54 +0200 Subject: [PATCH 41/47] Add allowKeyboardNavigation option --- .../src/ui/toolbar/index.ts | 21 ++ .../scss/widgets/base/toolbar/_mixins.scss | 2 +- packages/devextreme-vue/src/toolbar.ts | 3 + .../js/__internal/ui/toolbar/constants.ts | 2 +- .../toolbar/internal/keyboard.navigation.ts | 9 +- .../ui/toolbar/internal/toolbar.menu.ts | 6 +- .../ui/toolbar/strategy/toolbar.singleline.ts | 6 +- .../js/__internal/ui/toolbar/toolbar.base.ts | 20 +- .../js/__internal/ui/toolbar/toolbar.ts | 6 +- packages/devextreme/js/ui/toolbar.d.ts | 6 + .../toolbar.kbn.tests.js | 208 +++++++++--------- packages/devextreme/ts/dx.all.d.ts | 4 + 12 files changed, 169 insertions(+), 124 deletions(-) diff --git a/packages/devextreme-angular/src/ui/toolbar/index.ts b/packages/devextreme-angular/src/ui/toolbar/index.ts index bedf9a4dc117..e672848351f8 100644 --- a/packages/devextreme-angular/src/ui/toolbar/index.ts +++ b/packages/devextreme-angular/src/ui/toolbar/index.ts @@ -74,6 +74,19 @@ export class DxToolbarComponent extends DxComponent imp instance: DxToolbar = null; + /** + * [descr:dxToolbarOptions.allowKeyboardNavigation] + + */ + @Input() + get allowKeyboardNavigation(): boolean { + return this._getOption('allowKeyboardNavigation'); + } + set allowKeyboardNavigation(value: boolean) { + this._setOption('allowKeyboardNavigation', value); + } + + /** * [descr:dxToolbarOptions.dataSource] @@ -319,6 +332,13 @@ export class DxToolbarComponent extends DxComponent imp */ @Output() onOptionChanged: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() allowKeyboardNavigationChange: EventEmitter; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -438,6 +458,7 @@ export class DxToolbarComponent extends DxComponent imp { subscribe: 'itemHold', emit: 'onItemHold' }, { subscribe: 'itemRendered', emit: 'onItemRendered' }, { subscribe: 'optionChanged', emit: 'onOptionChanged' }, + { emit: 'allowKeyboardNavigationChange' }, { emit: 'dataSourceChange' }, { emit: 'disabledChange' }, { emit: 'elementAttrChange' }, diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index 1590809b54bd..e20206cb78bd 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -2,7 +2,7 @@ $accent-color, $border-radius, ) { - .dx-toolbar.dx-toolbar-focus-state-enabled { + .dx-toolbar.dx-toolbar-keyboard-navigation { [tabindex="0"]:focus-visible:not(.dx-toolbar-item) { outline: 2px solid $accent-color; outline-offset: 1px; diff --git a/packages/devextreme-vue/src/toolbar.ts b/packages/devextreme-vue/src/toolbar.ts index 7dd398f1668e..5fe8158c5fea 100644 --- a/packages/devextreme-vue/src/toolbar.ts +++ b/packages/devextreme-vue/src/toolbar.ts @@ -30,6 +30,7 @@ import { import { prepareConfigurationComponentConfig } from "./core/index"; type AccessibleOptions = Pick) | DataSource | DataSourceOptions | null | Store | string | Record>, disabled: Boolean, elementAttr: Object as PropType>, @@ -86,6 +88,7 @@ const componentConfig = { emits: { "update:isActive": null, "update:hoveredElement": null, + "update:allowKeyboardNavigation": null, "update:dataSource": null, "update:disabled": null, "update:elementAttr": null, diff --git a/packages/devextreme/js/__internal/ui/toolbar/constants.ts b/packages/devextreme/js/__internal/ui/toolbar/constants.ts index 8dd1e0d1746c..3bc5ffde9df4 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/constants.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/constants.ts @@ -1,2 +1,2 @@ export const TOOLBAR_CLASS = 'dx-toolbar'; -export const TOOLBAR_FOCUS_STATE_ENABLED_CLASS = 'dx-toolbar-focus-state-enabled'; +export const TOOLBAR_KEYBOARD_NAVIGATION_CLASS = 'dx-toolbar-keyboard-navigation'; diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts index b23e184c96a3..8d988b0a4809 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts @@ -43,6 +43,7 @@ export interface RovingTabIndexNavigatorConfig { getItemFocusTarget?: ($item: dxElementWrapper) => dxElementWrapper | undefined; onTabKey?: () => void; onEscapeKey?: () => void; + isEnabled?: () => boolean; } export class RovingTabIndexNavigator { @@ -211,7 +212,9 @@ export class RovingTabIndexNavigator { } updateRovingTabIndex($activeItem?: dxElementWrapper): void { - if (!this.config.component.option().focusStateEnabled) { + const { isEnabled } = this.config; + const enabled = isEnabled ? isEnabled() : !!this.config.component.option().focusStateEnabled; + if (!enabled) { return; } @@ -239,7 +242,9 @@ export class RovingTabIndexNavigator { } resetRovingTabIndex(itemsContainer: dxElementWrapper): void { - if (!this.config.component.option().focusStateEnabled) { + const { isEnabled } = this.config; + const enabled = isEnabled ? isEnabled() : !!this.config.component.option().focusStateEnabled; + if (!enabled) { return; } 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 09f1f3a204f4..0d7bb1f4e019 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -20,7 +20,7 @@ import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; import Button from '@ts/ui/button/wrapper'; import Popup from '@ts/ui/popup/m_popup'; -import { TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from '@ts/ui/toolbar/constants'; +import { TOOLBAR_KEYBOARD_NAVIGATION_CLASS } from '@ts/ui/toolbar/constants'; import ToolbarMenuList, { TOOLBAR_MENU_ACTION_CLASS } from '@ts/ui/toolbar/internal/toolbar.menu.list'; import { toggleItemFocusableElementTabIndex } from '@ts/ui/toolbar/toolbar.utils'; @@ -256,7 +256,7 @@ export default class DropDownMenu extends Widget { component.$wrapper() .addClass(DROP_DOWN_MENU_POPUP_WRAPPER_CLASS) .addClass(DROP_DOWN_MENU_POPUP_CLASS) - .toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!listFocusStateEnabled); + .toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!listFocusStateEnabled); }, deferRendering: false, preventScrollEvents: false, @@ -483,7 +483,7 @@ export default class DropDownMenu extends Widget { case 'listFocusStateEnabled': this._list?.option('focusStateEnabled', value); this._popup?.option('focusStateEnabled', !value); - this._popup?.$wrapper()?.toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!value); + this._popup?.$wrapper()?.toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!value); break; case 'onItemRendered': this._list?.option(name, value); 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 77a829b1e107..6f359fc4b499 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/strategy/toolbar.singleline.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/strategy/toolbar.singleline.ts @@ -57,13 +57,13 @@ export class SingleLineStrategy { const { disabled, - focusStateEnabled, + allowKeyboardNavigation, menuContainer, } = this._toolbar.option(); this._menu = this._toolbar._createComponent($menu, DropDownMenu, { disabled, - listFocusStateEnabled: focusStateEnabled, + listFocusStateEnabled: allowKeyboardNavigation, // eslint-disable-next-line @typescript-eslint/no-unsafe-return itemTemplate: () => menuItemTemplate, onItemClick: (e) => { itemClickAction(e); }, @@ -270,7 +270,7 @@ export class SingleLineStrategy { case 'disabled': this._menu?.option(name, value); break; - case 'focusStateEnabled': + case 'allowKeyboardNavigation': this._menu?.option('listFocusStateEnabled', value); break; case 'overflowMenuVisible': diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 1852aa74dfea..adf52235c043 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -23,7 +23,7 @@ import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor' import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; -import { TOOLBAR_CLASS, TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from './constants'; +import { TOOLBAR_CLASS, TOOLBAR_KEYBOARD_NAVIGATION_CLASS } from './constants'; import { enterKeyHandler, focusItemWidget, @@ -66,6 +66,7 @@ export interface ToolbarBaseProperties< CollectionWidgetBaseProperties, keyof Properties & keyof CollectionWidgetBaseProperties > { + allowKeyboardNavigation: boolean; grouped: boolean; renderAs: 'topToolbar'; useFlatButtons: boolean; @@ -153,6 +154,7 @@ class ToolbarBase< useFlatButtons: false, useDefaultButtons: false, focusStateEnabled: true, + allowKeyboardNavigation: true, loopItemFocus: true, }; } @@ -184,7 +186,7 @@ class ToolbarBase< _supportedKeys(): SupportedKeys { const keys = super._supportedKeys(); - if (!this.option('focusStateEnabled')) { + if (!this.option('allowKeyboardNavigation')) { return keys; } @@ -244,7 +246,7 @@ class ToolbarBase< _attachKeyboardEvents(): void { this._detachKeyboardEvents(); - if (!this.option('focusStateEnabled')) { + if (!this.option('allowKeyboardNavigation')) { this._keyboardListenerId = keyboard.on( this._keyboardEventBindingTarget(), null, @@ -257,6 +259,7 @@ class ToolbarBase< component: this._getContext(), itemsSelector: `${this._itemSelector()}, .dx-dropdownmenu-button`, direction: 'horizontal', + isEnabled: (): boolean => !!this.option('allowKeyboardNavigation'), }); this._navigator.attach(); } @@ -328,7 +331,7 @@ class ToolbarBase< // eslint-disable-next-line @typescript-eslint/no-invalid-void-type _moveFocus(location: string, e?: DxEvent): boolean | undefined | void { - if (!this.option('focusStateEnabled')) { + if (!this.option('allowKeyboardNavigation')) { const { focusedElement } = this.option(); return focusedElement ? super._moveFocus(location, e) : undefined; } @@ -400,10 +403,10 @@ class ToolbarBase< } _renderToolbar(): void { - const { focusStateEnabled } = this.option(); + const { allowKeyboardNavigation } = this.option(); this.$element() .addClass(TOOLBAR_CLASS) - .toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!focusStateEnabled); + .toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!allowKeyboardNavigation); this._$toolbarItemsContainer = $('
') .addClass(TOOLBAR_ITEMS_CONTAINER_CLASS) @@ -711,12 +714,13 @@ class ToolbarBase< case 'compactMode': this._applyCompactMode(); break; - case 'focusStateEnabled': - this.$element().toggleClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS, !!value); + case 'allowKeyboardNavigation': + this.$element().toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!value); if (!value) { this.option('focusedElement', null); } super._optionChanged(args); + this._attachKeyboardEvents(); break; case 'grouped': break; diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index 4950e376826e..399ad998e5d1 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -158,7 +158,7 @@ class Toolbar extends ToolbarBase { if (property === 'disabled' || property === 'options.disabled') { if (this._isMenuItem(item)) { toggleItemFocusableElementTabIndex(this, item); - } else if (this.option('focusStateEnabled')) { + } else if (this.option('allowKeyboardNavigation')) { this._resetRovingTabIndex(); } else { toggleItemFocusableElementTabIndex(this, item); @@ -184,7 +184,7 @@ class Toolbar extends ToolbarBase { menuItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); - if (this.option('focusStateEnabled')) { + if (this.option('allowKeyboardNavigation')) { this._resetRovingTabIndex(); } else { toolbarItems.forEach((item) => toggleItemFocusableElementTabIndex(this, item)); @@ -212,7 +212,7 @@ class Toolbar extends ToolbarBase { case 'multiline': this._invalidate(); break; - case 'focusStateEnabled': + case 'allowKeyboardNavigation': case 'disabled': super._optionChanged(args); diff --git a/packages/devextreme/js/ui/toolbar.d.ts b/packages/devextreme/js/ui/toolbar.d.ts index 3aa747d9ce96..fa784800ed05 100644 --- a/packages/devextreme/js/ui/toolbar.d.ts +++ b/packages/devextreme/js/ui/toolbar.d.ts @@ -110,6 +110,12 @@ export interface dxToolbarOptions< TItem extends ItemLike = any, TKey = any, > extends CollectionWidgetOptions, TItem, TKey> { + /** + * @docid + * @default true + * @public + */ + allowKeyboardNavigation?: boolean; /** * @docid * @type string | Array | Store | DataSource | DataSourceOptions | null diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js index d2a9d2095b72..3fc05a3aa3b2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -5,7 +5,7 @@ import { DROP_DOWN_MENU_BUTTON_CLASS, DROP_DOWN_MENU_POPUP_WRAPPER_CLASS, } from '__internal/ui/toolbar/internal/toolbar.menu'; -import { TOOLBAR_FOCUS_STATE_ENABLED_CLASS } from '__internal/ui/toolbar/constants'; +import { TOOLBAR_KEYBOARD_NAVIGATION_CLASS } from '__internal/ui/toolbar/constants'; import { BUTTON_CLASS } from '__internal/ui/button/button'; import { LIST_ITEM_CLASS } from '__internal/ui/list/list.base'; import { @@ -113,6 +113,8 @@ const findFocusTarget = ($item) => { if($menu.length) return $menu; const $native = $item.find('button:not([disabled]), input:not([disabled]), a[href], [tabindex]').first(); if($native.length) return $native; + const $tabEl = $item.find('[tabindex]').first(); + if($tabEl.length) return $tabEl; return $item; }; @@ -2138,12 +2140,12 @@ QUnit.module('Overflow menu', moduleConfig, function() { 'item[2] has tabindex=-1 (never focused)'); }); - QUnit.test('mouse click on overflow button opens menu; first item is focused (focusStateEnabled=true)', function(assert) { + QUnit.test('mouse click on overflow button opens menu; first item is focused (allowKeyboardNavigation=true)', function(assert) { const toolbar = makeOverflowToolbar(this.$element); const $overflowBtn = getOverflowBtn(this.$element); const menu = toolbar._layoutStrategy._menu; - assert.strictEqual(toolbar.option('focusStateEnabled'), true, 'focusStateEnabled is true (default)'); + assert.strictEqual(toolbar.option('allowKeyboardNavigation'), true, 'allowKeyboardNavigation is true (default)'); $overflowBtn.trigger('dxclick'); this.clock.tick(0); @@ -2994,9 +2996,9 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual($tabindex0.length, 0, 'No tabindex=0 elements in empty toolbar'); }); - QUnit.test('focusStateEnabled:false — no keyboard handling', function(assert) { + QUnit.test('allowKeyboardNavigation:false — no keyboard handling', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, @@ -3011,12 +3013,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { this.clock.tick(0); const focusAfter = toolbar.option('focusedElement'); - assert.strictEqual(focusBefore, focusAfter, 'focusedElement unchanged when focusStateEnabled:false'); + assert.strictEqual(focusBefore, focusAfter, 'focusedElement unchanged when allowKeyboardNavigation:false'); }); - QUnit.test('focusStateEnabled:false — roving tabindex is not applied', function(assert) { + QUnit.test('allowKeyboardNavigation:false — roving tabindex is not applied', function(assert) { this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, @@ -3028,12 +3030,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { el => $(el).attr('tabindex') === undefined || $(el).attr('tabindex') === '0', ); assert.strictEqual(allHaveNaturalTabindex, true, - 'buttons keep natural tabindex when focusStateEnabled:false'); + 'buttons keep natural tabindex when allowKeyboardNavigation:false'); }); - QUnit.test('focusStateEnabled:false propagates to overflow menu list but not to DropDownMenu itself', function(assert) { + QUnit.test('allowKeyboardNavigation:false propagates to overflow menu list but not to DropDownMenu itself', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, @@ -3053,9 +3055,9 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { 'ToolbarMenuList gets focusStateEnabled:false via listFocusStateEnabled'); }); - QUnit.test('changing focusStateEnabled at runtime propagates listFocusStateEnabled to menu and list', function(assert) { + QUnit.test('changing allowKeyboardNavigation at runtime propagates listFocusStateEnabled to menu and list', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, @@ -3070,7 +3072,7 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(menu.option('listFocusStateEnabled'), true, 'menu starts with listFocusStateEnabled:true'); assert.strictEqual(menu._list.option('focusStateEnabled'), true, 'list starts with focusStateEnabled:true'); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); assert.strictEqual(menu.option('focusStateEnabled'), true, 'DropDownMenu keeps its own focusStateEnabled:true after runtime change'); @@ -3080,9 +3082,9 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { 'ToolbarMenuList gets focusStateEnabled:false after runtime change'); }); - QUnit.test('changing focusStateEnabled at runtime toggles keyboard navigation', function(assert) { + QUnit.test('changing allowKeyboardNavigation at runtime toggles keyboard navigation', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, @@ -3101,9 +3103,9 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { this.clock.tick(0); assert.strictEqual($(toolbar.option('focusedElement')).get(0), $secondItem, - 'ArrowRight moves focus to second item when focusStateEnabled:true'); + 'ArrowRight moves focus to second item when allowKeyboardNavigation:true'); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); const focusBefore = toolbar.option('focusedElement'); dispatchKeydown($firstFocusTarget.get(0), 'ArrowRight'); @@ -3111,12 +3113,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const focusAfterDisabled = toolbar.option('focusedElement'); assert.strictEqual(focusBefore, focusAfterDisabled, - 'ArrowRight does not move focus after focusStateEnabled changed to false'); + 'ArrowRight does not move focus after allowKeyboardNavigation changed to false'); }); - QUnit.test('focusStateEnabled:true→false — items reset to natural tabindex (all 0)', function(assert) { + QUnit.test('allowKeyboardNavigation:true→false — items reset to natural tabindex (all 0)', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, @@ -3133,17 +3135,17 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(tabIndicesBefore[0], '0', 'First item has tabindex=0 (roving)'); assert.strictEqual(tabIndicesBefore[1], '-1', 'Second item has tabindex=-1 (roving)'); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); const tabIndicesAfter = $items.toArray().map(item => getItemFocusTarget($(item)).attr('tabindex')); - assert.strictEqual(tabIndicesAfter[0], '0', 'First item has natural tabindex=0 after focusStateEnabled:false'); - assert.strictEqual(tabIndicesAfter[1], '0', 'Second item has natural tabindex=0 after focusStateEnabled:false'); - assert.strictEqual(tabIndicesAfter[2], '0', 'Third item has natural tabindex=0 after focusStateEnabled:false'); + assert.strictEqual(tabIndicesAfter[0], '0', 'First item has natural tabindex=0 after allowKeyboardNavigation:false'); + assert.strictEqual(tabIndicesAfter[1], '0', 'Second item has natural tabindex=0 after allowKeyboardNavigation:false'); + assert.strictEqual(tabIndicesAfter[2], '0', 'Third item has natural tabindex=0 after allowKeyboardNavigation:false'); }); - QUnit.test('focusStateEnabled:false→true — roving tabindex is applied (only first item at 0)', function(assert) { + QUnit.test('allowKeyboardNavigation:false→true — roving tabindex is applied (only first item at 0)', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, { locateInMenu: 'never', widget: 'dxButton', options: { text: 'B' } }, @@ -3157,7 +3159,7 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(tabIndicesBefore[0], '0', 'All items start at natural tabindex=0'); assert.strictEqual(tabIndicesBefore[1], '0', 'All items start at natural tabindex=0'); - toolbar.option('focusStateEnabled', true); + toolbar.option('allowKeyboardNavigation', true); const tabIndicesAfter = $items.toArray().map(item => getItemFocusTarget($(item)).attr('tabindex')); assert.strictEqual(tabIndicesAfter[0], '0', 'First item gets tabindex=0 from roving tabindex'); @@ -3165,9 +3167,9 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { assert.strictEqual(tabIndicesAfter[2], '-1', 'Third item gets tabindex=-1 from roving tabindex'); }); - QUnit.test('focusStateEnabled:false — overflow menu items use toggleItemFocusableElementTabIndex (not roving)', function(assert) { + QUnit.test('allowKeyboardNavigation:false — overflow menu items use toggleItemFocusableElementTabIndex (not roving)', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, @@ -3185,12 +3187,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { return $btn.length === 0 || $btn.attr('tabindex') === '0' || $btn.attr('tabindex') === undefined; }); assert.strictEqual(allButtonsHaveTabindex, true, - 'menu items use natural tabindex (toggleItemFocusableElementTabIndex) when focusStateEnabled:false'); + 'menu items use natural tabindex (toggleItemFocusableElementTabIndex) when allowKeyboardNavigation:false'); }); - QUnit.test('focusStateEnabled:false — opening overflow menu does not auto-focus items', function(assert) { + QUnit.test('allowKeyboardNavigation:false — opening overflow menu does not auto-focus items', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, @@ -3203,12 +3205,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const focusedElement = menu._list.option('focusedElement'); assert.strictEqual(focusedElement, null, - 'no item auto-focused on open when focusStateEnabled:false'); + 'no item auto-focused on open when allowKeyboardNavigation:false'); }); - QUnit.test('focusStateEnabled:true — overflow menu uses roving tabindex', function(assert) { + QUnit.test('allowKeyboardNavigation:true — overflow menu uses roving tabindex', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, @@ -3276,7 +3278,7 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { 'Tab keydown is not prevented by toolbar'); }); - QUnit.test('focusStateEnabled:true (default) — toolbar element has dx-toolbar-focus-state-enabled class', function(assert) { + QUnit.test('allowKeyboardNavigation:true (default) — toolbar element has dx-toolbar-keyboard-navigation class', function(assert) { this.$element.dxToolbar({ items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, @@ -3284,56 +3286,56 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { }); assert.ok( - this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'toolbar has dx-toolbar-focus-state-enabled class when focusStateEnabled:true' + this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'toolbar has dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:true' ); }); - QUnit.test('focusStateEnabled:false — toolbar element does NOT have dx-toolbar-focus-state-enabled class', function(assert) { + QUnit.test('allowKeyboardNavigation:false — toolbar element does NOT have dx-toolbar-keyboard-navigation class', function(assert) { this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, ], }); assert.notOk( - this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'toolbar does not have dx-toolbar-focus-state-enabled class when focusStateEnabled:false' + this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'toolbar does not have dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:false' ); }); - QUnit.test('changing focusStateEnabled at runtime toggles dx-toolbar-focus-state-enabled class', function(assert) { + QUnit.test('changing allowKeyboardNavigation at runtime toggles dx-toolbar-keyboard-navigation class', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, ], }).dxToolbar('instance'); assert.ok( - this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'class is present when focusStateEnabled:true' + this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'class is present when allowKeyboardNavigation:true' ); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); assert.notOk( - this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'class is removed after setting focusStateEnabled:false' + this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'class is removed after setting allowKeyboardNavigation:false' ); - toolbar.option('focusStateEnabled', true); + toolbar.option('allowKeyboardNavigation', true); assert.ok( - this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'class is re-added after setting focusStateEnabled:true' + this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'class is re-added after setting allowKeyboardNavigation:true' ); }); - QUnit.test('focusStateEnabled:true — overflow popup wrapper has dx-toolbar-focus-state-enabled class', function(assert) { + QUnit.test('allowKeyboardNavigation:true — overflow popup wrapper has dx-toolbar-keyboard-navigation class', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, ], @@ -3345,14 +3347,14 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.ok( - $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'popup wrapper has dx-toolbar-focus-state-enabled class when focusStateEnabled:true' + $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'popup wrapper has dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:true' ); }); - QUnit.test('focusStateEnabled:false — overflow popup wrapper does NOT have dx-toolbar-focus-state-enabled class', function(assert) { + QUnit.test('allowKeyboardNavigation:false — overflow popup wrapper does NOT have dx-toolbar-keyboard-navigation class', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, ], @@ -3364,14 +3366,14 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.notOk( - $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'popup wrapper does not have dx-toolbar-focus-state-enabled class when focusStateEnabled:false' + $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'popup wrapper does not have dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:false' ); }); - QUnit.test('changing focusStateEnabled at runtime toggles dx-toolbar-focus-state-enabled on popup wrapper', function(assert) { + QUnit.test('changing allowKeyboardNavigation at runtime toggles dx-toolbar-keyboard-navigation on popup wrapper', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [ { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, ], @@ -3384,22 +3386,22 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.ok( - $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'popup wrapper has class when focusStateEnabled:true' + $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'popup wrapper has class when allowKeyboardNavigation:true' ); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); assert.notOk( - $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'popup wrapper loses class after setting focusStateEnabled:false' + $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'popup wrapper loses class after setting allowKeyboardNavigation:false' ); - toolbar.option('focusStateEnabled', true); + toolbar.option('allowKeyboardNavigation', true); assert.ok( - $wrapper.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'popup wrapper regains class after setting focusStateEnabled:true' + $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'popup wrapper regains class after setting allowKeyboardNavigation:true' ); }); }); @@ -4513,18 +4515,18 @@ QUnit.module('Tab key — toolbar does not intercept', moduleConfig, function() }); }); -QUnit.module('focusStateEnabled — runtime toggle', moduleConfig, function() { +QUnit.module('allowKeyboardNavigation — runtime toggle', moduleConfig, function() { QUnit.test('toggling false removes the focus-state-enabled marker class', function(assert) { const toolbar = createToolbar( [buttonItem('A'), buttonItem('B')], - { focusStateEnabled: true }, + { allowKeyboardNavigation: true }, ); - assert.ok(this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), - 'marker class present when focusStateEnabled:true'); + assert.ok(this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + 'marker class present when allowKeyboardNavigation:true'); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); - assert.notOk(this.$element.hasClass(TOOLBAR_FOCUS_STATE_ENABLED_CLASS), + assert.notOk(this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), 'marker class removed after toggling to false'); }); @@ -4532,20 +4534,20 @@ QUnit.module('focusStateEnabled — runtime toggle', moduleConfig, function() { const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); focusItemAt(toolbar, 0); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); const focusBefore = toolbar.option('focusedElement'); press('ArrowRight'); this.clock.tick(0); assert.strictEqual(toolbar.option('focusedElement'), focusBefore, - 'ArrowRight is ignored when focusStateEnabled becomes false'); + 'ArrowRight is ignored when allowKeyboardNavigation becomes false'); }); QUnit.test('toggling back to true re-enables arrow navigation', function(assert) { const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); - toolbar.option('focusStateEnabled', false); - toolbar.option('focusStateEnabled', true); + toolbar.option('allowKeyboardNavigation', false); + toolbar.option('allowKeyboardNavigation', true); focusItemAt(toolbar, 0); press('ArrowRight'); @@ -4553,7 +4555,7 @@ QUnit.module('focusStateEnabled — runtime toggle', moduleConfig, function() { assertFocusedItemAt(assert, toolbar, 1, 'navigation works again after re-enabling'); }); - QUnit.test('toolbar item containers never get dx-state-focused regardless of focusStateEnabled', function(assert) { + QUnit.test('toolbar item containers never get dx-state-focused regardless of allowKeyboardNavigation', function(assert) { const toolbar = createToolbar([buttonItem('A'), buttonItem('B')]); focusItemAt(toolbar, 0); this.clock.tick(0); @@ -4563,11 +4565,11 @@ QUnit.module('focusStateEnabled — runtime toggle', moduleConfig, function() { assert.strictEqual(this.$element.filter('.dx-state-focused').length, 0, 'toolbar root has no dx-state-focused while focused'); - toolbar.option('focusStateEnabled', false); + toolbar.option('allowKeyboardNavigation', false); this.clock.tick(0); assert.strictEqual(this.$element.find('.dx-toolbar-item.dx-state-focused').length, 0, - 'item container still has no dx-state-focused after focusStateEnabled becomes false'); + 'item container still has no dx-state-focused after allowKeyboardNavigation becomes false'); }); }); @@ -4658,10 +4660,10 @@ QUnit.module('Escape semantics (consolidated)', moduleConfig, function() { }); }); -QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleConfig, function() { - QUnit.test('_supportedKeys preserves base arrow/home/end handlers at focusStateEnabled:false', function(assert) { +QUnit.module('allowKeyboardNavigation:false — fallback delegation to base', moduleConfig, function() { + QUnit.test('_supportedKeys preserves base arrow/home/end handlers at allowKeyboardNavigation:false', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [buttonItem('A'), buttonItem('B')], }).dxToolbar('instance'); @@ -4672,9 +4674,9 @@ QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleCo assert.strictEqual(typeof keys.end, 'function', 'end inherited from CollectionWidget'); }); - QUnit.test('_supportedKeys deletes arrow/home/end handlers at focusStateEnabled:true (navigator owns)', function(assert) { + QUnit.test('_supportedKeys deletes arrow/home/end handlers at allowKeyboardNavigation:true (navigator owns)', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [buttonItem('A'), buttonItem('B')], }).dxToolbar('instance'); @@ -4685,9 +4687,9 @@ QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleCo assert.strictEqual(keys.end, undefined, 'end removed'); }); - QUnit.test('ArrowRight delegates to base CollectionWidget when focusStateEnabled:false (super attachment preserved)', function(assert) { + QUnit.test('ArrowRight delegates to base CollectionWidget when allowKeyboardNavigation:false (super attachment preserved)', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [buttonItem('A'), buttonItem('B')], }).dxToolbar('instance'); @@ -4701,28 +4703,28 @@ QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleCo 'ArrowRight moves focus via base CollectionWidget handler (super attachment preserved)'); }); - QUnit.test('navigator is not created at focusStateEnabled:false', function(assert) { + QUnit.test('navigator is not created at allowKeyboardNavigation:false', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [buttonItem('A'), buttonItem('B')], }).dxToolbar('instance'); assert.strictEqual(toolbar._navigator, undefined, - 'no RovingTabIndexNavigator instance when focusStateEnabled:false'); + 'no RovingTabIndexNavigator instance when allowKeyboardNavigation:false'); }); - QUnit.test('navigator IS created at focusStateEnabled:true', function(assert) { + QUnit.test('navigator IS created at allowKeyboardNavigation:true', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: true, + allowKeyboardNavigation: true, items: [buttonItem('A'), buttonItem('B')], }).dxToolbar('instance'); - assert.ok(toolbar._navigator, 'RovingTabIndexNavigator instance present at focusStateEnabled:true'); + assert.ok(toolbar._navigator, 'RovingTabIndexNavigator instance present at allowKeyboardNavigation:true'); }); - QUnit.test('_moveFocus at focusStateEnabled:false delegates to super and moves focusedElement', function(assert) { + QUnit.test('_moveFocus at allowKeyboardNavigation:false delegates to super and moves focusedElement', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [buttonItem('A'), buttonItem('B'), buttonItem('C')], }).dxToolbar('instance'); @@ -4731,12 +4733,12 @@ QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleCo toolbar._moveFocus('right'); assert.strictEqual($(toolbar.option('focusedElement')).get(0), $items.eq(1).get(0), - 'super._moveFocus moves focusedElement to next item at focusStateEnabled:false'); + 'super._moveFocus moves focusedElement to next item at allowKeyboardNavigation:false'); }); - QUnit.test('toolbar.menu.list also delegates _supportedKeys to super at focusStateEnabled:false', function(assert) { + QUnit.test('toolbar.menu.list also delegates _supportedKeys to super at allowKeyboardNavigation:false', function(assert) { const toolbar = this.$element.dxToolbar({ - focusStateEnabled: false, + allowKeyboardNavigation: false, items: [ { widget: 'dxButton', locateInMenu: 'never', options: { text: 'Visible' } }, { widget: 'dxButton', locateInMenu: 'always', options: { text: 'Menu A' } }, @@ -4748,7 +4750,7 @@ QUnit.module('focusStateEnabled:false — fallback delegation to base', moduleCo this.clock.tick(0); const keys = menu._list._supportedKeys(); - assert.strictEqual(typeof keys.upArrow, 'function', 'menu list inherits upArrow from ListBase at focusStateEnabled:false'); + assert.strictEqual(typeof keys.upArrow, 'function', 'menu list inherits upArrow from ListBase at allowKeyboardNavigation:false'); assert.strictEqual(typeof keys.downArrow, 'function', 'menu list inherits downArrow from ListBase'); }); }); diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 0cee04115773..38e6bd00bdd5 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -30753,6 +30753,10 @@ declare module DevExpress.ui { TItem extends DevExpress.ui.dxToolbar.ItemLike = any, TKey = any > extends CollectionWidgetOptions, TItem, TKey> { + /** + * [descr:dxToolbarOptions.allowKeyboardNavigation] + */ + allowKeyboardNavigation?: boolean; /** * [descr:dxToolbarOptions.dataSource] */ From 21d600998af309bb183b4b3aa6396c09a437a47d Mon Sep 17 00:00:00 2001 From: pharret31 Date: Fri, 29 May 2026 12:20:19 +0200 Subject: [PATCH 42/47] fix name --- .../devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss index d249802a848d..3939ab62f3b5 100644 --- a/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss @@ -3,7 +3,7 @@ $border-radius, $section-horizontal-margin, ) { - .dx-dropdownmenu-popup-wrapper.dx-toolbar-focus-state-enabled { + .dx-dropdownmenu-popup-wrapper.dx-toolbar-keyboard-navigation { .dx-dropdownmenu-list { .dx-list-item:has([tabindex="0"]:focus-visible), .dx-list-item[tabindex="0"]:focus-visible { From e0175bebd5175f377ac2e004d1376a40c653bf26 Mon Sep 17 00:00:00 2001 From: pharret31 Date: Fri, 29 May 2026 13:36:52 +0200 Subject: [PATCH 43/47] fix name of classes --- .../widgets/base/dropDownMenu/_mixins.scss | 2 +- .../scss/widgets/base/toolbar/_mixins.scss | 2 +- .../js/__internal/ui/toolbar/constants.ts | 3 +- .../ui/toolbar/internal/toolbar.menu.ts | 6 +-- .../js/__internal/ui/toolbar/toolbar.base.ts | 6 +-- .../toolbar.kbn.tests.js | 46 +++++++++---------- 6 files changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss index 3939ab62f3b5..129adac34b1d 100644 --- a/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/dropDownMenu/_mixins.scss @@ -3,7 +3,7 @@ $border-radius, $section-horizontal-margin, ) { - .dx-dropdownmenu-popup-wrapper.dx-toolbar-keyboard-navigation { + .dx-dropdownmenu-popup-wrapper.dx-dropdownmenu-list-focus-mode { .dx-dropdownmenu-list { .dx-list-item:has([tabindex="0"]:focus-visible), .dx-list-item[tabindex="0"]:focus-visible { diff --git a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss index e20206cb78bd..e76f9e4da44e 100644 --- a/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/base/toolbar/_mixins.scss @@ -2,7 +2,7 @@ $accent-color, $border-radius, ) { - .dx-toolbar.dx-toolbar-keyboard-navigation { + .dx-toolbar.dx-toolbar-focus-mode { [tabindex="0"]:focus-visible:not(.dx-toolbar-item) { outline: 2px solid $accent-color; outline-offset: 1px; diff --git a/packages/devextreme/js/__internal/ui/toolbar/constants.ts b/packages/devextreme/js/__internal/ui/toolbar/constants.ts index 3bc5ffde9df4..9af7d582a6d9 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/constants.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/constants.ts @@ -1,2 +1,3 @@ export const TOOLBAR_CLASS = 'dx-toolbar'; -export const TOOLBAR_KEYBOARD_NAVIGATION_CLASS = 'dx-toolbar-keyboard-navigation'; +export const TOOLBAR_FOCUS_MODE_CLASS = 'dx-toolbar-focus-mode'; +export const DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS = 'dx-dropdownmenu-list-focus-mode'; 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 0d7bb1f4e019..e92d1f8edcf8 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -20,7 +20,7 @@ import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; import Button from '@ts/ui/button/wrapper'; import Popup from '@ts/ui/popup/m_popup'; -import { TOOLBAR_KEYBOARD_NAVIGATION_CLASS } from '@ts/ui/toolbar/constants'; +import { DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS } from '@ts/ui/toolbar/constants'; import ToolbarMenuList, { TOOLBAR_MENU_ACTION_CLASS } from '@ts/ui/toolbar/internal/toolbar.menu.list'; import { toggleItemFocusableElementTabIndex } from '@ts/ui/toolbar/toolbar.utils'; @@ -256,7 +256,7 @@ export default class DropDownMenu extends Widget { component.$wrapper() .addClass(DROP_DOWN_MENU_POPUP_WRAPPER_CLASS) .addClass(DROP_DOWN_MENU_POPUP_CLASS) - .toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!listFocusStateEnabled); + .toggleClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS, !!listFocusStateEnabled); }, deferRendering: false, preventScrollEvents: false, @@ -483,7 +483,7 @@ export default class DropDownMenu extends Widget { case 'listFocusStateEnabled': this._list?.option('focusStateEnabled', value); this._popup?.option('focusStateEnabled', !value); - this._popup?.$wrapper()?.toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!value); + this._popup?.$wrapper()?.toggleClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS, !!value); break; case 'onItemRendered': this._list?.option(name, value); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index adf52235c043..8dd5f3fc675d 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -23,7 +23,7 @@ import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor' import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; -import { TOOLBAR_CLASS, TOOLBAR_KEYBOARD_NAVIGATION_CLASS } from './constants'; +import { TOOLBAR_CLASS, TOOLBAR_FOCUS_MODE_CLASS } from './constants'; import { enterKeyHandler, focusItemWidget, @@ -406,7 +406,7 @@ class ToolbarBase< const { allowKeyboardNavigation } = this.option(); this.$element() .addClass(TOOLBAR_CLASS) - .toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!allowKeyboardNavigation); + .toggleClass(TOOLBAR_FOCUS_MODE_CLASS, !!allowKeyboardNavigation); this._$toolbarItemsContainer = $('
') .addClass(TOOLBAR_ITEMS_CONTAINER_CLASS) @@ -715,7 +715,7 @@ class ToolbarBase< this._applyCompactMode(); break; case 'allowKeyboardNavigation': - this.$element().toggleClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS, !!value); + this.$element().toggleClass(TOOLBAR_FOCUS_MODE_CLASS, !!value); if (!value) { this.option('focusedElement', null); } 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 index 3fc05a3aa3b2..56eac47fdacc 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -5,7 +5,7 @@ import { DROP_DOWN_MENU_BUTTON_CLASS, DROP_DOWN_MENU_POPUP_WRAPPER_CLASS, } from '__internal/ui/toolbar/internal/toolbar.menu'; -import { TOOLBAR_KEYBOARD_NAVIGATION_CLASS } from '__internal/ui/toolbar/constants'; +import { TOOLBAR_FOCUS_MODE_CLASS, DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS } from '__internal/ui/toolbar/constants'; import { BUTTON_CLASS } from '__internal/ui/button/button'; import { LIST_ITEM_CLASS } from '__internal/ui/list/list.base'; import { @@ -3278,7 +3278,7 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { 'Tab keydown is not prevented by toolbar'); }); - QUnit.test('allowKeyboardNavigation:true (default) — toolbar element has dx-toolbar-keyboard-navigation class', function(assert) { + QUnit.test('allowKeyboardNavigation:true (default) — toolbar element has dx-toolbar-focus-mode class', function(assert) { this.$element.dxToolbar({ items: [ { locateInMenu: 'never', widget: 'dxButton', options: { text: 'A' } }, @@ -3286,12 +3286,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { }); assert.ok( - this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), - 'toolbar has dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:true' + this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), + 'toolbar has dx-toolbar-focus-mode class when allowKeyboardNavigation:true' ); }); - QUnit.test('allowKeyboardNavigation:false — toolbar element does NOT have dx-toolbar-keyboard-navigation class', function(assert) { + QUnit.test('allowKeyboardNavigation:false — toolbar element does NOT have dx-toolbar-focus-mode class', function(assert) { this.$element.dxToolbar({ allowKeyboardNavigation: false, items: [ @@ -3300,12 +3300,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { }); assert.notOk( - this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), - 'toolbar does not have dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:false' + this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), + 'toolbar does not have dx-toolbar-focus-mode class when allowKeyboardNavigation:false' ); }); - QUnit.test('changing allowKeyboardNavigation at runtime toggles dx-toolbar-keyboard-navigation class', function(assert) { + QUnit.test('changing allowKeyboardNavigation at runtime toggles dx-toolbar-focus-mode class', function(assert) { const toolbar = this.$element.dxToolbar({ allowKeyboardNavigation: true, items: [ @@ -3314,26 +3314,26 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { }).dxToolbar('instance'); assert.ok( - this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), 'class is present when allowKeyboardNavigation:true' ); toolbar.option('allowKeyboardNavigation', false); assert.notOk( - this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), 'class is removed after setting allowKeyboardNavigation:false' ); toolbar.option('allowKeyboardNavigation', true); assert.ok( - this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), 'class is re-added after setting allowKeyboardNavigation:true' ); }); - QUnit.test('allowKeyboardNavigation:true — overflow popup wrapper has dx-toolbar-keyboard-navigation class', function(assert) { + QUnit.test('allowKeyboardNavigation:true — overflow popup wrapper has dx-dropdownmenu-list-focus-mode class', function(assert) { const toolbar = this.$element.dxToolbar({ allowKeyboardNavigation: true, items: [ @@ -3347,12 +3347,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.ok( - $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), - 'popup wrapper has dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:true' + $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + 'popup wrapper has dx-dropdownmenu-list-focus-mode class when allowKeyboardNavigation:true' ); }); - QUnit.test('allowKeyboardNavigation:false — overflow popup wrapper does NOT have dx-toolbar-keyboard-navigation class', function(assert) { + QUnit.test('allowKeyboardNavigation:false — overflow popup wrapper does NOT have dx-dropdownmenu-list-focus-mode class', function(assert) { const toolbar = this.$element.dxToolbar({ allowKeyboardNavigation: false, items: [ @@ -3366,12 +3366,12 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.notOk( - $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), - 'popup wrapper does not have dx-toolbar-keyboard-navigation class when allowKeyboardNavigation:false' + $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + 'popup wrapper does not have dx-dropdownmenu-list-focus-mode class when allowKeyboardNavigation:false' ); }); - QUnit.test('changing allowKeyboardNavigation at runtime toggles dx-toolbar-keyboard-navigation on popup wrapper', function(assert) { + QUnit.test('changing allowKeyboardNavigation at runtime toggles dx-dropdownmenu-list-focus-mode on popup wrapper', function(assert) { const toolbar = this.$element.dxToolbar({ allowKeyboardNavigation: true, items: [ @@ -3386,21 +3386,21 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.ok( - $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper has class when allowKeyboardNavigation:true' ); toolbar.option('allowKeyboardNavigation', false); assert.notOk( - $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper loses class after setting allowKeyboardNavigation:false' ); toolbar.option('allowKeyboardNavigation', true); assert.ok( - $wrapper.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper regains class after setting allowKeyboardNavigation:true' ); }); @@ -4521,12 +4521,12 @@ QUnit.module('allowKeyboardNavigation — runtime toggle', moduleConfig, functio [buttonItem('A'), buttonItem('B')], { allowKeyboardNavigation: true }, ); - assert.ok(this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + assert.ok(this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), 'marker class present when allowKeyboardNavigation:true'); toolbar.option('allowKeyboardNavigation', false); - assert.notOk(this.$element.hasClass(TOOLBAR_KEYBOARD_NAVIGATION_CLASS), + assert.notOk(this.$element.hasClass(TOOLBAR_FOCUS_MODE_CLASS), 'marker class removed after toggling to false'); }); From 33aff9391c104032ac9ba345e4b7b47934ac0cf7 Mon Sep 17 00:00:00 2001 From: pharret31 Date: Fri, 29 May 2026 13:38:37 +0200 Subject: [PATCH 44/47] rename --- .../devextreme/js/__internal/ui/toolbar/constants.ts | 2 +- .../__internal/ui/toolbar/internal/toolbar.menu.ts | 6 +++--- .../tests/DevExpress.ui.widgets/toolbar.kbn.tests.js | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/toolbar/constants.ts b/packages/devextreme/js/__internal/ui/toolbar/constants.ts index 9af7d582a6d9..1d3df86f2a84 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/constants.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/constants.ts @@ -1,3 +1,3 @@ export const TOOLBAR_CLASS = 'dx-toolbar'; export const TOOLBAR_FOCUS_MODE_CLASS = 'dx-toolbar-focus-mode'; -export const DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS = 'dx-dropdownmenu-list-focus-mode'; +export const DROPDOWNMENU_LIST_FOCUS_MODE_CLASS = 'dx-dropdownmenu-list-focus-mode'; 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 e92d1f8edcf8..cc67f7107c37 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.ts @@ -20,7 +20,7 @@ import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; import Button from '@ts/ui/button/wrapper'; import Popup from '@ts/ui/popup/m_popup'; -import { DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS } from '@ts/ui/toolbar/constants'; +import { DROPDOWNMENU_LIST_FOCUS_MODE_CLASS } from '@ts/ui/toolbar/constants'; import ToolbarMenuList, { TOOLBAR_MENU_ACTION_CLASS } from '@ts/ui/toolbar/internal/toolbar.menu.list'; import { toggleItemFocusableElementTabIndex } from '@ts/ui/toolbar/toolbar.utils'; @@ -256,7 +256,7 @@ export default class DropDownMenu extends Widget { component.$wrapper() .addClass(DROP_DOWN_MENU_POPUP_WRAPPER_CLASS) .addClass(DROP_DOWN_MENU_POPUP_CLASS) - .toggleClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS, !!listFocusStateEnabled); + .toggleClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS, !!listFocusStateEnabled); }, deferRendering: false, preventScrollEvents: false, @@ -483,7 +483,7 @@ export default class DropDownMenu extends Widget { case 'listFocusStateEnabled': this._list?.option('focusStateEnabled', value); this._popup?.option('focusStateEnabled', !value); - this._popup?.$wrapper()?.toggleClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS, !!value); + this._popup?.$wrapper()?.toggleClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS, !!value); break; case 'onItemRendered': this._list?.option(name, value); 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 index 56eac47fdacc..bb84a3afc8cb 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -5,7 +5,7 @@ import { DROP_DOWN_MENU_BUTTON_CLASS, DROP_DOWN_MENU_POPUP_WRAPPER_CLASS, } from '__internal/ui/toolbar/internal/toolbar.menu'; -import { TOOLBAR_FOCUS_MODE_CLASS, DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS } from '__internal/ui/toolbar/constants'; +import { TOOLBAR_FOCUS_MODE_CLASS, DROPDOWNMENU_LIST_FOCUS_MODE_CLASS } from '__internal/ui/toolbar/constants'; import { BUTTON_CLASS } from '__internal/ui/button/button'; import { LIST_ITEM_CLASS } from '__internal/ui/list/list.base'; import { @@ -3347,7 +3347,7 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.ok( - $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + $wrapper.hasClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper has dx-dropdownmenu-list-focus-mode class when allowKeyboardNavigation:true' ); }); @@ -3366,7 +3366,7 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.notOk( - $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + $wrapper.hasClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper does not have dx-dropdownmenu-list-focus-mode class when allowKeyboardNavigation:false' ); }); @@ -3386,21 +3386,21 @@ QUnit.module('Extra — Core behaviors', moduleConfig, function() { const $wrapper = $(`.${DROP_DOWN_MENU_POPUP_WRAPPER_CLASS}`); assert.ok( - $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + $wrapper.hasClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper has class when allowKeyboardNavigation:true' ); toolbar.option('allowKeyboardNavigation', false); assert.notOk( - $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + $wrapper.hasClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper loses class after setting allowKeyboardNavigation:false' ); toolbar.option('allowKeyboardNavigation', true); assert.ok( - $wrapper.hasClass(DROPDOWN_MENU_LIST_FOCUS_MODE_CLASS), + $wrapper.hasClass(DROPDOWNMENU_LIST_FOCUS_MODE_CLASS), 'popup wrapper regains class after setting allowKeyboardNavigation:true' ); }); From e35bfc500647c09ee796463640551dac72336b7e Mon Sep 17 00:00:00 2001 From: pharret31 Date: Fri, 29 May 2026 14:07:37 +0200 Subject: [PATCH 45/47] refactor --- .../js/__internal/ui/toolbar/internal/keyboard.navigation.ts | 4 ++-- .../js/__internal/ui/toolbar/internal/toolbar.menu.list.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts index 8d988b0a4809..734b77a9309a 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts @@ -213,7 +213,7 @@ export class RovingTabIndexNavigator { updateRovingTabIndex($activeItem?: dxElementWrapper): void { const { isEnabled } = this.config; - const enabled = isEnabled ? isEnabled() : !!this.config.component.option().focusStateEnabled; + const enabled = isEnabled?.() ?? false; if (!enabled) { return; } @@ -243,7 +243,7 @@ export class RovingTabIndexNavigator { resetRovingTabIndex(itemsContainer: dxElementWrapper): void { const { isEnabled } = this.config; - const enabled = isEnabled ? isEnabled() : !!this.config.component.option().focusStateEnabled; + const enabled = isEnabled?.() ?? false; if (!enabled) { return; } 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 d35e9c288a8a..2bb4e3209f29 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts @@ -195,6 +195,7 @@ export default class ToolbarMenuList extends ListBase { getItemFocusTarget: ($item): dxElementWrapper => this._getItemFocusTarget($item), onEscapeKey: (): void => this._onEscapePress?.(), onTabKey: (): void => this._onTabPress?.(), + isEnabled: (): boolean => !!this.option('focusStateEnabled'), }); this._navigator.attach(); } From 3cf190a3c23fdf8fa763b70b3a344369562d89dc Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Mon, 1 Jun 2026 01:57:09 +0400 Subject: [PATCH 46/47] fix scenarios in demos --- .../toolbar/internal/keyboard.navigation.ts | 119 +++++++ .../js/__internal/ui/toolbar/toolbar.base.ts | 25 ++ .../toolbar.kbn.tests.js | 296 ++++++++++++++++++ 3 files changed, 440 insertions(+) diff --git a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts index bc9000cddaa3..11d75581d329 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/internal/keyboard.navigation.ts @@ -1,4 +1,5 @@ import { keyboard } from '@js/common/core/events/short'; +import domAdapter from '@js/core/dom_adapter'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { DxEvent } from '@js/events'; @@ -47,6 +48,11 @@ export interface RovingTabIndexNavigatorConfig { isEnabled?: () => boolean; } +export interface FocusRestoreDescriptor { + index: number | undefined; + overflow: boolean; +} + export class RovingTabIndexNavigator { private readonly config: RovingTabIndexNavigatorConfig; @@ -136,6 +142,8 @@ export class RovingTabIndexNavigator { return; } + this.syncFocusedItem(target); + const { focusedElement } = this.config.component.option(); const $focused = $(focusedElement); if ($focused.length && isItemWidgetOpened($focused)) { @@ -246,6 +254,21 @@ export class RovingTabIndexNavigator { } } + private syncFocusedItem(target: HTMLElement): void { + let $item = $(target).closest(this.config.itemsSelector); + + if (!$item.length) { + $item = $(target) + .find('[tabindex="0"]') + .closest(this.config.itemsSelector) + .first(); + } + + if ($item.length && defaultGetItemFocusTarget($item)?.length) { + this.config.component.option({ focusedElement: getPublicElement($item) }); + } + } + moveFocus(location: string, e?: DxEvent): void { this.config.component._moveFocus(location, e); } @@ -338,6 +361,102 @@ export class RovingTabIndexNavigator { this.$prevActiveItem = $newActive; } } + + // NOTE: tri-state result consumed before a full re-render: + // - descriptor: DOM focus was on a toolbar item -> remember it for restore; + // - null: focus moved to a real element outside the toolbar -> drop pending state; + // - undefined: navigation disabled or focus on body/null -> keep pending state intact + // (a nested re-render may run after the item DOM was already cleaned). + captureFocusedItem(): FocusRestoreDescriptor | null | undefined { + const enabled = this.config.isEnabled?.() ?? false; + if (!enabled) { + return undefined; + } + + const root = this.config.component.$element().get(0) as HTMLElement | undefined; + const active = domAdapter.getActiveElement(root); + const insideToolbar = !!active && active !== root && !!root?.contains(active); + + if (!insideToolbar) { + // Focus on body/null (e.g. the focused item was removed mid re-render) keeps the + // pending state, so a nested re-render does not lose the original capture. Focus on + // any other real element means the user moved away -> drop the pending state. + const body = domAdapter.getBody(); + return active && active !== body ? null : undefined; + } + + const $item = $(active).closest(this.config.itemsSelector); + if (!$item.length) { + return null; + } + + const itemIndexKey = this.config.component._itemIndexKey(); + const index = $item.data(itemIndexKey) as unknown as number | undefined; + + return { + index: typeof index === 'number' ? index : undefined, + overflow: $item.hasClass('dx-dropdownmenu-button'), + }; + } + + restoreFocus(descriptor: FocusRestoreDescriptor): void { + const enabled = this.config.isEnabled?.() ?? false; + if (!enabled) { + return; + } + + const $available = this.getAvailableItems(); + if (!$available.length) { + return; + } + + const $target = this.resolveRestoreTarget($available, descriptor); + if (!$target?.length) { + return; + } + + // NOTE: updateRovingTabIndex moves the single tab stop from the reset default + // (first item) onto the restored target before focus, so there is never a moment + // with two tab stops, regardless of whether focusin fires synchronously. + this.updateRovingTabIndex($target); + this.focusItemWidget($target); + } + + private resolveRestoreTarget( + $available: dxElementWrapper, + descriptor: FocusRestoreDescriptor, + ): dxElementWrapper | undefined { + const { index, overflow } = descriptor; + + if (overflow) { + const $overflow = $available.filter('.dx-dropdownmenu-button'); + if ($overflow.length) { + return $overflow.first(); + } + } + + if (index !== undefined) { + const itemIndexKey = this.config.component._itemIndexKey(); + const available = $available.toArray(); + const getIndex = (el: Element): number | undefined => ( + $(el).data(itemIndexKey) as unknown as number | undefined + ); + + const exact = available.find((el) => getIndex(el) === index); + if (exact) { + return $(exact); + } + + const nearest = available.find((el) => { + const elIndex = getIndex(el); + return elIndex !== undefined && elIndex >= index; + }); + + return $(nearest ?? available[available.length - 1]); + } + + return $available.first(); + } } export function enterKeyHandler( diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index 8dd5f3fc675d..a34e9ba2e442 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -24,6 +24,7 @@ import CollectionWidgetAsync from '@ts/ui/collection/collection_widget.async'; import type { CollectionItemKey, CollectionWidgetBaseProperties } from '@ts/ui/collection/collection_widget.base'; import { TOOLBAR_CLASS, TOOLBAR_FOCUS_MODE_CLASS } from './constants'; +import type { FocusRestoreDescriptor } from './internal/keyboard.navigation'; import { enterKeyHandler, focusItemWidget, @@ -89,6 +90,8 @@ class ToolbarBase< _navigator?: RovingTabIndexNavigator; + _pendingFocusDescriptor?: FocusRestoreDescriptor; + _getSynchronizableOptionsForCreateComponent(): (keyof TProperties)[] { return super._getSynchronizableOptionsForCreateComponent().filter((item) => item !== 'disabled'); } @@ -396,12 +399,34 @@ class ToolbarBase< this._arrangeItems(); this._updateFocusableItemsTabIndex(); + + const descriptor = this._pendingFocusDescriptor; + this._pendingFocusDescriptor = undefined; + if (descriptor) { + // NOTE: navigator exists only when allowKeyboardNavigation is enabled, so this + // restore is a no-op otherwise. It is recreated during the re-render, hence the + // descriptor (not the navigator) holds the pending state across the refresh. + this._navigator?.restoreFocus(descriptor); + } } _updateFocusableItemsTabIndex(): void { this._resetRovingTabIndex(); } + _invalidate(): void { + // NOTE: capture DOM focus before super resets focusedElement and cleans the item + // container, so it can be restored after the full re-render (roving tabIndex). + const captured = this._navigator?.captureFocusedItem(); + if (captured) { + this._pendingFocusDescriptor = captured; + } else if (captured === null) { + this._pendingFocusDescriptor = undefined; + } + + super._invalidate(); + } + _renderToolbar(): void { const { allowKeyboardNavigation } = this.option(); this.$element() 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 index 5fc9aadc0760..e0b8bd8334f3 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js @@ -4768,6 +4768,41 @@ QUnit.module('Item-level tabIndex option', moduleConfig, function() { }); QUnit.module('Roving tabindex — incremental vs reset paths', moduleConfig, function() { + QUnit.test('ArrowRight uses the DOM-focused item when focusStateEnabled is false and focusedElement is stale', function(assert) { + const toolbar = createToolbar([buttonItem('Attach'), buttonItem('Send')], { + focusStateEnabled: false, + }); + const $items = toolbar._getAvailableItems(); + const $attach = $items.eq(0); + const $send = $items.eq(1); + + focusItemAt(toolbar, 1); + + findFocusTarget($send).attr('tabindex', '-1'); + findFocusTarget($attach).attr('tabindex', '0'); + findFocusTarget($attach).get(0).focus(); + + press('ArrowRight', findFocusTarget($attach).get(0)); + + assert.strictEqual(toolbar.option('focusedElement'), $send.get(0), 'focus moved from DOM-focused Attach to Send'); + assert.strictEqual(document.activeElement, findFocusTarget($send).get(0), 'Send button received DOM focus'); + }); + + QUnit.test('ArrowRight uses the current roving tab stop when the keydown target is the toolbar root', function(assert) { + const toolbar = createToolbar([buttonItem('Attach'), buttonItem('Send')], { + focusStateEnabled: false, + }); + const $items = toolbar._getAvailableItems(); + const $send = $items.eq(1); + + toolbar.option('focusedElement', null); + + press('ArrowRight', toolbar.$element().get(0)); + + assert.strictEqual(toolbar.option('focusedElement'), $send.get(0), 'focus moved from current roving tab stop to Send'); + assert.strictEqual(document.activeElement, findFocusTarget($send).get(0), 'Send button received DOM focus'); + }); + QUnit.test('arrow navigation is incremental: unrelated items are not affected', function(assert) { // Use 4 items so we can distinguish the two changed items from the untouched ones const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C'), buttonItem('D')]); @@ -4847,6 +4882,267 @@ QUnit.module('Roving tabindex — incremental vs reset paths', moduleConfig, fun }); }); +QUnit.module('Focus restore on full re-render', moduleConfig, function() { + QUnit.test('option("items") keeps DOM focus on the same item', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B'), buttonItem('C')]); + focusItemAt(toolbar, 1); + + toolbar.option('items', [buttonItem('A'), buttonItem('B'), buttonItem('C')]); + this.clock.tick(0); + + const $item1 = toolbar._getAvailableItems().eq(1); + assert.strictEqual(document.activeElement, findFocusTarget($item1).get(0), + 'DOM focus restored to item #1 after full re-render'); + assertOneTabStop(assert, this.$element); + }); + + QUnit.test('dataSource reload keeps DOM focus on the same item', function(assert) { + const toolbar = this.$element.dxToolbar({ + dataSource: [buttonItem('A'), buttonItem('B'), buttonItem('C')], + }).dxToolbar('instance'); + this.clock.tick(0); + focusItemAt(toolbar, 2); + + toolbar.getDataSource().reload(); + this.clock.tick(0); + + const $item2 = toolbar._getAvailableItems().eq(2); + assert.strictEqual(document.activeElement, findFocusTarget($item2).get(0), + 'DOM focus restored after dataSource reload'); + assertOneTabStop(assert, this.$element); + }); + + QUnit.test('does not steal focus when the user is outside the toolbar', function(assert) { + const toolbar = createToolbar([buttonItem('A'), buttonItem('B')]); + const outside = $('