Skip to content

Commit

Permalink
feat(fe:FSADT1-1006): announce input field inline error messages (#904)
Browse files Browse the repository at this point in the history
  • Loading branch information
fterra-encora authored Apr 3, 2024
1 parent 0bf884c commit eb82c4c
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 52 deletions.
57 changes: 43 additions & 14 deletions frontend/src/components/forms/AutoCompleteInputComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,42 @@ We need the mock one (with no suffix) when the component mounts with a pre-fille
*/
const getComboBoxItemValue = (item: CodeNameType) => item.name + (item.code ? nameSuffix : "");
watch([cdsComboBoxRef, () => props.required, () => props.label], async ([cdsComboBox]) => {
if (cdsComboBox) {
// wait for the DOM updates to complete
await nextTick();
const input = cdsComboBox.shadowRoot?.querySelector("input");
if (input) {
// Propagate attributes to the input
input.required = props.required;
input.ariaLabel = props.label;
const ariaInvalidString = computed(() => (error.value ? "true" : "false"));
const isFocused = ref(false);
watch(
[cdsComboBoxRef, () => props.required, () => props.label, isFocused, ariaInvalidString],
async ([cdsComboBox]) => {
if (cdsComboBox) {
// wait for the DOM updates to complete
await nextTick();
const helperTextId = "helper-text";
const helperText = cdsComboBox.shadowRoot?.querySelector("[name='helper-text']");
if (helperText) {
helperText.id = helperTextId;
// For some reason the role needs to be dynamically changed to "alert" to announce.
if (isFocused.value) {
helperText.role = "generic";
} else {
helperText.role = ariaInvalidString.value === "true" ? "alert" : "generic";
}
}
const input = cdsComboBox.shadowRoot?.querySelector("input");
if (input) {
// Propagate attributes to the input
input.required = props.required;
input.ariaLabel = props.label;
input.ariaInvalid = ariaInvalidString.value;
// Use the helper text as a field description
input.setAttribute("aria-describedby", helperTextId);
}
}
}
});
},
);
// For some reason, if helper-text is empty, invalid-text message doesn't work.
const safeHelperText = computed(() => props.tip || " ");
Expand All @@ -206,11 +229,17 @@ const safeHelperText = computed(() => props.tip || " ");
:value="inputValue"
filterable
:invalid="error ? true : false"
:aria-invalid="ariaInvalidString"
:invalid-text="error"
aria-live="polite"
@cds-combo-box-selected="selectAutocompleteItem"
v-on:input="onTyping"
v-on:blur="(event: any) => validateInput(event.srcElement._filterInputValue)"
@focus="isFocused = true"
@blur="
(event: any) => {
isFocused = false;
validateInput(event.srcElement._filterInputValue);
}
"
:data-focus="id"
:data-scroll="id"
:data-id="'input-' + id"
Expand Down
34 changes: 31 additions & 3 deletions frontend/src/components/forms/DropdownInputComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,22 +145,44 @@ watch(
revalidateBus.on(() => validateInput(selectedValue.value));
const ariaInvalidString = computed(() => (error.value ? "true" : "false"));
const isFocused = ref(false);
// This is an array due to the v-for attribute.
const cdsComboBoxArrayRef = ref<InstanceType<typeof CDSComboBox>[] | null>(null);
watch(
[cdsComboBoxArrayRef, () => props.required, () => props.label],
[cdsComboBoxArrayRef, () => props.required, () => props.label, isFocused, ariaInvalidString],
async ([cdsComboBoxArray]) => {
if (cdsComboBoxArray) {
// wait for the DOM updates to complete
await nextTick();
const combo = cdsComboBoxArray[0];
const input = combo?.shadowRoot?.querySelector("input");
const helperTextId = "helper-text";
const helperText = combo.shadowRoot?.querySelector("[name='helper-text']");
if (helperText) {
helperText.id = helperTextId;
// For some reason the role needs to be dynamically changed to "alert" to announce.
if (isFocused.value) {
helperText.role = "generic";
} else {
helperText.role = ariaInvalidString.value === "true" ? "alert" : "generic";
}
}
if (input) {
// Propagate attributes to the input
input.required = props.required;
input.ariaLabel = props.label;
input.ariaInvalid = ariaInvalidString.value;
// Use the helper text as a field description
input.setAttribute("aria-describedby", helperTextId);
}
}
},
Expand Down Expand Up @@ -189,10 +211,16 @@ const safeHelperText = computed(() => props.tip || " ");
:label="placeholder"
:value="selectedValue"
:invalid="error ? true : false"
:aria-invalid="ariaInvalidString"
:invalidText="error"
aria-live="polite"
@cds-combo-box-selected="selectItem"
@blur="(event: any) => validateInput(event.target.value)"
@focus="isFocused = true"
@blur="
(event: any) => {
isFocused = false;
validateInput(event.target.value);
}
"
:data-focus="id"
:data-scroll="id"
v-shadow="3"
Expand Down
56 changes: 43 additions & 13 deletions frontend/src/components/forms/MultiselectInputComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,44 @@ watch(
revalidateBus.on(() => validateInput(selectedValue.value));
const cdsMultiSelectRef = ref<InstanceType<typeof CDSMultiSelect> | null>(null);
const ariaInvalidString = computed(() => (error.value ? "true" : "false"));
const isFocused = ref(false);
watch([cdsMultiSelectRef, () => props.required], async ([cdsMultiSelect]) => {
if (cdsMultiSelect) {
// wait for the DOM updates to complete
await nextTick();
const cdsMultiSelectRef = ref<InstanceType<typeof CDSMultiSelect> | null>(null);
const triggerDiv = cdsMultiSelect.shadowRoot?.querySelector("div[role='button']");
if (triggerDiv) {
// Properly indicate as required.
triggerDiv.ariaRequired = props.required ? "true" : "false";
watch(
[cdsMultiSelectRef, () => props.required, isFocused, ariaInvalidString],
async ([cdsMultiSelect]) => {
if (cdsMultiSelect) {
// wait for the DOM updates to complete
await nextTick();
const helperTextId = "helper-text";
const helperText = cdsMultiSelect.shadowRoot?.querySelector("[name='helper-text']");
if (helperText) {
helperText.id = helperTextId;
// For some reason the role needs to be dynamically changed to "alert" to announce.
if (isFocused.value) {
helperText.role = "generic";
} else {
helperText.role = ariaInvalidString.value === "true" ? "alert" : "generic";
}
}
const triggerDiv = cdsMultiSelect.shadowRoot?.querySelector("div[role='button']");
if (triggerDiv) {
// Properly indicate as required.
triggerDiv.ariaRequired = props.required ? "true" : "false";
triggerDiv.ariaInvalid = ariaInvalidString.value;
// Use the helper text as a field description
triggerDiv.setAttribute("aria-describedby", helperTextId);
}
}
}
});
},
);
</script>

<template>
Expand All @@ -144,11 +168,17 @@ watch([cdsMultiSelectRef, () => props.required], async ([cdsMultiSelect]) => {
:data-required-label="requiredLabel"
:helper-text="tip"
:invalid="error ? true : false"
:aria-invalid="ariaInvalidString"
:invalid-text="error"
aria-live="polite"
filterable
@cds-multi-select-selected="selectItems"
@blur="(event: any) => validateInput(event.target.value)"
@focus="isFocused = true"
@blur="
(event: any) => {
isFocused = false;
validateInput(event.target.value);
}
"
:data-focus="id"
:data-scroll="id"
>
Expand Down
50 changes: 43 additions & 7 deletions frontend/src/components/forms/RadioInputComponent.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { ref, watch, nextTick, computed } from "vue";
// Carbon
import "@carbon/web-components/es/components/radio-button/index";
import type { CDSRadioButtonGroup, CDSRadioButton } from "@carbon/web-components";
Expand Down Expand Up @@ -72,17 +72,47 @@ const updateSelectedValue = (event: any) =>
(selectedValue.value = event.detail.value);
revalidateBus.on(() => validateInput());
const cdsRadioButtonGroup = ref<InstanceType<typeof CDSRadioButtonGroup> | null>(null);
const ariaInvalidString = computed(() => (error.value ? "true" : "false"));
watch(cdsRadioButtonGroup, async (value) => {
if (value) {
const isFocused = ref(false);
const cdsRadioButtonGroupRef = ref<InstanceType<typeof CDSRadioButtonGroup> | null>(null);
watch([cdsRadioButtonGroupRef, isFocused, ariaInvalidString], async ([cdsRadioButtonGroup]) => {
if (cdsRadioButtonGroup) {
// wait for the DOM updates to complete
await nextTick();
const fieldset = value.shadowRoot?.querySelector("fieldset");
const helperTextId = "helper-text";
const helperText = cdsRadioButtonGroup.shadowRoot?.querySelector(".cds--form__helper-text");
if (helperText) {
helperText.id = helperTextId;
}
const invalidTextId = "invalid-text";
const invalidText = cdsRadioButtonGroup.shadowRoot?.querySelector(".cds--form-requirement");
if (invalidText) {
invalidText.id = invalidTextId;
// For some reason the role needs to be dynamically changed to "alert" to announce.
if (isFocused.value) {
invalidText.role = "generic";
} else {
invalidText.role = ariaInvalidString.value === "true" ? "alert" : "generic";
}
}
const fieldset = cdsRadioButtonGroup.shadowRoot?.querySelector("fieldset");
if (fieldset) {
fieldset.role = "radiogroup";
fieldset.setAttribute("aria-label", props.label);
fieldset.ariaInvalid = ariaInvalidString.value;
// Use the helper text as a field description
fieldset.setAttribute(
"aria-describedby",
ariaInvalidString.value === "true" ? invalidTextId : helperTextId,
);
}
}
});
Expand All @@ -108,13 +138,16 @@ watch([cdsRadioButtonArrayRef, () => props.required], async (cdsRadioButtonArray
}
}
});
// For the radio-button component the Screen Reader has proved more responsive to alerts with aria-live set.
const ariaLive = "polite";
</script>

<template>
<div class="grouping-01">
<div class="input-group">
<cds-radio-button-group
ref="cdsRadioButtonGroup"
ref="cdsRadioButtonGroupRef"
:id="id + 'rb'"
:name="id + 'rb'"
:legend-text="label"
Expand All @@ -124,8 +157,9 @@ watch([cdsRadioButtonArrayRef, () => props.required], async (cdsRadioButtonArray
:helper-text="tip"
v-model="selectedValue"
:invalid="error ? true : false"
:aria-invalid="ariaInvalidString"
:invalid-text="error"
aria-live="polite"
:aria-live="ariaLive"
@cds-radio-button-group-changed="updateSelectedValue"
:data-focus="id"
:data-scroll="id"
Expand All @@ -141,6 +175,8 @@ watch([cdsRadioButtonArrayRef, () => props.required], async (cdsRadioButtonArray
:value="option.value"
role="radio"
:aria-checked="selectedValue === option.value"
@focus="isFocused = true"
@blur="isFocused = false"
/>
</cds-radio-button-group>
</div>
Expand Down
Loading

0 comments on commit eb82c4c

Please sign in to comment.