diff --git a/e2e/testcafe-devextreme/tests/accessibility/popover.ts b/e2e/testcafe-devextreme/tests/accessibility/popover.ts index bd5f24f5c997..3e862f61a89f 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/popover.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/popover.ts @@ -1,11 +1,32 @@ import { Properties } from 'devextreme/ui/popover.d'; +import { ToolbarItem } from 'devextreme/ui/popup.d'; import url from '../../helpers/getPageUrl'; import { defaultSelector, testAccessibility, Configuration } from '../../helpers/accessibility/test'; +import { isFluent } from '../../helpers/themeUtils'; import { Options } from '../../helpers/generateOptionMatrix'; fixture.disablePageReloads`Accessibility` .page(url(__dirname, '../container.html')); +const toolbarItems: ToolbarItem[] = [ + { + location: 'before', + widget: 'dxButton', + options: { + icon: 'back', + }, + }, +]; + +// NOTE: dialog-mode popovers (toolbarItems or showTitle + showCloseButton) have no +// accessible name unless a title is set. Providing a default dialog name is a separate +// dialog-labeling task. +const a11yCheckConfig = isFluent() ? { + rules: { 'aria-dialog-name': { enabled: false } }, +} : { + runOnly: 'color-contrast', +}; + const options: Options = { visible: [true], target: [defaultSelector], @@ -14,23 +35,36 @@ const options: Options = { showTitle: [true, false], title: [undefined, 'title'], showCloseButton: [true, false], - toolbarItems: [ - undefined, - [ - { - location: 'before', - widget: 'dxButton', - options: { - icon: 'back', - }, - }, - ], - ], + toolbarItems: [undefined, toolbarItems], + // NOTE: a tooltip-mode popover is named from its content (aria-tooltip-name, WCAG 4.1.2) + contentTemplate: [() => 'Popover content'], }; const configuration: Configuration = { component: 'dxPopover', options, + a11yCheckConfig, + // NOTE: a shown popover wrapper is appended to the viewport (body), + // so the default '#container' context does not include the overlay markup + selector: { include: [['#container'], ['.dx-popover-wrapper']] }, }; testAccessibility(configuration); + +// NOTE: a hidden popover keeps its overlay markup inside the widget root element, +// so the default '#container' context covers both the target attributes +// (aria-describedby) and the overlay content +const invisibleConfiguration: Configuration = { + component: 'dxPopover', + options: { + visible: [false], + deferRendering: [true, false], + target: [defaultSelector], + width: [300], + height: [280], + toolbarItems: [undefined, toolbarItems], + contentTemplate: [() => 'Popover content'], + }, +}; + +testAccessibility(invisibleConfiguration); diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts index e1f5d1cc355b..cb2ff9e168c1 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/common/keyboardNavigation/keyboardNavigation.functional.ts @@ -4930,8 +4930,8 @@ test('Grids a11y: Fix the header filter and the column chooser focus issue and u const dataGrid = new DataGrid('#container'); const filterIconElement = dataGrid.getHeaders().getHeaderRow(0).getHeaderCell(0).getFilterIcon(); const headerFilter = new HeaderFilter(); - const columnChooser = dataGrid.getColumnChooser(); const columnChooserButton = dataGrid.getColumnChooserButton(); + const columnChooserCloseButton = dataGrid.getColumnChooser().getCloseButton(); await t .expect(dataGrid.isReady()) @@ -4942,7 +4942,7 @@ test('Grids a11y: Fix the header filter and the column chooser focus issue and u .ok() .click(columnChooserButton) .pressKey('tab tab tab') - .expect(columnChooser.content.focused) + .expect(columnChooserCloseButton.focused) .ok(); }) .before(async () => { diff --git a/packages/devextreme/js/__internal/core/utils/__tests__/m_dom.test.ts b/packages/devextreme/js/__internal/core/utils/__tests__/m_dom.test.ts new file mode 100644 index 000000000000..b758b1305915 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/__tests__/m_dom.test.ts @@ -0,0 +1,154 @@ +import { + beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import { + addAriaDescriptionId, + getAriaDescriptionIds, + removeAriaDescriptionId, + setAriaDescriptionIds, +} from '@ts/core/utils/m_dom'; + +describe('DOM utils', () => { + let element: HTMLElement; + + beforeEach(() => { + element = document.createElement('div'); + }); + + describe('getAriaDescriptionIds', () => { + it('should return an empty array when the attribute is absent', () => { + expect(getAriaDescriptionIds(element)).toEqual([]); + }); + + it('should return an empty array when the attribute is empty', () => { + element.setAttribute('aria-describedby', ''); + + expect(getAriaDescriptionIds(element)).toEqual([]); + }); + + it('should return a single id', () => { + element.setAttribute('aria-describedby', 'id-1'); + + expect(getAriaDescriptionIds(element)).toEqual(['id-1']); + }); + + it('should split multiple ids separated by spaces', () => { + element.setAttribute('aria-describedby', 'id-1 id-2 id-3'); + + expect(getAriaDescriptionIds(element)).toEqual(['id-1', 'id-2', 'id-3']); + }); + + it('should ignore extra whitespace between ids', () => { + element.setAttribute('aria-describedby', ' id-1 id-2 '); + + expect(getAriaDescriptionIds(element)).toEqual(['id-1', 'id-2']); + }); + }); + + describe('setAriaDescriptionIds', () => { + it('should set the attribute for a single id', () => { + setAriaDescriptionIds(element, ['id-1']); + + expect(element.getAttribute('aria-describedby')).toBe('id-1'); + }); + + it('should join multiple ids with a single space', () => { + setAriaDescriptionIds(element, ['id-1', 'id-2', 'id-3']); + + expect(element.getAttribute('aria-describedby')).toBe('id-1 id-2 id-3'); + }); + + it('should remove the attribute when the ids list is empty', () => { + element.setAttribute('aria-describedby', 'id-1'); + + setAriaDescriptionIds(element, []); + + expect(element.hasAttribute('aria-describedby')).toBe(false); + }); + + it('should not rewrite the attribute when the value is unchanged', () => { + element.setAttribute('aria-describedby', 'id-1 id-2'); + const setAttributeSpy = jest.spyOn(element, 'setAttribute'); + + setAriaDescriptionIds(element, ['id-1', 'id-2']); + + expect(setAttributeSpy).not.toHaveBeenCalled(); + }); + + it('should be reversible with getAriaDescriptionIds', () => { + setAriaDescriptionIds(element, ['id-1', 'id-2']); + + expect(getAriaDescriptionIds(element)).toEqual(['id-1', 'id-2']); + }); + }); + + describe('addAriaDescriptionId', () => { + it('should add the id to the empty attribute and return true', () => { + const result = addAriaDescriptionId(element, 'id-1'); + + expect(result).toBe(true); + expect(element.getAttribute('aria-describedby')).toBe('id-1'); + }); + + it('should append the id to existing ids and return true', () => { + element.setAttribute('aria-describedby', 'id-1 id-2'); + + const result = addAriaDescriptionId(element, 'id-3'); + + expect(result).toBe(true); + expect(element.getAttribute('aria-describedby')).toBe('id-1 id-2 id-3'); + }); + + it('should not add the id if it already exists and return false', () => { + element.setAttribute('aria-describedby', 'id-1 id-2'); + const setAttributeSpy = jest.spyOn(element, 'setAttribute'); + + const result = addAriaDescriptionId(element, 'id-2'); + + expect(result).toBe(false); + expect(element.getAttribute('aria-describedby')).toBe('id-1 id-2'); + expect(setAttributeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('removeAriaDescriptionId', () => { + it('should do nothing if the attribute is absent', () => { + const removeAttributeSpy = jest.spyOn(element, 'removeAttribute'); + const setAttributeSpy = jest.spyOn(element, 'setAttribute'); + + removeAriaDescriptionId(element, 'id-1'); + + expect(element.hasAttribute('aria-describedby')).toBe(false); + expect(removeAttributeSpy).not.toHaveBeenCalled(); + expect(setAttributeSpy).not.toHaveBeenCalled(); + }); + + it('should remove the attribute when the last id is removed', () => { + element.setAttribute('aria-describedby', 'id-1'); + + removeAriaDescriptionId(element, 'id-1'); + + expect(element.hasAttribute('aria-describedby')).toBe(false); + }); + + it('should remove a single id from the list of multiple ids', () => { + element.setAttribute('aria-describedby', 'id-1 id-2 id-3'); + + removeAriaDescriptionId(element, 'id-2'); + + expect(element.getAttribute('aria-describedby')).toBe('id-1 id-3'); + }); + + it('should not change the attribute if the id is not present', () => { + element.setAttribute('aria-describedby', 'id-1 id-3'); + const setAttributeSpy = jest.spyOn(element, 'setAttribute'); + const removeAttributeSpy = jest.spyOn(element, 'removeAttribute'); + + removeAriaDescriptionId(element, 'id-2'); + + expect(element.getAttribute('aria-describedby')).toBe('id-1 id-3'); + expect(setAttributeSpy).not.toHaveBeenCalled(); + expect(removeAttributeSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/core/utils/m_dom.ts b/packages/devextreme/js/__internal/core/utils/m_dom.ts index de76df1374fe..891b6c449ac2 100644 --- a/packages/devextreme/js/__internal/core/utils/m_dom.ts +++ b/packages/devextreme/js/__internal/core/utils/m_dom.ts @@ -174,6 +174,45 @@ export const isElementInDom = ($element) => { return !!$(shadowHost || element).closest(getWindow().document).length; }; +const ARIA_DESCRIBEDBY_ATTRIBUTE = 'aria-describedby'; + +export const getAriaDescriptionIds = (element: Element): string[] => (element.getAttribute(ARIA_DESCRIBEDBY_ATTRIBUTE) ?? '').split(/\s+/).filter(Boolean); + +export const setAriaDescriptionIds = (element: Element, ids: string[]): void => { + const value = ids.join(' '); + + if (!value) { + element.removeAttribute(ARIA_DESCRIBEDBY_ATTRIBUTE); + } else if (element.getAttribute(ARIA_DESCRIBEDBY_ATTRIBUTE) !== value) { + element.setAttribute(ARIA_DESCRIBEDBY_ATTRIBUTE, value); + } +}; + +// Adds the id to the element's aria-describedby list, preserving ids from other +// owners. Returns true when the id was actually added (was not present yet). +export const addAriaDescriptionId = (element: Element, id: string): boolean => { + const ids = getAriaDescriptionIds(element); + + if (ids.includes(id)) { + return false; + } + + ids.push(id); + setAriaDescriptionIds(element, ids); + + return true; +}; + +// Removes the id from the element's aria-describedby list, keeping ids from other owners. +export const removeAriaDescriptionId = (element: Element, id: string): void => { + const ids = getAriaDescriptionIds(element); + const restIds = ids.filter((token) => token !== id); + + if (restIds.length !== ids.length) { + setAriaDescriptionIds(element, restIds); + } +}; + export default { resetActiveElement, clearSelection, @@ -187,4 +226,6 @@ export default { insertBefore, replaceWith, isElementInDom, + addAriaDescriptionId, + removeAriaDescriptionId, }; diff --git a/packages/devextreme/js/__internal/scheduler/header/calendar.ts b/packages/devextreme/js/__internal/scheduler/header/calendar.ts index 2eb2b5526805..8104a1a3cfc6 100644 --- a/packages/devextreme/js/__internal/scheduler/header/calendar.ts +++ b/packages/devextreme/js/__internal/scheduler/header/calendar.ts @@ -54,6 +54,10 @@ export default class SchedulerCalendar extends Widget { const overlayConfig = { contentTemplate: (): dxElementWrapper => this.createOverlayContent(), + // NOTE: The calendar is interactive content, not a text hint: describing + // the navigator button with the whole month grid would be noise for AT. + // eslint-disable-next-line @typescript-eslint/naming-convention + _describeTarget: false, onShown: (): void => { this.calendar?.focus(); }, diff --git a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts index 7328eeb016e6..acbdc90aacc5 100644 --- a/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts +++ b/packages/devextreme/js/__internal/scheduler/tooltip_strategies/desktop_tooltip_strategy.ts @@ -59,11 +59,14 @@ export class DesktopTooltipStrategy extends TooltipStrategyBase { onShown: this.onShown.bind(this), contentTemplate: this.getContentTemplate(dataList), wrapperAttr: { class: APPOINTMENT_TOOLTIP_WRAPPER_CLASS }, + // eslint-disable-next-line @typescript-eslint/naming-convention + _preventDialogContainerFocus: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + _popoverContentRole: 'dialog', tabFocusLoopEnabled: this.extraOptions?.tabFocusLoopEnabled, }) as Tooltip; tooltip.setAria({ - role: 'dialog', label: messageLocalization.format('dxScheduler-appointmentListAriaLabel'), }); diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popover.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popover.ts new file mode 100644 index 000000000000..4db8b22852df --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popover.ts @@ -0,0 +1,23 @@ +import Popover from '@ts/ui/popover/popover'; +import type Popup from '@ts/ui/popup/popup'; + +import { PopupModel } from './popup'; + +const CLASSES = { + popover: 'dx-popover', + popoverWrapper: 'dx-popover-wrapper', +}; + +export class PopoverModel extends PopupModel { + protected getRootClass(): string { + return CLASSES.popover; + } + + protected getWrapperClass(): string { + return CLASSES.popoverWrapper; + } + + public getInstance(): Popup { + return Popover.getInstance(this.getRoot()); + } +} 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 f3cb8569e43d..bf04cf870ed7 100644 --- a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popup.ts +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/popup.ts @@ -1,6 +1,7 @@ -import Popup from '@js/ui/popup'; +import Popup from '@ts/ui/popup/popup'; const CLASSES = { + popup: 'dx-popup', popupWrapper: 'dx-popup-wrapper', popupTitle: 'dx-popup-title', overlayContent: 'dx-overlay-content', @@ -11,8 +12,20 @@ const SELECTORS = { }; export class PopupModel { + protected getRootClass(): string { + return CLASSES.popup; + } + + protected getWrapperClass(): string { + return CLASSES.popupWrapper; + } + + protected getRoot(): HTMLElement { + return document.body.querySelector(`.${this.getRootClass()}`) as HTMLElement; + } + protected getPopupWrapper(): HTMLElement { - return document.body.querySelector(`.${CLASSES.popupWrapper}`) as HTMLElement; + return document.body.querySelector(`.${this.getWrapperClass()}`) as HTMLElement; } public getOverlayContent(): HTMLElement { @@ -20,6 +33,10 @@ export class PopupModel { return wrapper?.querySelector(`.${CLASSES.overlayContent}`) as HTMLElement; } + public getRole(): string | null { + return this.getOverlayContent()?.getAttribute('role') ?? null; + } + public isVisible(): boolean { return !!this.getOverlayContent(); } @@ -35,8 +52,7 @@ export class PopupModel { } public getInstance(): Popup { - const element = this.getElement(); - return Popup.getInstance(element) as Popup; + return Popup.getInstance(this.getRoot()); } public getCancelButton(): HTMLElement { diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tooltip.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tooltip.ts new file mode 100644 index 000000000000..a02cf676d00c --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/tooltip.ts @@ -0,0 +1,23 @@ +import type Popup from '@ts/ui/popup/popup'; +import Tooltip from '@ts/ui/tooltip'; + +import { PopoverModel } from './popover'; + +const CLASSES = { + tooltip: 'dx-tooltip', + tooltipWrapper: 'dx-tooltip-wrapper', +}; + +export class TooltipModel extends PopoverModel { + protected getRootClass(): string { + return CLASSES.tooltip; + } + + protected getWrapperClass(): string { + return CLASSES.tooltipWrapper; + } + + public getInstance(): Popup { + return Tooltip.getInstance(this.getRoot()); + } +} 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 new file mode 100644 index 000000000000..793e598f0dce --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/tooltip.aria_role.test.ts @@ -0,0 +1,59 @@ +import { + afterEach, beforeAll, describe, expect, it, +} from '@jest/globals'; +import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; +import { TooltipModel } from '@ts/ui/__tests__/__mock__/model/tooltip'; + +import Tooltip, { type TooltipProperties } from '../tooltip'; + +interface RoleScenario { + scenario: string; + options: Partial; + role: string; +} + +const tooltips: TooltipModel[] = []; + +const createTooltip = async (options: Partial): Promise => { + const $element = $('
').appendTo(document.body); + // @ts-expect-error DOMComponent constructor is not typed for direct instantiation + const instance = new Tooltip($element, options); + + await instance.show(); + + const model = new TooltipModel(); + tooltips.push(model); + + return model; +}; + +describe('Tooltip overlay content aria role', () => { + beforeAll(() => { + fx.off = true; + }); + + afterEach(() => { + tooltips.forEach((model) => model.getInstance().dispose()); + tooltips.length = 0; + document.body.innerHTML = ''; + }); + + describe('derived from configuration', () => { + const scenarios: RoleScenario[] = [ + { scenario: 'a default tooltip', options: {}, role: 'tooltip' }, + { scenario: 'toolbar items are specified', options: { toolbarItems: [{ text: 'OK' }] }, role: 'dialog' }, + { + scenario: 'a title and a close button are shown', + options: { showTitle: true, title: 'Title', showCloseButton: true }, + role: 'tooltip', + }, + ]; + + it.each(scenarios)('is "$role" when $scenario', async ({ options, role }) => { + const model = await createTooltip(options); + + expect(model.getRole()).toBe(role); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/action_sheet.ts b/packages/devextreme/js/__internal/ui/action_sheet.ts index 2f598ec2d0a5..f97fcca8974f 100644 --- a/packages/devextreme/js/__internal/ui/action_sheet.ts +++ b/packages/devextreme/js/__internal/ui/action_sheet.ts @@ -179,10 +179,11 @@ class ActionSheet extends CollectionWidget { width: this.option('width') || 200, height: this.option('height') || 'auto', target: this.option('target'), + // NOTE: popover set role based on toolbarOptions, but + // ActionSheet rendered buttons not in a toolbar, so we use option. + _popoverContentRole: 'dialog', })); - this._popup.$overlayContent().attr('role', 'dialog'); - this._popup.$wrapper()?.addClass(ACTION_SHEET_POPOVER_WRAPPER_CLASS); } diff --git a/packages/devextreme/js/__internal/ui/lookup.ts b/packages/devextreme/js/__internal/ui/lookup.ts index 461a78405986..930c4202db15 100644 --- a/packages/devextreme/js/__internal/ui/lookup.ts +++ b/packages/devextreme/js/__internal/ui/lookup.ts @@ -678,6 +678,10 @@ class Lookup extends DropDownList { shading: false, hideOnParentScroll: true, _fixWrapperPosition: false, + // NOTE: popover set role based on toolbarOptions, but + // Lookup with showCancelButton: false, do not have toolbar, so we use option. + _popoverContentRole: 'dialog', + _preventDialogContainerFocus: true, width: this._isInitialOptionValue('dropDownOptions.width') ? (): number => getOuterWidth(this.$element()) as number : popupConfig.width, @@ -686,8 +690,6 @@ class Lookup extends DropDownList { // @ts-expect-error fix on Dom Component level this._popup = this._createComponent(this._$popup, Popover, options); - this._popup.$overlayContent().attr('role', 'dialog'); - this._popup.on({ showing: this._popupShowingHandler.bind(this), shown: this._popupShownHandler.bind(this), diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index 8770c4d94899..da442c5bb2ba 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -839,6 +839,7 @@ class Overlay< return this._hidingDeferred.promise(); } + // Note: method helps Scheduler Appointments to avoid Focus Race Condition _forceFocusLost(): void { const activeElement = domAdapter.getActiveElement(); const shouldResetActiveElement = !!this._$content?.find(activeElement).length; @@ -992,14 +993,20 @@ class Overlay< const $currentElement = $elements?.eq(i) ?? null; const $reverseElement = $elements?.eq(elementsCount - i) ?? null; - // @ts-expect-error is should can get function as callback - if (!$first && $currentElement.is(selectors.tabbable)) { - $first = $currentElement; + if (!$first && $currentElement) { + // @ts-expect-error is should can get function as callback + const isTabbableAndNotOverlay = $currentElement?.not(`.${OVERLAY_CONTENT_CLASS}`).is(selectors.tabbable); + if (isTabbableAndNotOverlay) { + $first = $currentElement; + } } - // @ts-expect-error is should can get function as callback - if (!$last && $reverseElement.is(selectors.tabbable)) { - $last = $reverseElement; + if (!$last && $reverseElement) { + // @ts-expect-error is should can get function as callback + const isTabbableAndNotOverlay = $reverseElement?.not(`.${OVERLAY_CONTENT_CLASS}`).is(selectors.tabbable); + if (isTabbableAndNotOverlay) { + $last = $reverseElement; + } } if ($first && $last) { 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 new file mode 100644 index 000000000000..dc11ae95e127 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/popover/__tests__/popover.aria_role.test.ts @@ -0,0 +1,89 @@ +import { + afterEach, beforeAll, describe, expect, it, +} from '@jest/globals'; +import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; +import { PopoverModel } from '@ts/ui/__tests__/__mock__/model/popover'; + +import Popover, { type PopoverProperties } from '../popover'; + +interface RoleScenario { + scenario: string; + options: Partial; + role: string; +} + +const popovers: PopoverModel[] = []; + +const createPopover = async (options: Partial): Promise => { + const $element = $('
').appendTo(document.body); + // @ts-expect-error DOMComponent constructor is not typed for direct instantiation + const instance = new Popover($element, options); + + await instance.show(); + + const model = new PopoverModel(); + popovers.push(model); + + return model; +}; + +describe('Popover overlay content aria role', () => { + beforeAll(() => { + fx.off = true; + }); + + afterEach(() => { + popovers.forEach((model) => model.getInstance().dispose()); + popovers.length = 0; + document.body.innerHTML = ''; + }); + + describe('derived from configuration', () => { + const scenarios: RoleScenario[] = [ + { scenario: 'a default popover', options: {}, role: 'tooltip' }, + { scenario: 'toolbar items are specified', options: { toolbarItems: [{ text: 'OK' }] }, role: 'dialog' }, + { + scenario: 'a title and a close button are shown', + options: { showTitle: true, title: 'Title', showCloseButton: true }, + role: 'dialog', + }, + { scenario: 'only a title is shown', options: { showTitle: true, title: 'Title' }, role: 'tooltip' }, + { scenario: 'only a close button is shown', options: { showCloseButton: true }, role: 'tooltip' }, + ]; + + it.each(scenarios)('is "$role" when $scenario', async ({ options, role }) => { + const model = await createPopover(options); + + expect(model.getRole()).toBe(role); + }); + }); + + describe('forced through _popoverContentRole', () => { + it('uses the forced role regardless of the configuration predicate', async () => { + const model = await createPopover({ _popoverContentRole: 'dialog' }); + + expect(model.getRole()).toBe('dialog'); + }); + }); + + describe('updated when the configuration changes at runtime', () => { + it('switches to "dialog" when toolbar items are added', async () => { + const model = await createPopover({}); + expect(model.getRole()).toBe('tooltip'); + + model.getInstance().option('toolbarItems', [{ text: 'OK' }]); + + expect(model.getRole()).toBe('dialog'); + }); + + it('switches back to "tooltip" when toolbar items are cleared', async () => { + const model = await createPopover({ toolbarItems: [{ text: 'OK' }] }); + expect(model.getRole()).toBe('dialog'); + + model.getInstance().option('toolbarItems', []); + + expect(model.getRole()).toBe('tooltip'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 1baad14ec720..0739651602a2 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -8,6 +8,7 @@ import type { DeepPartial } from '@js/core'; import registerComponent from '@js/core/component_registrator'; import domAdapter from '@js/core/dom_adapter'; import { getPublicElement } from '@js/core/element'; +import Guid from '@js/core/guid'; import type { DefaultOptionsRule } from '@js/core/options/utils'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; @@ -23,6 +24,7 @@ import type { DxEvent, PointerInteractionEvent } from '@js/events'; import type { Properties } from '@js/ui/popover'; import { current, isMaterial, isMaterialBased } from '@js/ui/themes'; import errors from '@js/ui/widget/ui.errors'; +import { addAriaDescriptionId, removeAriaDescriptionId } from '@ts/core/utils/m_dom'; import type { OptionChanged } from '@ts/core/widget/types'; import type { DisplaySide, @@ -92,6 +94,12 @@ export interface PopoverProperties extends Omit>; + _popoverContentId?: string; + + _$describedTargets?: dxElementWrapper; + _getDefaultOptions(): TProperties { return { ...super._getDefaultOptions(), @@ -132,6 +144,7 @@ class Popover< arrowPosition: '', arrowOffset: 0, _fixWrapperPosition: true, + _describeTarget: true, }; } @@ -185,10 +198,7 @@ class Popover< this.$element().addClass(POPOVER_CLASS); this.$wrapper()?.addClass(POPOVER_WRAPPER_CLASS); - const { toolbarItems, visible } = this.option(); - - const isInteractive = toolbarItems?.length; - this.setAria('role', isInteractive ? 'dialog' : 'tooltip'); + const { visible } = this.option(); if (visible) { this._attachEscapeKeyHandler(); @@ -234,6 +244,173 @@ class Popover< this._attachHoverableOverlay(); } + _renderContent(): void { + super._renderContent(); + + this._syncAriaAttributes(); + } + + _syncAriaAttributes(): void { + this.setAria('role', this._getEffectiveAriaRole()); + this._syncTargetAriaDescription(); + this._syncFocusOptions(); + } + + _syncFocusOptions(): void { + if (this.option('_preventDialogContainerFocus')) { + return; + } + + const isDialog = this._getEffectiveAriaRole() === 'dialog'; + this._setOptionWithoutOptionChange('focusStateEnabled', isDialog); + this._setOptionWithoutOptionChange('tabFocusLoopEnabled', isDialog); + } + + // Intentional no-op: Focus target logic is inherited from Widget, + // uses in Popup and do not need here. + _renderFocusTarget(): void {} + + _getFocusTarget(): dxElementWrapper | null | undefined { + const $firstFocusableTarget = this._findTabbableBounds().$first; + if ($firstFocusableTarget?.length) { + return $firstFocusableTarget; + } + + const $overlay = this.$overlayContent(); + if ($overlay?.length) { + $overlay.attr('tabindex', '-1'); + return $overlay; + } + + return null; + } + + _focusTarget(): dxElementWrapper { + return this._getFocusTarget() ?? this.$overlayContent(); + } + + _restoreTargetFocus(): void { + const $targets = this._getAriaDescriptionTargets(); + const targetElement = $targets.first().get(0); + + if (targetElement && domAdapter.getBody().contains(targetElement)) { + // @ts-expect-error trigger should be typed on type 'EventsEngineType' + eventsEngine.trigger($targets.first(), 'focus'); + } + } + + _forceFocusLost(): void { + if (this._getEffectiveAriaRole() === 'dialog' && !this.option('_preventDialogContainerFocus')) { + this._restoreTargetFocus(); + } else { + super._forceFocusLost(); + } + } + + protected _getAriaRole(): string { + const { toolbarItems, showTitle, showCloseButton } = this.option(); + + const isDialog = Boolean(toolbarItems?.length) || Boolean(showTitle && showCloseButton); + + return isDialog ? 'dialog' : 'tooltip'; + } + + _getEffectiveAriaRole(): string { + const { _popoverContentRole: popoverContentRole } = this.option(); + + return popoverContentRole ?? this._getAriaRole(); + } + + // NOTE: An accessible name on a tooltip can mask its content for assistive + // technologies, so the title labels the overlay only in dialog mode. + _toggleAriaLabel(): void { + if (this._getEffectiveAriaRole() === 'tooltip') { + this.setAria('labelledby', null, this.$overlayContent()); + return; + } + + super._toggleAriaLabel(); + } + + _getPopoverContentId(): string { + this._popoverContentId = this._popoverContentId ?? `dx-${new Guid()}`; + return this._popoverContentId; + } + + _shouldDescribeTarget(): boolean { + const { + target, + // eslint-disable-next-line @typescript-eslint/naming-convention + _describeTarget, + } = this.option(); + + return Boolean(target) && Boolean(_describeTarget) && this._getEffectiveAriaRole() === 'tooltip'; + } + + _getAriaDescriptionTargets(): dxElementWrapper { + const { target } = this.option(); + const elements: Element[] = []; + + $(target).each((_, node) => { + if (domAdapter.isElementNode(node)) { + elements.push(node); + } + + return true; + }); + + return $(elements); + } + + _syncTargetAriaDescription(): void { + if (!this._shouldDescribeTarget()) { + this._removeTargetAriaDescription(); + return; + } + + const id = this._getPopoverContentId(); + const $overlayContent = this.$overlayContent(); + $overlayContent.attr('id', id); + + const $targets = this._getAriaDescriptionTargets(); + + if (!$targets.length) { + this._removeTargetAriaDescription(); + return; + } + + const targetElements = new Set($targets.toArray()); + const previousElements = new Set(this._$describedTargets?.toArray() ?? []); + + previousElements.forEach((element) => { + if (!targetElements.has(element)) { + removeAriaDescriptionId(element, id); + } + }); + + const describedElements = $targets.toArray().filter( + (element) => addAriaDescriptionId(element, id) || previousElements.has(element), + ); + + this._$describedTargets = describedElements.length ? $(describedElements) : undefined; + } + + _removeTargetAriaDescription(): void { + const id = this._getPopoverContentId(); + + if (!this._$describedTargets) { + return; + } + + this._$describedTargets.each((_, element) => { + removeAriaDescriptionId(element, id); + + return true; + }); + + this._$describedTargets = undefined; + } + _detachEvents(target: PopoverTarget): void { this._detachEvent(target, 'show'); this._detachEvent(target, 'hide'); @@ -310,7 +487,7 @@ class Popover< _detachHoverableOverlay(): void { const $overlayContent = this.$overlayContent(); - if (!$overlayContent.length) { + if (!$overlayContent?.length) { return; } @@ -720,7 +897,17 @@ class Popover< super._clean(); } + _shouldResetActiveElement(): boolean { + const activeElement = domAdapter.getActiveElement(); + return domAdapter.isNode(activeElement) && !!this._$content?.get(0)?.contains(activeElement); + } + _dispose(): void { + const { visible } = this.option(); + if (visible && this._shouldResetActiveElement() && this._getEffectiveAriaRole() === 'dialog') { + this._restoreTargetFocus(); + } + this._removeTargetAriaDescription(); this._detachEscapeKeyHandler(); super._dispose(); } @@ -743,6 +930,7 @@ class Popover< } this._positionController.updateTarget(value as TProperties['target']); this._invalidate(); + this._syncAriaAttributes(); break; case 'showEvent': case 'hideEvent': { @@ -782,6 +970,17 @@ class Popover< super._optionChanged(args); break; } + case 'toolbarItems': + case 'showTitle': + case 'showCloseButton': + super._optionChanged(args); + this._syncAriaAttributes(); + break; + case '_popoverContentRole': + case '_describeTarget': + this._syncAriaAttributes(); + this._toggleAriaLabel(); + break; default: super._optionChanged(args); } diff --git a/packages/devextreme/js/__internal/ui/popup/__tests__/popup.aria_role.test.ts b/packages/devextreme/js/__internal/ui/popup/__tests__/popup.aria_role.test.ts new file mode 100644 index 000000000000..157b4e99687b --- /dev/null +++ b/packages/devextreme/js/__internal/ui/popup/__tests__/popup.aria_role.test.ts @@ -0,0 +1,55 @@ +import { + afterEach, beforeAll, describe, expect, it, +} from '@jest/globals'; +import fx from '@js/common/core/animation/fx'; +import $ from '@js/core/renderer'; +import { PopupModel } from '@ts/ui/__tests__/__mock__/model/popup'; + +import Popup, { type PopupProperties } from '../popup'; + +interface RoleScenario { + scenario: string; + options: Partial; +} + +const popups: PopupModel[] = []; + +const createPopup = async (options: Partial): Promise => { + const $element = $('
').appendTo(document.body); + // @ts-expect-error DOMComponent constructor is not typed for direct instantiation + const instance = new Popup($element, options); + + await instance.show(); + + const model = new PopupModel(); + popups.push(model); + + return model; +}; + +describe('Popup overlay content aria role', () => { + beforeAll(() => { + fx.off = true; + }); + + afterEach(() => { + popups.forEach((model) => model.getInstance().dispose()); + popups.length = 0; + document.body.innerHTML = ''; + }); + + describe('is always "dialog"', () => { + const scenarios: RoleScenario[] = [ + { scenario: 'a default popup', options: {} }, + { scenario: 'a title is shown', options: { showTitle: true, title: 'Title' } }, + { scenario: 'no title is shown', options: { showTitle: false } }, + { scenario: 'toolbar items are specified', options: { toolbarItems: [{ text: 'OK' }] } }, + ]; + + it.each(scenarios)('for $scenario', async ({ options }) => { + const model = await createPopup(options); + + expect(model.getRole()).toBe('dialog'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/popup/popup.ts b/packages/devextreme/js/__internal/ui/popup/popup.ts index 412d7519427e..70042deef2b7 100644 --- a/packages/devextreme/js/__internal/ui/popup/popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/popup.ts @@ -42,7 +42,6 @@ import { isMaterialBased, } from '@js/ui/themes'; import type { Properties as ToolbarProperties } from '@js/ui/toolbar'; -import type Toolbar from '@js/ui/toolbar'; import windowUtils from '@ts/core/utils/m_window'; import type { OptionChanged } from '@ts/core/widget/types'; import type { SupportedKeys } from '@ts/core/widget/widget'; @@ -63,6 +62,7 @@ import type { PopupPositionControllerConstructor, } from '@ts/ui/popup/popup_position_controller'; import { PopupPositionController } from '@ts/ui/popup/popup_position_controller'; +import type ToolbarBase from '@ts/ui/toolbar/toolbar.base'; import type { ToolbarBaseProperties } from '@ts/ui/toolbar/toolbar.base'; // STYLE popup @@ -85,7 +85,6 @@ const POPUP_HAS_CLOSE_BUTTON_CLASS = 'dx-has-close-button'; const POPUP_CONTENT_FLEX_HEIGHT_CLASS = 'dx-popup-flex-height'; const POPUP_CONTENT_INHERIT_HEIGHT_CLASS = 'dx-popup-inherit-height'; -const TOOLBAR_LABEL_CLASS = 'dx-toolbar-label'; const DISABLED_STATE_CLASS = 'dx-state-disabled'; export const TEMPLATE_WRAPPER_CLASS = 'dx-template-wrapper'; @@ -198,11 +197,11 @@ class Popup< _$topToolbar?: dxElementWrapper | null; - _topToolbar?: Toolbar; + _topToolbar?: ToolbarBase; _$bottomToolbar?: dxElementWrapper | null; - _bottomToolbar?: Toolbar; + _bottomToolbar?: ToolbarBase; _$popupContent?: dxElementWrapper | null; @@ -438,7 +437,7 @@ class Popup< this._toggleContentScrollClass(); - this.$overlayContent().attr('role', 'dialog'); + this.setAria('role', this._getAriaRole()); } _render(): void { @@ -609,7 +608,7 @@ class Popup< $toolbarContainer, { onInitialized: (e): void => { - this._topToolbar = e.component; + this._topToolbar = e.component as unknown as ToolbarBase; }, }, ); @@ -659,7 +658,7 @@ class Popup< { compactMode: true, onInitialized: (e): void => { - this._bottomToolbar = e.component; + this._bottomToolbar = e.component as unknown as ToolbarBase; }, }, ); @@ -767,20 +766,28 @@ class Popup< const integrationOptions = this._getIntegrationOptions(); - // @ts-expect-error integrationOptions instance.option({ ...options, integrationOptions, }); } + protected _getAriaRole(): string { + return 'dialog'; + } + _toggleAriaLabel(): void { const { title, showTitle } = this.option(); - const shouldSetAriaLabel = showTitle && Boolean(title); - const titleId = shouldSetAriaLabel ? new Guid().toString() : null; + const isLabelRequired = Boolean(showTitle) && Boolean(title); - this._$topToolbar?.find(`.${TOOLBAR_LABEL_CLASS}`).eq(0).attr('id', titleId); - this.$overlayContent().attr('aria-labelledby', titleId); + const titleId = isLabelRequired ? new Guid().toString() : null; + const isLabelAttributeSet = this._topToolbar?.setLabelAttribute('id', titleId); + + this.setAria( + 'labelledby', + isLabelRequired && isLabelAttributeSet ? titleId : null, + this.$overlayContent(), + ); } _animateShowing(): void { @@ -1409,6 +1416,7 @@ class Popup< break; case 'titleTemplate': { this._renderTopToolbarImpl(); + this._toggleAriaLabel(); this._renderGeometry(); triggerResizeEvent(this.$overlayContent()); break; diff --git a/packages/devextreme/js/__internal/ui/slider/m_slider_tooltip.ts b/packages/devextreme/js/__internal/ui/slider/m_slider_tooltip.ts index 635e0c189d8c..ed992a470512 100644 --- a/packages/devextreme/js/__internal/ui/slider/m_slider_tooltip.ts +++ b/packages/devextreme/js/__internal/ui/slider/m_slider_tooltip.ts @@ -37,7 +37,7 @@ class SliderTooltip extends Tooltip { templatesRenderAsynchronously: false, _fixWrapperPosition: false, useResizeObserver: false, - + _describeTarget: false, showMode: 'onHover', format: (value) => value, value: 0, diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts index c4ae4ebb7185..909940237b86 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.base.ts @@ -732,6 +732,21 @@ class ToolbarBase< clearTimeout(this._waitParentAnimationTimeout); } + setLabelAttribute( + name: string, + value: string | number | boolean | null = null, + ): boolean { + const $label = this.$element().find(`.${TOOLBAR_LABEL_CLASS}`).eq(0); + + if (!$label.length) { + return false; + } + + $label.attr(name, value); + + return true; + } + _updateDimensionsInMaterial(): void { if (isMaterial(current())) { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/packages/devextreme/js/__internal/ui/tooltip.ts b/packages/devextreme/js/__internal/ui/tooltip.ts index 5b80fba3eb67..65f656925261 100644 --- a/packages/devextreme/js/__internal/ui/tooltip.ts +++ b/packages/devextreme/js/__internal/ui/tooltip.ts @@ -1,7 +1,4 @@ import registerComponent from '@js/core/component_registrator'; -import Guid from '@js/core/guid'; -import $ from '@js/core/renderer'; -import { isWindow } from '@js/core/utils/type'; import Popover from '@js/ui/popover/ui.popover'; import type { PopoverProperties } from '@ts/ui/popover/popover'; @@ -15,8 +12,6 @@ const TOOLTIP_WRAPPER_CLASS = 'dx-tooltip-wrapper'; class Tooltip< TProperties extends TooltipProperties = TooltipProperties, > extends Popover { - _contentId?: string; - _getDefaultOptions(): TProperties { return { ...super._getDefaultOptions(), @@ -39,31 +34,10 @@ class Tooltip< super._render(); } - _renderContent(): void { - super._renderContent(); - - this._toggleAriaAttributes(); - } - - _toggleAriaDescription(showing: boolean): void { - const { target } = this.option(); - const $target = $(target); - const label = showing ? this._contentId : undefined; - - if (!isWindow($target.get(0))) { - this.setAria('describedby', label, $target); - } - } - - _toggleAriaAttributes(): void { - this._contentId = `dx-${new Guid()}`; - - // @ts-expect-error dxElementWrapper typings - this.$overlayContent().attr({ - id: this._contentId, - }); + protected _getAriaRole(): string { + const { toolbarItems } = this.option(); - this._toggleAriaDescription(true); + return toolbarItems?.length ? 'dialog' : 'tooltip'; } } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js index 076f033dfa29..1176c8994059 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/lookup.tests.js @@ -1156,6 +1156,24 @@ QUnit.module('Lookup', { }); }); + QUnit.test('popover mode should keep role="dialog" after the cancel button toolbar becomes empty', function(assert) { + const instance = $('#lookup').dxLookup({ + usePopover: true, + showCancelButton: true + }).dxLookup('instance'); + + this.togglePopup(); + + const $overlayContent = $(instance.content()).parent(); + + assert.strictEqual($overlayContent.attr('role'), 'dialog', 'role is dialog after opening'); + + instance.option('showCancelButton', false); + + assert.strictEqual($overlayContent.attr('role'), 'dialog', 'role stays dialog with an empty toolbar'); + assert.strictEqual($('#lookup').attr('aria-describedby'), undefined, 'lookup element is not described by the popover content'); + }); + QUnit.test('showEvent/hideEvent is null when usePopover is true', function(assert) { this.instance.option({ usePopover: true diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js index b1036770423d..9205430c2753 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/desktopTooltip.tests.js @@ -94,7 +94,7 @@ QUnit.test('createComponent should be called with correct options', async functi assert.equal(stubCreateComponent.getCall(0).args[0][0].className, 'dx-scheduler-appointment-tooltip-wrapper'); assert.deepEqual(stubCreateComponent.getCall(0).args[1], Tooltip); - assert.equal(Object.keys(stubCreateComponent.getCall(0).args[2]).length, 7); + assert.equal(Object.keys(stubCreateComponent.getCall(0).args[2]).length, 9); assert.equal(stubCreateComponent.getCall(0).args[2].target, 'target'); assert.equal(stubCreateComponent.getCall(0).args[2].maxHeight, 200); assert.equal(stubCreateComponent.getCall(0).args[2].rtlEnabled, true); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/actionSheet.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/actionSheet.tests.js index 247ab7425531..befd4d4ff9c2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/actionSheet.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/actionSheet.tests.js @@ -64,6 +64,19 @@ QUnit.module('action sheet', { assert.strictEqual($popoverInstance.$content().parent().attr('role'), 'dialog'); }); + QUnit.test('popover mode target should not have aria-describedby', function(assert) { + const instance = $('#actionSheet').dxActionSheet({ + usePopover: true, + target: $('#container') + }).dxActionSheet('instance'); + + assert.strictEqual($('#container').attr('aria-describedby'), undefined, 'target is not described before showing'); + + instance.show(); + + assert.strictEqual($('#container').attr('aria-describedby'), undefined, 'target is not described after showing'); + }); + QUnit.test('popup should have role="dialog" attribute', function(assert) { $('#actionSheet').dxActionSheet({ usePopover: false }); 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 38866971b0ff..5beb8f10e63d 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -2383,6 +2383,486 @@ QUnit.module('accessibility', { assert.strictEqual($overlay.attr('role'), 'dialog'); }); + QUnit.test('default popover has role="tooltip" and describes its target by the overlay content id', function(assert) { + new Popover($('#what'), { target: '#where' }); + const $overlay = $(`.${OVERLAY_CONTENT_CLASS}`); + const contentId = $overlay.attr('id'); + + assert.strictEqual($overlay.attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.ok(contentId, 'overlay content has an id'); + assert.strictEqual($('#where').attr('aria-describedby'), contentId, 'target is described by the overlay content id'); + }); + + QUnit.module('target aria-describedby', () => { + const getDescribedBy = ($element) => ($element.attr('aria-describedby') || '').split(/\s+/).filter(Boolean); + + QUnit.test('target should be described by the overlay content id (hidden popover, default deferRendering)', function(assert) { + new Popover($('#what'), { target: '#where' }); + + const contentId = $(`.${OVERLAY_CONTENT_CLASS}`).attr('id'); + + assert.ok(contentId, 'overlay content has an id'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target is described by the overlay content id'); + }); + + QUnit.test('target should be described when deferRendering is false', function(assert) { + new Popover($('#what'), { target: '#where', deferRendering: false }); + + const contentId = $(`.${OVERLAY_CONTENT_CLASS}`).attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target is described by the overlay content id'); + }); + + QUnit.test('target should be described when popover is created visible', function(assert) { + new Popover($('#what'), { target: '#where', visible: true }); + + const contentId = $(`.${OVERLAY_CONTENT_CLASS}`).attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target is described by the overlay content id'); + }); + + QUnit.test('no aria-describedby should be added when target is not specified', function(assert) { + new Popover($('#what'), {}); + + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'unrelated element is not described'); + }); + + QUnit.test('existing aria-describedby ids on target should be preserved', function(assert) { + $('#where').attr('aria-describedby', 'custom-help'); + + new Popover($('#what'), { target: '#where' }); + + const contentId = $(`.${OVERLAY_CONTENT_CLASS}`).attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), ['custom-help', contentId], 'custom id is preserved and popover id is appended'); + }); + + QUnit.test('popover id should not be duplicated on target after repaint', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + + popover.repaint(); + + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target contains the popover id exactly once'); + }); + + QUnit.test('show/hide/show cycle should keep a single stable id on target', function(assert) { + const popover = new Popover($('#what'), { target: '#where', animation: null }); + const initialId = popover.$overlayContent().attr('id'); + + popover.show(); + popover.hide(); + popover.show(); + + assert.strictEqual(popover.$overlayContent().attr('id'), initialId, 'overlay content id is stable across shows'); + assert.deepEqual(getDescribedBy($('#where')), [initialId], 'target contains the id exactly once'); + }); + + QUnit.test('dispose should remove only the popover id from target aria-describedby', function(assert) { + $('#where').attr('aria-describedby', 'custom-help'); + + const popover = new Popover($('#what'), { target: '#where' }); + + popover.dispose(); + + assert.deepEqual(getDescribedBy($('#where')), ['custom-help'], 'only the popover id is removed'); + }); + + QUnit.test('dispose should remove aria-describedby attribute when no other ids remain', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + + popover.dispose(); + + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'attribute is removed when the id list becomes empty'); + }); + + QUnit.test('two popovers describing the same target should not interfere', function(assert) { + const $secondPopover = $('
').appendTo('body'); + + try { + const popover1 = new Popover($('#what'), { target: '#where' }); + const popover2 = new Popover($('#popover2'), { target: '#where' }); + + const id1 = popover1.$overlayContent().attr('id'); + const id2 = popover2.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [id1, id2], 'target is described by both popovers'); + + popover1.dispose(); + + assert.deepEqual(getDescribedBy($('#where')), [id2], 'remaining popover id is kept after the first one is disposed'); + } finally { + $secondPopover.remove(); + } + }); + + QUnit.test('changing the target option should move the description to the new target', function(assert) { + const $where2 = $('
').appendTo('body'); + + try { + const popover = new Popover($('#what'), { target: '#where' }); + const contentId = popover.$overlayContent().attr('id'); + + popover.option('target', '#where2'); + + assert.deepEqual(getDescribedBy($('#where')), [], 'old target no longer contains the popover id'); + assert.deepEqual(getDescribedBy($('#where2')), [contentId], 'new target is described by the same stable id'); + } finally { + $where2.remove(); + } + }); + + QUnit.test('show(target) should move the description to the new target', function(assert) { + const $where2 = $('
').appendTo('body'); + + try { + const popover = new Popover($('#what'), { target: '#where', animation: null }); + const contentId = popover.$overlayContent().attr('id'); + + popover.show('#where2'); + + assert.deepEqual(getDescribedBy($('#where')), [], 'old target no longer contains the popover id'); + assert.deepEqual(getDescribedBy($('#where2')), [contentId], 'new target is described by the same stable id'); + } finally { + $where2.remove(); + } + }); + + QUnit.test('jQuery/renderer wrapper target should be described', function(assert) { + const popover = new Popover($('#what'), { target: $('#where') }); + + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'wrapped target is described'); + }); + + QUnit.test('native Element target should be described', function(assert) { + const popover = new Popover($('#what'), { target: document.getElementById('where') }); + + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'element target is described'); + }); + + QUnit.test('selector target that appears after creation should be described on show', function(assert) { + const popover = new Popover($('#what'), { target: '#deferredTarget', animation: null }); + + const $deferredTarget = $('
').appendTo('body'); + + try { + popover.show(); + + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($deferredTarget), [contentId], 'deferred target is described after show'); + } finally { + $deferredTarget.remove(); + } + }); + + QUnit.test('window target should not get aria-describedby and should not raise errors', function(assert) { + new Popover($('#what'), { target: window }); + + assert.strictEqual($('body').attr('aria-describedby'), undefined, 'body is not described'); + assert.strictEqual($(document.documentElement).attr('aria-describedby'), undefined, 'documentElement is not described'); + }); + + QUnit.test('dispose with a target removed from the DOM should not raise errors', function(assert) { + const $detachedTarget = $('
').appendTo('body'); + const popover = new Popover($('#what'), { target: '#detachedTarget' }); + + $detachedTarget.remove(); + popover.dispose(); + + assert.ok(true, 'no exception is thrown'); + }); + + QUnit.test('popover with toolbarItems should not describe its target', function(assert) { + const popover = new Popover($('#what'), { target: '#where', toolbarItems: [{ text: 'OK' }] }); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target is not described'); + }); + + QUnit.test('adding toolbarItems at runtime should remove the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + + popover.option('toolbarItems', [{ text: 'OK' }]); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target description is removed'); + }); + + QUnit.test('clearing toolbarItems at runtime should restore the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where', toolbarItems: [{ text: 'OK' }] }); + + popover.option('toolbarItems', []); + + const contentId = popover.$overlayContent().attr('id'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target description is restored'); + }); + + QUnit.test('popover with showTitle and showCloseButton should be a dialog and should not describe its target', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + showTitle: true, + title: 'Title', + showCloseButton: true, + }); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target is not described'); + }); + + QUnit.test('runtime showTitle/showCloseButton transitions should update role and target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + const contentId = popover.$overlayContent().attr('id'); + + popover.option({ showTitle: true, title: 'Title', showCloseButton: true }); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target description is removed'); + + popover.option('showCloseButton', false); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip again'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target description is restored'); + }); + + QUnit.test('tooltip-mode popover with a title should not have aria-labelledby', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + showTitle: true, + title: 'Details', + deferRendering: false, + }); + + const contentId = popover.$overlayContent().attr('id'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.strictEqual(popover.$overlayContent().attr('aria-labelledby'), undefined, 'overlay content is not labelled by the title'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target is described by the content'); + }); + + QUnit.test('dialog-mode popover with a title should keep aria-labelledby', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + toolbarItems: [{ text: 'OK' }], + showTitle: true, + title: 'Details', + deferRendering: false, + }); + + const titleId = popover.$overlayContent().attr('aria-labelledby'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.ok(titleId, 'overlay content has an aria-labelledby value'); + // NOTE: the generated title id may start with a digit, so `#${titleId}` is not a valid CSS selector + assert.strictEqual(popover.$overlayContent().find(`[id="${titleId}"]`).length, 1, 'the label id references an existing title element'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target is not described'); + }); + + QUnit.test('switching from dialog to tooltip mode at runtime should clear aria-labelledby', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + toolbarItems: [{ text: 'OK' }], + showTitle: true, + title: 'Details', + deferRendering: false, + }); + + popover.option('toolbarItems', []); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.strictEqual(popover.$overlayContent().attr('aria-labelledby'), undefined, 'aria-labelledby is cleared'); + }); + + QUnit.test('changing contentTemplate should keep the stable id and the target description', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + contentTemplate: () => 'first', + }); + const contentId = popover.$overlayContent().attr('id'); + + popover.option('contentTemplate', () => 'second'); + + assert.strictEqual(popover.$overlayContent().attr('id'), contentId, 'overlay content id is stable'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target description is unchanged'); + }); + + QUnit.test('disabled popover should still describe its target', function(assert) { + const popover = new Popover($('#what'), { target: '#where', disabled: true }); + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target is described in tooltip mode'); + }); + + QUnit.test('runtime titleTemplate change in dialog mode should not leave a dangling aria-labelledby', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + toolbarItems: [{ text: 'OK' }], + showTitle: true, + title: 'Details', + deferRendering: false, + }); + + popover.option('titleTemplate', () => 'custom title'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'overlay content role is dialog'); + assert.strictEqual(popover.$overlayContent().attr('aria-labelledby'), undefined, 'aria-labelledby is removed when the custom title renders no label element'); + }); + + QUnit.test('runtime titleTemplate change in tooltip mode should keep aria-labelledby absent', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + showTitle: true, + title: 'Details', + deferRendering: false, + }); + const contentId = popover.$overlayContent().attr('id'); + + popover.option('titleTemplate', () => 'custom title'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.strictEqual(popover.$overlayContent().attr('aria-labelledby'), undefined, 'aria-labelledby is absent'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target description is unchanged'); + }); + + QUnit.test('internal _popoverContentRole should force the role and prevent the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where', _popoverContentRole: 'dialog', animation: null }); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'forced role is applied at creation'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target is not described at creation'); + + popover.show(); + popover.option('toolbarItems', []); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'forced role survives an empty toolbarItems change'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target is still not described'); + }); + + QUnit.test('internal _describeTarget=false should prevent the target description in tooltip mode', function(assert) { + const popover = new Popover($('#what'), { target: '#where', _describeTarget: false }); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'overlay content role is tooltip'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target is not described'); + }); + + QUnit.test('runtime _popoverContentRole change should update the role and the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + const contentId = popover.$overlayContent().attr('id'); + + popover.option('_popoverContentRole', 'dialog'); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'forced role is applied'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target description is removed'); + + popover.option('_popoverContentRole', null); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'tooltip', 'computed role is restored'); + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target description is restored'); + }); + + QUnit.test('runtime _describeTarget change should toggle the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + const contentId = popover.$overlayContent().attr('id'); + + popover.option('_describeTarget', false); + + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target description is removed'); + + popover.option('_describeTarget', true); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'target description is restored'); + }); + + QUnit.module('runtime target changes', { + beforeEach: function() { + this.$target1 = $('
').appendTo('body'); + this.$target2 = $('
').appendTo('body'); + }, + afterEach: function() { + this.$target1.remove(); + this.$target2.remove(); + } + }, () => { + QUnit.test('runtime target change from none to element should add target description', function(assert) { + const popover = new Popover($('#what'), { target: null, deferRendering: false }); + + assert.strictEqual(this.$target1.attr('aria-describedby'), undefined, 'target is not described yet'); + + popover.option('target', '#target1'); + + const contentId = popover.$overlayContent().attr('id'); + assert.deepEqual(getDescribedBy(this.$target1), [contentId], 'target description is added after option change'); + }); + + QUnit.test('runtime target change from element to none should remove target description', function(assert) { + const popover = new Popover($('#what'), { target: '#target1', deferRendering: false }); + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy(this.$target1), [contentId], 'target is initially described'); + + popover.option('target', null); + + assert.strictEqual(this.$target1.attr('aria-describedby'), undefined, 'target description is removed after option change'); + }); + + QUnit.test('runtime target change should update target descriptions', function(assert) { + const popover = new Popover($('#what'), { target: '#target1', deferRendering: false }); + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy(this.$target1), [contentId], 'initial target is described'); + assert.strictEqual(this.$target2.attr('aria-describedby'), undefined, 'new target is not described yet'); + + popover.option('target', '#target2'); + + assert.strictEqual(this.$target1.attr('aria-describedby'), undefined, 'old target description is removed'); + assert.deepEqual(getDescribedBy(this.$target2), [contentId], 'new target description is added'); + }); + }); + + QUnit.test('a manually wired token equal to an external content id should not be claimed by the popover', function(assert) { + $('#where').attr('aria-describedby', 'manual-content-id'); + + const popover = new Popover($('#what'), { target: '#where' }); + + popover.$overlayContent().attr('id', 'manual-content-id'); + popover.repaint(); + + assert.ok(getDescribedBy($('#where')).includes('manual-content-id'), 'no duplicate token is added after the id is adopted'); + + popover.dispose(); + + assert.ok(getDescribedBy($('#where')).includes('manual-content-id'), 'the manual token is preserved after dispose'); + }); + + QUnit.test('aria-describedby is added when target option is specified and removed when target is set to null', function(assert) { + const popover = new Popover($('#what'), { target: '#where' }); + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'aria-describedby is initially added to the target'); + + popover.option('target', null); + + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'aria-describedby is removed from the old target'); + }); + + QUnit.test('aria-describedby is not added when target is initially null, and is added when target is set to an element', function(assert) { + const popover = new Popover($('#what'), { target: null }); + + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target does not have aria-describedby initially'); + + popover.option('target', '#where'); + const contentId = popover.$overlayContent().attr('id'); + + assert.deepEqual(getDescribedBy($('#where')), [contentId], 'aria-describedby is added to the new target'); + }); + }); + QUnit.module('WCAG - dismissible', () => { QUnit.test('should hide visible popover on esc press', function(assert) { const popover = new Popover($('#what'), { @@ -2560,4 +3040,181 @@ 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: [{ widget: 'dxButton', options: { 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'); + }); + + QUnit.test('Popover should toggle focusStateEnabled and tabFocusLoopEnabled when changing toolbarItems at runtime', function(assert) { + const instance = new Popover(this.$element, { + target: this.$target, + toolbarItems: [], + visible: false, + }); + + instance.show(); + this.clock.tick(0); + + assert.strictEqual(instance.option('focusStateEnabled'), false, 'initially focusStateEnabled is false'); + assert.strictEqual(instance.option('tabFocusLoopEnabled'), false, 'initially tabFocusLoopEnabled is false'); + + instance.option('toolbarItems', [{ text: 'OK' }]); + this.clock.tick(0); + + assert.strictEqual(instance.option('focusStateEnabled'), true, 'focusStateEnabled becomes true when toolbarItems is added'); + assert.strictEqual(instance.option('tabFocusLoopEnabled'), true, 'tabFocusLoopEnabled becomes true when toolbarItems is added'); + + instance.option('toolbarItems', []); + this.clock.tick(0); + + assert.strictEqual(instance.option('focusStateEnabled'), false, 'focusStateEnabled becomes false when toolbarItems is removed'); + assert.strictEqual(instance.option('tabFocusLoopEnabled'), false, 'tabFocusLoopEnabled becomes false when toolbarItems is removed'); + }); + + QUnit.test('Popover should not change focusStateEnabled and tabFocusLoopEnabled when _preventDialogContainerFocus is true', function(assert) { + const instance = new Popover(this.$element, { + target: this.$target, + _preventDialogContainerFocus: true, + focusStateEnabled: false, + tabFocusLoopEnabled: false, + toolbarItems: [], + visible: false, + }); + + instance.show(); + this.clock.tick(0); + + instance.option('toolbarItems', [{ text: 'OK' }]); + this.clock.tick(0); + + assert.strictEqual(instance.option('focusStateEnabled'), false, 'focusStateEnabled remains false due to _preventDialogContainerFocus'); + assert.strictEqual(instance.option('tabFocusLoopEnabled'), false, 'tabFocusLoopEnabled remains false due to _preventDialogContainerFocus'); + }); + }); }); + diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js index d7aa88967581..1fcb96b26eef 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popup.tests.js @@ -223,6 +223,33 @@ QUnit.module('basic', { assert.strictEqual($overlayContent.attr('aria-labelledby'), undefined); }); + QUnit.test('aria-labelledby should not be set when a custom titleTemplate renders no label element', function(assert) { + const instance = $('#popup').dxPopup({ + title: 'title', + titleTemplate: () => $('
').text('custom title'), + visible: true, + }).dxPopup('instance'); + + const $overlayContent = instance.$content().parent(); + + assert.strictEqual($overlayContent.attr('aria-labelledby'), undefined); + }); + + QUnit.test('aria-labelledby should not reference a missing element after a runtime titleTemplate change', function(assert) { + const instance = $('#popup').dxPopup({ + title: 'title', + visible: true, + }).dxPopup('instance'); + + const $overlayContent = instance.$content().parent(); + + assert.ok($overlayContent.attr('aria-labelledby'), 'aria-labelledby is set for the default title'); + + instance.option('titleTemplate', () => $('
').text('custom title')); + + assert.strictEqual($overlayContent.attr('aria-labelledby'), undefined); + }); + QUnit.test('popup wrapper should have fixed or absolute position in fullscreen', function(assert) { $('#popup').dxPopup({ fullScreen: true, visible: true }); 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 b117b7e50137..338c6c17edca 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js @@ -184,6 +184,30 @@ QUnit.module('accessibility', () => { }); + QUnit.test('role should stay "tooltip" when showTitle and showCloseButton are enabled', function(assert) { + const $tooltip = $('#tooltip'); + new Tooltip($tooltip, { + target: '#target', + showTitle: true, + title: 'title', + showCloseButton: true + }); + const $overlayContent = $tooltip.find(`.${OVERLAY_CONTENT_CLASS}`); + + assert.equal($overlayContent.attr('role'), 'tooltip'); + }); + + QUnit.test('role should be "dialog" when toolbarItems are specified', function(assert) { + const $tooltip = $('#tooltip'); + new Tooltip($tooltip, { + target: '#target', + toolbarItems: [{ text: 'ok' }] + }); + const $overlayContent = $tooltip.find(`.${OVERLAY_CONTENT_CLASS}`); + + assert.equal($overlayContent.attr('role'), 'dialog'); + }); + QUnit.module('WCAG - dismissible', () => { QUnit.test('should hide visible tooltip on Escape key press', function(assert) { const tooltip = new Tooltip($('#tooltip'), { diff --git a/packages/testcafe-models/dataGrid/columnChooser.ts b/packages/testcafe-models/dataGrid/columnChooser.ts index c805689a4bdf..cb98b7bd5fbb 100644 --- a/packages/testcafe-models/dataGrid/columnChooser.ts +++ b/packages/testcafe-models/dataGrid/columnChooser.ts @@ -15,6 +15,7 @@ const CLASS = { itemContent: 'dx-item-content', itemContentToolbar: 'dx-toolbar-item-content', emptyMessage: 'dx-empty-message', + closeButton: 'dx-closebutton', }; export default class ColumnChooser extends FocusableElement { @@ -93,4 +94,8 @@ export default class ColumnChooser extends FocusableElement { getEmptyMessage(): Selector { return this.content.find(`.${CLASS.emptyMessage}`); } -} \ No newline at end of file + + getCloseButton(): Selector { + return this.element.find(`.${CLASS.closeButton}`); + } +}