diff --git a/ui/src/components/field/QField.json b/ui/src/components/field/QField.json index a677cd84fe0..9963a4b3033 100644 --- a/ui/src/components/field/QField.json +++ b/ui/src/components/field/QField.json @@ -62,6 +62,21 @@ } }, "returns": null + }, + "ariaInvalid": { + "type": "String", + "desc": "Value for the control's 'aria-invalid' attribute when the field has an error", + "examples": ["'true'"] + }, + "ariaDescribedby": { + "type": "String", + "desc": "Element id for the field error message; Use it for the control's 'aria-describedby' attribute when rendering a custom control", + "examples": ["'qf_363270c0-7a83-62b1-8dcf-6dfd64ee38fa_error'"] + }, + "ariaErrormessage": { + "type": "String", + "desc": "Element id for the field error message; Use it for the control's 'aria-errormessage' attribute when rendering a custom control", + "examples": ["'qf_363270c0-7a83-62b1-8dcf-6dfd64ee38fa_error'"] } } }, diff --git a/ui/src/components/field/QField.test.js b/ui/src/components/field/QField.test.js new file mode 100644 index 00000000000..913ea95602a --- /dev/null +++ b/ui/src/components/field/QField.test.js @@ -0,0 +1,35 @@ +import { mount } from '@vue/test-utils' +import { h } from 'vue' +import { describe, expect, test } from 'vitest' + +import QField from './QField.js' + +describe('[QField API]', () => { + describe('[Accessibility]', () => { + test('passes error ARIA values to custom control slots', () => { + const wrapper = mount(QField, { + props: { + modelValue: '', + error: true, + errorMessage: 'Choose a value' + }, + slots: { + control: scope => + h('input', { + id: scope.id, + 'aria-invalid': scope.ariaInvalid, + 'aria-describedby': scope.ariaDescribedby, + 'aria-errormessage': scope.ariaErrormessage + }) + } + }) + + const input = wrapper.get('input') + const messageId = wrapper.get('.q-field__messages').attributes('id') + + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.attributes('aria-describedby')).toBe(messageId) + expect(input.attributes('aria-errormessage')).toBe(messageId) + }) + }) +}) diff --git a/ui/src/components/input/QInput.js b/ui/src/components/input/QInput.js index db83806d45d..3a732ef50f5 100644 --- a/ui/src/components/input/QInput.js +++ b/ui/src/components/input/QInput.js @@ -153,6 +153,18 @@ export default createComponent({ readonly: props.readonly } + if (state.hasError?.value === true) { + acc['aria-invalid'] = 'true' + + if (state.errorMessageId?.value !== void 0) { + acc['aria-errormessage'] = state.errorMessageId.value + acc['aria-describedby'] = + acc['aria-describedby'] !== void 0 + ? `${acc['aria-describedby']} ${state.errorMessageId.value}` + : state.errorMessageId.value + } + } + if (!isTextarea.value) { acc.type = props.type } diff --git a/ui/src/components/input/QInput.test.js b/ui/src/components/input/QInput.test.js new file mode 100644 index 00000000000..bab5d989c76 --- /dev/null +++ b/ui/src/components/input/QInput.test.js @@ -0,0 +1,49 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, test } from 'vitest' + +import QInput from './QInput.js' + +describe('[QInput API]', () => { + describe('[Accessibility]', () => { + test('exposes error state and message relationship on the native control', () => { + const wrapper = mount(QInput, { + props: { + modelValue: '', + label: 'Email', + error: true, + errorMessage: 'Email is required' + } + }) + + const input = wrapper.get('input') + const message = wrapper.get('.q-field__messages') + const messageId = message.attributes('id') + + expect(message.text()).toBe('Email is required') + expect(messageId).toBeTruthy() + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.attributes('aria-errormessage')).toBe(messageId) + expect(input.attributes('aria-describedby')?.split(' ')).toContain( + messageId + ) + }) + + test('preserves existing aria-describedby values when adding the error message', () => { + const wrapper = mount(QInput, { + attrs: { + 'aria-describedby': 'external-help' + }, + props: { + modelValue: '', + error: true, + errorMessage: 'Required' + } + }) + + const describedBy = wrapper.get('input').attributes('aria-describedby') + const messageId = wrapper.get('.q-field__messages').attributes('id') + + expect(describedBy?.split(' ')).toEqual(['external-help', messageId]) + }) + }) +}) diff --git a/ui/src/composables/private.use-field/use-field.js b/ui/src/composables/private.use-field/use-field.js index 3494002b2bd..c5144c3dd22 100644 --- a/ui/src/composables/private.use-field/use-field.js +++ b/ui/src/composables/private.use-field/use-field.js @@ -207,6 +207,18 @@ export default function useField(state) { const { isDirtyModel, hasRules, hasError, errorMessage, resetValidation } = useValidate(state.focused, state.innerLoading) + const errorMessageId = computed(() => + hasError.value && state.targetUid.value + ? `${state.targetUid.value}_error` + : void 0 + ) + + Object.assign(state, { + hasError, + errorMessage, + errorMessageId + }) + const floatingLabel = state.floatingLabel !== void 0 ? computed( @@ -290,7 +302,10 @@ export default function useField(state) { focused: state.focused.value, floatingLabel: floatingLabel.value, modelValue: props.modelValue, - emitValue: state.emitValue + emitValue: state.emitValue, + ariaInvalid: hasError.value === true ? 'true' : void 0, + ariaDescribedby: errorMessageId.value, + ariaErrormessage: errorMessageId.value })) const attributes = computed(() => { @@ -595,6 +610,7 @@ export default function useField(state) { 'div', { key, + id: hasError.value === true ? errorMessageId.value : void 0, class: 'q-field__messages col' }, msg