Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/content/docs/2.components/form-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,33 @@ slots:
:u-input{placeholder="Enter your email" class="w-full"}
::

### Multiple fields

You can pass an array of names to the `name` prop to track errors for multiple fields. This is useful when you have a complex field that is composed of multiple inputs.
Make sure you're passing manually name to fields inside the form field to link fields to state (and then errors accordingly).
Error messages of fields inside the form field will be displayed in the form field itself.

::component-code
---
prettier: true
props:
label: Duration
name: ['duration.value', 'duration.unit']
slots:
default: |

<div class="flex gap-2">
<UInput name="duration.value" type="number" />
<USelect name="duration.unit" :items="['min', 'h']" />
</div>
---

<div class="flex gap-2">
<UInput name="duration.value" type="number" />
<USelect name="duration.unit" :items="['min', 'h']" />
</div>
::

### Error

Use the `error` prop to display an error message below the form control. When used together with the `help` prop, the `error` prop takes precedence.
Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/2.components/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ It requires two props:

Errors are reported directly to the [FormField](/docs/components/form-field) component based on the `name` or `error-pattern` prop. This means the validation rules defined for the `email` attribute in your schema will be applied to `<FormField name="email">`{lang="vue"}.

You can also [pass an array of names to the `name` prop](/docs/components/form-field#multiple-fields) to track errors for multiple fields. This is useful when you have a complex field that is composed of multiple inputs.

Nested validation rules are handled using dot notation. For example, a rule like `{ user: z.object({ email: z.string() }) }`{lang="ts"} will be applied to `<FormField name="user.email">`{lang="vue"}.

### Custom validation
Expand Down
13 changes: 11 additions & 2 deletions playgrounds/nuxt/app/pages/components/form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import FormExampleNested from '../../../../../docs/app/components/content/exampl

const schema = z.object({
email: z.email(),
password: z.string('Password is required').min(8),
tos: z.literal(true)
password: z.string().min(8, 'Password must be at least 8 characters'),
tos: z.literal(true),
duration: z.number().min(1),
durationUnit: z.enum(['min', 'h'])
})

type Schema = z.input<typeof schema>
Expand Down Expand Up @@ -42,6 +44,13 @@ const disabled = ref(false)
<UInput v-model="state.password" type="password" />
</UFormField>

<UFormField label="Duration" :name="['duration', 'durationUnit']">
<div class="flex items-center gap-2">
<UInput v-model="state.duration" name="duration" type="number" class="w-24" />
<USelect v-model="state.durationUnit" name="durationUnit" :items="['sec', 'min', 'h']" class="w-24" />
</div>
</UFormField>

<UFormField name="tos">
<UCheckbox v-model="state.tos" label="I accept the terms and conditions" />
</UFormField>
Expand Down
35 changes: 28 additions & 7 deletions src/runtime/components/FormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface FormFieldProps {
*/
as?: any
/** The name of the FormField. Also used to match form errors. */
name?: string
name?: string | string[]
/** A regular expression to match form error names. */
errorPattern?: RegExp
label?: string
Expand Down Expand Up @@ -60,26 +60,47 @@ const slots = defineSlots<FormFieldSlots>()

const appConfig = useAppConfig() as FormField['AppConfig']

const name = props.name ? (Array.isArray(props.name) ? props.name : [props.name]) : []

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })({
size: props.size,
required: props.required
}))

const formErrors = inject<Ref<FormError[]> | null>(formErrorsInjectionKey, null)

const error = computed(() => props.error || formErrors?.value?.find(error => error.name === props.name || (props.errorPattern && error.name?.match(props.errorPattern)))?.message)
const error = computed(() => {
if (props.error) return props.error
if (!formErrors?.value) return undefined

function matchPattern(errorName: string) {
return props.errorPattern && errorName?.match(props.errorPattern)
}

return formErrors.value.find(error => (
!error.name // if no name is provided, it matches any error
|| name.includes(error.name)
|| matchPattern(error.name)
))?.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

const formInputs = inject(formInputsInjectionKey, undefined)
watch(id, () => {
if (formInputs && props.name) {
formInputs.value[props.name] = { id: id.value, pattern: props.errorPattern }
}
}, { immediate: true })
watch(
id,
() => {
if (formInputs && name?.length > 0) {
name.forEach((name) => {
formInputs.value[name] = { id: id.value, pattern: props.errorPattern }
})
}
},
{ immediate: true }
)

provide(inputIdInjectionKey, id)

Expand Down
19 changes: 14 additions & 5 deletions src/runtime/composables/useFormField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,34 +43,43 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
}
}

const fieldName = computed(() => {
const name = props?.name ?? formField?.value.name
if (Array.isArray(name)) return undefined
return name
})

function emitFormEvent(type: FormInputEvents, name?: string, eager?: boolean) {
if (formBus && formField && name) {
formBus.emit({ type, name, eager })
}
}

function emitFormBlur() {
emitFormEvent('blur', formField?.value.name)
emitFormEvent('blur', fieldName.value)
}

function emitFormFocus() {
emitFormEvent('focus', formField?.value.name)
emitFormEvent('focus', fieldName.value)
}

function emitFormChange() {
emitFormEvent('change', formField?.value.name)
emitFormEvent('change', fieldName.value)
}

const emitFormInput = useDebounceFn(
() => {
emitFormEvent('input', formField?.value.name, !opts?.deferInputValidation || formField?.value.eagerValidation)
emitFormEvent('input', fieldName.value, !opts?.deferInputValidation || formField?.value.eagerValidation)
},
formField?.value.validateOnInputDelay ?? formOptions?.value.validateOnInputDelay ?? 0
)

return {
id: computed(() => props?.id ?? inputId?.value),
name: computed(() => props?.name ?? formField?.value.name),
name: computed(() => {
const name = props?.name ?? formField?.value.name
return Array.isArray(name) ? undefined : name
}),
size: computed(() => props?.size ?? formField?.value.size),
color: computed(() => formField?.value.error ? 'error' : props?.color),
highlight: computed(() => formField?.value.error ? true : props?.highlight),
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface FormInjectedOptions {
}

export interface FormFieldInjectedOptions<T> {
name?: string
name?: string | string[]
size?: GetObjectField<T, 'size'>
error?: string | boolean
eagerValidation?: boolean
Expand Down
Loading