Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5ccace2
Popover: support content reading on opening
EugeniyKiyashko Jul 15, 2026
f5e4c3b
Apply aria role to containers
Raushen Jul 22, 2026
1b99a65
Add jest tests
Raushen Jul 22, 2026
3ccd065
Fix QUnit tests
Raushen Jul 22, 2026
72272ac
Popover: update aria-describedby logic in popover and remove it from …
dmlvr Jul 24, 2026
a85bed3
Merge branch 'main' into feature/26_2_popover_support_content_reading
dmlvr Jul 28, 2026
048ec5f
refactoring
dmlvr Jul 28, 2026
6947bd8
remove multiple target tests
dmlvr Jul 28, 2026
8e0f3d5
Popover: focus management for dialog role (#34413)
dmlvr Jul 29, 2026
7c5f974
refactoring
dmlvr Jul 29, 2026
25530d7
_syncTargetAriaDescription refactoring
dmlvr Jul 29, 2026
3358b17
_ensurePopoverContentId refactoring
dmlvr Jul 30, 2026
7858fb2
fix tests errors
dmlvr Jul 30, 2026
1bee023
remove basic _forceFocusLost
dmlvr Jul 30, 2026
be1129a
refactoring
dmlvr Jul 30, 2026
9e76bf2
add testcases for addAriaDescriptionId and removeAriaDescriptionId
dmlvr Aug 3, 2026
c76193e
update _getFocusTarget
dmlvr Aug 3, 2026
990cde6
update Note for excluded e2e tests
dmlvr Aug 3, 2026
1f633ba
add getCloseButton for DataGrid ColumnChooser
dmlvr Aug 3, 2026
6613f78
fix by review
dmlvr Aug 3, 2026
34b47cb
fix _syncFocusOptions and add some tests for change focusStateEnabled…
dmlvr Aug 3, 2026
58ba85c
update scheduler's appointment tooltip
dmlvr Aug 3, 2026
95970ab
prevent tooltip Focus Behavior for appointment tooltip
dmlvr Aug 3, 2026
0ed0f35
update comment for popover a11y tests
dmlvr Aug 3, 2026
b9be9a9
update _forceFocusLost for role "dialog"
dmlvr Aug 3, 2026
6e19d16
change options order for appointment tooltip
dmlvr Aug 3, 2026
1358c99
update expected options count for appointment tooltip
dmlvr Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions e2e/testcafe-devextreme/tests/accessibility/popover.ts
Original file line number Diff line number Diff line change
@@ -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<Properties> = {
visible: [true],
target: [defaultSelector],
Expand All @@ -14,23 +35,36 @@ const options: Options<Properties> = {
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);
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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 () => {
Expand Down
154 changes: 154 additions & 0 deletions packages/devextreme/js/__internal/core/utils/__tests__/m_dom.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
41 changes: 41 additions & 0 deletions packages/devextreme/js/__internal/core/utils/m_dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
Raushen marked this conversation as resolved.

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,
Expand All @@ -187,4 +226,6 @@ export default {
insertBefore,
replaceWith,
isElementInDom,
addAriaDescriptionId,
removeAriaDescriptionId,
};
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export default class SchedulerCalendar extends Widget<HeaderCalendarOptions> {

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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Popup>(this.getRoot());
}
}
Loading
Loading