Skip to content

Commit 04d5b32

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

5 files changed

Lines changed: 821 additions & 4 deletions

File tree

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

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
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 { closeItemWidget, getItemFocusTarget, isItemWidgetOpened, setItemWidgetFocusState } from '@ts/ui/toolbar/toolbar.utils';
1115

1216
export const TOOLBAR_MENU_ACTION_CLASS = 'dx-toolbar-menu-action';
1317
const TOOLBAR_HIDDEN_BUTTON_CLASS = 'dx-toolbar-hidden-button';
@@ -19,6 +23,12 @@ const SCROLLVIEW_CONTENT_CLASS = 'dx-scrollview-content';
1923

2024
type ActionableComponents = Extract<ToolbarItemComponent, 'dxButton' | 'dxButtonGroup'>;
2125
export default class ToolbarMenuList extends ListBase {
26+
_captureKeydownHandler?: EventListener;
27+
28+
_onEscapePress?: () => void;
29+
30+
_keyboardListenerId?: string;
31+
2232
protected _activeStateUnit(): string {
2333
return `.${TOOLBAR_MENU_ACTION_CLASS}:not(.${TOOLBAR_HIDDEN_BUTTON_GROUP_CLASS})`;
2434
}
@@ -130,6 +140,323 @@ export default class ToolbarMenuList extends ListBase {
130140
};
131141
}
132142

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

143470
_clean(): void {
471+
this._detachCaptureKeyHandler();
144472
this._getSections().empty();
145473
super._clean();
146474
}

0 commit comments

Comments
 (0)