diff --git a/e2e/testcafe-devextreme/tests/accessibility/list.ts b/e2e/testcafe-devextreme/tests/accessibility/list.ts index ab16f80f5922..fefba67ef7c7 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/list.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/list.ts @@ -35,7 +35,7 @@ const configurationWithSimpleItems: Configuration = { testAccessibility(configurationWithSimpleItems); -const groupedItems = [[ +const groupedItems = [ { key: 'Mr. John Heart', items: ['Choose between PPO and HMO Health Plan', 'Google AdWords Strategy', 'New Brochures', 'Update NDA Agreement', 'Review Product Recall Report by Engineering Team'], @@ -64,7 +64,7 @@ const groupedItems = [[ key: 'Dr. Kent Samuelson', items: ['Update Sales Strategy Documents', 'Review Revenue Projections', 'Refund Request Template'], }, -]]; +]; const options: Options = { dataSource: [groupedItems], diff --git a/packages/devextreme/js/__internal/ui/list/m_list.base.ts b/packages/devextreme/js/__internal/ui/list/m_list.base.ts index 65807b49f01e..3d06625d4b13 100644 --- a/packages/devextreme/js/__internal/ui/list/m_list.base.ts +++ b/packages/devextreme/js/__internal/ui/list/m_list.base.ts @@ -704,14 +704,20 @@ export const ListBase = CollectionWidget.inherit({ _collapseGroupHandler($group, toggle) { const deferred = Deferred(); - if ($group.hasClass(LIST_GROUP_COLLAPSED_CLASS) === toggle) { + const $groupHeader = $group.children(`.${LIST_GROUP_HEADER_CLASS}`); + const collapsed = $group.hasClass(LIST_GROUP_COLLAPSED_CLASS); + + this._updateGroupHeaderAriaExpanded($groupHeader, collapsed); + + if (collapsed === toggle) { return deferred.resolve(); } const $groupBody = $group.children(`.${LIST_GROUP_BODY_CLASS}`); - const startHeight = getOuterHeight($groupBody); + let endHeight = 0; + if (startHeight === 0) { setHeight($groupBody, 'auto'); endHeight = getOuterHeight($groupBody); @@ -766,18 +772,17 @@ export const ListBase = CollectionWidget.inherit({ }, _setListAria() { - const { items, allowItemDeleting } = this.option(); + const { items, allowItemDeleting, collapsibleGroups } = this.option(); const label = allowItemDeleting ? messageLocalization.format('dxList-listAriaLabel-deletable') : messageLocalization.format('dxList-listAriaLabel'); - const listArea = items?.length ? { - role: 'listbox', - label, - } : { - role: undefined, - label: undefined, + const shouldSetAria = items?.length && !collapsibleGroups; + + const listArea = { + role: shouldSetAria ? 'listbox' : undefined, + label: shouldSetAria ? label : undefined, }; this.setAria(listArea, this._$listContainer); @@ -852,27 +857,67 @@ export const ListBase = CollectionWidget.inherit({ } }, - _renderGroup(index, group) { - const $groupElement = $('
') - .addClass(LIST_GROUP_CLASS) - .appendTo(this._getItemsContainer()); + _setGroupAria($group, groupHeaderId): void { + const { collapsibleGroups } = this.option(); - const id = `dx-${new Guid().toString()}`; const groupAria = { - role: 'group', + role: collapsibleGroups ? undefined : 'group', + // eslint-disable-next-line spellcheck/spell-checker + labelledby: collapsibleGroups ? undefined : groupHeaderId, + }; + + this.setAria(groupAria, $group); + }, + + _updateGroupHeaderAriaExpanded($groupHeader, expanded): void { + this.setAria({ expanded }, $groupHeader); + }, + + _setGroupHeaderAria($groupHeader, listGroupBodyId): void { + const { collapsibleGroups } = this.option(); + + const groupHeaderAria = { + role: collapsibleGroups ? 'button' : undefined, + expanded: collapsibleGroups ? true : undefined, + controls: collapsibleGroups ? listGroupBodyId : undefined, + }; + + this.setAria(groupHeaderAria, $groupHeader); + }, + + _setGroupBodyAria($groupBody, groupHeaderId): void { + const { collapsibleGroups } = this.option(); + + const groupHeaderAria = { + role: collapsibleGroups ? 'listbox' : undefined, // eslint-disable-next-line spellcheck/spell-checker - labelledby: id, + labelledby: collapsibleGroups ? groupHeaderId : undefined, }; - this.setAria(groupAria, $groupElement); + this.setAria(groupHeaderAria, $groupBody); + }, + + _renderGroup(index, group) { + const $groupElement = $('
') + .addClass(LIST_GROUP_CLASS) + .appendTo(this._getItemsContainer()); + + const groupHeaderId = `dx-${new Guid().toString()}`; const $groupHeaderElement = $('
') .addClass(LIST_GROUP_HEADER_CLASS) - .attr('id', id) + .attr('id', groupHeaderId) .appendTo($groupElement); - const groupTemplateName = this.option('groupTemplate'); - const groupTemplate = this._getTemplate(group.template || groupTemplateName, group, index, $groupHeaderElement); + const { groupTemplate: templateName } = this.option(); + + const groupTemplate = this._getTemplate( + group.template || templateName, + group, + index, + $groupHeaderElement, + ); + const renderArgs = { index, itemData: group, @@ -887,9 +932,13 @@ export const ListBase = CollectionWidget.inherit({ this._renderingGroupIndex = index; + const groupBodyId = `dx-${new Guid().toString()}`; + const $groupBody = $('
') .addClass(LIST_GROUP_BODY_CLASS) + .attr('id', groupBodyId) .appendTo($groupElement); + // @ts-expect-error each(groupItemsGetter(group) || [], (itemIndex, item) => { this._renderItem({ group: index, item: itemIndex }, item, $groupBody); @@ -900,6 +949,10 @@ export const ListBase = CollectionWidget.inherit({ groupIndex: index, groupData: group, }); + + this._setGroupAria($groupElement, groupHeaderId); + this._setGroupHeaderAria($groupHeaderElement, groupBodyId); + this._setGroupBodyAria($groupBody, groupHeaderId); }, downInkRippleHandler(e) { 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 c83e7f787ba8..eef1258a5aac 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/listParts/commonTests.js @@ -4628,4 +4628,183 @@ QUnit.module('Accessibility', () => { $switchableButton = $(`.${SWITCHABLE_DELETE_BUTTON_CLASS}`); checkButtonAttributes(assert, $switchableButton); }); + + [true, false].forEach(collapsibleGroups => { + QUnit.test(`Grouped list items element should have correct aria if collapsibleGroups: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + + const $listItems = $element.find(`.${LIST_ITEMS_CLASS}`); + + const expectedListItemsAria = { + role: collapsibleGroups ? undefined : 'listbox', + 'aria-label': collapsibleGroups ? undefined : 'Items', + }; + + assert.strictEqual($listItems.attr('role'), expectedListItemsAria.role, 'role is correct'); + assert.strictEqual($listItems.attr('aria-label'), expectedListItemsAria['aria-label'], 'aria-label is correct'); + }); + + QUnit.test(`Group element should have correct aria if collapsibleGroups: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + + const $groupElement = $element.find(`.${LIST_GROUP_CLASS}`); + + const expectedGroupAria = { + role: collapsibleGroups ? undefined : 'group', + 'aria-labelledby': collapsibleGroups ? false : true, + }; + + assert.strictEqual($groupElement.attr('role'), expectedGroupAria.role, 'role is correct'); + assert.strictEqual(!!$groupElement.attr('aria-labelledby'), expectedGroupAria['aria-labelledby'], 'aria-labelledby is correct'); + }); + + QUnit.test(`Group header element should have correct aria if collapsibleGroups: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + + const $groupHeader = $element.find(`.${LIST_GROUP_HEADER_CLASS}`); + + const expectedGroupHeaderAria = { + role: collapsibleGroups ? 'button' : undefined, + 'aria-expanded': collapsibleGroups ? 'true' : undefined, + 'aria-controls': collapsibleGroups ? true : false, + }; + + assert.strictEqual($groupHeader.attr('role'), expectedGroupHeaderAria.role, 'role is correct'); + assert.strictEqual($groupHeader.attr('aria-expanded'), expectedGroupHeaderAria['aria-expanded'], 'aria-expanded is correct'); + assert.strictEqual(!!$groupHeader.attr('aria-controls'), expectedGroupHeaderAria['aria-controls'], 'aria-controls is correct'); + }); + + QUnit.test(`Group body element should have correct aria if collapsibleGroups: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + + const $groupBody = $element.find(`.${LIST_GROUP_BODY_CLASS}`); + + const expectedGroupBodyAria = { + role: collapsibleGroups ? 'listbox' : undefined, + 'aria-labelledby': collapsibleGroups ? true : false, + }; + + assert.strictEqual($groupBody.attr('role'), expectedGroupBodyAria.role, 'role is correct'); + assert.strictEqual(!!$groupBody.attr('aria-labelledby'), expectedGroupBodyAria['aria-labelledby'], 'aria-labelledby is correct'); + }); + + QUnit.test(`Grouped list items element should have correct aria if collapsibleGroups is changed in runtime, init value: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + const instance = $element.dxList('instance'); + + instance.option('collapsibleGroups', !collapsibleGroups); + + const $listItems = $element.find(`.${LIST_ITEMS_CLASS}`); + + const expectedListItemsAria = { + role: collapsibleGroups ? 'listbox' : undefined, + 'aria-label': collapsibleGroups ? 'Items' : undefined, + }; + + assert.strictEqual($listItems.attr('role'), expectedListItemsAria.role, 'role is correct'); + assert.strictEqual($listItems.attr('aria-label'), expectedListItemsAria['aria-label'], 'aria-label is correct'); + }); + + QUnit.test(`Group element should have correct aria if collapsibleGroups is changed in runtime, init value: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + const instance = $element.dxList('instance'); + + instance.option('collapsibleGroups', !collapsibleGroups); + + const $groupElement = $element.find(`.${LIST_GROUP_CLASS}`); + + const expectedGroupAria = { + role: collapsibleGroups ? 'group' : undefined, + 'aria-labelledby': collapsibleGroups ? true : false, + }; + + assert.strictEqual($groupElement.attr('role'), expectedGroupAria.role, 'role is correct'); + assert.strictEqual(!!$groupElement.attr('aria-labelledby'), expectedGroupAria['aria-labelledby'], 'aria-labelledby is correct'); + }); + + QUnit.test(`Group header element should have correct aria if collapsibleGroups is changed in runtime, init value: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + const instance = $element.dxList('instance'); + + instance.option('collapsibleGroups', !collapsibleGroups); + + const $groupHeader = $element.find(`.${LIST_GROUP_HEADER_CLASS}`); + + const expectedGroupHeaderAria = { + role: collapsibleGroups ? undefined : 'button', + 'aria-expanded': collapsibleGroups ? undefined : 'true', + 'aria-controls': collapsibleGroups ? false : true, + }; + + assert.strictEqual($groupHeader.attr('role'), expectedGroupHeaderAria.role, 'role is correct'); + assert.strictEqual($groupHeader.attr('aria-expanded'), expectedGroupHeaderAria['aria-expanded'], 'aria-expanded is correct'); + assert.strictEqual(!!$groupHeader.attr('aria-controls'), expectedGroupHeaderAria['aria-controls'], 'aria-controls is correct'); + }); + + QUnit.test(`Group body element should have correct aria if collapsibleGroups is changed in runtime, init value: ${collapsibleGroups}`, function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups, + }); + const instance = $element.dxList('instance'); + + instance.option('collapsibleGroups', !collapsibleGroups); + + const $groupBody = $element.find(`.${LIST_GROUP_BODY_CLASS}`); + + const expectedGroupBodyAria = { + role: collapsibleGroups ? undefined : 'listbox', + 'aria-labelledby': collapsibleGroups ? false : true, + }; + + assert.strictEqual($groupBody.attr('role'), expectedGroupBodyAria.role, 'role is correct'); + assert.strictEqual(!!$groupBody.attr('aria-labelledby'), expectedGroupBodyAria['aria-labelledby'], 'aria-labelledby is correct'); + }); + }); + + QUnit.test('Group header element should have correct aria-expanded after click if collapsibleGroups: true', function(assert) { + const $element = $('#list').dxList({ + dataSource: [{ key: 1, items: [1] }], + grouped: true, + collapsibleGroups: true, + }); + + const $groupHeader = $element.find(`.${LIST_GROUP_HEADER_CLASS}`); + assert.strictEqual($groupHeader.attr('aria-expanded'), 'true', 'aria-expanded is correct'); + + $groupHeader.trigger('dxclick'); + assert.strictEqual($groupHeader.attr('aria-expanded'), 'false', 'aria-expanded is changed'); + + $groupHeader.trigger('dxclick'); + assert.strictEqual($groupHeader.attr('aria-expanded'), 'true', 'aria-expanded is changed'); + }); });