Skip to content

Commit 96adfdf

Browse files
fix(validation-messages): DLT-3430 fix VoiceOver silent on validation messages (#1285)
- Always keep the aria-live container in the DOM so VoiceOver registers it before content is injected (removed v-if from ValidationMessages root) - Switch to aria-live="assertive" on the container; polite is suppressed during the focus transitions that trigger validation on blur - Remove role="status" from individual message elements — caused Safari/VoiceOver to silently drop announcements - Wire aria-invalid and aria-describedby on all native form controls: Input, SelectMenu, Radio, Checkbox, InputGroup, ComboboxMultiSelect - Fix messagesChildProps binding order to appear before :id="messagesId" across all components, preventing consumer overrides from clobbering the container ID - Add InteractiveForm story to ValidationMessages for manual VoiceOver regression testing - Add ARIA wiring unit tests to all affected components
1 parent cb9992f commit 96adfdf

16 files changed

Lines changed: 587 additions & 58 deletions

File tree

packages/dialtone-vue/components/Checkbox/Checkbox.test.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,8 @@ describe('DtCheckbox Tests', () => {
203203
expect(label.exists()).toBe(false);
204204
});
205205

206-
it('should remove the checkbox description/messages container if neither is provided', () => {
207-
expect(descriptionMessagesContainer.exists()).toBe(false);
206+
it('should keep the checkbox description/messages container in the DOM', () => {
207+
expect(descriptionMessagesContainer.exists()).toBe(true);
208208
});
209209

210210
it('should keep the input checkbox', () => {
@@ -351,6 +351,36 @@ describe('DtCheckbox Tests', () => {
351351
});
352352
});
353353
});
354+
355+
describe('ARIA validation wiring', () => {
356+
describe('When a critical validation message is provided', () => {
357+
beforeEach(() => {
358+
mockProps = { messages: [{ message: 'Error', type: VALIDATION_MESSAGE_TYPES.CRITICAL }] };
359+
360+
updateWrapper();
361+
});
362+
363+
it('should set aria-invalid on the input', () => {
364+
expect(input.attributes('aria-invalid')).toBe('true');
365+
});
366+
367+
it('should set aria-describedby on the input pointing to the messages container', () => {
368+
const messagesContainer = wrapper.find('[data-qa="dt-checkbox-validation-messages"]');
369+
370+
expect(input.attributes('aria-describedby')).toBe(messagesContainer.attributes('id'));
371+
});
372+
});
373+
374+
describe('When no validation messages are provided', () => {
375+
it('should not set aria-invalid on the input', () => {
376+
expect(input.attributes('aria-invalid')).toBeUndefined();
377+
});
378+
379+
it('should not set aria-describedby on the input', () => {
380+
expect(input.attributes('aria-describedby')).toBeUndefined();
381+
});
382+
});
383+
});
354384
});
355385

356386
describe('Interactivity Tests', () => {

packages/dialtone-vue/components/Checkbox/Checkbox.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
:disabled="internalDisabled"
1414
:class="['d-checkbox', inputValidationClass, inputClass]"
1515
:aria-label="!showLabel && label ? label : undefined"
16+
:aria-invalid="ariaInvalid"
17+
:aria-describedby="ariaDescribedBy"
1618
v-bind="removeClassStyleAttrs($attrs)"
1719
:indeterminate.prop="internalIndeterminate"
1820
v-on="inputListeners"
@@ -34,7 +36,6 @@
3436
</dt-text>
3537
</label>
3638
<div
37-
v-if="$slots.description || description || hasMessages"
3839
class="d-checkbox__messages"
3940
data-qa="checkbox-description-messages"
4041
>
@@ -54,10 +55,11 @@
5455
</slot>
5556
</dt-text>
5657
<dt-validation-messages
58+
v-bind="messagesChildProps"
59+
:id="messagesId"
5760
:validation-messages="formattedMessages"
5861
:show-messages="showMessages"
5962
:class="messagesClass"
60-
v-bind="messagesChildProps"
6163
data-qa="dt-checkbox-validation-messages"
6264
/>
6365
</div>
@@ -72,7 +74,8 @@ import {
7274
GroupableMixin,
7375
MessagesMixin,
7476
} from '@/common/mixins/input';
75-
import { removeClassStyleAttrs } from '@/common/utils';
77+
import { removeClassStyleAttrs, getUniqueString, getValidationState } from '@/common/utils';
78+
import { VALIDATION_MESSAGE_TYPES } from '@/common/constants';
7679
import { CHECKBOX_INPUT_VALIDATION_CLASSES } from './CheckboxConstants';
7780
import { DtValidationMessages } from '../ValidationMessages';
7881
import { DtText, TEXT_SIZE_MODIFIERS, TEXT_STRENGTH_MODIFIERS } from '@/components/Text';
@@ -148,6 +151,12 @@ export default {
148151
'focusout',
149152
],
150153
154+
data () {
155+
return {
156+
messagesId: getUniqueString(),
157+
};
158+
},
159+
151160
computed: {
152161
resolvedLabelSize () {
153162
return this.labelSize ?? 300;
@@ -173,6 +182,14 @@ export default {
173182
return this.formattedMessages.length && this.showMessages;
174183
},
175184
185+
ariaInvalid () {
186+
return getValidationState(this.formattedMessages) === VALIDATION_MESSAGE_TYPES.CRITICAL ? 'true' : undefined;
187+
},
188+
189+
ariaDescribedBy () {
190+
return this.showMessages && this.formattedMessages.length > 0 ? this.messagesId : undefined;
191+
},
192+
176193
inputListeners () {
177194
return {
178195
/*

packages/dialtone-vue/components/CheckboxGroup/CheckboxGroup.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('Checkbox Group Tests', () => {
146146

147147
updateWrapper();
148148

149-
expect(checkboxGroupMessages.exists()).toBe(false);
149+
expect(checkboxGroupMessages.findAll('[data-qa="validation-message"]').length).toBe(0);
150150
});
151151
});
152152
});

packages/dialtone-vue/components/ComboboxMultiSelect/ComboboxMultiSelect.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,39 @@ describe('DtComboboxMultiSelect Tests', () => {
261261
});
262262
});
263263
});
264+
265+
describe('ARIA validation wiring', () => {
266+
describe('When a critical validation message is provided', () => {
267+
beforeEach(async () => {
268+
await wrapper.setProps({
269+
maxSelected: 1,
270+
maxSelectedMessage: [{ message: 'Too many selected', type: VALIDATION_MESSAGE_TYPES.CRITICAL }],
271+
selectedItems: ['item1', 'item2'],
272+
});
273+
await flushPromises();
274+
_setChildWrappers();
275+
});
276+
277+
it('should set aria-invalid on the input', () => {
278+
expect(input.attributes('aria-invalid')).toBe('true');
279+
});
280+
281+
it('should set aria-describedby on the input pointing to the messages container', () => {
282+
const messagesContainer = wrapper.find('[data-qa="validation-messages-container"]');
283+
expect(input.attributes('aria-describedby')).toBe(messagesContainer.attributes('id'));
284+
});
285+
});
286+
287+
describe('When no validation messages are provided', () => {
288+
it('should not set aria-invalid on the input', () => {
289+
expect(input.attributes('aria-invalid')).toBeUndefined();
290+
});
291+
292+
it('should not set aria-describedby on the input', () => {
293+
expect(input.attributes('aria-describedby')).toBeUndefined();
294+
});
295+
});
296+
});
264297
});
265298

266299
describe('Interactivity Tests', () => {

packages/dialtone-vue/components/ComboboxMultiSelect/ComboboxMultiSelect.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
:input-wrapper-class="inputWrapperClass"
6060
:disabled="disabled"
6161
:aria-label="label"
62+
:aria-invalid="ariaInvalid"
63+
:aria-describedby="ariaDescribedBy"
6264
:label="showLabel ? label : ''"
6365
:description="description"
6466
:placeholder="inputPlaceHolder"
@@ -70,6 +72,7 @@
7072
/>
7173

7274
<dt-validation-messages
75+
:id="messagesId"
7376
:validation-messages="maxSelectedMessage"
7477
:show-messages="showValidationMessages"
7578
/>
@@ -131,15 +134,15 @@ import DtInput from '@/components/Input/Input.vue';
131134
import DtChip from '@/components/Chip/Chip.vue';
132135
import DtValidationMessages from '@/components/ValidationMessages/ValidationMessages.vue';
133136
import { validationMessageValidator } from '@/common/validators';
134-
import { extractVueListeners, extractNonListeners, hasSlotContent, returnFirstEl } from '@/common/utils';
137+
import { extractVueListeners, extractNonListeners, hasSlotContent, returnFirstEl, getUniqueString, getValidationState } from '@/common/utils';
135138
import {
136139
POPOVER_APPEND_TO_VALUES,
137140
} from '@/components/Popover/PopoverConstants';
138141
import {
139142
CHIP_SIZES,
140143
CHIP_TOP_POSITION,
141144
} from './ComboboxMultiSelectConstants';
142-
import { COMPONENT_SIZES } from '@/common/constants';
145+
import { COMPONENT_SIZES, VALIDATION_MESSAGE_TYPES } from '@/common/constants';
143146
144147
export default {
145148
name: 'DtComboboxMultiSelect',
@@ -503,6 +506,7 @@ export default {
503506
hasSlotContent,
504507
inputFocused: false,
505508
hideInputText: false,
509+
messagesId: getUniqueString(),
506510
};
507511
},
508512
@@ -553,6 +557,14 @@ export default {
553557
};
554558
},
555559
560+
ariaInvalid () {
561+
return getValidationState(this.maxSelectedMessage) === VALIDATION_MESSAGE_TYPES.CRITICAL ? 'true' : undefined;
562+
},
563+
564+
ariaDescribedBy () {
565+
return this.showValidationMessages && this.maxSelectedMessage.length > 0 ? this.messagesId : undefined;
566+
},
567+
556568
chipWrapperClass () {
557569
return {
558570
[`d-recipe-combobox-multi-select__chip-wrapper-${COMPONENT_SIZES[String(this.size)] || this.size}--collapsed`]: !this.inputFocused && this.collapseOnFocusOut,

packages/dialtone-vue/components/Input/Input.test.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { mount } from '@vue/test-utils';
22
import { INPUT_SIZES } from './InputConstants';
3+
import { VALIDATION_MESSAGE_TYPES } from '@/common/constants';
34
import { DtIcon } from '@/components/Icon';
45
import { DtText } from '@/components/Text';
56
import DtInput from './Input.vue';
@@ -51,7 +52,7 @@ describe('DtInput tests', () => {
5152
label = wrapper.find('[data-qa="dt-input-label"]');
5253
description = wrapper.find('[data-qa="dt-input-description"]');
5354
nativeInput = wrapper.find('input');
54-
nativeTextarea = wrapper.find('textarea');
55+
nativeTextarea = wrapper.find('[data-qa="dt-input-input"]');
5556
leftIconWrapper = wrapper.find('[data-qa="dt-input-left-icon-wrapper"]');
5657
rightIconWrapper = wrapper.find('[data-qa="dt-input-right-icon-wrapper"]');
5758
};
@@ -143,7 +144,7 @@ describe('DtInput tests', () => {
143144

144145
updateWrapper();
145146

146-
nativeTextarea = wrapper.find('textarea');
147+
nativeTextarea = wrapper.find('[data-qa="dt-input-input"]');
147148

148149
expect(nativeTextarea.attributes('aria-label')).toBe(baseProps.label);
149150
});
@@ -828,6 +829,76 @@ describe('DtInput tests', () => {
828829
});
829830
});
830831

832+
describe('Accessibility Tests', () => {
833+
describe('ARIA validation wiring', () => {
834+
describe('When type is input', () => {
835+
describe('When a critical validation message is provided', () => {
836+
beforeEach(() => {
837+
mockProps = { messages: [{ message: 'Error', type: VALIDATION_MESSAGE_TYPES.CRITICAL }] };
838+
839+
updateWrapper();
840+
});
841+
842+
it('should set aria-invalid on the input', () => {
843+
expect(nativeInput.attributes('aria-invalid')).toBe('true');
844+
});
845+
846+
it('should set aria-describedby on the input pointing to the messages container', () => {
847+
const messagesContainer = wrapper.find('[data-qa="dt-input-messages"]');
848+
849+
expect(nativeInput.attributes('aria-describedby')).toBe(messagesContainer.attributes('id'));
850+
});
851+
});
852+
853+
describe('When no validation messages are provided', () => {
854+
it('should not set aria-invalid on the input', () => {
855+
expect(nativeInput.attributes('aria-invalid')).toBeUndefined();
856+
});
857+
858+
it('should not set aria-describedby on the input', () => {
859+
expect(nativeInput.attributes('aria-describedby')).toBeUndefined();
860+
});
861+
});
862+
});
863+
864+
describe('When type is textarea', () => {
865+
describe('When a critical validation message is provided', () => {
866+
beforeEach(() => {
867+
mockProps = { type: 'textarea', messages: [{ message: 'Error', type: VALIDATION_MESSAGE_TYPES.CRITICAL }] };
868+
869+
updateWrapper();
870+
});
871+
872+
it('should set aria-invalid on the textarea', () => {
873+
expect(wrapper.find('[data-qa="dt-input-input"]').attributes('aria-invalid')).toBe('true');
874+
});
875+
876+
it('should set aria-describedby on the textarea pointing to the messages container', () => {
877+
const messagesContainer = wrapper.find('[data-qa="dt-input-messages"]');
878+
879+
expect(wrapper.find('[data-qa="dt-input-input"]').attributes('aria-describedby')).toBe(messagesContainer.attributes('id'));
880+
});
881+
});
882+
883+
describe('When no validation messages are provided', () => {
884+
beforeEach(() => {
885+
mockProps = { type: 'textarea' };
886+
887+
updateWrapper();
888+
});
889+
890+
it('should not set aria-invalid on the textarea', () => {
891+
expect(wrapper.find('[data-qa="dt-input-input"]').attributes('aria-invalid')).toBeUndefined();
892+
});
893+
894+
it('should not set aria-describedby on the textarea', () => {
895+
expect(wrapper.find('[data-qa="dt-input-input"]').attributes('aria-describedby')).toBeUndefined();
896+
});
897+
});
898+
});
899+
});
900+
});
901+
831902
describe('Extendability Tests', () => {
832903
it('should handle pass through props/attrs', async () => {
833904
expect(nativeInput.attributes()).toMatchObject(baseAttrs);

packages/dialtone-vue/components/Input/Input.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686
:class="inputClasses()"
8787
:maxlength="shouldLimitMaxLength ? validationProps.length.max : null"
8888
:aria-label="!showLabel && label ? label : undefined"
89+
:aria-invalid="ariaInvalid"
90+
:aria-describedby="ariaDescribedBy"
8991
data-qa="dt-input-input"
9092
v-bind="removeClassStyleAttrs($attrs)"
9193
v-on="inputListeners"
@@ -101,6 +103,8 @@
101103
:class="inputClasses()"
102104
:maxlength="shouldLimitMaxLength ? validationProps.length.max : null"
103105
:aria-label="!showLabel && label ? label : undefined"
106+
:aria-invalid="ariaInvalid"
107+
:aria-describedby="ariaDescribedBy"
104108
data-qa="dt-input-input"
105109
v-bind="removeClassStyleAttrs($attrs)"
106110
v-on="inputListeners"
@@ -128,10 +132,11 @@
128132
</div>
129133
</label>
130134
<dt-validation-messages
135+
v-bind="messagesChildProps"
136+
:id="messagesId"
131137
:validation-messages="validationMessages"
132138
:show-messages="showMessages"
133139
:class="messagesClass"
134-
v-bind="messagesChildProps"
135140
data-qa="dt-input-messages"
136141
/>
137142
</div>
@@ -431,6 +436,7 @@ export default {
431436
hasSlotContent,
432437
isComposing: false,
433438
justEndedComposition: false,
439+
messagesId: getUniqueString(),
434440
};
435441
},
436442
@@ -515,6 +521,14 @@ export default {
515521
return getValidationState(this.validationMessages);
516522
},
517523
524+
ariaInvalid () {
525+
return this.inputState === VALIDATION_MESSAGE_TYPES.CRITICAL ? 'true' : undefined;
526+
},
527+
528+
ariaDescribedBy () {
529+
return this.showMessages && this.validationMessages.length > 0 ? this.messagesId : undefined;
530+
},
531+
518532
defaultLengthCalculation () {
519533
return this.calculateLength(this.modelValue);
520534
},

0 commit comments

Comments
 (0)