Skip to content

Commit 51fafc1

Browse files
author
marker dao ®
committed
RadioGroup: Move radio semantic from container to value container
1 parent 2d047d5 commit 51fafc1

5 files changed

Lines changed: 203 additions & 13 deletions

File tree

e2e/testcafe-devextreme/tests/accessibility/radioGroup.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,29 @@ const configuration: Configuration = {
2424
};
2525

2626
testAccessibility(configuration);
27+
28+
const buttons = [
29+
{
30+
text: 'custom 1',
31+
},
32+
{
33+
text: 'custom 2',
34+
},
35+
];
36+
37+
const interactiveItemsConfiguration: Configuration = {
38+
component: 'dxRadioGroup',
39+
a11yCheckConfig,
40+
options: {
41+
items: [buttons],
42+
itemTemplate: [
43+
(itemData, _, itemElement) => {
44+
const $button = $('<button>').text(itemData.text);
45+
46+
itemElement.append($button);
47+
},
48+
],
49+
},
50+
};
51+
52+
testAccessibility(interactiveItemsConfiguration);

packages/devextreme/js/__internal/ui/collection/collection_widget.base.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ class CollectionWidget<
156156
> extends Widget<TProperties> {
157157
private _focusedItemId?: string;
158158

159-
// eslint-disable-next-line no-restricted-globals
160159
private _itemFocusTimeout?: ReturnType<typeof setTimeout>;
161160

162161
private _itemRenderAction?: (event?: ActionArgs<TItem>) => void;
@@ -605,6 +604,10 @@ class CollectionWidget<
605604
this.setAria('activedescendant', null, $target);
606605
}
607606

607+
_getIdTarget($target: dxElementWrapper): dxElementWrapper {
608+
return $target;
609+
}
610+
608611
_refreshItemId(
609612
$target: dxElementWrapper,
610613
needCleanItemId: boolean | undefined,
@@ -616,10 +619,12 @@ class CollectionWidget<
616619
return;
617620
}
618621

622+
const $idTarget = this._getIdTarget($target);
623+
619624
if (!needCleanItemId && focusedElement) {
620-
this.setAria('id', this.getFocusedItemId(), $target);
625+
this.setAria('id', this.getFocusedItemId(), $idTarget);
621626
} else {
622-
this.setAria('id', null, $target);
627+
this.setAria('id', null, $idTarget);
623628
}
624629
}
625630

packages/devextreme/js/__internal/ui/radio_group/m_radio_collection.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Guid from '@js/core/guid';
12
import type { dxElementWrapper } from '@js/core/renderer';
23
import $ from '@js/core/renderer';
34
import { deferRender } from '@js/core/utils/common';
@@ -13,6 +14,8 @@ const RADIO_BUTTON_ICON_DOT_CLASS = 'dx-radiobutton-icon-dot';
1314
const RADIO_VALUE_CONTAINER_CLASS = 'dx-radio-value-container';
1415
const RADIO_BUTTON_CLASS = 'dx-radiobutton';
1516

17+
const ITEM_CONTENT_CLASS = 'dx-item-content';
18+
1619
export type Properties = CollectionWidgetBaseProperties<RadioCollection>;
1720

1821
class RadioCollection extends CollectionWidget<Properties> {
@@ -29,9 +32,7 @@ class RadioCollection extends CollectionWidget<Properties> {
2932
const defaultOptions = super._getDefaultOptions();
3033

3134
// @ts-expect-error
32-
return extend(defaultOptions, DataExpressionMixin._dataExpressionDefaultOptions(), {
33-
_itemAttributes: { role: 'radio' },
34-
});
35+
return extend(defaultOptions, DataExpressionMixin._dataExpressionDefaultOptions());
3536
}
3637

3738
_initMarkup(): void {
@@ -47,8 +48,18 @@ class RadioCollection extends CollectionWidget<Properties> {
4748
return this._focusTarget();
4849
}
4950

51+
// eslint-disable-next-line class-methods-use-this
52+
_getIdTarget($target: dxElementWrapper): dxElementWrapper {
53+
const $radioContainer = $target.find(`.${RADIO_VALUE_CONTAINER_CLASS}`);
54+
55+
return $radioContainer;
56+
}
57+
5058
_postprocessRenderItem(args): void {
51-
const { itemData: { html }, itemElement } = args;
59+
const { itemData, itemElement } = args;
60+
const { html } = itemData;
61+
62+
const contentId = `dx-${new Guid()}`;
5263

5364
if (!html) {
5465
const $radio = $('<div>').addClass(RADIO_BUTTON_ICON_CLASS);
@@ -58,9 +69,23 @@ class RadioCollection extends CollectionWidget<Properties> {
5869
const $radioContainer = $('<div>').append($radio).addClass(RADIO_VALUE_CONTAINER_CLASS);
5970

6071
$(itemElement).prepend($radioContainer);
72+
73+
const aria = {
74+
role: 'radio',
75+
// eslint-disable-next-line spellcheck/spell-checker
76+
labelledby: contentId,
77+
};
78+
79+
this.setAria(aria, $radioContainer);
6180
}
6281

6382
super._postprocessRenderItem(args);
83+
84+
if (!html) {
85+
const $itemContent = $(itemElement).find(`.${ITEM_CONTENT_CLASS}`);
86+
87+
$itemContent.attr('id', contentId);
88+
}
6489
}
6590

6691
_processSelectableItem(
@@ -75,7 +100,9 @@ class RadioCollection extends CollectionWidget<Properties> {
75100
.first()
76101
.toggleClass(RADIO_BUTTON_ICON_CHECKED_CLASS, isSelected);
77102

78-
this.setAria('checked', isSelected, $itemElement);
103+
const $radioContainer = $itemElement.find(`.${RADIO_VALUE_CONTAINER_CLASS}`);
104+
105+
this.setAria('checked', isSelected, $radioContainer);
79106
}
80107

81108
_refreshContent(): void {

packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/radioGroup.markup.tests.js

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const RADIO_BUTTON_CLASS = 'dx-radiobutton';
2121
const RADIO_BUTTON_CHECKED_CLASS = 'dx-radiobutton-checked';
2222
const RADIO_GROUP_VERTICAL_CLASS = 'dx-radiogroup-vertical';
2323
const RADIO_GROUP_HORIZONTAL_CLASS = 'dx-radiogroup-horizontal';
24+
const RADIO_VALUE_CONTAINER_CLASS = 'dx-radio-value-container';
25+
const ITEM_CONTENT_CLASS = 'dx-item-content';
2426

2527
const moduleConfig = {
2628
beforeEach: function() {
@@ -319,23 +321,80 @@ QUnit.module('Aria accessibility', {
319321
helper.checkAttributes(helper.$widget, { role: 'radiogroup', tabindex: '0' }, 'widget');
320322
});
321323

322-
QUnit.test('Items: [1, 2, 3], Item.selected: true', function() {
324+
QUnit.test('Items: [1, 2, 3], Item.selected: true', function(assert) {
323325
helper.createWidget({ items: [1, 2, 3], value: 1 });
324326

325327
helper.checkAttributes(helper.$widget, { role: 'radiogroup', tabindex: '0' }, 'widget');
326-
helper.checkItemsAttributes([0], { attributes: ['aria-checked'], role: 'radio' });
328+
helper.checkItemsAttributes([], {});
329+
330+
helper.getItems().each((index, item) => {
331+
const $radioContainer = $(item).find(`.${RADIO_VALUE_CONTAINER_CLASS}`);
332+
const $itemContent = $(item).find(`.${ITEM_CONTENT_CLASS}`);
333+
const contentId = $itemContent.attr('id');
334+
335+
assert.ok(contentId, `item[${index}] content element has an id`);
336+
assert.strictEqual($radioContainer.attr('role'), 'radio', `item[${index}] radio container has role="radio"`);
337+
assert.strictEqual($radioContainer.attr('aria-checked'), index === 0 ? 'true' : 'false', `item[${index}] radio container has correct aria-checked`);
338+
assert.strictEqual($radioContainer.attr('aria-labelledby'), contentId, `item[${index}] radio container aria-labelledby references content id`);
339+
});
327340
});
328341

329-
QUnit.test('Items: [1, 2, 3], Item.selected: true, set focusedElement -> clean focusedElement', function() {
342+
QUnit.test('Items: [1, 2, 3], Item.selected: true, set focusedElement -> clean focusedElement', function(assert) {
330343
helper.createWidget({ items: [1, 2, 3], value: 1 });
331344

332345
helper.widget.option('focusedElement', helper.getItems().eq(0));
333346
helper.checkAttributes(helper.$widget, { role: 'radiogroup', tabindex: '0' }, 'widget');
334-
helper.checkItemsAttributes([0], { attributes: ['aria-checked'], role: 'radio' });
347+
helper.checkItemsAttributes([], {});
348+
349+
helper.getItems().each((index, item) => {
350+
const $radioContainer = $(item).find(`.${RADIO_VALUE_CONTAINER_CLASS}`);
351+
const $itemContent = $(item).find(`.${ITEM_CONTENT_CLASS}`);
352+
const contentId = $itemContent.attr('id');
353+
354+
assert.ok(contentId, `item[${index}] content element has an id`);
355+
assert.strictEqual($radioContainer.attr('role'), 'radio', `item[${index}] radio container has role="radio"`);
356+
assert.strictEqual($radioContainer.attr('aria-checked'), index === 0 ? 'true' : 'false', `item[${index}] radio container has correct aria-checked`);
357+
assert.strictEqual($radioContainer.attr('aria-labelledby'), contentId, `item[${index}] radio container aria-labelledby references content id`);
358+
});
335359

336360
helper.widget.option('focusedElement', null);
337361
helper.checkAttributes(helper.$widget, { role: 'radiogroup', tabindex: '0' }, 'widget');
338-
helper.checkItemsAttributes([0], { attributes: ['aria-checked'], role: 'radio' });
362+
helper.checkItemsAttributes([], {});
363+
364+
helper.getItems().each((index, item) => {
365+
const $radioContainer = $(item).find(`.${RADIO_VALUE_CONTAINER_CLASS}`);
366+
const $itemContent = $(item).find(`.${ITEM_CONTENT_CLASS}`);
367+
const contentId = $itemContent.attr('id');
368+
369+
assert.ok(contentId, `item[${index}] content element has an id after clearing focusedElement`);
370+
assert.strictEqual($radioContainer.attr('role'), 'radio', `item[${index}] radio container has role="radio" after clearing focusedElement`);
371+
assert.strictEqual($radioContainer.attr('aria-checked'), index === 0 ? 'true' : 'false', `item[${index}] radio container has correct aria-checked after clearing focusedElement`);
372+
assert.strictEqual($radioContainer.attr('aria-labelledby'), contentId, `item[${index}] radio container aria-labelledby references content id after clearing focusedElement`);
373+
});
374+
});
375+
376+
QUnit.test('Items with itemTemplate: radio container has correct aria attributes', function(assert) {
377+
helper.createWidget({
378+
items: [{ text: 'custom 1' }, { text: 'custom 2' }],
379+
itemTemplate(itemData, _, itemElement) {
380+
const $button = $('<button>').text(itemData.text);
381+
itemElement.append($button);
382+
},
383+
});
384+
385+
helper.checkAttributes(helper.$widget, { role: 'radiogroup', tabindex: '0' }, 'widget');
386+
helper.checkItemsAttributes([], {});
387+
388+
helper.getItems().each((index, item) => {
389+
const $radioContainer = $(item).find(`.${RADIO_VALUE_CONTAINER_CLASS}`);
390+
const $itemContent = $(item).find(`.${ITEM_CONTENT_CLASS}`);
391+
const contentId = $itemContent.attr('id');
392+
393+
assert.ok(contentId, `item[${index}] content element has an id`);
394+
assert.strictEqual($radioContainer.attr('role'), 'radio', `item[${index}] radio container has role="radio"`);
395+
assert.strictEqual($radioContainer.attr('aria-checked'), 'false', `item[${index}] radio container has aria-checked="false" initially`);
396+
assert.strictEqual($radioContainer.attr('aria-labelledby'), contentId, `item[${index}] radio container aria-labelledby references content id`);
397+
});
339398
});
340399
});
341400

packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/radioGroup.tests.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,79 @@ module('accessibility', moduleConfig, () => {
297297
assert.strictEqual($(this).attr('aria-describedby'), undefined, 'aria-describedby is removed from radio button');
298298
});
299299
});
300+
301+
test('with itemTemplate, radio container should have role="radio", aria-labelledby and aria-checked', function(assert) {
302+
const items = [
303+
{ text: 'custom 1' },
304+
{ text: 'custom 2' },
305+
];
306+
const $radioGroup = createRadioGroup({
307+
items,
308+
itemTemplate(itemData, _, itemElement) {
309+
const $button = $('<button>').text(itemData.text);
310+
itemElement.append($button);
311+
},
312+
});
313+
const instance = getInstance($radioGroup);
314+
const $buttons = $(instance.itemElements());
315+
316+
$buttons.each(function(index) {
317+
const $radioContainer = $(this).find('.dx-radio-value-container');
318+
const $itemContent = $(this).find('.dx-item-content');
319+
const contentId = $itemContent.attr('id');
320+
321+
assert.ok(contentId, `item[${index}] content element has an id`);
322+
assert.strictEqual($radioContainer.attr('role'), 'radio', `item[${index}] radio container has role="radio"`);
323+
assert.strictEqual($radioContainer.attr('aria-checked'), 'false', `item[${index}] radio container has aria-checked="false" initially`);
324+
assert.strictEqual($radioContainer.attr('aria-labelledby'), contentId, `item[${index}] radio container aria-labelledby references content id`);
325+
});
326+
});
327+
328+
test('with itemTemplate, aria-checked on radio container should be updated on value change', function(assert) {
329+
const items = [
330+
{ text: 'custom 1' },
331+
{ text: 'custom 2' },
332+
];
333+
const $radioGroup = createRadioGroup({
334+
items,
335+
itemTemplate(itemData, _, itemElement) {
336+
const $button = $('<button>').text(itemData.text);
337+
itemElement.append($button);
338+
},
339+
});
340+
const instance = getInstance($radioGroup);
341+
const $itemElements = $(instance.itemElements());
342+
const $firstRadioContainer = $itemElements.eq(0).find('.dx-radio-value-container');
343+
const $secondRadioContainer = $itemElements.eq(1).find('.dx-radio-value-container');
344+
345+
assert.strictEqual($firstRadioContainer.attr('aria-checked'), 'false', 'first item radio container has aria-checked="false" before selection');
346+
347+
$itemElements.eq(0).trigger('dxclick');
348+
349+
assert.strictEqual($firstRadioContainer.attr('aria-checked'), 'true', 'first item radio container has aria-checked="true" after selection');
350+
assert.strictEqual($secondRadioContainer.attr('aria-checked'), 'false', 'second item radio container has aria-checked="false" after first item selected');
351+
});
352+
353+
test('with itemTemplate, item element should not have role="radio" set directly', function(assert) {
354+
const items = [
355+
{ text: 'custom 1' },
356+
{ text: 'custom 2' },
357+
];
358+
const $radioGroup = createRadioGroup({
359+
items,
360+
itemTemplate(itemData, _, itemElement) {
361+
const $button = $('<button>').text(itemData.text);
362+
itemElement.append($button);
363+
},
364+
});
365+
const instance = getInstance($radioGroup);
366+
const $buttons = $(instance.itemElements());
367+
368+
$buttons.each(function(index) {
369+
assert.notStrictEqual($(this).attr('role'), 'radio', `item[${index}] element itself does not have role="radio"`);
370+
assert.strictEqual($(this).attr('aria-checked'), undefined, `item[${index}] element does not have aria-checked`);
371+
});
372+
});
300373
});
301374

302375
module('hidden input', () => {

0 commit comments

Comments
 (0)