From a7272c3cb6c168251d15ee2fcc81c65f4e82e4eb Mon Sep 17 00:00:00 2001 From: dmlvr Date: Thu, 3 Sep 2026 15:04:40 +0300 Subject: [PATCH 1/2] fix and tests --- .../js/__internal/ui/list/list.base.ts | 31 +++++ .../lookup.tests.js | 56 ++++++++ .../listParts/commonTests.js | 126 ++++++++++++++++-- 3 files changed, 202 insertions(+), 11 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/list/list.base.ts b/packages/devextreme/js/__internal/ui/list/list.base.ts index d3411be672fd..0b557aeb41d5 100644 --- a/packages/devextreme/js/__internal/ui/list/list.base.ts +++ b/packages/devextreme/js/__internal/ui/list/list.base.ts @@ -85,6 +85,7 @@ const LIST_SELECT_CHECKBOX = 'dx-list-select-checkbox'; const LIST_SELECT_RADIOBUTTON = 'dx-list-select-radiobutton'; const WRAP_ITEM_TEXT_CLASS = 'dx-wrap-item-text'; const SELECT_ALL_ITEM_SELECTOR = '.dx-list-select-all'; +export const SCREEN_READER_ONLY_CLASS = 'dx-screen-reader-only'; const LIST_ITEM_DATA_KEY = 'dxListItemData'; const LIST_FEEDBACK_SHOW_TIMEOUT = 70; @@ -132,6 +133,8 @@ export class ListBase extends CollectionWidget { _$nextButton!: dxElementWrapper | null; + _$a11yStatusContainer?: dxElementWrapper; + _holdTimer?: ReturnType; _loadNextPageTimer?: ReturnType; @@ -975,6 +978,7 @@ export class ListBase extends CollectionWidget { this._itemElementsCache = $(); this.$element().addClass(LIST_CLASS); + this._renderA11yStatusContainer(); super._initMarkup(); const { useInkRipple } = this.option(); @@ -997,10 +1001,37 @@ export class ListBase extends CollectionWidget { const isEmpty = super._renderEmptyMessage(rootNodes); this.setAria({ role: isEmpty ? undefined : 'application' }, this._focusTarget()); + this._updateA11yStatusText(); return isEmpty; } + _renderA11yStatusContainer(): void { + const isContainerExistingInDOM = this._$a11yStatusContainer?.parent().is(this.$element()); + + if (isContainerExistingInDOM) { + return; + } + + this._$a11yStatusContainer = $('
') + .addClass(SCREEN_READER_ONLY_CLASS) + .attr('role', 'status') + .appendTo(this.$element()); + } + + _updateA11yStatusText(): void { + if (this._dataController.isLoading()) { + this._$a11yStatusContainer?.text(''); + return; + } + + const { noDataText } = this.option(); + const itemsCount = this._editStrategy.itemsGetter().length; + const itemsLabel = messageLocalization.format('dxList-listAriaLabel'); + + this._$a11yStatusContainer?.text(itemsCount ? `${itemsLabel}: ${itemsCount}` : noDataText ?? ''); + } + _isMultiSelectMode(): boolean { const { selectionMode } = this.option(); return selectionMode === 'multiple' || selectionMode === 'all'; 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 1176c8994059..472cd7a1c70d 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 @@ -3969,6 +3969,62 @@ if(devices.real().deviceType === 'desktop') { }); } +QUnit.module('screen reader announcements (T1334729)', { + beforeEach: function() { + fx.off = true; + this.clock = sinon.useFakeTimers(); + + this.instance = $('#lookup').dxLookup({ + dataSource: [1, 2, 3], + searchTimeout: 0, + 'dropDownOptions.animation': null, + opened: true + }).dxLookup('instance'); + + this.search = (value) => { + this.instance._searchBox.option('value', value); + this.clock.tick(0); + }; + + this.getA11yStatus = () => $(`.${LIST_CLASS}`).children('[role="status"]'); + this.getItemsAnnouncement = (itemsCount) => `${messageLocalization.format('dxList-listAriaLabel')}: ${itemsCount}`; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + } +}, () => { + QUnit.test('list should have a single live region', function(assert) { + assert.strictEqual(this.getA11yStatus().length, 1); + }); + + QUnit.test('item count should be announced when the drop-down is opened', function(assert) { + assert.strictEqual(this.getA11yStatus().text(), this.getItemsAnnouncement(3)); + }); + + QUnit.test('noDataText should be announced if search returns no items', function(assert) { + this.search('4'); + + assert.strictEqual(this.getA11yStatus().text(), messageLocalization.format('dxCollectionWidget-noDataText')); + }); + + QUnit.test('item count should be announced if search returns items', function(assert) { + this.search('1'); + + assert.strictEqual(this.getA11yStatus().text(), this.getItemsAnnouncement(1)); + }); + + QUnit.test('live region should not be re-created on search', function(assert) { + const a11yStatusElement = this.getA11yStatus().get(0); + + this.search('4'); + + assert.strictEqual(this.getA11yStatus().get(0), a11yStatusElement, 'the live region is the same element'); + assert.strictEqual(a11yStatusElement.textContent, messageLocalization.format('dxCollectionWidget-noDataText'), + 'the announcement is written into that element, otherwise screen readers do not announce it'); + }); +}); + QUnit.module('default options', { beforeEach: function() { fx.off = true; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js index 806250bc64e4..8293b6cb9836 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js @@ -18,10 +18,11 @@ import ArrayStore from 'common/data/array_store'; import { CustomStore } from 'common/data/custom_store'; import DOMComponent from 'core/dom_component'; import List from 'ui/list'; -import { setScrollView } from '__internal/ui/list/list.base'; +import { SCREEN_READER_ONLY_CLASS, setScrollView } from '__internal/ui/list/list.base'; import ScrollView from 'ui/scroll_view'; import eventsEngine from 'common/core/events/core/events_engine'; import ariaAccessibilityTestHelper from '../../../helpers/ariaAccessibilityTestHelper.js'; +import messageLocalization from 'common/core/localization/message'; const LIST_ITEM_CLASS = 'dx-list-item'; const LIST_ITEM_CONTENT_CLASS = 'dx-list-item-content'; @@ -1004,7 +1005,7 @@ QUnit.module('options', moduleSetup, () => { pageLoadMode: 'scrollBottom' }); - assert.equal(element.text(), '12345'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '12345'); assert.deepEqual(element.dxList('instance').option('items'), [1, 2, 3, 4, 5]); }); @@ -1014,7 +1015,7 @@ QUnit.module('options', moduleSetup, () => { pageLoadMode: 'scrollBottom' }); - assert.equal(element.text(), '12345'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '12345'); assert.deepEqual(element.dxList('instance').option('items'), [1, 2, 3, 4, 5]); }); @@ -1024,7 +1025,7 @@ QUnit.module('options', moduleSetup, () => { pageLoadMode: 'scrollBottom' }); - assert.equal(element.text(), '12345'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '12345'); assert.deepEqual(element.dxList('instance').option('items'), [1, 2, 3, 4, 5]); }); @@ -1068,7 +1069,7 @@ QUnit.module('options changed', moduleSetup, () => { pageLoadMode: 'scrollBottom' }); - assert.equal(element.text(), '01354'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '01354'); assert.deepEqual(element.dxList('instance').option('items'), [0, 1, 3, 5, 4]); element.dxList({ @@ -1077,7 +1078,7 @@ QUnit.module('options changed', moduleSetup, () => { } }); - assert.equal(element.text(), '26897'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '26897'); assert.deepEqual(element.dxList('instance').option('items'), [2, 6, 8, 9, 7]); }); @@ -1086,13 +1087,13 @@ QUnit.module('options changed', moduleSetup, () => { items: [0, 1, 3, 5, 4] }); - assert.equal(element.text(), '01354'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '01354'); element.dxList({ items: [2, 6, 8, 9, 7] }); - assert.equal(element.text(), '26897'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '26897'); }); QUnit.test('scrollingEnabled', function(assert) { @@ -2759,14 +2760,14 @@ QUnit.module('infinite list scenario', moduleSetup, () => { } }); - assert.equal(element.text(), '12', 'correct items generated'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '12', 'correct items generated'); assert.deepEqual(element.dxList('instance').option('items'), [1, 2], 'correct items presented in options'); element.find(`.${LIST_ITEM_CLASS}`).data('rendered', true); element.dxScrollView('instance').scrollBottom(); - assert.equal(element.text(), '1234', 'correct items generated'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '1234', 'correct items generated'); assert.deepEqual(element.dxList('instance').option('items'), [1, 2, 3, 4], 'correct items presented in options'); assert.strictEqual(element.find(`.${LIST_ITEM_CLASS}`).eq(0).data('rendered'), true, 'first item is not rerendered'); @@ -2875,7 +2876,7 @@ QUnit.module('infinite list scenario', moduleSetup, () => { element.dxScrollView('instance').scrollBottom(); - assert.equal(element.text(), '12', 'error occurred'); + assert.equal(element.find(`.${LIST_ITEMS_CLASS}`).text(), '12', 'error occurred'); assert.equal(element.dxList('instance')._startIndexForAppendedItems, null, 'flag set correctly'); }); @@ -4952,6 +4953,9 @@ if(devices.real().deviceType === 'desktop') { } QUnit.module('Accessibility', () => { + const getA11yStatus = (instance) => instance.$element().children('[role="status"]'); + const getItemsAnnouncement = (itemsCount) => `${messageLocalization.format('dxList-listAriaLabel')}: ${itemsCount}`; + QUnit.test('SelectAll checkbox should have aria-label="Select All" attribute', function(assert) { $('#list').dxList({ selectionMode: 'all', @@ -5378,4 +5382,104 @@ QUnit.module('Accessibility', () => { assert.strictEqual(instance.$element().find(`.${SCROLLVIEW_CONTENT_CLASS}`).eq(0).attr('role'), 'application'); }); }); + + QUnit.test('list should render a single visually hidden live region (T1334729)', function(assert) { + const instance = $('#list').dxList({ items: ['Item 1'] }).dxList('instance'); + + const $a11yStatus = getA11yStatus(instance); + + assert.strictEqual($a11yStatus.length, 1, 'the list has a single live region'); + assert.ok($a11yStatus.hasClass(SCREEN_READER_ONLY_CLASS), 'the live region is visually hidden'); + }); + + QUnit.test('item count should be announced (T1334729)', function(assert) { + const instance = $('#list').dxList({ items: ['Item 1', 'Item 2'] }).dxList('instance'); + + assert.strictEqual(getA11yStatus(instance).text(), getItemsAnnouncement(2)); + }); + + QUnit.test('item count of a grouped list should not count groups (T1334729)', function(assert) { + const instance = $('#list').dxList({ + grouped: true, + items: [ + { key: 'group 1', items: ['Item 1', 'Item 2'] }, + { key: 'group 2', items: ['Item 3'] }, + ], + }).dxList('instance'); + + assert.strictEqual(getA11yStatus(instance).text(), getItemsAnnouncement(3)); + }); + + QUnit.test('noDataText should be announced when the list is empty (T1334729)', function(assert) { + const instance = $('#list').dxList({ items: [] }).dxList('instance'); + + assert.strictEqual(getA11yStatus(instance).text(), messageLocalization.format('dxCollectionWidget-noDataText')); + }); + + QUnit.test('custom noDataText should be announced and follow its runtime change (T1334729)', function(assert) { + const instance = $('#list').dxList({ items: [], noDataText: 'custom-no-data' }).dxList('instance'); + + assert.strictEqual(getA11yStatus(instance).text(), 'custom-no-data', 'custom noDataText is announced'); + + instance.option('noDataText', 'another-no-data'); + + assert.strictEqual(getA11yStatus(instance).text(), 'another-no-data', 'announcement is updated after runtime change'); + }); + + QUnit.test('nothing should be announced when the list is empty and noDataText is empty (T1334729)', function(assert) { + const instance = $('#list').dxList({ items: [], noDataText: '' }).dxList('instance'); + + assert.strictEqual(getA11yStatus(instance).text(), ''); + }); + + QUnit.test('nothing should be announced while the initial data is loading (T1334729)', function(assert) { + const loadResult = $.Deferred(); + const instance = $('#list').dxList({ + dataSource: new CustomStore({ load: () => loadResult.promise(), loadMode: 'processed' }) + }).dxList('instance'); + + assert.strictEqual(getA11yStatus(instance).text(), '', 'nothing is announced while data is loading'); + + loadResult.resolve(['Item 1', 'Item 2']); + + assert.strictEqual(getA11yStatus(instance).text(), getItemsAnnouncement(2), 'item count is announced once data is loaded'); + }); + + QUnit.test('announcement should be cleared while new data is loading (T1334729)', function(assert) { + const instance = $('#list').dxList({ items: ['Item 1', 'Item 2', 'Item 3'] }).dxList('instance'); + const loadResult = $.Deferred(); + + instance.option('dataSource', new CustomStore({ load: () => loadResult.promise(), loadMode: 'processed' })); + + assert.strictEqual(getA11yStatus(instance).text(), '', + 'neither the outdated item count nor noDataText is announced while data is loading'); + + loadResult.resolve(['Item 1']); + + assert.strictEqual(getA11yStatus(instance).text(), getItemsAnnouncement(1), 'item count is announced once data is loaded'); + }); + + [true, false].forEach(repaintChangesOnly => { + QUnit.test(`noDataText should be announced when dataSource is cleared at runtime and repaintChangesOnly=${repaintChangesOnly} (T1334729)`, function(assert) { + const instance = $('#list').dxList({ dataSource: ['Item 1'], repaintChangesOnly }).dxList('instance'); + const a11yStatusElement = getA11yStatus(instance).get(0); + + instance.option('dataSource', []); + + assert.strictEqual(getA11yStatus(instance).get(0), a11yStatusElement, + 'the live region is not re-created, otherwise screen readers do not announce its text'); + assert.strictEqual(getA11yStatus(instance).text(), messageLocalization.format('dxCollectionWidget-noDataText')); + }); + + QUnit.test(`item count should be announced when dataSource is set at runtime and repaintChangesOnly=${repaintChangesOnly} (T1334729)`, function(assert) { + const instance = $('#list').dxList({ dataSource: [], repaintChangesOnly }).dxList('instance'); + const a11yStatusElement = getA11yStatus(instance).get(0); + + instance.option('dataSource', ['Item 1', 'Item 2']); + + assert.strictEqual(getA11yStatus(instance).get(0), a11yStatusElement, + 'the live region is not re-created, otherwise screen readers do not announce its text'); + assert.strictEqual(getA11yStatus(instance).text(), getItemsAnnouncement(2)); + }); + }); }); From d13c29e518170ce497b138d18a182f392179af50 Mon Sep 17 00:00:00 2001 From: dmlvr Date: Thu, 3 Sep 2026 16:26:52 +0300 Subject: [PATCH 2/2] update snapshots --- .../options.integration.test.ts.snap | 54 +++++++++++++++++++ .../view.integration.test.tsx.snap | 12 +++++ 2 files changed, 66 insertions(+) diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/__snapshots__/options.integration.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/__snapshots__/options.integration.test.ts.snap index 6b48d20c0743..4ed2f2e07526 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/__snapshots__/options.integration.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/__snapshots__/options.integration.test.ts.snap @@ -267,6 +267,12 @@ exports[`Options Column.HeaderFilter dataSource: custom dataSource 1`] = ` style="width: 222px; height: 90px;" />
+
+ Items: 2 +
+
+ Items: 2 +
+
+ Items: 2 +
+
+ Items: 2 +
+
+ Items: 5 +
+
+ Items: 5 +
+
+ Items: 5 +
+
+ Items: 1 +
+
+ Items: 1 +
+
+ Items: 5 +
+
+ Items: 5 +