diff --git a/packages/devextreme/js/__internal/ui/lookup.ts b/packages/devextreme/js/__internal/ui/lookup.ts index 8da99a7499f7..a736b76d9c3a 100644 --- a/packages/devextreme/js/__internal/ui/lookup.ts +++ b/packages/devextreme/js/__internal/ui/lookup.ts @@ -679,6 +679,7 @@ class Lookup extends DropDownList { hideOnParentScroll: true, _fixWrapperPosition: false, _overlayContentRole: 'dialog', + _preventDialogFocus: true, width: this._isInitialOptionValue('dropDownOptions.width') ? (): number => getOuterWidth(this.$element()) as number : popupConfig.width, diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index 8770c4d94899..883f9fb708d3 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -624,7 +624,7 @@ class Overlay< if (focusStateEnabled) { // @ts-expect-error trigger should be typed on type 'EventsEngineType' - eventsEngine.trigger(this._focusTarget(), 'focus'); + eventsEngine.trigger(this._getFocusTarget(), 'focus'); } completeShowAnimation.call(this, element, config); @@ -993,12 +993,14 @@ class Overlay< const $reverseElement = $elements?.eq(elementsCount - i) ?? null; // @ts-expect-error is should can get function as callback - if (!$first && $currentElement.is(selectors.tabbable)) { + if (!$first && $currentElement.is(selectors.tabbable) + // @ts-expect-error is should can get function as callback + && !$currentElement.is(this._$content)) { $first = $currentElement; } // @ts-expect-error is should can get function as callback - if (!$last && $reverseElement.is(selectors.tabbable)) { + if (!$last && $reverseElement.is(selectors.tabbable) && !$reverseElement.is(this._$content)) { $last = $reverseElement; } @@ -1011,7 +1013,7 @@ class Overlay< } _tabKeyHandler(e: KeyboardEvent): void { - if (normalizeKeyName(e) !== TAB_KEY || !this._isTopOverlay()) { + if (normalizeKeyName(e) !== TAB_KEY || !this._isTopOverlay() || e.defaultPrevented) { return; } @@ -1032,7 +1034,7 @@ class Overlay< if (shouldPreventDefault) { e.preventDefault(); - const $focusElement = e.shiftKey ? $lastTabbable : $firstTabbable; + const $focusElement = (e.shiftKey ? $lastTabbable : $firstTabbable) ?? this._$content; // @ts-expect-error trigger should be typed on type 'EventsEngineType' eventsEngine.trigger($focusElement, 'focusin'); @@ -1149,6 +1151,7 @@ class Overlay< this._appendContentToElement(); super._renderContent(); + this._renderFocusTarget(); } _isParentHidden(): boolean { @@ -1446,9 +1449,21 @@ class Overlay< }); } + _renderFocusTarget(): void {} + + _getFocusTarget(): dxElementWrapper | null | undefined { + const $firstFocusableTarget = this._findTabbableBounds().$first; + + if ($firstFocusableTarget?.length) { + return $firstFocusableTarget; + } + + return this._$content as dxElementWrapper; + } + // @ts-expect-error LSP _focusTarget(): dxElementWrapper | null | undefined { - return this._$content; + return this._getFocusTarget(); } _attachKeyboardEvents(): void { diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 484a40647017..49bf8e7f52d8 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -111,6 +111,8 @@ export interface PopoverProperties extends Omit item.getAttribute('tabindex') >= 0); + .filter((_, item) => item.getAttribute('tabindex') >= 0 && item !== this.$overlayContent()?.get(0)); } } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js index 10006ec957f7..5a242d235b86 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -3009,4 +3009,135 @@ QUnit.module('accessibility', { assert.ok(instance.option('visible'), 'popover remains visible when pointer re-enters overlay before delay expires'); }); }); + + QUnit.module('dialog mode focus management and accessibility', { + beforeEach() { + this.clock = sinon.useFakeTimers(); + this.$element = $('#what'); + this.$target = $('#where'); + }, + afterEach() { + this.clock.restore(); + } + }, () => { + QUnit.test('Popover in dialog mode should enable focusStateEnabled and tabFocusLoopEnabled on show', function(assert) { + const instance = new Popover(this.$element, { + target: this.$target, + toolbarItems: [{ text: 'OK' }], + visible: false, + }); + + instance.show(); + this.clock.tick(0); + + assert.strictEqual(instance.option('focusStateEnabled'), true, 'focusStateEnabled is enabled for dialog mode'); + assert.strictEqual(instance.option('tabFocusLoopEnabled'), true, 'tabFocusLoopEnabled is enabled for dialog mode'); + }); + + QUnit.test('Popover in dialog mode should move focus inside on show and restore focus to target when hidden', function(assert) { + this.$target.attr('tabindex', 0).focus(); + assert.strictEqual(document.activeElement, this.$target.get(0), 'target is focused before show'); + + const instance = new Popover(this.$element, { + target: this.$target, + toolbarItems: [{ text: 'OK' }], + visible: false, + }); + + instance.show(); + this.clock.tick(500); + + const isFocusInside = $(document.activeElement).closest(wrapper()).length > 0; + assert.strictEqual(isFocusInside, true, 'focus moved inside popover wrapper on show'); + + instance.hide(); + this.clock.tick(500); + + assert.strictEqual(document.activeElement, this.$target.get(0), 'focus is restored to target after hide'); + }); + + QUnit.test('Popover in dialog mode should loop focus from last to first element on tab keypress', function(assert) { + const instance = new Popover(this.$element, { + target: this.$target, + toolbarItems: [ + { widget: 'dxButton', options: { text: 'OK' } }, + { widget: 'dxButton', options: { text: 'Cancel' } } + ], + visible: false, + }); + + instance.show(); + this.clock.tick(500); + + const bounds = instance._findTabbableBounds(); + const firstFocusable = bounds.$first.get(0); + const lastFocusable = bounds.$last.get(0); + + $(lastFocusable).focus(); + + const tabEvent = $.Event('keydown', { key: 'Tab' }); + $(document).trigger(tabEvent); + + assert.strictEqual(document.activeElement, firstFocusable, 'focus looped to the first element'); + }); + + QUnit.test('Popover in dialog mode should loop focus from first to last element on shift+tab keypress', function(assert) { + const instance = new Popover(this.$element, { + target: this.$target, + toolbarItems: [ + { widget: 'dxButton', options: { text: 'OK', } }, + { widget: 'dxButton', options: { text: 'Cancel', } } + ], + visible: false, + }); + + instance.show(); + this.clock.tick(500); + + const bounds = instance._findTabbableBounds(); + const firstFocusable = bounds.$first.get(0); + const lastFocusable = bounds.$last.get(0); + + $(firstFocusable).focus(); + + const shiftTabEvent = $.Event('keydown', { key: 'Tab', shiftKey: true }); + $(document).trigger(shiftTabEvent); + + assert.strictEqual(document.activeElement, lastFocusable, 'focus looped to the last element'); + }); + + QUnit.test('Popover in dialog mode should focus first tabbable element inside content on show', function(assert) { + const instance = new Popover(this.$element, { + target: this.$target, + contentTemplate: function() { + return $('
'); + }, + toolbarItems: [{ text: 'OK' }], + visible: false, + }); + + instance.show(); + this.clock.tick(500); + + const $input1 = $('#input1'); + assert.strictEqual(document.activeElement, $input1.get(0), 'first tabbable element is focused'); + }); + + QUnit.test('Popover in dialog mode should restore focus to target on dispose when visible', function(assert) { + this.$target.attr('tabindex', 0).focus(); + + const instance = new Popover(this.$element, { + target: this.$target, + toolbarItems: [{ text: 'OK' }], + visible: false, + }); + + instance.show(); + + instance.dispose(); + + assert.strictEqual(document.activeElement, this.$target.get(0), 'focus is restored to target after dispose'); + }); + }); }); +