Skip to content

Commit e926260

Browse files
Toolbar: support keyboard navigation according to APG W3C
1 parent 7dccaba commit e926260

6 files changed

Lines changed: 826 additions & 5 deletions

File tree

packages/devextreme/js/__internal/ui/toolbar/internal/toolbar.menu.list.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import type { ToolbarItemComponent } from '@js/common';
2+
import { keyboard } from '@js/common/core/events/short';
23
import type { DataSourceOptions } from '@js/common/data';
34
import type { dxElementWrapper } from '@js/core/renderer';
45
import $ from '@js/core/renderer';
56
import { each } from '@js/core/utils/iterator';
67
import type { DxEvent } from '@js/events';
78
import type { Item } from '@js/ui/toolbar';
9+
import { getPublicElement } from '@ts/core/m_element';
810
import type { ActionConfig } from '@ts/core/widget/component';
11+
import type { SupportedKeys } from '@ts/core/widget/widget';
912
import type { ItemRenderInfo, ItemTemplate } from '@ts/ui/collection/collection_widget.base';
1013
import { ListBase } from '@ts/ui/list/list.base';
14+
import {
15+
closeItemWidget, getItemFocusTarget, isItemWidgetOpened, setItemWidgetFocusState,
16+
} from '@ts/ui/toolbar/toolbar.utils';
1117

1218
export const TOOLBAR_MENU_ACTION_CLASS = 'dx-toolbar-menu-action';
1319
const TOOLBAR_HIDDEN_BUTTON_CLASS = 'dx-toolbar-hidden-button';
@@ -19,6 +25,12 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content';
1925

2026
type ActionableComponents = Extract<ToolbarItemComponent, 'dxButton' | 'dxButtonGroup'>;
2127
export default class ToolbarMenuList extends ListBase {
28+
_captureKeydownHandler?: EventListener;
29+
30+
_onEscapePress?: () => void;
31+
32+
_keyboardListenerId?: string;
33+
2234
protected _activeStateUnit(): string {
2335
return `.${TOOLBAR_MENU_ACTION_CLASS}:not(.${TOOLBAR_HIDDEN_BUTTON_GROUP_CLASS})`;
2436
}
@@ -130,6 +142,323 @@ export default class ToolbarMenuList extends ListBase {
130142
};
131143
}
132144

145+
_supportedKeys(): SupportedKeys {
146+
const keys = super._supportedKeys();
147+
148+
delete keys.leftArrow;
149+
delete keys.rightArrow;
150+
delete keys.upArrow;
151+
delete keys.downArrow;
152+
delete keys.home;
153+
delete keys.end;
154+
155+
const originalEnter = keys.enter;
156+
keys.enter = (e: DxEvent<KeyboardEvent>): void => {
157+
const target = e.target as HTMLElement;
158+
159+
if (this._isTextInputTarget(target) || this._isMenuTarget(target)) {
160+
return;
161+
}
162+
163+
const { focusedElement } = this.option();
164+
const $item = $(focusedElement);
165+
166+
if ($item.length) {
167+
const $textEditor = $item.find('.dx-texteditor-input').first();
168+
if ($textEditor.length) {
169+
e.preventDefault();
170+
($textEditor.get(0) as HTMLElement).focus();
171+
return;
172+
}
173+
174+
const $menu = $item.find('.dx-menu').first();
175+
if ($menu.length) {
176+
e.preventDefault();
177+
const menuInstance = $menu.data('dxMenu');
178+
if (menuInstance) {
179+
// @ts-expect-error ts-error
180+
menuInstance.focus();
181+
}
182+
return;
183+
}
184+
}
185+
186+
originalEnter?.call(this, e);
187+
};
188+
189+
return keys;
190+
}
191+
192+
_attachKeyboardEvents(): void {
193+
this._detachKeyboardEvents();
194+
195+
const { focusStateEnabled } = this.option();
196+
197+
if (focusStateEnabled) {
198+
this._keyboardListenerId = keyboard.on(
199+
this._keyboardEventBindingTarget(),
200+
null,
201+
(opts) => this._keyboardHandler(opts),
202+
);
203+
204+
this._attachCaptureKeyHandler();
205+
}
206+
}
207+
208+
_detachKeyboardEvents(): void {
209+
if (this._keyboardListenerId) {
210+
keyboard.off(this._keyboardListenerId);
211+
this._keyboardListenerId = undefined;
212+
}
213+
214+
this._detachCaptureKeyHandler();
215+
}
216+
217+
_attachCaptureKeyHandler(): void {
218+
this._detachCaptureKeyHandler();
219+
220+
const element = this.$element().get(0) as HTMLElement;
221+
222+
this._captureKeydownHandler = (evt: Event): void => {
223+
const e = evt as KeyboardEvent;
224+
const target = e.target as HTMLElement;
225+
226+
const isTextInput = this._isTextInputTarget(target);
227+
const isMenu = this._isMenuTarget(target);
228+
229+
if ((isTextInput || isMenu) && e.key !== 'Escape') {
230+
return;
231+
}
232+
233+
if (e.key === 'Escape' && (isTextInput || isMenu)) {
234+
e.preventDefault();
235+
e.stopPropagation();
236+
237+
const $item = $(target).closest(this._itemSelector());
238+
if ($item.length && closeItemWidget($item)) {
239+
return;
240+
}
241+
242+
if ($item.length) {
243+
this._focusItemWidget($item);
244+
}
245+
246+
return;
247+
}
248+
249+
if (e.key === 'Escape') {
250+
e.preventDefault();
251+
e.stopPropagation();
252+
this._onEscapePress?.();
253+
254+
return;
255+
}
256+
257+
const keyToLocation: Record<string, string> = {
258+
ArrowDown: 'down',
259+
ArrowUp: 'up',
260+
Home: 'first',
261+
End: 'last',
262+
};
263+
264+
const location = keyToLocation[e.key];
265+
266+
if (!location) {
267+
return;
268+
}
269+
270+
const { focusedElement } = this.option();
271+
const $focused = $(focusedElement);
272+
if ($focused.length && isItemWidgetOpened($focused)) {
273+
return;
274+
}
275+
276+
e.preventDefault();
277+
e.stopPropagation();
278+
279+
this._moveFocus(location);
280+
};
281+
282+
element.addEventListener('keydown', this._captureKeydownHandler, true);
283+
}
284+
285+
_detachCaptureKeyHandler(): void {
286+
if (this._captureKeydownHandler) {
287+
const element = this.$element().get(0) as HTMLElement;
288+
element.removeEventListener('keydown', this._captureKeydownHandler, true);
289+
this._captureKeydownHandler = undefined;
290+
}
291+
}
292+
293+
_isTextInputTarget(target: HTMLElement): boolean {
294+
const tagName = target.tagName.toLowerCase();
295+
296+
return (tagName === 'input' || tagName === 'textarea')
297+
&& $(target).closest('.dx-texteditor').length > 0;
298+
}
299+
300+
_isMenuTarget(target: HTMLElement): boolean {
301+
return $(target).closest('.dx-menu').length > 0;
302+
}
303+
304+
_getAvailableItems($itemElements?: dxElementWrapper): dxElementWrapper {
305+
const $visible = this._getVisibleItems($itemElements);
306+
const elements = Array.from($visible.toArray()).filter(
307+
(item) => !!getItemFocusTarget($(item))?.length,
308+
);
309+
310+
return $(elements) as unknown as dxElementWrapper;
311+
}
312+
313+
_setFocusedItem($target: dxElementWrapper): void {
314+
super._setFocusedItem($target);
315+
this._updateRovingTabIndex($target);
316+
}
317+
318+
_updateRovingTabIndex($activeItem?: dxElementWrapper): void {
319+
const $items = this._getAvailableItems();
320+
let hasActive = false;
321+
322+
$items.each((_index: number, item: Element): boolean => {
323+
const $item = $(item);
324+
const $focusTarget = getItemFocusTarget($item);
325+
326+
if ($focusTarget?.length) {
327+
const isActive = !!$activeItem?.length && $item.get(0) === $activeItem.get(0);
328+
$focusTarget.attr('tabIndex', isActive ? 0 : -1);
329+
if (isActive) {
330+
hasActive = true;
331+
}
332+
333+
const $input = $focusTarget.hasClass('dx-texteditor')
334+
? $focusTarget.find('.dx-texteditor-input')
335+
: undefined;
336+
337+
if ($input?.length) {
338+
if (!isActive) {
339+
$input.attr('tabIndex', -1);
340+
}
341+
342+
const hasDropDown = $focusTarget.hasClass('dx-dropdowneditor');
343+
if (!hasDropDown && !$focusTarget.attr('role')) {
344+
const label = $input.attr('aria-label')
345+
?? $input.attr('placeholder')
346+
?? '';
347+
// @ts-expect-error ts-error
348+
$focusTarget.attr({
349+
role: 'textbox',
350+
'aria-readonly': 'true',
351+
'aria-label': label,
352+
});
353+
}
354+
}
355+
356+
const $menu = $item.find('.dx-menu');
357+
if ($menu.length) {
358+
$menu.attr('tabIndex', -1);
359+
$menu.find('[tabindex]').attr('tabIndex', -1);
360+
}
361+
}
362+
363+
return true;
364+
});
365+
366+
if (!hasActive) {
367+
const $first = $items.first();
368+
if ($first.length) {
369+
const $firstTarget = getItemFocusTarget($first);
370+
$firstTarget?.attr('tabIndex', 0);
371+
}
372+
}
373+
}
374+
375+
_focusInHandler(e: DxEvent): void {
376+
const $target = $(e.target as Element);
377+
const $item = $target.closest(this._itemSelector());
378+
379+
if ($item.length && getItemFocusTarget($item)?.length) {
380+
this.option('focusedElement', getPublicElement($item));
381+
}
382+
}
383+
384+
_focusItemWidget($item: dxElementWrapper): void {
385+
const $focusTarget = getItemFocusTarget($item);
386+
if (!$focusTarget?.length) {
387+
return;
388+
}
389+
390+
($focusTarget.get(0) as HTMLElement).focus();
391+
setItemWidgetFocusState($item, true);
392+
}
393+
394+
_focusOutHandler(e: DxEvent): void {
395+
const { relatedTarget } = e as DxEvent & { relatedTarget: Element };
396+
const target = e.target as Element;
397+
398+
if (relatedTarget && this.$element().get(0)?.contains(relatedTarget)) {
399+
return;
400+
}
401+
402+
if (relatedTarget && $(relatedTarget).closest('.dx-overlay-content').length) {
403+
return;
404+
}
405+
406+
if (target && $(target).closest('.dx-overlay-content').length) {
407+
return;
408+
}
409+
410+
const { focusedElement } = this.option();
411+
const $focused = $(focusedElement);
412+
if ($focused.length) {
413+
setItemWidgetFocusState($focused, false);
414+
}
415+
416+
super._focusOutHandler(e);
417+
}
418+
419+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
420+
_moveFocus(location: string): boolean | undefined | void {
421+
const { focusedElement: prevFocusedElement } = this.option();
422+
const $prev = $(prevFocusedElement);
423+
if ($prev.length) {
424+
closeItemWidget($prev);
425+
setItemWidgetFocusState($prev, false);
426+
}
427+
428+
const result = super._moveFocus(location);
429+
430+
const { focusedElement } = this.option();
431+
const $focused = $(focusedElement);
432+
if ($focused.length) {
433+
this._focusItemWidget($focused);
434+
}
435+
436+
return result;
437+
}
438+
439+
focusFirstItem(): void {
440+
const $first = this._getAvailableItems().first();
441+
if ($first.length) {
442+
this.option('focusedElement', getPublicElement($first));
443+
this._focusItemWidget($first);
444+
}
445+
}
446+
447+
focusLastItem(): void {
448+
const $last = this._getAvailableItems().last();
449+
if ($last.length) {
450+
this.option('focusedElement', getPublicElement($last));
451+
this._focusItemWidget($last);
452+
}
453+
}
454+
455+
_postProcessRenderItems(): void {
456+
super._postProcessRenderItems();
457+
458+
const { focusedElement } = this.option();
459+
this._updateRovingTabIndex($(focusedElement));
460+
}
461+
133462
_itemClickHandler(
134463
e: DxEvent,
135464
args?: Record<string, unknown>,
@@ -141,6 +470,7 @@ export default class ToolbarMenuList extends ListBase {
141470
}
142471

143472
_clean(): void {
473+
this._detachCaptureKeyHandler();
144474
this._getSections().empty();
145475
super._clean();
146476
}

0 commit comments

Comments
 (0)