Skip to content

Commit

Permalink
feat(FormField): set aria-describedby and aria-invalid attributes (
Browse files Browse the repository at this point in the history
  • Loading branch information
romhml authored Jan 20, 2025
1 parent b8d9972 commit b95b913
Show file tree
Hide file tree
Showing 21 changed files with 275 additions and 135 deletions.
4 changes: 2 additions & 2 deletions src/runtime/components/Checkbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const modelValue = defineModel<boolean | 'indeterminate'>({ default: undefined }
const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue'))
const appConfig = useAppConfig()
const { id: _id, emitFormChange, emitFormInput, size, color, name, disabled } = useFormField<CheckboxProps>(props)
const { id: _id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<CheckboxProps>(props)
const id = _id.value ?? useId()
const ui = computed(() => checkbox({
Expand All @@ -92,7 +92,7 @@ function onUpdate(value: any) {
<div :class="ui.container({ class: props.ui?.container })">
<CheckboxRoot
:id="id"
v-bind="rootProps"
v-bind="{ ...rootProps, ...ariaAttrs }"
v-model="modelValue"
:name="name"
:disabled="disabled"
Expand Down
14 changes: 10 additions & 4 deletions src/runtime/components/FormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
const error = computed(() => props.error || formErrors?.value?.find(error => error.name === props.name || (props.errorPattern && error.name.match(props.errorPattern)))?.message)
const id = ref(useId())
// Copies id's initial value to bind aria-attributes such as aria-describedby.
// This is required for the RadioGroup component which unsets the id value.
const ariaId = id.value
provide(inputIdInjectionKey, id)
Expand All @@ -75,7 +78,10 @@ provide(formFieldInjectionKey, computed(() => ({
size: props.size,
eagerValidation: props.eagerValidation,
validateOnInputDelay: props.validateOnInputDelay,
errorPattern: props.errorPattern
errorPattern: props.errorPattern,
hint: props.hint,
description: props.description,
ariaId
}) as FormFieldInjectedOptions<FormFieldProps>))
</script>

Expand All @@ -88,14 +94,14 @@ provide(formFieldInjectionKey, computed(() => ({
{{ label }}
</slot>
</Label>
<span v-if="hint || !!slots.hint" :class="ui.hint({ class: props.ui?.hint })">
<span v-if="hint || !!slots.hint" :id="`${ariaId}-hint`" :class="ui.hint({ class: props.ui?.hint })">
<slot name="hint" :hint="hint">
{{ hint }}
</slot>
</span>
</div>

<p v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<p v-if="description || !!slots.description" :id="`${ariaId}-description`" :class="ui.description({ class: props.ui?.description })">
<slot name="description" :description="description">
{{ description }}
</slot>
Expand All @@ -105,7 +111,7 @@ provide(formFieldInjectionKey, computed(() => ({
<div :class="[(label || !!slots.label || description || !!slots.description) && ui.container({ class: props.ui?.container })]">
<slot :error="error" />

<p v-if="(typeof error === 'string' && error) || !!slots.error" :class="ui.error({ class: props.ui?.error })">
<p v-if="(typeof error === 'string' && error) || !!slots.error" :id="`${ariaId}-error`" :class="ui.error({ class: props.ui?.error })">
<slot name="error" :error="error">
{{ error }}
</slot>
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const slots = defineSlots<InputSlots>()
const [modelValue, modelModifiers] = defineModel<string | number>()
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled } = useFormField<InputProps>(props, { deferInputValidation: true })
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props, { deferInputValidation: true })
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
Expand Down Expand Up @@ -166,7 +166,7 @@ onMounted(() => {
:disabled="disabled"
:required="required"
:autocomplete="autocomplete"
v-bind="$attrs"
v-bind="{ ...$attrs, ...ariaAttrs }"
@input="onInput"
@blur="onBlur"
@change="onChange"
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', '
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
const { emitFormBlur, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled } = useFormField<InputProps>(props)
const { emitFormBlur, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
Expand Down Expand Up @@ -365,7 +365,7 @@ defineExpose({
<ComboboxInput v-model="searchTerm" :display-value="displayValue" as-child>
<TagsInputInput
ref="inputRef"
v-bind="$attrs"
v-bind="{ ...$attrs, ...ariaAttrs }"
:placeholder="placeholder"
:required="required"
:class="ui.tagsInput({ class: props.ui?.tagsInput })"
Expand All @@ -379,7 +379,7 @@ defineExpose({
ref="inputRef"
v-model="searchTerm"
:display-value="displayValue"
v-bind="$attrs"
v-bind="{ ...$attrs, ...ariaAttrs }"
:type="type"
:placeholder="placeholder"
:required="required"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputNumber.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ defineSlots<InputNumberSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
const { emitFormBlur, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled } = useFormField<InputNumberProps>(props)
const { emitFormBlur, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
const { t, code: codeLocale } = useLocale()
const locale = computed(() => props.locale || codeLocale.value)
Expand Down Expand Up @@ -152,7 +152,7 @@ defineExpose({
@update:model-value="onUpdate"
>
<NumberFieldInput
v-bind="$attrs"
v-bind="{ ...$attrs, ...ariaAttrs }"
ref="inputRef"
:placeholder="placeholder"
:required="required"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/PinInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const props = withDefaults(defineProps<PinInputProps>(), {
const emits = defineEmits<PinInputEmits>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits)
const { emitFormInput, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled } = useFormField<PinInputProps>(props)
const { emitFormInput, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
const ui = computed(() => pinInput({
color: color.value,
Expand All @@ -77,7 +77,7 @@ function onBlur(event: FocusEvent) {

<template>
<PinInputRoot
v-bind="rootProps"
v-bind="{ ...rootProps, ...ariaAttrs }"
:id="id"
:name="name"
:class="ui.root({ class: [props.class, props.ui?.root] })"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/RadioGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const slots = defineSlots<RadioGroupSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'loop', 'required'), emits)
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled } = useFormField<RadioGroupProps<T>>(props, { bind: false })
const { emitFormChange, emitFormInput, color, name, size, id: _id, disabled, ariaAttrs } = useFormField<RadioGroupProps<T>>(props, { bind: false })
const id = _id.value ?? useId()
const ui = computed(() => radioGroup({
Expand Down Expand Up @@ -147,7 +147,7 @@ function onUpdate(value: any) {
:class="ui.root({ class: [props.class, props.ui?.root] })"
@update:model-value="onUpdate"
>
<fieldset :class="ui.fieldset({ class: props.ui?.fieldset })">
<fieldset :class="ui.fieldset({ class: props.ui?.fieldset })" v-bind="ariaAttrs">
<legend v-if="legend || !!slots.legend" :class="ui.legend({ class: props.ui?.legend })">
<slot name="legend">
{{ legend }}
Expand Down
11 changes: 6 additions & 5 deletions src/runtime/components/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,11 @@ const emits = defineEmits<SelectEmits<T, V, M>>()
const slots = defineSlots<SelectSlots<T, M>>()
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'disabled', 'autocomplete', 'required', 'multiple'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'disabled', 'autocomplete', 'required', 'multiple'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as SelectContentProps)
const arrowProps = toRef(() => props.arrow as SelectArrowProps)
const { emitFormChange, emitFormInput, emitFormBlur, size: formGroupSize, color, id, name, highlight, disabled } = useFormField<InputProps>(props)
const { emitFormChange, emitFormInput, emitFormBlur, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
Expand Down Expand Up @@ -186,16 +186,17 @@ function onUpdateOpen(value: boolean) {
<!-- eslint-disable vue/no-template-shadow -->
<template>
<SelectRoot
:id="id"
v-slot="{ modelValue, open }"
v-bind="rootProps"
:name="name"
v-bind="rootProps"
:autocomplete="autocomplete"
:disabled="disabled"
:default-value="defaultValue as (AcceptableValue | AcceptableValue[] | undefined)"
:model-value="modelValue as (AcceptableValue | AcceptableValue[] | undefined)"
@update:model-value="onUpdate"
@update:open="onUpdateOpen"
>
<SelectTrigger :class="ui.base({ class: [props.class, props.ui?.base] })">
<SelectTrigger :id="id" :class="ui.base({ class: [props.class, props.ui?.base] })" v-bind="ariaAttrs">
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :model-value="(modelValue as M extends true ? AcceptableValue[] : AcceptableValue)" :open="open" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffse
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as InputProps)
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled } = useFormField<InputProps>(props)
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
Expand Down Expand Up @@ -298,7 +298,7 @@ function onUpdateOpen(value: boolean) {
<ComboboxRoot
:id="id"
v-slot="{ modelValue, open }"
v-bind="rootProps"
v-bind="{ ...rootProps, ...ariaAttrs }"
ignore-filter
as-child
:name="name"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Slider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const modelValue = defineModel<number | number[]>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'orientation', 'min', 'max', 'step', 'minStepsBetweenThumbs', 'inverted'), emits)
const { id, emitFormChange, emitFormInput, size, color, name, disabled } = useFormField<SliderProps>(props)
const { id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<SliderProps>(props)
const defaultSliderValue = computed(() => {
if (typeof props.defaultValue === 'number') {
Expand Down Expand Up @@ -95,7 +95,7 @@ function onChange(value: any) {

<template>
<SliderRoot
v-bind="rootProps"
v-bind="{ ...rootProps, ...ariaAttrs }"
:id="id"
v-model="sliderValue"
:name="name"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Switch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const modelValue = defineModel<boolean>({ default: undefined })
const appConfig = useAppConfig()
const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue'))
const { id: _id, emitFormChange, emitFormInput, size, color, name, disabled } = useFormField<SwitchProps>(props)
const { id: _id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<SwitchProps>(props)
const id = _id.value ?? useId()
const ui = computed(() => switchTv({
Expand All @@ -93,7 +93,7 @@ function onUpdate(value: any) {
<div :class="ui.container({ class: props.ui?.container })">
<SwitchRoot
:id="id"
v-bind="rootProps"
v-bind="{ ...rootProps, ...ariaAttrs }"
v-model="modelValue"
:name="name"
:disabled="disabled || loading"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Textarea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const emits = defineEmits<TextareaEmits>()
const [modelValue, modelModifiers] = defineModel<string | number>()
const { emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled } = useFormField<TextareaProps>(props, { deferInputValidation: true })
const { emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps>(props, { deferInputValidation: true })
const ui = computed(() => textarea({
color: color.value,
Expand Down Expand Up @@ -185,7 +185,7 @@ onMounted(() => {
:class="ui.base({ class: props.ui?.base })"
:disabled="disabled"
:required="required"
v-bind="$attrs"
v-bind="{ ...$attrs, ...ariaAttrs }"
@input="onInput"
@blur="onBlur"
@change="onChange"
Expand Down
18 changes: 15 additions & 3 deletions src/runtime/composables/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ type Props<T> = {
name?: string
size?: GetObjectField<T, 'size'>
color?: GetObjectField<T, 'color'>
legend?: string
highlight?: boolean
disabled?: boolean
}
Expand All @@ -29,13 +28,14 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
const inputId = inject(inputIdInjectionKey, undefined)

if (formField && inputId) {
if (opts?.bind === false || props?.legend) {
if (opts?.bind === false) {
// Removes for="..." attribute on label for RadioGroup and alike.
inputId.value = undefined
} else if (props?.id) {
// Updates for="..." attribute on label if props.id is provided.
inputId.value = props?.id
}

if (formInputs && formField.value.name && inputId.value) {
formInputs.value[formField.value.name] = { id: inputId.value, pattern: formField.value.errorPattern }
}
Expand Down Expand Up @@ -77,6 +77,18 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
disabled: computed(() => formOptions?.value.disabled || props?.disabled),
emitFormBlur,
emitFormInput,
emitFormChange
emitFormChange,
ariaAttrs: computed(() => {
if (!formField?.value) return

const descriptiveAttrs = ['error' as const, 'hint' as const, 'description' as const]
.filter(type => formField?.value?.[type])
.map(type => `${formField?.value.ariaId}-${type}`) || []

return {
'aria-describedby': descriptiveAttrs.join(' '),
'aria-invalid': !!formField?.value.error
}
})
}
}
3 changes: 3 additions & 0 deletions src/runtime/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export interface FormFieldInjectedOptions<T> {
eagerValidation?: boolean
validateOnInputDelay?: number
errorPattern?: RegExp
hint?: string
description?: string
ariaId: string
}

export interface ValidateReturnSchema<T> {
Expand Down
Loading

0 comments on commit b95b913

Please sign in to comment.