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 {
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
48 changes: 48 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,45 @@ class ValidationSummary extends CollectionWidget<ValidationSummaryProperties> {
});

this.option('items', items);

this._announceOnGroupValidation();
}

_announceOnGroupValidation(): void {
const { items } = this.option();

if (!items?.length) {
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')
.appendTo(this.element());
}

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

this._$announceContainer?.text(text);
}

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

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

super._initMarkup();
}

Expand Down Expand Up @@ -192,6 +239,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,117 @@ 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();

this.summary = this.fixture.createSummary();
this.validator = sinon.createStubInstance(Validator);

this.formatErrors = (errors) => errors.join('. ');
}
}, () => {
QUnit.test('ValidationSummary should have announce container with validation summary for screen reader', function(assert) {
const messages = ['test message 1', 'test message 2'];

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

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

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

QUnit.test('ValidationSummary announce container should have role=alert attribute', function(assert) {
this.summary._groupValidationHandler({
isValid: false,
brokenRules: [{
message: 'test message',
validator: this.validator,
}],
validators: [this.validator],
});

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

assert.strictEqual($announceContainer.attr('role'), 'alert', 'role=alert is present');
});

QUnit.test('ValidationSummary should remove announce container when there are no validation errors', function(assert) {
this.summary._groupValidationHandler({
isValid: true,
validators: [this.validator],
});

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

assert.strictEqual($announceContainer.length, 0, 'announce container is not present');
});

QUnit.test('ValidationSummary should not create announce container if errors are the same', function(assert) {
const message = 'test message';

const groupValidationPayload = {
isValid: false,
brokenRules: [{
message,
validator: this.validator,
}],
validators: [this.validator],
};

this.summary._groupValidationHandler(groupValidationPayload);
this.summary._groupValidationHandler(groupValidationPayload);

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

assert.strictEqual($announceContainer.get(0), undefined, 'announce container was not created');
});

[
['second'],
['first', 'second'],
['second', 'first'],
['first', 'second', 'third'],
[''],
['', ''],
].forEach((errors) => {
QUnit.test(`ValidationSummary should accordingly update announce container when errors change from ["first"] to ${JSON.stringify(errors)}`, function(assert) {
this.summary._groupValidationHandler({
isValid: false,
brokenRules: [{
message: 'first',
validator: this.validator,
}],
validators: [this.validator],
});

const brokenRules = errors.map((error) => ({
message: error,
validator: this.validator,
}));

this.summary._groupValidationHandler({
isValid: false,
brokenRules,
validators: [this.validator],
});

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

assert.strictEqual($announceContainer.text(), this.formatErrors(errors), 'announce container has updated text');
});
});
});
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',
};

const ViewTypeClassesMap = {
Expand Down
Loading