Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 0 additions & 4 deletions apps/demos/testing/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
4 changes: 3 additions & 1 deletion e2e/testcafe-devextreme/tests/accessibility/tabPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Properties> = {
Expand Down
4 changes: 3 additions & 1 deletion e2e/testcafe-devextreme/tests/accessibility/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Properties> = {
Expand Down
24 changes: 24 additions & 0 deletions packages/devextreme/js/__internal/core/utils/m_icon.ts
Original file line number Diff line number Diff line change
@@ -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>(.+?)<\/title>/;

export const getImageSourceType = (source) => {
if (!source || typeof source !== 'string') {
return false;
Expand Down Expand Up @@ -41,3 +47,21 @@ export const getImageContainer = (source) => {
return null;
}
};

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') : undefined;
}
case 'dxIcon':
return messageLocalization.format(camelize(source, true)) || source;
case 'fontIcon':
return source;
case 'svg':
return SVG_TITLE_REGEXP.exec(source)?.[1];
default:
return undefined;
}
};
33 changes: 2 additions & 31 deletions packages/devextreme/js/__internal/ui/button/button.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
}
Expand Down
32 changes: 30 additions & 2 deletions packages/devextreme/js/__internal/ui/tabs/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -136,6 +140,8 @@ export interface TabsProperties extends Properties, Omit<

badgeExpr?: (data) => string | undefined;

_itemAriaLabelExpr?: (data: Item) => string | undefined;

_indicatorPosition?: Position | null;
}

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -318,6 +331,20 @@ 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);

this.setAria('label', label, itemElement);
}

_renderScrolling(): void {
const removeClasses = [TABS_STRETCHED_CLASS, TABS_EXPANDED_CLASS, OVERFLOW_HIDDEN_CLASS];
this.$element().removeClass(removeClasses.join(' '));
Expand Down Expand Up @@ -836,6 +863,7 @@ class Tabs extends CollectionWidgetLiveUpdate<TabsProperties> {
super._optionChanged(args);
break;
case 'badgeExpr':
case '_itemAriaLabelExpr':
this._invalidate();
break;
case 'focusedElement': {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -144,3 +147,36 @@ 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: 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</title><path d="M0 0h1v1H0z"/></svg>', expected: 'Svg title', description: 'an svg with a title' },
{ source: '<svg><title></title><path d="M0 0h1v1H0z"/></svg>', expected: undefined, description: 'an svg with an empty title' },
{ source: '<svg><path d="M0 0h1v1H0z"/></svg>', expected: undefined, description: 'an svg without a title' },
{ source: '', expected: undefined, description: 'an empty string' },
].forEach(({ source, expected, description }) => {
test(`should return ${JSON.stringify(expected) || 'undefined'} 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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $('<div>').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: '<b>Find content</b>' },
],
});

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) => `<i class="dx-icon dx-icon-${data.icon}"></i>`,
});

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');
});
});
});
});
Loading