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..bbc3b69253 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 @@ -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 @@ -67,7 +69,20 @@ 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 + + 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. @@ -75,11 +90,17 @@ 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 && name?.length > 0) { + name.forEach((name) => { + formInputs.value[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