From 60641cb9e765b54d4b21893b2e17704f621c8663 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 14:52:43 +0400 Subject: [PATCH 1/2] Tabs: label icon-only tabs after their icon, as Button does (#5077) --- apps/demos/testing/common.test.ts | 4 - .../tests/accessibility/tabPanel.ts | 4 +- .../tests/accessibility/tabs.ts | 4 +- .../js/__internal/core/utils/m_icon.ts | 24 ++++ .../js/__internal/ui/button/button.tsx | 33 +---- .../js/__internal/ui/tab_panel/tab_panel.ts | 9 +- .../devextreme/js/__internal/ui/tabs/tabs.ts | 33 ++++- .../tests/DevExpress.core/utils.icon.tests.js | 35 +++++ .../tabPanel.markup.tests.js | 92 +++++++++++++ .../tabs.markup.tests.js | 130 ++++++++++++++++++ 10 files changed, 328 insertions(+), 40 deletions(-) diff --git a/apps/demos/testing/common.test.ts b/apps/demos/testing/common.test.ts index 40c4cd51fe31..192deec511ad 100644 --- a/apps/demos/testing/common.test.ts +++ b/apps/demos/testing/common.test.ts @@ -111,10 +111,6 @@ const getIgnoredRules = (testName) => { 'Gantt-Validation': ['aria-required-parent', 'aria-valid-attr-value'], 'Localization-UsingGlobalize': ['label'], - - // Icon-only tabs render no text, so they have no accessible name. - // Naming them needs Tabs widget support (like Button labels icon-only buttons). - 'Tabs-Overview': ['aria-tab-name'], }; return [ diff --git a/e2e/testcafe-devextreme/tests/accessibility/tabPanel.ts b/e2e/testcafe-devextreme/tests/accessibility/tabPanel.ts index 33872a37b182..ae6dd7dc7d9b 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/tabPanel.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/tabPanel.ts @@ -14,7 +14,9 @@ const items: Item[] = [ { title: 'Olivia Peyton', text: 'Olivia Peyton' }, { title: 'Ed Holmes', text: 'Ed Holmes' }, { title: 'Wally Hobbs', text: 'Wally Hobbs' }, - { title: 'Brad Jameson', text: 'Brad Jameson' }, + { title: 'Brad Jameson', text: 'Brad Jameson', icon: 'user' }, + { icon: 'chart', text: 'Chart' }, + { icon: 'find', badge: '3', text: 'Find' }, ]; const options: Options = { diff --git a/e2e/testcafe-devextreme/tests/accessibility/tabs.ts b/e2e/testcafe-devextreme/tests/accessibility/tabs.ts index 9000466ab201..e156e60a4f15 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/tabs.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/tabs.ts @@ -14,7 +14,9 @@ const items: Item[] = [ { text: 'Olivia Peyton' }, { text: 'Ed Holmes' }, { text: 'Wally Hobbs' }, - { text: 'Brad Jameson' }, + { text: 'Brad Jameson', icon: 'user' }, + { icon: 'chart' }, + { icon: 'find', badge: '3' }, ]; const options: Options = { diff --git a/packages/devextreme/js/__internal/core/utils/m_icon.ts b/packages/devextreme/js/__internal/core/utils/m_icon.ts index 41e144fa31e9..2b1fb5e467bb 100644 --- a/packages/devextreme/js/__internal/core/utils/m_icon.ts +++ b/packages/devextreme/js/__internal/core/utils/m_icon.ts @@ -1,8 +1,14 @@ +import messageLocalization from '@js/common/core/localization/message'; import $ from '@js/core/renderer'; +import { camelize } from '@js/core/utils/inflector'; export const ICON_CLASS = 'dx-icon'; const SVG_ICON_CLASS = 'dx-svg-icon'; +const NOT_URL_REGEXP = /^(?!(?:https?:\/\/)|(?:ftp:\/\/)|(?:www\.))[^\s]+$/; +const FILE_NAME_REGEXP = /.+\/([^.]+)\..+$/; +const SVG_TITLE_REGEXP = /(.*?)<\/title>/; + export const getImageSourceType = (source) => { if (!source || typeof source !== 'string') { return false; @@ -41,3 +47,21 @@ export const getImageContainer = (source) => { return null; } }; + +export const getImageAriaLabel = (source: string): string => { + switch (getImageSourceType(source)) { + case 'image': { + const isPathToImage = !source.includes('base64') && NOT_URL_REGEXP.test(source); + + return isPathToImage ? source.replace(FILE_NAME_REGEXP, '$1') : ''; + } + case 'dxIcon': + return messageLocalization.format(camelize(source, true)) || source; + case 'fontIcon': + return source; + case 'svg': + return SVG_TITLE_REGEXP.exec(source)?.[1] ?? ''; + default: + return ''; + } +}; diff --git a/packages/devextreme/js/__internal/ui/button/button.tsx b/packages/devextreme/js/__internal/ui/button/button.tsx index e9b8ef9ce442..85f0dc9916c5 100644 --- a/packages/devextreme/js/__internal/ui/button/button.tsx +++ b/packages/devextreme/js/__internal/ui/button/button.tsx @@ -1,10 +1,7 @@ import { click } from '@js/common/core/events/short'; -import messageLocalization from '@js/common/core/localization/message'; import devices from '@js/core/devices'; import type { DefaultOptionsRule } from '@js/core/options/utils'; import { convertRulesToOptions, createDefaultOptionRules } from '@js/core/options/utils'; -import { getImageSourceType } from '@js/core/utils/icon'; -import { camelize } from '@js/core/utils/inflector'; import type { DxEvent } from '@js/events'; import type { Properties as ButtonProperties, TemplateData } from '@js/ui/button.d'; import { current, isMaterial } from '@js/ui/themes'; @@ -16,6 +13,7 @@ import type { EffectReturn } from '@ts/core/r1/utils/effect_return'; import { getTemplate } from '@ts/core/r1/utils/index'; import { Widget } from '@ts/core/r1/widget'; import { combineClasses } from '@ts/core/utils/combine_classes'; +import { getImageAriaLabel } from '@ts/core/utils/m_icon'; import { createRef as infernoCreateRef } from 'inferno'; import { Icon } from './icon'; @@ -229,34 +227,7 @@ export class Button extends InfernoWrapperComponent<ButtonProps> { get aria(): Record<string, string> { const { icon, text } = this.props; - let label = text ?? ''; - - if (!text && icon) { - const iconSource = getImageSourceType(icon); - - switch (iconSource) { - case 'image': - { - const notURLRegexp = /^(?!(?:https?:\/\/)|(?:ftp:\/\/)|(?:www\.))[^\s]+$/; - const isPathToImage = !icon.includes('base64') && notURLRegexp.test(icon); - label = isPathToImage ? icon.replace(/.+\/([^.]+)\..+$/, '$1') : ''; - break; - } - case 'dxIcon': - label = messageLocalization.format(camelize(icon, true)) || icon; - break; - case 'fontIcon': - label = icon; - break; - case 'svg': { - const titleRegexp = /<title>(.*?)<\/title>/; - label = titleRegexp.exec(icon)?.[1] ?? ''; - break; - } - default: - break; - } - } + const label = !text && icon ? getImageAriaLabel(icon) : (text ?? ''); return { role: 'button', diff --git a/packages/devextreme/js/__internal/ui/tab_panel/tab_panel.ts b/packages/devextreme/js/__internal/ui/tab_panel/tab_panel.ts index 0022f9aa56d5..827c7cdbe7ad 100644 --- a/packages/devextreme/js/__internal/ui/tab_panel/tab_panel.ts +++ b/packages/devextreme/js/__internal/ui/tab_panel/tab_panel.ts @@ -12,11 +12,11 @@ import { getPublicElement } from '@js/core/element'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { BindableTemplate } from '@js/core/templates/bindable_template'; -import { getImageContainer } from '@js/core/utils/icon'; import { isDefined, isPlainObject } from '@js/core/utils/type'; import type { DxEvent } from '@js/events'; import type { Item, Properties } from '@js/ui/tab_panel'; import { current as currentTheme, isFluent, isMaterialBased } from '@js/ui/themes'; +import { getImageAriaLabel, getImageContainer } from '@ts/core/utils/m_icon'; import supportUtils from '@ts/core/utils/m_support'; import type { OptionChanged } from '@ts/core/widget/types'; import type { MultiViewProperties } from '@ts/ui/multi_view/multi_view'; @@ -388,6 +388,13 @@ class TabPanel extends MultiView<TabPanelProperties> { iconPosition, stylingMode, _itemAttributes: { class: TABPANEL_TABS_ITEM_CLASS }, + _itemAriaLabelExpr: (data: Item): string | undefined => { + if (data?.title || !data?.icon) { + return undefined; + } + + return getImageAriaLabel(data.icon); + }, _indicatorPosition: tabsIndicatorPosition, }; } diff --git a/packages/devextreme/js/__internal/ui/tabs/tabs.ts b/packages/devextreme/js/__internal/ui/tabs/tabs.ts index 9a8d09132c7e..c296fceef4d1 100644 --- a/packages/devextreme/js/__internal/ui/tabs/tabs.ts +++ b/packages/devextreme/js/__internal/ui/tabs/tabs.ts @@ -9,7 +9,6 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import resizeObserverSingleton from '@js/core/resize_observer'; import { BindableTemplate } from '@js/core/templates/bindable_template'; -import { getImageContainer } from '@js/core/utils/icon'; import { each } from '@js/core/utils/iterator'; import { getHeight, getOuterWidth, getWidth } from '@js/core/utils/size'; import { isDefined, isPlainObject } from '@js/core/utils/type'; @@ -27,8 +26,13 @@ import type { } from '@js/ui/tabs'; import { current as currentTheme, isFluent, isMaterial } from '@js/ui/themes'; import { render } from '@ts/core/utils/ink_ripple'; +import { getImageAriaLabel, getImageContainer } from '@ts/core/utils/m_icon'; import type { OptionChanged } from '@ts/core/widget/types'; -import type { CollectionItemInfo, InkRippleEvent } from '@ts/ui/collection/collection_widget.base'; +import type { + CollectionItemInfo, + InkRippleEvent, + PostprocessRenderItemInfo, +} from '@ts/ui/collection/collection_widget.base'; import type { CollectionWidgetLiveUpdateProperties } from '@ts/ui/collection/collection_widget.live_update'; import Scrollable from '@ts/ui/scroll_view/scrollable'; import { @@ -136,6 +140,8 @@ export interface TabsProperties extends Properties, Omit< badgeExpr?: (data) => string | undefined; + _itemAriaLabelExpr?: (data: Item) => string | undefined; + _indicatorPosition?: Position | null; } @@ -180,6 +186,13 @@ class Tabs extends CollectionWidgetLiveUpdate<TabsProperties> { badgeExpr(data: Item): string | undefined { return data?.badge; }, + _itemAriaLabelExpr(data: Item): string | undefined { + if (data?.text || data?.html || !data?.icon) { + return undefined; + } + + return getImageAriaLabel(data.icon); + }, _itemAttributes: { role: 'tab' }, _indicatorPosition: null, }; @@ -318,6 +331,22 @@ class Tabs extends CollectionWidgetLiveUpdate<TabsProperties> { this._renderScrolling(); } + _postprocessRenderItem(args: PostprocessRenderItemInfo<Item>): void { + super._postprocessRenderItem(args); + + this._renderItemAriaLabel(args); + } + + _renderItemAriaLabel({ itemData, itemElement }: PostprocessRenderItemInfo<Item>): void { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { _itemAriaLabelExpr } = this.option(); + const label = _itemAriaLabelExpr?.(itemData); + + if (label) { + this.setAria('label', label, itemElement); + } + } + _renderScrolling(): void { const removeClasses = [TABS_STRETCHED_CLASS, TABS_EXPANDED_CLASS, OVERFLOW_HIDDEN_CLASS]; this.$element().removeClass(removeClasses.join(' ')); diff --git a/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js b/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js index 2956a9598ff1..af0c789bd4d1 100644 --- a/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js @@ -1,4 +1,7 @@ import { getImageSourceType, getImageContainer } from 'core/utils/icon'; +import { getImageAriaLabel } from '__internal/core/utils/m_icon'; +import localization from 'localization'; +import ja from 'localization/messages/ja.json!'; const { module: testModule, test } = QUnit; @@ -144,3 +147,35 @@ testModule('icon utils', { }); }); }); + +testModule('getImageAriaLabel', () => { + [ + { source: 'close', expected: 'Close', description: 'a dxIcon with a localized message' }, + { source: 'iconName', expected: 'iconName', description: 'a dxIcon without a localized message' }, + { source: 'fa fa-home', expected: 'fa fa-home', description: 'a font icon' }, + { source: '/path/file.png', expected: 'file', description: 'a path to an image' }, + { source: 'https://example.com/path/file.png', expected: '', description: 'an image URL' }, + { source: 'www.example.com/file.png', expected: '', description: 'an image URL without a protocol' }, + { source: 'data:image/png;base64,qwerty', expected: '', description: 'a base64 image' }, + { source: '<svg><title>Svg title', expected: 'Svg title', description: 'an svg with a title' }, + { source: '', expected: '', description: 'an svg without a title' }, + { source: '', expected: '', description: 'an empty string' }, + ].forEach(({ source, expected, description }) => { + test(`should return "${expected}" for ${description}`, function(assert) { + assert.strictEqual(getImageAriaLabel(source), expected); + }); + }); + + test('should localize the dxIcon name with the current locale', function(assert) { + const defaultLocale = localization.locale(); + + try { + localization.loadMessages(ja); + localization.locale('ja'); + + assert.strictEqual(getImageAriaLabel('close'), '閉じる'); + } finally { + localization.locale(defaultLocale); + } + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.markup.tests.js index 738a7c0e6c4b..0c3a12da8732 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabPanel.markup.tests.js @@ -202,4 +202,96 @@ QUnit.module('aria accessibility', () => { instance.option('focusedElement', $element.find('.dx-item:eq(1)')); assert.equal($element.attr('aria-activedescendant'), undefined, 'aria-activedescendant does not exist after selection update'); }); + + QUnit.module('aria-label of tabs', { + beforeEach: function() { + this.createTabPanel = (options) => { + this.$element = $('
').appendTo('#qunit-fixture').dxTabPanel(options); + this.instance = this.$element.dxTabPanel('instance'); + }; + this.getTab = (index) => this.$element.find(`.${TABS_ITEM_CLASS}`).eq(index); + } + }, () => { + QUnit.test('tab with title and icon should not get aria-label', function(assert) { + this.createTabPanel({ items: [{ title: 'User', icon: 'user' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + QUnit.test('string item should not get aria-label', function(assert) { + this.createTabPanel({ items: ['User'] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + QUnit.test('tab of the item without title and icon should not get aria-label', function(assert) { + this.createTabPanel({ items: [{ text: 'User content' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + [ + { icon: 'user', expected: 'user', description: 'a dxIcon without a localized message' }, + { icon: 'close', expected: 'Close', description: 'a dxIcon with a localized message' }, + { icon: '/path/file.png', expected: 'file', description: 'a path to an image' }, + ].forEach(({ icon, expected, description }) => { + QUnit.test(`icon-only tab should get aria-label "${expected}" for ${description}`, function(assert) { + this.createTabPanel({ items: [{ icon }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), expected); + }); + }); + + QUnit.test('icon-only tab should get aria-label even if the item has content text or html', function(assert) { + this.createTabPanel({ + items: [ + { icon: 'user', text: 'User content' }, + { icon: 'find', html: 'Find content' }, + ], + }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user', 'item with text'); + assert.strictEqual(this.getTab(1).attr('aria-label'), 'find', 'item with html'); + }); + + QUnit.test('icon-only tab with badge should get aria-label derived from icon', function(assert) { + this.createTabPanel({ items: [{ icon: 'user', badge: '5' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user'); + }); + + QUnit.test('aria-label should be derived from item data when a custom itemTitleTemplate is used', function(assert) { + this.createTabPanel({ + items: [{ icon: 'user' }, { icon: 'user', title: 'User' }], + itemTitleTemplate: (data) => ``, + }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user', 'icon-only item'); + assert.strictEqual(this.getTab(1).attr('aria-label'), undefined, 'item with title'); + }); + + [true, false].forEach((repaintChangesOnly) => { + QUnit.test(`aria-label should follow item title and icon changes, repaintChangesOnly: ${repaintChangesOnly}`, function(assert) { + this.createTabPanel({ items: [{ title: 'User', icon: 'user' }], repaintChangesOnly }); + + this.instance.option('items[0].title', ''); + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user', 'label appears when title is cleared'); + + this.instance.option('items[0].icon', 'close'); + assert.strictEqual(this.getTab(0).attr('aria-label'), 'Close', 'label follows the icon'); + + this.instance.option('items[0].title', 'Close'); + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined, 'label is removed when title is set'); + }); + + QUnit.test(`aria-label should be recalculated when items are replaced, repaintChangesOnly: ${repaintChangesOnly}`, function(assert) { + this.createTabPanel({ items: [{ icon: 'user' }], repaintChangesOnly }); + + this.instance.option('items', [{ title: 'User', icon: 'user' }, { icon: 'find' }]); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined, 'title and icon item'); + assert.strictEqual(this.getTab(1).attr('aria-label'), 'find', 'icon-only item'); + }); + }); + }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js index cb451d655c54..291bc31bf29a 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js @@ -1,10 +1,12 @@ import $ from 'jquery'; import Tabs from 'ui/tabs'; +import { DataSource } from 'common/data/data_source/data_source'; import windowUtils from 'core/utils/window'; import ariaAccessibilityTestHelper from '../../helpers/ariaAccessibilityTestHelper.js'; import { TABS_CLASS, TABS_WRAPPER_CLASS, + TABS_ITEM_CLASS, TABS_ITEM_TEXT_SPAN_CLASS, TABS_ITEM_TEXT_SPAN_PSEUDO_CLASS } from '__internal/ui/tabs/tabs'; @@ -195,6 +197,134 @@ QUnit.module('Aria accessibility', { }); }); +QUnit.module('Aria label', { + beforeEach: function() { + this.createTabs = (options) => { + this.$element = $('#tabs').dxTabs(options); + this.instance = this.$element.dxTabs('instance'); + }; + this.getTab = (index) => this.$element.find(`.${TABS_ITEM_CLASS}`).eq(index); + } +}, () => { + QUnit.test('item with text and icon should not get aria-label', function(assert) { + this.createTabs({ items: [{ text: 'User', icon: 'user' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + QUnit.test('item with html and icon should not get aria-label', function(assert) { + this.createTabs({ items: [{ html: 'User', icon: 'user' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + QUnit.test('string item should not get aria-label', function(assert) { + this.createTabs({ items: ['User'] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + QUnit.test('item without text and icon should not get aria-label', function(assert) { + this.createTabs({ items: [{ badge: '5' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + + [ + { icon: 'user', expected: 'user', description: 'a dxIcon without a localized message' }, + { icon: 'close', expected: 'Close', description: 'a dxIcon with a localized message' }, + { icon: 'fa fa-home', expected: 'fa fa-home', description: 'a font icon' }, + { icon: '/path/file.png', expected: 'file', description: 'a path to an image' }, + { icon: 'Svg title', expected: 'Svg title', description: 'an svg with a title' }, + ].forEach(({ icon, expected, description }) => { + QUnit.test(`icon-only item should get aria-label "${expected}" for ${description}`, function(assert) { + this.createTabs({ items: [{ icon }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), expected); + }); + }); + + [ + { icon: 'data:image/png;base64,qwerty', description: 'a base64 image' }, + { icon: 'https://example.com/path/file.png', description: 'an image URL' }, + { icon: '', description: 'an svg without a title' }, + ].forEach(({ icon, description }) => { + QUnit.test(`icon-only item should not get aria-label for ${description}`, function(assert) { + this.createTabs({ items: [{ icon }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined); + }); + }); + + QUnit.test('icon-only item with badge should get aria-label derived from icon', function(assert) { + this.createTabs({ items: [{ icon: 'user', badge: '5' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user'); + }); + + QUnit.test('only icon-only items should get aria-label', function(assert) { + this.createTabs({ items: [{ text: 'Users' }, { icon: 'user' }, { text: 'Find', icon: 'find' }] }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined, 'text item'); + assert.strictEqual(this.getTab(1).attr('aria-label'), 'user', 'icon-only item'); + assert.strictEqual(this.getTab(2).attr('aria-label'), undefined, 'text and icon item'); + }); + + QUnit.test('aria-label should be derived from item data when a custom itemTemplate is used', function(assert) { + this.createTabs({ + items: [{ icon: 'user' }, { icon: 'user', text: 'User' }], + itemTemplate: (data) => ``, + }); + + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user', 'icon-only item'); + assert.strictEqual(this.getTab(1).attr('aria-label'), undefined, 'item with text'); + }); + + [true, false].forEach((repaintChangesOnly) => { + QUnit.test(`aria-label should follow item text and icon changes, repaintChangesOnly: ${repaintChangesOnly}`, function(assert) { + this.createTabs({ items: [{ text: 'User', icon: 'user' }], repaintChangesOnly }); + + this.instance.option('items[0].text', ''); + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user', 'label appears when text is cleared'); + + this.instance.option('items[0].icon', 'close'); + assert.strictEqual(this.getTab(0).attr('aria-label'), 'Close', 'label follows the icon'); + + this.instance.option('items[0].text', 'Close'); + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined, 'label is removed when text is set'); + }); + + QUnit.test(`aria-label should be recalculated when items are replaced, repaintChangesOnly: ${repaintChangesOnly}`, function(assert) { + this.createTabs({ items: [{ icon: 'user' }], repaintChangesOnly }); + + this.instance.option('items', [{ text: 'User', icon: 'user' }, { icon: 'find' }]); + + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined, 'text and icon item'); + assert.strictEqual(this.getTab(1).attr('aria-label'), 'find', 'icon-only item'); + }); + }); + + QUnit.test('items inserted and updated through the data source push should get aria-label', function(assert) { + const data = [{ id: 0, text: 'User', icon: 'user' }]; + const dataSource = new DataSource({ + paginate: false, + pushAggregationTimeout: 0, + load: () => data, + key: 'id', + }); + + this.createTabs({ dataSource, repaintChangesOnly: true }); + + const store = dataSource.store(); + + store.push([{ type: 'insert', data: { id: 1, icon: 'find' } }]); + assert.strictEqual(this.getTab(1).attr('aria-label'), 'find', 'inserted icon-only item'); + + store.push([{ type: 'update', key: 0, data: { id: 0, text: '', icon: 'user' } }]); + assert.strictEqual(this.getTab(0).attr('aria-label'), 'user', 'updated item without text'); + }); +}); + QUnit.module('Default template', { beforeEach: function() { this.prepareItemTest = (data) => { From 197498a4324abadf8a80a602b4738272b02b604e Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 16:28:54 +0400 Subject: [PATCH 2/2] Tabs: handle the _itemAriaLabelExpr option change, and let the label rule remove aria-label too (#5077) --- .../devextreme/js/__internal/core/utils/m_icon.ts | 10 +++++----- .../devextreme/js/__internal/ui/button/button.tsx | 2 +- packages/devextreme/js/__internal/ui/tabs/tabs.ts | 5 ++--- .../tests/DevExpress.core/utils.icon.tests.js | 13 +++++++------ .../DevExpress.ui.widgets/tabs.markup.tests.js | 10 ++++++++++ 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/devextreme/js/__internal/core/utils/m_icon.ts b/packages/devextreme/js/__internal/core/utils/m_icon.ts index 2b1fb5e467bb..9a41ae01a46b 100644 --- a/packages/devextreme/js/__internal/core/utils/m_icon.ts +++ b/packages/devextreme/js/__internal/core/utils/m_icon.ts @@ -7,7 +7,7 @@ const SVG_ICON_CLASS = 'dx-svg-icon'; const NOT_URL_REGEXP = /^(?!(?:https?:\/\/)|(?:ftp:\/\/)|(?:www\.))[^\s]+$/; const FILE_NAME_REGEXP = /.+\/([^.]+)\..+$/; -const SVG_TITLE_REGEXP = /(.*?)<\/title>/; +const SVG_TITLE_REGEXP = /<title>(.+?)<\/title>/; export const getImageSourceType = (source) => { if (!source || typeof source !== 'string') { @@ -48,20 +48,20 @@ export const getImageContainer = (source) => { } }; -export const getImageAriaLabel = (source: string): string => { +export const getImageAriaLabel = (source: string): string | undefined => { switch (getImageSourceType(source)) { case 'image': { const isPathToImage = !source.includes('base64') && NOT_URL_REGEXP.test(source); - return isPathToImage ? source.replace(FILE_NAME_REGEXP, '$1') : ''; + return isPathToImage ? source.replace(FILE_NAME_REGEXP, '$1') : undefined; } case 'dxIcon': return messageLocalization.format(camelize(source, true)) || source; case 'fontIcon': return source; case 'svg': - return SVG_TITLE_REGEXP.exec(source)?.[1] ?? ''; + return SVG_TITLE_REGEXP.exec(source)?.[1]; default: - return ''; + return undefined; } }; diff --git a/packages/devextreme/js/__internal/ui/button/button.tsx b/packages/devextreme/js/__internal/ui/button/button.tsx index 85f0dc9916c5..fdc740883193 100644 --- a/packages/devextreme/js/__internal/ui/button/button.tsx +++ b/packages/devextreme/js/__internal/ui/button/button.tsx @@ -227,7 +227,7 @@ export class Button extends InfernoWrapperComponent<ButtonProps> { get aria(): Record<string, string> { const { icon, text } = this.props; - const label = !text && icon ? getImageAriaLabel(icon) : (text ?? ''); + const label = !text && icon ? getImageAriaLabel(icon) : text; return { role: 'button', diff --git a/packages/devextreme/js/__internal/ui/tabs/tabs.ts b/packages/devextreme/js/__internal/ui/tabs/tabs.ts index c296fceef4d1..8b24c7fe49dd 100644 --- a/packages/devextreme/js/__internal/ui/tabs/tabs.ts +++ b/packages/devextreme/js/__internal/ui/tabs/tabs.ts @@ -342,9 +342,7 @@ class Tabs extends CollectionWidgetLiveUpdate<TabsProperties> { const { _itemAriaLabelExpr } = this.option(); const label = _itemAriaLabelExpr?.(itemData); - if (label) { - this.setAria('label', label, itemElement); - } + this.setAria('label', label, itemElement); } _renderScrolling(): void { @@ -865,6 +863,7 @@ class Tabs extends CollectionWidgetLiveUpdate<TabsProperties> { super._optionChanged(args); break; case 'badgeExpr': + case '_itemAriaLabelExpr': this._invalidate(); break; case 'focusedElement': { diff --git a/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js b/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js index af0c789bd4d1..c61c0e8a06b9 100644 --- a/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.core/utils.icon.tests.js @@ -154,14 +154,15 @@ testModule('getImageAriaLabel', () => { { source: 'iconName', expected: 'iconName', description: 'a dxIcon without a localized message' }, { source: 'fa fa-home', expected: 'fa fa-home', description: 'a font icon' }, { source: '/path/file.png', expected: 'file', description: 'a path to an image' }, - { source: 'https://example.com/path/file.png', expected: '', description: 'an image URL' }, - { source: 'www.example.com/file.png', expected: '', description: 'an image URL without a protocol' }, - { source: 'data:image/png;base64,qwerty', expected: '', description: 'a base64 image' }, + { source: 'https://example.com/path/file.png', expected: undefined, description: 'an image URL' }, + { source: 'www.example.com/file.png', expected: undefined, description: 'an image URL without a protocol' }, + { source: 'data:image/png;base64,qwerty', expected: undefined, description: 'a base64 image' }, { source: '<svg><title>Svg title', expected: 'Svg title', description: 'an svg with a title' }, - { source: '', expected: '', description: 'an svg without a title' }, - { source: '', expected: '', description: 'an empty string' }, + { source: '', expected: undefined, description: 'an svg with an empty title' }, + { source: '', expected: undefined, description: 'an svg without a title' }, + { source: '', expected: undefined, description: 'an empty string' }, ].forEach(({ source, expected, description }) => { - test(`should return "${expected}" for ${description}`, function(assert) { + test(`should return ${JSON.stringify(expected) || 'undefined'} for ${description}`, function(assert) { assert.strictEqual(getImageAriaLabel(source), expected); }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js index 291bc31bf29a..05fa8dab32f4 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tabs.markup.tests.js @@ -304,6 +304,16 @@ QUnit.module('Aria label', { }); }); + QUnit.test('aria-label should be recalculated when the _itemAriaLabelExpr option changes', function(assert) { + this.createTabs({ items: [{ icon: 'user', badge: 'New' }] }); + + this.instance.option('_itemAriaLabelExpr', (data) => data.badge); + assert.strictEqual(this.getTab(0).attr('aria-label'), 'New', 'label follows the new rule'); + + this.instance.option('_itemAriaLabelExpr', () => undefined); + assert.strictEqual(this.getTab(0).attr('aria-label'), undefined, 'label is removed when the rule returns nothing'); + }); + QUnit.test('items inserted and updated through the data source push should get aria-label', function(assert) { const data = [{ id: 0, text: 'User', icon: 'user' }]; const dataSource = new DataSource({