diff --git a/packages/devextreme/js/__internal/ui/list/list.base.ts b/packages/devextreme/js/__internal/ui/list/list.base.ts index d3411be672fd..3e75a7189db2 100644 --- a/packages/devextreme/js/__internal/ui/list/list.base.ts +++ b/packages/devextreme/js/__internal/ui/list/list.base.ts @@ -997,10 +997,25 @@ export class ListBase extends CollectionWidget { const isEmpty = super._renderEmptyMessage(rootNodes); this.setAria({ role: isEmpty ? undefined : 'application' }, this._focusTarget()); + this._updateEmptyMessageTabStop(); return isEmpty; } + _renderFocusTarget(): void { + super._renderFocusTarget(); + + this._updateEmptyMessageTabStop(); + } + + _updateEmptyMessageTabStop(): void { + const { tabIndex } = this.option(); + + if (isDefined(tabIndex)) { + this._focusTarget().attr('tabIndex', this._$noData ? 0 : tabIndex); + } + } + _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..8582b8b96a6d 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 @@ -72,6 +72,7 @@ const POPUP_TITLE_CLASS = 'dx-popup-title'; const POPUP_CONTENT_CLASS = 'dx-popup-content'; const LIST_CLASS = 'dx-list'; +const EMPTY_MESSAGE_CLASS = 'dx-empty-message'; const LIST_ITEM_CLASS = 'dx-list-item'; const LIST_ITEM_SELECTED_CLASS = 'dx-list-item-selected'; const LIST_GROUP_HEADER_CLASS = 'dx-list-group-header'; @@ -299,6 +300,33 @@ QUnit.module('Lookup', { assert.equal(loadCalledCount, 3, 'Loading dataSource count is OK'); }); + QUnit.test('noDataText should be reachable with the tab key if search returns no items (T1334729)', function(assert) { + const instance = $('#thirdLookup').dxLookup({ + dataSource: [1, 2, 3], + searchTimeout: 0, + 'dropDownOptions.animation': null, + opened: true + }).dxLookup('instance'); + + const getItemContainer = () => $(`.${LIST_CLASS} .${SCROLL_VIEW_CONTENT_CLASS}`).eq(0); + + assert.strictEqual(getItemContainer().attr('tabindex'), '-1', 'list is not a tab stop while it has items'); + + instance._searchBox.option('value', '4'); + this.clock.tick(0); + + assert.strictEqual($(`.${LIST_CLASS} .${EMPTY_MESSAGE_CLASS}`).length, 1, 'no data message is rendered'); + assert.strictEqual(getItemContainer().attr('tabindex'), '0', + 'empty list is a tab stop, so screen readers announce its content on the tab key press'); + assert.strictEqual(getItemContainer().text(), messageLocalization.format('dxCollectionWidget-noDataText'), + 'the tab stop exposes noDataText to screen readers'); + + instance._searchBox.option('value', '1'); + this.clock.tick(0); + + assert.strictEqual(getItemContainer().attr('tabindex'), '-1', 'tab stop is removed once items are found'); + }); + QUnit.test('onContentReady fire with lookup\'s option \'minSearchLength\' at first show (Q575560)', function(assert) { let count = 0; this.element @@ -3776,7 +3804,8 @@ if(devices.real().deviceType === 'desktop') { }; const listItemContainerAttributes = { - tabindex: searchEnabled ? '-1' : '0', + // NOTE: the widget has no items, so the empty message is a tab stop (T1334729) + tabindex: '0', }; let fieldAttributes = { @@ -3820,7 +3849,8 @@ if(devices.real().deviceType === 'desktop') { helper.widget.option('searchEnabled', newSearchEnabled); listAttributes.id = helper.widget._listId; - listItemContainerAttributes.tabindex = newSearchEnabled ? '-1' : '0'; + // NOTE: the widget still has no items, so the empty message stays a tab stop (T1334729) + listItemContainerAttributes.tabindex = '0'; fieldAttributes = { role: 'combobox', @@ -3933,7 +3963,8 @@ if(devices.real().deviceType === 'desktop') { const $scrollView = $list.find(`.${SCROLL_VIEW_CONTENT_CLASS}`); const $itemsContainer = $list.find(`.${LIST_ITEMS_CLASS}`); - helper.checkAttributes($scrollView, { tabindex: '-1' }); + // NOTE: an empty list is a tab stop, so screen readers announce noDataText (T1334729) + helper.checkAttributes($scrollView, { tabindex: '0' }); helper.checkAttributes($itemsContainer, { }); helper.widget.option(dataSourcePropertyName, [1, 2, 3]); @@ -3941,7 +3972,7 @@ if(devices.real().deviceType === 'desktop') { helper.checkAttributes($itemsContainer, { 'aria-label': 'Items', role: 'listbox' }); helper.widget.option(dataSourcePropertyName, []); - helper.checkAttributes($scrollView, { tabindex: '-1' }); + helper.checkAttributes($scrollView, { tabindex: '0' }); helper.checkAttributes($itemsContainer, { }); }); }); 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..4b43afd63a24 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js @@ -5378,4 +5378,44 @@ QUnit.module('Accessibility', () => { assert.strictEqual(instance.$element().find(`.${SCROLLVIEW_CONTENT_CLASS}`).eq(0).attr('role'), 'application'); }); }); + + const getTabIndex = (instance) => instance.$element().find(`.${SCROLLVIEW_CONTENT_CLASS}`).eq(0).attr('tabindex'); + + [true, false].forEach(repaintChangesOnly => { + QUnit.test(`empty list should be a tab stop when dataSource is empty on init and repaintChangesOnly=${repaintChangesOnly} (T1334729)`, function(assert) { + const instance = $('#list').dxList({ dataSource: [], tabIndex: -1, repaintChangesOnly }).dxList('instance'); + + assert.strictEqual(getTabIndex(instance), '0', + 'empty message is reachable with the tab key, so screen readers announce it'); + }); + + QUnit.test(`list should keep the configured tabIndex when dataSource has items on init and repaintChangesOnly=${repaintChangesOnly} (T1334729)`, function(assert) { + const instance = $('#list').dxList({ dataSource: ['Item 1'], tabIndex: -1, repaintChangesOnly }).dxList('instance'); + + assert.strictEqual(getTabIndex(instance), '-1'); + }); + + QUnit.test(`list should become a tab stop when dataSource is cleared at runtime and repaintChangesOnly=${repaintChangesOnly} (T1334729)`, function(assert) { + const instance = $('#list').dxList({ dataSource: ['Item 1'], tabIndex: -1, repaintChangesOnly }).dxList('instance'); + + instance.option('dataSource', []); + + assert.strictEqual(getTabIndex(instance), '0'); + }); + + QUnit.test(`configured tabIndex should be restored when dataSource is set at runtime and repaintChangesOnly=${repaintChangesOnly} (T1334729)`, function(assert) { + const instance = $('#list').dxList({ dataSource: [], tabIndex: -1, repaintChangesOnly }).dxList('instance'); + + instance.option('dataSource', ['Item 1']); + + assert.strictEqual(getTabIndex(instance), '-1'); + }); + }); + + QUnit.test('empty list should not become a tab stop if the list can not have focus (T1334729)', function(assert) { + const instance = $('#list').dxList({ dataSource: [], tabIndex: null }).dxList('instance'); + + assert.strictEqual(getTabIndex(instance), undefined, + 'lists that must not receive focus, such as the SelectBox drop-down list, stay unfocusable'); + }); });