Skip to content
Open
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
9 changes: 0 additions & 9 deletions packages/devextreme-scss/scss/widgets/base/_gridBase.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,6 @@
flex: 0 0 auto;
}

// NOTE: a11y aria-live container must be visible to allow screen readers read it
.dx-gridbase-a11y-status-container {
position: fixed;
left: 0;
top: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: polygon(0 0);
}

@mixin grid-base($widget-name) {
$grid-cell-padding: 7px;
$grid-texteditor-input-padding: 32px;
Expand Down
10 changes: 10 additions & 0 deletions packages/devextreme-scss/scss/widgets/base/_ui.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
z-index: $max-integer;
}

.dx-screen-reader-only {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.dx-screen-reader-only {
.dx-a11y-status-container {

position: absolute;
height: 1px;
width: 1px;
overflow: hidden;
white-space: nowrap;
clip: rect(0 0 0 0);
clip-path: inset(50%);
}

/* animations */

.dx-animating {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@ $scheduler-appointment-collector-margin: 3px;
$scheduler-appointment-collector-height: 22px;
$scheduler-popup-scrollable-content-padding: 20px;

// NOTE: a11y aria-live container must be visible to allow screen readers read it
.dx-scheduler-a11y-status-container {
position: fixed;
left: 0;
top: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: polygon(0 0);
}

.dx-scheduler-legacy-appointment-popup {
.dx-popup-content {
padding-top: 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { dxElementWrapper } from '@js/core/renderer';
import $ from '@js/core/renderer';

const CLASSES = {
container: 'dx-gridbase-a11y-status-container',
container: 'dx-screen-reader-only',
};

export interface A11yStatusContainerComponentProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ exports[`common initial render should be successfull 1`] = `
role="group"
>
<div
class="dx-gridbase-a11y-status-container dx-cardview-exclude-flexbox"
class="dx-screen-reader-only dx-cardview-exclude-flexbox"
role="status"
/>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CLASSES as BASE_CLASSES } from '../const';

const CLASSES = {
...BASE_CLASSES,
container: 'dx-gridbase-a11y-status-container',
container: 'dx-screen-reader-only',
};

export interface A11yStatusContainerComponentProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class SchedulerModel {
}

getStatusContent(): string {
return this.container.querySelector('.dx-scheduler-a11y-status-container')?.textContent ?? '';
return this.container.querySelector('.dx-screen-reader-only')?.textContent ?? '';
}

getAppointment(text?: string): AppointmentModel<HTMLDivElement | null> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { dxElementWrapper } from '@js/core/renderer';
import $ from '@js/core/renderer';

const CLASSES = {
container: 'dx-scheduler-a11y-status-container',
container: 'dx-screen-reader-only',
};

export const createA11yStatusContainer = (statusText = ''): dxElementWrapper => $('<div>')
Expand Down
46 changes: 46 additions & 0 deletions packages/devextreme/js/__internal/ui/m_validation_summary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import eventsEngine from '@js/common/core/events/core/events_engine';
import registerComponent from '@js/core/component_registrator';
import type { dxElementWrapper } from '@js/core/renderer';
import $ from '@js/core/renderer';
// @ts-expect-error ts-error
import { grep } from '@js/core/utils/common';
import { extend } from '@js/core/utils/extend';
Expand All @@ -12,6 +14,7 @@ import ValidationEngine from './m_validation_engine';
import type ValidationGroup from './m_validation_group';

const VALIDATION_SUMMARY_CLASS = 'dx-validationsummary';
const SCREEN_READER_ONLY_CLASS = 'dx-screen-reader-only';
const ITEM_CLASS = `${VALIDATION_SUMMARY_CLASS}-item`;
const ITEM_DATA_KEY = `${VALIDATION_SUMMARY_CLASS}-item-data`;

Expand All @@ -26,6 +29,10 @@ class ValidationSummary extends CollectionWidget<ValidationSummaryProperties> {

validators?: any[];

_$announceContainer?: dxElementWrapper;

_lastAnnouncedText?: string;

groupSubscription?: (params) => void;

_getDefaultOptions(): ValidationSummaryProperties {
Expand Down Expand Up @@ -117,6 +124,43 @@ class ValidationSummary extends CollectionWidget<ValidationSummaryProperties> {
});

this.option('items', items);

this._announceOnGroupValidation(items);
}

_announceOnGroupValidation(items): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to pass an extra argument to this function? We already have access to the current items value inside it.

if (!items?.length) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kindly check all code blocks and make sure that all scenarios are covered by tests. Currently, I see only tests for basic scenarios.

this._lastAnnouncedText = '';
this._removeAnnounceContainer();
return;
}

const text = items.map((item) => item.text).join('. ');

if (text !== this._lastAnnouncedText) {
this._lastAnnouncedText = text;
this._announceText(text);
}
}

_removeAnnounceContainer(): void {
this._$announceContainer?.remove();
this._$announceContainer = undefined;
}

_renderAnnounceContainer(): void {
this._removeAnnounceContainer();

this._$announceContainer = $('<div>')
.addClass(SCREEN_READER_ONLY_CLASS)
.attr('role', 'alert')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it’s better to use the setAria(...) utility function

.appendTo(this.element());
}

_announceText(text: string): void {
this._renderAnnounceContainer();

this._$announceContainer?.text(text);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t see any runtime processing. Is it not necessary?

}

_itemValidationHandler({ isValid, validator, brokenRules }): void {
Expand Down Expand Up @@ -164,6 +208,7 @@ class ValidationSummary extends CollectionWidget<ValidationSummaryProperties> {

_initMarkup(): void {
this.$element().addClass(VALIDATION_SUMMARY_CLASS);

super._initMarkup();
}

Expand Down Expand Up @@ -192,6 +237,7 @@ class ValidationSummary extends CollectionWidget<ValidationSummaryProperties> {
}

_dispose(): void {
this._removeAnnounceContainer();
super._dispose();
this._unsubscribeGroup();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ QUnit.module('General', {
});


QUnit.test('Summary can subscribe on group\'s Validated event', function(assert) {
QUnit.test('Summary can subscribe on group Validated event', function(assert) {
const group = 'group1';
const validator = sinon.createStubInstance(Validator);
validator.validate.returns({ isValid: true, brokenRule: null });
Expand Down Expand Up @@ -173,7 +173,7 @@ QUnit.module('Regression', {
assert.strictEqual(group, summary.option('validationGroup'));
});

QUnit.test('T212238: Summary can subscribe on group\'s Validated event when Summary is created before any validator in group', function(assert) {
QUnit.test('T212238: Summary can subscribe on group Validated event when Summary is created before any validator in group', function(assert) {
const group = 'group1';
const validator = sinon.createStubInstance(Validator);
validator.validate.returns({ isValid: true, brokenRule: null });
Expand Down Expand Up @@ -232,7 +232,7 @@ QUnit.module('Regression', {
});
});

QUnit.module('Update on validator\'s validation', {
QUnit.module('Update on validator validation', {
beforeEach: function() {
this.fixture = new Fixture();
}
Expand Down Expand Up @@ -408,7 +408,7 @@ QUnit.module('Update on validator\'s validation', {
assert.equal(items[1].text, message + ' 2', 'Message should be updated');
});

QUnit.test('T270338: Summary should subscribe to validator\'s events only once', function(assert) {
QUnit.test('T270338: Summary should subscribe to validator events only once', function(assert) {
const validator1 = this.fixture.createValidator({
validationGroup: 'group1',
validationRules: [{
Expand All @@ -428,7 +428,7 @@ QUnit.module('Update on validator\'s validation', {
assert.equal(spy.callCount, 1, 'Render of validation summary should be called only once');
});

QUnit.test('T270338 - the \'items\' option changed should not be called if validator state is not changed', function(assert) {
QUnit.test('T270338 - the items option changed should not be called if validator state is not changed', function(assert) {
let itemsChangedCallCount = 0;
const validator = this.fixture.createValidator({
validationGroup: 'group',
Expand All @@ -452,3 +452,56 @@ QUnit.module('Update on validator\'s validation', {
assert.equal(itemsChangedCallCount, 1, 'items should not be changed if the validator state is not changed');
});
});

QUnit.module('Accessibility', {
beforeEach: function() {
this.fixture = new Fixture();
}
}, () => {
QUnit.test('ValidationSummary should have announce container with validation summary for screen reader', function(assert) {
const summary = this.fixture.createSummary();
const validator = sinon.createStubInstance(Validator);
const messages = ['test message 1', 'test message 2'];

summary._groupValidationHandler({
isValid: false,
brokenRules: [{
type: 'async',
message: messages[0],
validator: validator,
},
{
type: 'async',
message: messages[1],
validator: validator,
}],
validators: [validator],
});

const items = summary.option('items');

const $announceContainer = summary.$element().find('.dx-screen-reader-only');

assert.strictEqual($announceContainer.length, 1, 'announce container is present');
assert.strictEqual($announceContainer.text(), messages.join('. '), 'announce container has correct text');
});

QUnit.test('ValidationSummary announce container should have role=alert attribute', function(assert) {
const summary = this.fixture.createSummary();
const validator = sinon.createStubInstance(Validator);

summary._groupValidationHandler({
isValid: false,
brokenRules: [{
type: 'async',
message: 'test message',
validator: validator,
}],
validators: [validator],
});

const $announceContainer = summary.$element().find('.dx-screen-reader-only');

assert.strictEqual($announceContainer.attr('role'), 'alert', 'role=alert is present');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4257,7 +4257,7 @@ QUnit.module('reset', () => {

const validationItemsBeforeReset = $(`.${FORM_VALIDATION_SUMMARY}`).children();

assert.strictEqual(validationItemsBeforeReset.length, 2, 'form has validation summary items before reset');
assert.strictEqual(validationItemsBeforeReset.length, 3, 'form has validation summary items before reset');

form.reset();

Expand All @@ -4284,7 +4284,7 @@ QUnit.module('reset', () => {

const summaryItemsAfterValidate = $(`.${FORM_VALIDATION_SUMMARY}`).children();

assert.strictEqual(summaryItemsAfterValidate.length, 2, 'form has validation summary after validation');
assert.strictEqual(summaryItemsAfterValidate.length, 3, 'form has validation summary after validation');
});

[
Expand Down
2 changes: 1 addition & 1 deletion packages/testcafe-models/scheduler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const CLASS = {
workspaceBothScrollbar: 'dx-scheduler-work-space-both-scrollbar',

workSpace: 'dx-scheduler-work-space',
statusContainer: 'dx-scheduler-a11y-status-container ',
statusContainer: 'dx-screen-reader-only',
Copy link
Contributor

@EugeniyKiyashko EugeniyKiyashko Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will a single 'dx-screen-reader-only' class be sufficient if there is more than one container on the page?? Kindly check all cases

};

const ViewTypeClassesMap = {
Expand Down
Loading