From 7c31784a9074571076ace11aaceba7b91d4ea5e5 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Tue, 21 Jul 2026 15:04:56 +0300 Subject: [PATCH 1/8] add some more popover tests --- .../DevExpress.ui.widgets/popover.tests.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 5a4289478f4f..679cbc37dd4d 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -3002,4 +3002,52 @@ 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'); + }); + }); }); + From f793e3c463bdc42eaeaa3d67dcfb9ef4811c3ba2 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Tue, 21 Jul 2026 15:59:02 +0300 Subject: [PATCH 2/8] move and return focus for dialog role --- .../js/__internal/ui/popover/popover.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 982ec49bf041..f2ed17bce0ca 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -274,6 +274,48 @@ class Popover< _syncAriaAttributes(): void { this._renderAriaRole(); this._syncTargetAriaDescription(); + this._syncFocusOptions(); + } + + _syncFocusOptions(): void { + if (this._getEffectiveAriaRole() === 'dialog') { + this._setOptionWithoutOptionChange('focusStateEnabled', true); + this._setOptionWithoutOptionChange('tabFocusLoopEnabled', true); + } + } + + _focusTarget(): dxElementWrapper | null | undefined { + if (this._getEffectiveAriaRole() === 'dialog') { + const tabbable = this._findTabbableBounds().$first; + if (tabbable && tabbable.length) { + return tabbable; + } + + const $content = this.$overlayContent(); + if ($content.attr('tabindex') === undefined) { + $content.attr('tabindex', -1); + } + return $content; + } + + return super._focusTarget(); + } + + _forceFocusLost(): void { + if (this._getEffectiveAriaRole() === 'dialog') { + this._restoreTargetFocus(); + } else { + super._forceFocusLost(); + } + } + + _restoreTargetFocus(): void { + const $targets = this._getAriaDescriptionTargets(); + + if ($targets.length) { + // @ts-expect-error trigger should be typed on type 'EventsEngineType' + eventsEngine.trigger($targets.first(), 'focus'); + } } _getExpectedAriaRole(): string { @@ -929,6 +971,9 @@ class Popover< } _dispose(): void { + if (this.option('visible') && this._getEffectiveAriaRole() === 'dialog') { + this._restoreTargetFocus(); + } this._removeTargetAriaDescription(); this._detachEscapeKeyHandler(); super._dispose(); From a17511a6d5d8e2f90aab6bb43d3564c026ac148a Mon Sep 17 00:00:00 2001 From: dmlvr Date: Wed, 22 Jul 2026 14:18:34 +0300 Subject: [PATCH 3/8] fix after merging --- .../js/__internal/ui/popover/popover.ts | 26 +++++++--- .../DevExpress.ui.widgets/popover.tests.js | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 50503613583d..3d8c6217a536 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -245,6 +245,16 @@ class Popover< eventsEngine.off(domAdapter.getDocument(), eventName, this._documentEscapeKeyHandler); } + _visibilityChanged(visible: boolean): void { + super._visibilityChanged(visible); + + if (visible) { + this._attachEscapeKeyHandler(); + } else { + this._detachEscapeKeyHandler(); + } + } + _render(): void { super._render(); @@ -292,14 +302,6 @@ class Popover< return super._focusTarget(); } - _forceFocusLost(): void { - if (this._getEffectiveAriaRole() === 'dialog') { - this._restoreTargetFocus(); - } else { - super._forceFocusLost(); - } - } - _restoreTargetFocus(): void { const $targets = this._getAriaDescriptionTargets(); @@ -309,6 +311,14 @@ class Popover< } } + _forceFocusLost(): void { + if (this._getEffectiveAriaRole() === 'dialog') { + this._restoreTargetFocus(); + } else { + super._forceFocusLost(); + } + } + _getAriaRole(): string { const { toolbarItems, showTitle, showCloseButton } = this.option(); 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 5b2fb3034882..f8af4797953f 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -3033,6 +3033,56 @@ QUnit.module('accessibility', { 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: [ + { text: 'OK', options: { focusStateEnabled: true } }, + { text: 'Cancel', options: { focusStateEnabled: true } } + ], + 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: [ + { text: 'OK', options: { focusStateEnabled: true } }, + { text: 'Cancel', options: { focusStateEnabled: true } } + ], + 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'); + }); }); }); From d9aa676cb1123cd4135a2d591413ba1b289ea4a6 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Wed, 22 Jul 2026 16:38:47 +0300 Subject: [PATCH 4/8] update _syncFocusOptions --- .../devextreme/js/__internal/ui/popover/popover.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 3d8c6217a536..7dbe9b17a87e 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -156,6 +156,8 @@ class Popover< hideOnParentScroll: true, arrowPosition: '', arrowOffset: 0, + focusStateEnabled: undefined, + tabFocusLoopEnabled: undefined, _fixWrapperPosition: true, _describeTarget: true, }; @@ -280,8 +282,15 @@ class Popover< _syncFocusOptions(): void { if (this._getEffectiveAriaRole() === 'dialog') { - this._setOptionWithoutOptionChange('focusStateEnabled', true); - this._setOptionWithoutOptionChange('tabFocusLoopEnabled', true); + const { focusStateEnabled, tabFocusLoopEnabled } = this.option(); + + if (focusStateEnabled === undefined) { + this._setOptionWithoutOptionChange('focusStateEnabled', true); + } + + if (tabFocusLoopEnabled === undefined) { + this._setOptionWithoutOptionChange('tabFocusLoopEnabled', true); + } } } From 5f6187d6e5b5de6e713f9b02e2b88ad8c085532f Mon Sep 17 00:00:00 2001 From: dmlvr Date: Wed, 22 Jul 2026 17:02:21 +0300 Subject: [PATCH 5/8] refactoring --- .../js/__internal/ui/popover/popover.ts | 36 ++++++++-------- .../DevExpress.ui.widgets/popover.tests.js | 41 +++++++++++++++++-- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 7dbe9b17a87e..7481942ec3e0 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -247,16 +247,6 @@ class Popover< eventsEngine.off(domAdapter.getDocument(), eventName, this._documentEscapeKeyHandler); } - _visibilityChanged(visible: boolean): void { - super._visibilityChanged(visible); - - if (visible) { - this._attachEscapeKeyHandler(); - } else { - this._detachEscapeKeyHandler(); - } - } - _render(): void { super._render(); @@ -280,6 +270,8 @@ class Popover< this._syncFocusOptions(); } + // NOTE: If options are explicitly specified, they are not overwritten. + // Now Lookup uses focusStateEnabled false and overlayRole dialog. _syncFocusOptions(): void { if (this._getEffectiveAriaRole() === 'dialog') { const { focusStateEnabled, tabFocusLoopEnabled } = this.option(); @@ -294,18 +286,26 @@ class Popover< } } + _renderFocusTarget(): void { + if (this._getEffectiveAriaRole() !== 'dialog') { + const { tabIndex } = this.option(); + // @ts-expect-error + this._focusTarget().attr('tabIndex', tabIndex); + } + } + _focusTarget(): dxElementWrapper | null | undefined { if (this._getEffectiveAriaRole() === 'dialog') { - const tabbable = this._findTabbableBounds().$first; - if (tabbable && tabbable.length) { - return tabbable; + const $firstFocusableTarget = this._findTabbableBounds().$first; + if ($firstFocusableTarget?.length) { + return $firstFocusableTarget; } - const $content = this.$overlayContent(); - if ($content.attr('tabindex') === undefined) { - $content.attr('tabindex', -1); - } - return $content; + const $overlayContent = this.$overlayContent(); + const { tabIndex } = this.option(); + // @ts-expect-error + $overlayContent.attr('tabindex', tabIndex); + return $overlayContent; } return super._focusTarget(); 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 f8af4797953f..7f20d8bcdb18 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -3038,8 +3038,8 @@ QUnit.module('accessibility', { const instance = new Popover(this.$element, { target: this.$target, toolbarItems: [ - { text: 'OK', options: { focusStateEnabled: true } }, - { text: 'Cancel', options: { focusStateEnabled: true } } + { widget: 'dxButton', options: { text: 'OK' } }, + { widget: 'dxButton', options: { text: 'Cancel' } } ], visible: false, }); @@ -3063,8 +3063,8 @@ QUnit.module('accessibility', { const instance = new Popover(this.$element, { target: this.$target, toolbarItems: [ - { text: 'OK', options: { focusStateEnabled: true } }, - { text: 'Cancel', options: { focusStateEnabled: true } } + { widget: 'dxButton', options: { text: 'OK', } }, + { widget: 'dxButton', options: { text: 'Cancel', } } ], visible: false, }); @@ -3083,6 +3083,39 @@ QUnit.module('accessibility', { 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'); + }); }); }); From 41c93ba0276f3c9776cc0e4258c9b821d15e4443 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Thu, 23 Jul 2026 16:48:28 +0300 Subject: [PATCH 6/8] fix by review comment --- packages/devextreme/js/__internal/ui/popover/popover.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 7481942ec3e0..c8222c7fe857 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -955,7 +955,8 @@ class Popover< } _dispose(): void { - if (this.option('visible') && this._getEffectiveAriaRole() === 'dialog') { + const { visible } = this.option(); + if (visible && this._getEffectiveAriaRole() === 'dialog') { this._restoreTargetFocus(); } this._removeTargetAriaDescription(); From b81cd6d08ad713c5151e54ddd6ac688173c1560a Mon Sep 17 00:00:00 2001 From: dmlvr Date: Thu, 23 Jul 2026 18:42:31 +0300 Subject: [PATCH 7/8] update tabindex for overlay --- packages/devextreme/js/__internal/ui/popover/popover.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index c8222c7fe857..4accbe5449b1 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -302,9 +302,9 @@ class Popover< } const $overlayContent = this.$overlayContent(); - const { tabIndex } = this.option(); - // @ts-expect-error - $overlayContent.attr('tabindex', tabIndex); + if ($overlayContent.attr('tabindex') === undefined) { + $overlayContent.attr('tabindex', -1); + } return $overlayContent; } From cd436bde506241ebab4685bc8e21315e67bddbb5 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Fri, 24 Jul 2026 17:11:10 +0300 Subject: [PATCH 8/8] add internal Option to prevent focus management for dialog role (Lookup edge case) --- .../devextreme/js/__internal/ui/lookup.ts | 1 + .../js/__internal/ui/popover/popover.ts | 19 +++++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/lookup.ts b/packages/devextreme/js/__internal/ui/lookup.ts index 3ff0dbb242aa..e3f9a1610f12 100644 --- a/packages/devextreme/js/__internal/ui/lookup.ts +++ b/packages/devextreme/js/__internal/ui/lookup.ts @@ -683,6 +683,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/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 4accbe5449b1..dc8aa7991293 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