diff --git a/packages/devextreme-scss/scss/widgets/base/_gridBase.scss b/packages/devextreme-scss/scss/widgets/base/_gridBase.scss index 01b9a2a2dad7..dffaf1751d2b 100644 --- a/packages/devextreme-scss/scss/widgets/base/_gridBase.scss +++ b/packages/devextreme-scss/scss/widgets/base/_gridBase.scss @@ -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; diff --git a/packages/devextreme-scss/scss/widgets/base/_ui.scss b/packages/devextreme-scss/scss/widgets/base/_ui.scss index 48a4d628c3cd..98de253df885 100644 --- a/packages/devextreme-scss/scss/widgets/base/_ui.scss +++ b/packages/devextreme-scss/scss/widgets/base/_ui.scss @@ -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 { diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss index b34a4ed89777..0a0f2411c040 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/_common.scss @@ -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; diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/a11y_status_container_component.ts b/packages/devextreme/js/__internal/grids/grid_core/views/a11y_status_container_component.ts index 875af6837f7d..3a75fac9c3e6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/a11y_status_container_component.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/a11y_status_container_component.ts @@ -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 { diff --git a/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap b/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap index d3e76a106f46..bca54befcf6d 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap @@ -10,7 +10,7 @@ exports[`common initial render should be successfull 1`] = ` role="group" >
{ diff --git a/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_render.ts b/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_render.ts index a07a6fbd817d..558f93c69c36 100644 --- a/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_render.ts +++ b/packages/devextreme/js/__internal/scheduler/a11y_status/a11y_status_render.ts @@ -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 => $('
') diff --git a/packages/devextreme/js/__internal/ui/m_validation_summary.ts b/packages/devextreme/js/__internal/ui/m_validation_summary.ts index 8b8ea4d2ecd6..2750b640e444 100644 --- a/packages/devextreme/js/__internal/ui/m_validation_summary.ts +++ b/packages/devextreme/js/__internal/ui/m_validation_summary.ts @@ -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'; @@ -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`; @@ -26,6 +29,10 @@ class ValidationSummary extends CollectionWidget { validators?: any[]; + _$announceContainer?: dxElementWrapper; + + _lastAnnouncedText?: string; + groupSubscription?: (params) => void; _getDefaultOptions(): ValidationSummaryProperties { @@ -117,6 +124,45 @@ class ValidationSummary extends CollectionWidget { }); 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 = $('
') + .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 { @@ -164,6 +210,7 @@ class ValidationSummary extends CollectionWidget { _initMarkup(): void { this.$element().addClass(VALIDATION_SUMMARY_CLASS); + super._initMarkup(); } @@ -192,6 +239,7 @@ class ValidationSummary extends CollectionWidget { } _dispose(): void { + this._removeAnnounceContainer(); super._dispose(); this._unsubscribeGroup(); } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/validationSummary.markup.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/validationSummary.markup.tests.js index c2fc8bb49abd..945fe2b73cc7 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/validationSummary.markup.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/validationSummary.markup.tests.js @@ -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 }); @@ -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 }); @@ -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(); } @@ -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: [{ @@ -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', @@ -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'); + }); + }); +}); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js index 287061f56c2a..2a437b23dec1 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js @@ -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(); @@ -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'); }); [ diff --git a/packages/testcafe-models/scheduler/index.ts b/packages/testcafe-models/scheduler/index.ts index 656ba4d115e2..f7ddc2d654cf 100644 --- a/packages/testcafe-models/scheduler/index.ts +++ b/packages/testcafe-models/scheduler/index.ts @@ -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 = {