From b42415c4fd9f91b9972b3d4c0e19c57815293333 Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Sun, 23 Nov 2025 00:22:21 +0100 Subject: [PATCH 1/2] feat(FieldForm): add multiple fields support --- docs/content/docs/2.components/form-field.md | 27 ++++++++++++++ docs/content/docs/2.components/form.md | 2 ++ .../nuxt/app/pages/components/form.vue | 13 +++++-- src/runtime/components/FormField.vue | 35 +++++++++++++++---- src/runtime/composables/useFormField.ts | 19 +++++++--- src/runtime/types/form.ts | 2 +- 6 files changed, 83 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/2.components/form-field.md b/docs/content/docs/2.components/form-field.md index da1a402f00..3d0932a1ca 100644 --- a/docs/content/docs/2.components/form-field.md +++ b/docs/content/docs/2.components/form-field.md @@ -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: | + +
+ + +
+--- + +
+ + +
+:: + ### 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. diff --git a/docs/content/docs/2.components/form.md b/docs/content/docs/2.components/form.md index 4bd2642520..821d29c7d1 100644 --- a/docs/content/docs/2.components/form.md +++ b/docs/content/docs/2.components/form.md @@ -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 ``{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 ``{lang="vue"}. ### Custom validation diff --git a/playgrounds/nuxt/app/pages/components/form.vue b/playgrounds/nuxt/app/pages/components/form.vue index 9e88f0705d..92127cc388 100644 --- a/playgrounds/nuxt/app/pages/components/form.vue +++ b/playgrounds/nuxt/app/pages/components/form.vue @@ -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 @@ -42,6 +44,13 @@ const disabled = ref(false) + +
+ + +
+
+ diff --git a/src/runtime/components/FormField.vue b/src/runtime/components/FormField.vue index ffe5381497..4be1f70d3d 100644 --- a/src/runtime/components/FormField.vue +++ b/src/runtime/components/FormField.vue @@ -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 @@ -67,7 +67,18 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.formField || const formErrors = inject | 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 + + if (props.name) { + if (Array.isArray(props.name)) { + return formErrors.value.find(error => props.name!.includes(error.name!) || (props.errorPattern && error.name?.match(props.errorPattern)))?.message + } + return formErrors.value.find(error => error.name === props.name || (props.errorPattern && error.name?.match(props.errorPattern)))?.message + } + return undefined +}) const id = ref(useId()) // Copies id's initial value to bind aria-attributes such as aria-describedby. @@ -75,11 +86,21 @@ const id = ref(useId()) 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 && props.name) { + if (Array.isArray(props.name)) { + props.name.forEach((name) => { + formInputs.value[name] = { id: id.value, pattern: props.errorPattern } + }) + } else { + formInputs.value[props.name] = { id: id.value, pattern: props.errorPattern } + } + } + }, + { immediate: true } +) provide(inputIdInjectionKey, id) diff --git a/src/runtime/composables/useFormField.ts b/src/runtime/composables/useFormField.ts index 354c6772ec..523dfbf49f 100644 --- a/src/runtime/composables/useFormField.ts +++ b/src/runtime/composables/useFormField.ts @@ -43,6 +43,12 @@ export function useFormField(props?: Props, 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 }) @@ -50,27 +56,30 @@ export function useFormField(props?: Props, opts?: { bind?: boolean, defer } 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), diff --git a/src/runtime/types/form.ts b/src/runtime/types/form.ts index e86f3e1b45..1d8c928b89 100644 --- a/src/runtime/types/form.ts +++ b/src/runtime/types/form.ts @@ -88,7 +88,7 @@ export interface FormInjectedOptions { } export interface FormFieldInjectedOptions { - name?: string + name?: string | string[] size?: GetObjectField error?: string | boolean eagerValidation?: boolean From 0a799bd375d670b7057d552c898814d77e2ac900 Mon Sep 17 00:00:00 2001 From: Michel EDIGHOFFER Date: Sun, 23 Nov 2025 21:59:22 +0100 Subject: [PATCH 2/2] fix(FormField): refactor error matching logic and name handling --- src/runtime/components/FormField.vue | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/runtime/components/FormField.vue b/src/runtime/components/FormField.vue index 4be1f70d3d..bbc3b69253 100644 --- a/src/runtime/components/FormField.vue +++ b/src/runtime/components/FormField.vue @@ -60,6 +60,8 @@ const slots = defineSlots() 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 @@ -71,13 +73,15 @@ const error = computed(() => { if (props.error) return props.error if (!formErrors?.value) return undefined - if (props.name) { - if (Array.isArray(props.name)) { - return formErrors.value.find(error => props.name!.includes(error.name!) || (props.errorPattern && error.name?.match(props.errorPattern)))?.message - } - return formErrors.value.find(error => error.name === props.name || (props.errorPattern && error.name?.match(props.errorPattern)))?.message + function matchPattern(errorName: string) { + return props.errorPattern && errorName?.match(props.errorPattern) } - return undefined + + 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()) @@ -89,14 +93,10 @@ const formInputs = inject(formInputsInjectionKey, undefined) watch( id, () => { - if (formInputs && props.name) { - if (Array.isArray(props.name)) { - props.name.forEach((name) => { - formInputs.value[name] = { id: id.value, pattern: props.errorPattern } - }) - } else { - formInputs.value[props.name] = { id: id.value, pattern: props.errorPattern } - } + if (formInputs && name?.length > 0) { + name.forEach((name) => { + formInputs.value[name] = { id: id.value, pattern: props.errorPattern } + }) } }, { immediate: true }