From 5ccace2413593aa1b9e1eb1c1b41405e6442e467 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 15 Jul 2026 13:37:05 +0400 Subject: [PATCH] Popover: support content reading on opening --- .../tests/accessibility/popover.ts | 60 ++- .../__internal/scheduler/header/calendar.ts | 4 + .../js/__internal/ui/action_sheet.ts | 3 +- .../devextreme/js/__internal/ui/lookup.ts | 3 +- .../js/__internal/ui/popover/popover.ts | 227 ++++++++- .../js/__internal/ui/popup/popup.ts | 8 +- .../devextreme/js/__internal/ui/tooltip.ts | 12 + .../lookup.tests.js | 18 + .../actionSheet.tests.js | 13 + .../DevExpress.ui.widgets/popover.tests.js | 442 ++++++++++++++++++ .../DevExpress.ui.widgets/popup.tests.js | 27 ++ .../DevExpress.ui.widgets/tooltip.tests.js | 24 + 12 files changed, 820 insertions(+), 21 deletions(-) diff --git a/e2e/testcafe-devextreme/tests/accessibility/popover.ts b/e2e/testcafe-devextreme/tests/accessibility/popover.ts index bd5f24f5c997..7bbd721b9ee4 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/popover.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/popover.ts @@ -1,11 +1,34 @@ 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 (see dialog-labeling-known-issue.md), so the best-practice rule +// is disabled for the combinations where the name is intentionally absent. +// Re-enable this rule once that task lands. +const a11yCheckConfig = isFluent() ? { + rules: { 'aria-dialog-name': { enabled: false } }, +} : { + runOnly: 'color-contrast', +}; + const options: Options = { visible: [true], target: [defaultSelector], @@ -14,23 +37,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/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/ui/action_sheet.ts b/packages/devextreme/js/__internal/ui/action_sheet.ts index b81109ddc007..03b3f3de24d6 100644 --- a/packages/devextreme/js/__internal/ui/action_sheet.ts +++ b/packages/devextreme/js/__internal/ui/action_sheet.ts @@ -180,10 +180,9 @@ class ActionSheet extends CollectionWidget { width: this.option('width') || 200, height: this.option('height') || 'auto', target: this.option('target'), + _overlayContentRole: '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 59480ceab46c..3ff0dbb242aa 100644 --- a/packages/devextreme/js/__internal/ui/lookup.ts +++ b/packages/devextreme/js/__internal/ui/lookup.ts @@ -682,6 +682,7 @@ class Lookup extends DropDownList { shading: false, hideOnParentScroll: true, _fixWrapperPosition: false, + _overlayContentRole: 'dialog', width: this._isInitialOptionValue('dropDownOptions.width') ? (): number => getOuterWidth(this.$element()) as number : popupConfig.width, @@ -690,8 +691,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/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 6e5676cf2189..982ec49bf041 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'; @@ -68,6 +69,20 @@ const HOVER_HIDE_DELAY = 50; const ESC_KEY_NAME = 'escape'; +const ARIA_DESCRIBEDBY_ATTRIBUTE = 'aria-describedby'; + +const getAriaDescriptionIds = (element: Element): string[] => (element.getAttribute(ARIA_DESCRIBEDBY_ATTRIBUTE) ?? '').split(/\s+/).filter(Boolean); + +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); + } +}; + type PopoverTarget = string | dxElementWrapper | Element | undefined; type PopoverEventOption = 'showEvent' | 'hideEvent'; @@ -92,6 +107,10 @@ export interface PopoverProperties extends Omit>; + _popoverContentId?: string; + + _ariaDescriptionId?: string; + + _$describedTargets?: dxElementWrapper; + + _lastAppliedAriaRole?: string; + _getDefaultOptions(): TProperties { return { ...super._getDefaultOptions(), @@ -132,6 +159,7 @@ class Popover< arrowPosition: '', arrowOffset: 0, _fixWrapperPosition: true, + _describeTarget: true, }; } @@ -185,10 +213,14 @@ class Popover< this.$element().addClass(POPOVER_CLASS); this.$wrapper()?.addClass(POPOVER_WRAPPER_CLASS); - const { toolbarItems, visible } = this.option(); + const { visible } = this.option(); - const isInteractive = toolbarItems?.length; - this.setAria('role', isInteractive ? 'dialog' : 'tooltip'); + // NOTE: Popup applies a default role on the overlay content in its own _init. + // Popover owns role management from here, so claim the current value before + // syncing; otherwise the "do not clobber an external role" guard in + // _renderAriaRole would mistake the inherited default for a user override. + this._lastAppliedAriaRole = this._getActualAriaRole(); + this._renderAriaRole(); if (visible) { this._attachEscapeKeyHandler(); @@ -233,6 +265,183 @@ class Popover< this._attachHoverableOverlay(); } + _renderContent(): void { + super._renderContent(); + + this._syncAriaAttributes(); + } + + _syncAriaAttributes(): void { + this._renderAriaRole(); + this._syncTargetAriaDescription(); + } + + _getExpectedAriaRole(): string { + const { + toolbarItems, + showTitle, + showCloseButton, + // eslint-disable-next-line @typescript-eslint/naming-convention + _overlayContentRole, + } = this.option(); + + if (_overlayContentRole) { + return _overlayContentRole; + } + + const isDialog = Boolean(toolbarItems?.length) || Boolean(showTitle && showCloseButton); + + return isDialog ? 'dialog' : 'tooltip'; + } + + // NOTE: An externally assigned role (user code) must win: the expected role + // applies only while this instance owns the current attribute value. + _getEffectiveAriaRole(): string { + const actualRole = this._getActualAriaRole(); + + if (actualRole === undefined || actualRole === this._lastAppliedAriaRole) { + return this._getExpectedAriaRole(); + } + + return actualRole; + } + + _renderAriaRole(): void { + const role = this._getEffectiveAriaRole(); + + if (role === this._getActualAriaRole()) { + return; + } + + this.setAria('role', role); + this._lastAppliedAriaRole = role; + } + + // 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.$overlayContent().attr('aria-labelledby', null); + return; + } + + super._toggleAriaLabel(); + } + + _ensurePopoverContentId(): string { + const $overlayContent = this.$overlayContent(); + const existingId = $overlayContent.attr('id'); + + if (existingId) { + return existingId; + } + + this._popoverContentId = this._popoverContentId ?? `dx-${new Guid()}`; + $overlayContent.attr('id', this._popoverContentId); + + return this._popoverContentId; + } + + _getActualAriaRole(): string | undefined { + return this.$overlayContent().attr('role') ?? undefined; + } + + _shouldDescribeTarget(): boolean { + const { + target, + // eslint-disable-next-line @typescript-eslint/naming-convention + _describeTarget, + } = this.option(); + + return Boolean(target) && Boolean(_describeTarget) && this._getActualAriaRole() === '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._ensurePopoverContentId(); + + if (this._ariaDescriptionId && this._ariaDescriptionId !== id) { + this._removeTargetAriaDescription(); + } + + 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)) { + const restIds = getAriaDescriptionIds(element).filter((token) => token !== id); + + setAriaDescriptionIds(element, restIds); + } + }); + + const describedElements: Element[] = []; + + $targets.each((_, element) => { + const ids = getAriaDescriptionIds(element); + + if (!ids.includes(id)) { + ids.push(id); + setAriaDescriptionIds(element, ids); + describedElements.push(element); + } else if (previousElements.has(element)) { + // NOTE: A pre-existing token this instance did not add is foreign, + // so it is never claimed and cleanup cannot strip a manual description. + describedElements.push(element); + } + + return true; + }); + + this._ariaDescriptionId = describedElements.length ? id : undefined; + this._$describedTargets = describedElements.length ? $(describedElements) : undefined; + } + + _removeTargetAriaDescription(): void { + const id = this._ariaDescriptionId; + + if (!id || !this._$describedTargets) { + return; + } + + this._$describedTargets.each((_, element) => { + const restIds = getAriaDescriptionIds(element).filter((token) => token !== id); + + setAriaDescriptionIds(element, restIds); + + return true; + }); + + this._ariaDescriptionId = undefined; + this._$describedTargets = undefined; + } + _detachEvents(target: PopoverTarget): void { this._detachEvent(target, 'show'); this._detachEvent(target, 'hide'); @@ -720,6 +929,7 @@ class Popover< } _dispose(): void { + this._removeTargetAriaDescription(); this._detachEscapeKeyHandler(); super._dispose(); } @@ -781,6 +991,17 @@ class Popover< super._optionChanged(args); break; } + case 'toolbarItems': + case 'showTitle': + case 'showCloseButton': + super._optionChanged(args); + this._syncAriaAttributes(); + break; + case '_overlayContentRole': + case '_describeTarget': + this._syncAriaAttributes(); + this._toggleAriaLabel(); + break; default: super._optionChanged(args); } diff --git a/packages/devextreme/js/__internal/ui/popup/popup.ts b/packages/devextreme/js/__internal/ui/popup/popup.ts index ad97b1deecf6..f2be6af2b116 100644 --- a/packages/devextreme/js/__internal/ui/popup/popup.ts +++ b/packages/devextreme/js/__internal/ui/popup/popup.ts @@ -775,10 +775,13 @@ class Popup< _toggleAriaLabel(): void { const { title, showTitle } = this.option(); - const shouldSetAriaLabel = showTitle && Boolean(title); + const $label = this._$topToolbar?.find(`.${TOOLBAR_LABEL_CLASS}`).eq(0); + // NOTE: A custom titleTemplate can render no label element; aria-labelledby + // must not reference a missing id then. + const shouldSetAriaLabel = Boolean(showTitle) && Boolean(title) && Boolean($label?.length); const titleId = shouldSetAriaLabel ? new Guid().toString() : null; - this._$topToolbar?.find(`.${TOOLBAR_LABEL_CLASS}`).eq(0).attr('id', titleId); + $label?.attr('id', titleId); this.$overlayContent().attr('aria-labelledby', titleId); } @@ -1408,6 +1411,7 @@ class Popup< break; case 'titleTemplate': { this._renderTopToolbarImpl(); + this._toggleAriaLabel(); this._renderGeometry(); triggerResizeEvent(this.$overlayContent()); break; diff --git a/packages/devextreme/js/__internal/ui/tooltip.ts b/packages/devextreme/js/__internal/ui/tooltip.ts index 5b80fba3eb67..84e3de9692ad 100644 --- a/packages/devextreme/js/__internal/ui/tooltip.ts +++ b/packages/devextreme/js/__internal/ui/tooltip.ts @@ -45,6 +45,18 @@ class Tooltip< this._toggleAriaAttributes(); } + // NOTE: Tooltip manages the target relationship itself (_toggleAriaAttributes), + // so the inherited Popover sync must stay disabled to avoid a second describedby id. + _syncAriaAttributes(): void {} + + // NOTE: dxTooltip keeps its legacy role behavior: only toolbarItems make it + // a dialog; the Popover showTitle/showCloseButton predicate does not apply. + _getExpectedAriaRole(): string { + const { toolbarItems } = this.option(); + + return toolbarItems?.length ? 'dialog' : 'tooltip'; + } + _toggleAriaDescription(showing: boolean): void { const { target } = this.option(); const $target = $(target); 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 68edb2bdae39..fe2b2e8ebdff 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/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..5a4289478f4f 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,448 @@ QUnit.module('accessibility', { assert.strictEqual($overlay.attr('role'), 'dialog'); }); + // NOTE: Popup._init sets role="dialog" on the overlay content by default. + // A tooltip-mode Popover must override that inherited default (and, because + // describing a target requires role="tooltip", must then describe its target). + // Regression: the "do not clobber an external role" guard must not treat the + // inherited Popup default as an external override. See popover.ts _init. + QUnit.test('default popover should override the inherited Popup dialog role and describe its target', function(assert) { + new Popover($('#what'), { target: '#where' }); + const $overlay = $(`.${OVERLAY_CONTENT_CLASS}`); + const contentId = $overlay.attr('id'); + + assert.strictEqual($overlay.attr('role'), 'tooltip', 'inherited dialog role is overridden with 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 matching multiple elements should describe all matches', function(assert) { + const $sharedTargets = $('
').appendTo('body'); + + try { + const popover = new Popover($('#what'), { target: '.shared-target' }); + const contentId = popover.$overlayContent().attr('id'); + + $sharedTargets.each((_, element) => { + assert.deepEqual(getDescribedBy($(element)), [contentId], 'matched element is described'); + }); + } finally { + $sharedTargets.remove(); + } + }); + + 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('externally forced role should be preserved and should prevent the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where', animation: null }); + + popover.$overlayContent().attr('role', 'dialog'); + popover.show(); + + assert.strictEqual(popover.$overlayContent().attr('role'), 'dialog', 'external role is not overwritten'); + assert.strictEqual($('#where').attr('aria-describedby'), undefined, 'target description is removed on the next sync'); + }); + + 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 _overlayContentRole should force the role and prevent the target description', function(assert) { + const popover = new Popover($('#what'), { target: '#where', _overlayContentRole: '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 _overlayContentRole 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('_overlayContentRole', '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('_overlayContentRole', 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.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.deepEqual(getDescribedBy($('#where')), ['manual-content-id'], 'no duplicate token is added after the id is adopted'); + + popover.dispose(); + + assert.deepEqual(getDescribedBy($('#where')), ['manual-content-id'], 'the manual token is preserved after dispose'); + }); + }); + QUnit.module('WCAG - dismissible', () => { QUnit.test('should hide visible popover on esc press', function(assert) { const popover = new Popover($('#what'), { 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 468828b4ea61..5e0927ca8aab 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'), {