From f0fca417981f9216dbf55d68df10c4de33cbc183 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 14 May 2026 14:10:22 +0400 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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 }) }], }); },