From 7cef21aea8fac64ebf178a9799f8f4eb18feb7a0 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 27 Oct 2026 14:20:08 +0400 Subject: [PATCH 1/3] Popover: keep aria attributes on the overlay content when the toolbar gets focusable elements at runtime (#5410) --- .../ui/__tests__/__mock__/model/popup.ts | 12 ++++ .../ui/__tests__/tooltip.aria_role.test.ts | 25 ++++++- .../__tests__/popover.aria_role.test.ts | 70 ++++++++++++++++++- .../js/__internal/ui/popover/popover.ts | 4 ++ .../DevExpress.ui.widgets/popover.tests.js | 69 ++++++++++++++++++ .../DevExpress.ui.widgets/tooltip.tests.js | 16 +++++ 6 files changed, 194 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popup.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popup.ts index bf04cf870ed7..5258919e6089 100644 --- a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popup.ts +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popup.ts @@ -5,6 +5,9 @@ const CLASSES = { popupWrapper: 'dx-popup-wrapper', popupTitle: 'dx-popup-title', overlayContent: 'dx-overlay-content', + closeButton: 'dx-closebutton', + toolbar: 'dx-toolbar', + button: 'dx-button', }; const SELECTORS = { @@ -37,6 +40,15 @@ export class PopupModel { return this.getOverlayContent()?.getAttribute('role') ?? null; } + public getCloseButton(): HTMLElement | null { + return this.getOverlayContent()?.querySelector(`.${CLASSES.closeButton}`) ?? null; + } + + public getToolbarButtons(): HTMLElement[] { + const buttons = this.getOverlayContent()?.querySelectorAll(`.${CLASSES.toolbar} .${CLASSES.button}`); + return Array.from(buttons ?? []); + } + public isVisible(): boolean { return !!this.getOverlayContent(); } diff --git a/packages/devextreme/js/__internal/ui/__tests__/tooltip.aria_role.test.ts b/packages/devextreme/js/__internal/ui/__tests__/tooltip.aria_role.test.ts index 793e598f0dce..bbdcc6676269 100644 --- a/packages/devextreme/js/__internal/ui/__tests__/tooltip.aria_role.test.ts +++ b/packages/devextreme/js/__internal/ui/__tests__/tooltip.aria_role.test.ts @@ -1,5 +1,5 @@ import { - afterEach, beforeAll, describe, expect, it, + afterAll, afterEach, beforeAll, describe, expect, it, } from '@jest/globals'; import fx from '@js/common/core/animation/fx'; import $ from '@js/core/renderer'; @@ -56,4 +56,27 @@ describe('Tooltip overlay content aria role', () => { expect(model.getRole()).toBe(role); }); }); + + describe('on a shown tooltip with focusable toolbar elements', () => { + const originalGetClientRects = Element.prototype.getClientRects; + + beforeAll(() => { + // jsdom performs no layout, so without this stub nothing counts as visible + // and the toolbar buttons are never tabbable. + Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList; + }); + + afterAll(() => { + Element.prototype.getClientRects = originalGetClientRects; + }); + + it('sets the role on the overlay content, not on the toolbar button, when toolbar items with a button are added', async () => { + const model = await createTooltip({}); + + model.getInstance().option('toolbarItems', [{ widget: 'dxButton', options: { text: 'OK' } }]); + + expect(model.getRole()).toBe('dialog'); + expect(model.getToolbarButtons().map((button) => button.getAttribute('role'))).toEqual(['button']); + }); + }); }); diff --git a/packages/devextreme/js/__internal/ui/popover/__tests__/popover.aria_role.test.ts b/packages/devextreme/js/__internal/ui/popover/__tests__/popover.aria_role.test.ts index dc11ae95e127..812bf605baf2 100644 --- a/packages/devextreme/js/__internal/ui/popover/__tests__/popover.aria_role.test.ts +++ b/packages/devextreme/js/__internal/ui/popover/__tests__/popover.aria_role.test.ts @@ -1,5 +1,5 @@ import { - afterEach, beforeAll, describe, expect, it, + afterAll, afterEach, beforeAll, describe, expect, it, } from '@jest/globals'; import fx from '@js/common/core/animation/fx'; import $ from '@js/core/renderer'; @@ -86,4 +86,72 @@ describe('Popover overlay content aria role', () => { expect(model.getRole()).toBe('tooltip'); }); }); + + describe('on a shown popover with focusable toolbar elements', () => { + const originalGetClientRects = Element.prototype.getClientRects; + + beforeAll(() => { + // jsdom performs no layout, so without this stub nothing counts as visible + // and the toolbar buttons are never tabbable. + Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList; + }); + + afterAll(() => { + Element.prototype.getClientRects = originalGetClientRects; + }); + + it('sets the role on the overlay content, not on the close button, when a title and a close button are enabled', async () => { + const model = await createPopover({}); + + model.getInstance().option({ showTitle: true, title: 'Title', showCloseButton: true }); + + expect(model.getRole()).toBe('dialog'); + expect(model.getCloseButton()?.getAttribute('role')).toBe('button'); + }); + + it('switches back to "tooltip" when the close button is hidden again', async () => { + const model = await createPopover({}); + model.getInstance().option({ showTitle: true, title: 'Title', showCloseButton: true }); + + model.getInstance().option('showCloseButton', false); + + expect(model.getRole()).toBe('tooltip'); + expect(model.getCloseButton()).toBeNull(); + }); + + it('sets the role on the overlay content, not on the toolbar button, when toolbar items with a button are added', async () => { + const model = await createPopover({}); + + model.getInstance().option('toolbarItems', [{ widget: 'dxButton', options: { text: 'OK' } }]); + + expect(model.getRole()).toBe('dialog'); + expect(model.getToolbarButtons().map((button) => button.getAttribute('role'))).toEqual(['button']); + }); + + it('applies a forced _popoverContentRole to the overlay content, not to the close button', async () => { + const model = await createPopover({ showTitle: true, title: 'Title', showCloseButton: true }); + + model.getInstance().option('_popoverContentRole', 'tooltip'); + + expect(model.getRole()).toBe('tooltip'); + expect(model.getCloseButton()?.getAttribute('role')).toBe('button'); + + model.getInstance().option('_popoverContentRole', undefined); + + expect(model.getRole()).toBe('dialog'); + expect(model.getCloseButton()?.getAttribute('role')).toBe('button'); + }); + + it('toggles aria-disabled on the overlay content when disabled changes', async () => { + const model = await createPopover({ showTitle: true, title: 'Title', showCloseButton: true }); + + model.getInstance().option('disabled', true); + + expect(model.getOverlayContent().getAttribute('aria-disabled')).toBe('true'); + + model.getInstance().option('disabled', false); + + expect(model.getOverlayContent().getAttribute('aria-disabled')).toBeNull(); + }); + }); }); diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index e7457e08c0ba..c328b98c9664 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -288,6 +288,10 @@ class Popover< // uses in Popup and do not need here. _renderFocusTarget(): void {} + _getAriaTarget(): dxElementWrapper { + return this.$overlayContent(); + } + _getFocusTarget(): dxElementWrapper | null | undefined { const $firstFocusableTarget = this._findTabbableBounds().$first; if ($firstFocusableTarget?.length) { 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 276f63e40b2a..19ab0fc88519 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -3314,5 +3314,74 @@ QUnit.module('accessibility', { assert.strictEqual(instance.option('tabFocusLoopEnabled'), false, 'tabFocusLoopEnabled remains false due to _preventDialogContainerFocus'); }); }); + + QUnit.module('shown popover with focusable toolbar elements', () => { + const POPUP_CLOSE_BUTTON_CLASS = 'dx-closebutton'; + const TOOLBAR_BUTTON_SELECTOR = '.dx-toolbar .dx-button'; + + const createShownPopover = (options) => new Popover($('#what'), $.extend({ + target: '#where', + visible: true, + animation: null, + }, options)); + + const getCloseButton = (popover) => popover.$overlayContent().find(`.${POPUP_CLOSE_BUTTON_CLASS}`); + + QUnit.test('enabling showTitle and showCloseButton at runtime should set the role on the overlay content and keep the close button role', function(assert) { + const popover = createShownPopover(); + + popover.option({ showTitle: true, title: 'Title', showCloseButton: true }); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual(getCloseButton(popover).attr('role'), 'button', 'close button keeps its role'); + }); + + QUnit.test('hiding the close button at runtime should restore role="tooltip" on the overlay content', function(assert) { + const popover = createShownPopover({ showTitle: true, title: 'Title', showCloseButton: true }); + + popover.option('showCloseButton', false); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.strictEqual(getCloseButton(popover).length, 0, 'close button is removed'); + }); + + QUnit.test('adding toolbar items with a button at runtime should set the role on the overlay content and keep the button role', function(assert) { + const popover = createShownPopover(); + + popover.option('toolbarItems', [{ widget: 'dxButton', options: { text: 'OK' } }]); + + const $button = popover.$overlayContent().find(TOOLBAR_BUTTON_SELECTOR); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual($button.length, 1, 'toolbar button is rendered'); + assert.strictEqual($button.attr('role'), 'button', 'toolbar button keeps its role'); + }); + + QUnit.test('runtime _popoverContentRole change should apply to the overlay content and keep the close button role', function(assert) { + const popover = createShownPopover({ showTitle: true, title: 'Title', showCloseButton: true }); + + popover.option('_popoverContentRole', 'tooltip'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'forced role is applied to the overlay content'); + assert.strictEqual(getCloseButton(popover).attr('role'), 'button', 'close button keeps its role'); + + popover.option('_popoverContentRole', undefined); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'computed role is restored on the overlay content'); + assert.strictEqual(getCloseButton(popover).attr('role'), 'button', 'close button still keeps its role'); + }); + + QUnit.test('runtime disabled change should toggle aria-disabled on the overlay content', function(assert) { + const popover = createShownPopover({ showTitle: true, title: 'Title', showCloseButton: true }); + + popover.option('disabled', true); + + assert.strictEqual(popover.$overlayContent().attr('aria-disabled'), 'true', 'overlay content is marked disabled'); + + popover.option('disabled', false); + + assert.strictEqual(popover.$overlayContent().attr('aria-disabled'), undefined, 'overlay content is not marked disabled'); + }); + }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js index 338c6c17edca..d57e8eefbcc2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js @@ -208,6 +208,22 @@ QUnit.module('accessibility', () => { assert.equal($overlayContent.attr('role'), 'dialog'); }); + QUnit.test('adding toolbar items with a button at runtime should set the role on the overlay content and keep the button role', function(assert) { + const tooltip = new Tooltip($('#tooltip'), { + target: '#target', + visible: true, + animation: null + }); + + tooltip.option('toolbarItems', [{ widget: 'dxButton', options: { text: 'OK' } }]); + + const $overlayContent = tooltip.$overlayContent(); + const $button = $overlayContent.find('.dx-toolbar .dx-button'); + + assert.equal($overlayContent.attr('role'), 'dialog', 'overlay content role is dialog'); + assert.equal($button.attr('role'), 'button', 'toolbar button keeps its role'); + }); + QUnit.module('WCAG - dismissible', () => { QUnit.test('should hide visible tooltip on Escape key press', function(assert) { const tooltip = new Tooltip($('#tooltip'), { From 9a01cbbc25a7a71a10888623b60ec3fe408c511d Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 27 Oct 2026 14:36:21 +0400 Subject: [PATCH 2/3] Popover: use class constants in the aria QUnit tests (#5410) --- .../testing/tests/DevExpress.ui.widgets/popover.tests.js | 8 ++++---- .../testing/tests/DevExpress.ui.widgets/tooltip.tests.js | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) 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 19ab0fc88519..229fee29c9ed 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -20,6 +20,9 @@ const POPOVER_WITHOUT_TITLE_CLASS = 'dx-popover-without-title'; const POPOVER_TITLE_CLASS = 'dx-popup-title'; const POPUP_CONTENT_CLASS = 'dx-popup-content'; const OVERLAY_CONTENT_CLASS = 'dx-overlay-content'; +const POPUP_CLOSE_BUTTON_CLASS = 'dx-closebutton'; +const TOOLBAR_CLASS = 'dx-toolbar'; +const BUTTON_CLASS = 'dx-button'; const positionAtWindowCenter = function(element) { positionUtils.setup(element, { @@ -3316,9 +3319,6 @@ QUnit.module('accessibility', { }); QUnit.module('shown popover with focusable toolbar elements', () => { - const POPUP_CLOSE_BUTTON_CLASS = 'dx-closebutton'; - const TOOLBAR_BUTTON_SELECTOR = '.dx-toolbar .dx-button'; - const createShownPopover = (options) => new Popover($('#what'), $.extend({ target: '#where', visible: true, @@ -3350,7 +3350,7 @@ QUnit.module('accessibility', { popover.option('toolbarItems', [{ widget: 'dxButton', options: { text: 'OK' } }]); - const $button = popover.$overlayContent().find(TOOLBAR_BUTTON_SELECTOR); + const $button = popover.$overlayContent().find(`.${TOOLBAR_CLASS} .${BUTTON_CLASS}`); assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); assert.strictEqual($button.length, 1, 'toolbar button is rendered'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js index d57e8eefbcc2..4d20314c23d7 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js @@ -9,6 +9,8 @@ const TOOLTIP_CLASS = 'dx-tooltip'; const TOOLTIP_WRAPPER_CLASS = 'dx-tooltip-wrapper'; const DX_INVISIBILITY_CLASS = 'dx-state-invisible'; const OVERLAY_CONTENT_CLASS = 'dx-overlay-content'; +const TOOLBAR_CLASS = 'dx-toolbar'; +const BUTTON_CLASS = 'dx-button'; const wrapper = function() { return $('body').find('.' + TOOLTIP_WRAPPER_CLASS); @@ -218,7 +220,7 @@ QUnit.module('accessibility', () => { tooltip.option('toolbarItems', [{ widget: 'dxButton', options: { text: 'OK' } }]); const $overlayContent = tooltip.$overlayContent(); - const $button = $overlayContent.find('.dx-toolbar .dx-button'); + const $button = $overlayContent.find(`.${TOOLBAR_CLASS} .${BUTTON_CLASS}`); assert.equal($overlayContent.attr('role'), 'dialog', 'overlay content role is dialog'); assert.equal($button.attr('role'), 'button', 'toolbar button keeps its role'); From 851f0af160f12a970d5982220019b31c374fcb48 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 27 Oct 2026 14:46:19 +0400 Subject: [PATCH 3/3] Popup: own the aria target so every descendant keeps aria attributes on the overlay content (#5410) --- packages/devextreme/js/__internal/ui/popover/popover.ts | 4 ---- packages/devextreme/js/__internal/ui/popup/popup.ts | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index c328b98c9664..e7457e08c0ba 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -288,10 +288,6 @@ class Popover< // uses in Popup and do not need here. _renderFocusTarget(): void {} - _getAriaTarget(): dxElementWrapper { - return this.$overlayContent(); - } - _getFocusTarget(): dxElementWrapper | null | undefined { const $firstFocusableTarget = this._findTabbableBounds().$first; if ($firstFocusableTarget?.length) { diff --git a/packages/devextreme/js/__internal/ui/popup/popup.ts b/packages/devextreme/js/__internal/ui/popup/popup.ts index 94d52609f018..a1c78f6eb8e5 100644 --- a/packages/devextreme/js/__internal/ui/popup/popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/popup.ts @@ -792,6 +792,10 @@ class Popup< }); } + _getAriaTarget(): dxElementWrapper { + return this.$overlayContent(); + } + protected _getAriaRole(): string { return 'dialog'; }