feat(a11y): associate form errors with input fields

Adds aria-invalid, aria-describedby, and role='alert' to error
messages in FormField and Password components so screen readers
announce validation errors.

Fixes WCAG 3.3.1 (Error Identification).
This commit is contained in:
kolaente 2026-04-12 14:18:57 +02:00 committed by kolaente
parent a0d0379e95
commit 4618f3491b
2 changed files with 21 additions and 3 deletions

View File

@ -35,6 +35,7 @@ const slots = useSlots()
const generatedId = useId()
const inputId = computed(() => props.id ?? generatedId)
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
const hasAddon = computed(() => !!slots.addon)
const fieldClasses = computed(() => [
@ -82,13 +83,18 @@ defineExpose({
class="two-col"
>
<span>{{ label }}</span>
<slot :id="inputId">
<slot
:id="inputId"
:error-id="errorId"
>
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput"
>
</slot>
@ -109,13 +115,18 @@ defineExpose({
{{ label }}
</label>
<div :class="controlClasses">
<slot :id="inputId">
<slot
:id="inputId"
:error-id="errorId"
>
<input
:id="inputId"
ref="inputRef"
v-bind="{ ...$attrs, ...inputBindings }"
:class="inputClasses"
:disabled="disabled || undefined"
:aria-invalid="error ? true : undefined"
:aria-describedby="errorId"
@input="handleInput"
>
</slot>
@ -129,7 +140,9 @@ defineExpose({
</template>
<p
v-if="error"
:id="errorId"
class="help is-danger"
role="alert"
>
{{ error }}
</p>

View File

@ -9,6 +9,8 @@
:type="passwordFieldType"
:autocomplete="autocomplete"
:tabindex="tabindex"
:aria-invalid="isValid !== true ? true : undefined"
:aria-describedby="errorId"
@keyup.enter="e => $emit('submit', e)"
@focusout="() => {validate(); validateAfterFirst = true}"
@keyup="() => {validateAfterFirst ? validate() : null}"
@ -25,14 +27,16 @@
</div>
<p
v-if="isValid !== true"
:id="errorId"
class="help is-danger"
role="alert"
>
{{ isValid }}
</p>
</template>
<script lang="ts" setup>
import {ref, watchEffect} from 'vue'
import {computed, ref, watchEffect} from 'vue'
import {useDebounceFn} from '@vueuse/core'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
@ -61,6 +65,7 @@ const password = ref('')
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const isValid = ref<true | string>(props.validateInitially === true ? true : '')
const validateAfterFirst = ref(false)
const errorId = computed(() => isValid.value !== true ? 'password-error' : undefined)
const validate = useDebounceFn(() => {
const valid = validatePassword(password.value, props.validateMinLength)