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/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'; } 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..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, { @@ -3314,5 +3317,71 @@ QUnit.module('accessibility', { assert.strictEqual(instance.option('tabFocusLoopEnabled'), false, 'tabFocusLoopEnabled remains false due to _preventDialogContainerFocus'); }); }); + + QUnit.module('shown popover with focusable toolbar elements', () => { + 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_CLASS} .${BUTTON_CLASS}`); + + 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..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); @@ -208,6 +210,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(`.${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'); + }); + QUnit.module('WCAG - dismissible', () => { QUnit.test('should hide visible tooltip on Escape key press', function(assert) { const tooltip = new Tooltip($('#tooltip'), {