Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/devextreme/js/__internal/core/widget/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/devextreme/js/__internal/ui/list/list.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +25,12 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content';

type ActionableComponents = Extract<ToolbarItemComponent, 'dxButton' | 'dxButtonGroup'>;
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})`;
}
Expand Down Expand Up @@ -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<KeyboardEvent>): 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<string, string> = {
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<string, unknown>,
Expand All @@ -141,6 +470,7 @@ export default class ToolbarMenuList extends ListBase {
}

_clean(): void {
this._detachCaptureKeyHandler();
this._getSections().empty();
super._clean();
}
Expand Down
Loading
Loading