Compare commits

..

1 Commits

Author SHA1 Message Date
Romain Hamel
d8f29e019d feat(Form): add validate-on error-input
Adds `error-input` option to the validate on prop to validate on input
only when the field has an error.

Resolves #2599
2025-06-14 18:09:02 +02:00
19 changed files with 59 additions and 66 deletions

View File

@@ -98,6 +98,7 @@ The Form component automatically triggers validation when an input emits an `inp
- Validation on `input` occurs **as you type**.
- Validation on `change` occurs when you **commit to a value**.
- Validation on `blur` happens when an input **loses focus**.
- Validation on `error-input` happens when as you type on an input with an error.
You can control when validation happens this using the `validate-on` prop.

View File

@@ -16,7 +16,7 @@ const feedbacks = [
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col gap-4 ms-[-38px]">
<div v-for="(feedback, count) in feedbacks" :key="count" class="flex items-center">
<UFormField v-bind="feedback" label="Email" name="email" variant="inline">
<UFormField v-bind="feedback" label="Email" name="email">
<UInput placeholder="john@lennon.com" />
</UFormField>
</div>
@@ -41,8 +41,6 @@ const feedbacks = [
:size="size"
label="Email"
description="This is a description"
hint="This is a hint"
help="This is a help"
name="email"
>
<UInput placeholder="john@lennon.com" />

View File

@@ -57,7 +57,7 @@ const disabled = ref(false)
<div class="border border-default rounded-lg">
<div class="py-2 px-4 flex gap-4 items-center">
<UFormField label="Validate on" class="flex items-center gap-2">
<USelectMenu v-model="validateOn" :items="['input', 'change', 'blur']" multiple class="w-48" />
<USelectMenu v-model="validateOn" :items="['input', 'change', 'blur', 'error-input']" multiple class="w-48" />
</UFormField>
<UCheckbox v-model="disabled" label="Disabled" />
</div>

View File

@@ -2,7 +2,7 @@
import type { DeepReadonly } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId, InferInput, InferOutput, FormData } from '../types/form'
import type { FormSchema, FormError, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId, InferInput, InferOutput, FormData, FormValidateOn } from '../types/form'
import type { ComponentConfig } from '../types/utils'
type FormConfig = ComponentConfig<typeof theme, AppConfig, 'form'>
@@ -23,7 +23,8 @@ export interface FormProps<S extends FormSchema, T extends boolean = true> {
* The list of input events that trigger the form validation.
* @defaultValue `['blur', 'change', 'input']`
*/
validateOn?: FormInputEvents[]
validateOn?: FormValidateOn[]
/** Disable all inputs inside the form. */
disabled?: boolean
/**
@@ -77,7 +78,7 @@ type O = InferOutput<S>
const props = withDefaults(defineProps<FormProps<S, T>>(), {
validateOn() {
return ['input', 'blur', 'change'] as FormInputEvents[]
return ['input', 'blur', 'change'] as FormValidateOn[]
},
validateOnInputDelay: 300,
attach: true,
@@ -110,12 +111,14 @@ onMounted(async () => {
nestedForms.value.set(event.formId, { validate: event.validate })
} else if (event.type === 'detach') {
nestedForms.value.delete(event.formId)
} else if (props.validateOn?.includes(event.type) && !loading.value) {
} else if (props.validateOn?.includes(event.type as FormValidateOn) && !loading.value) {
if (event.type !== 'input') {
await _validate({ name: event.name, silent: true, nested: false })
} else if (event.eager || blurredFields.has(event.name)) {
await _validate({ name: event.name, silent: true, nested: false })
}
} else if (props.validateOn?.includes('error-input') && errors.value?.find(e => e.name === event.name)) {
await _validate({ name: event.name, silent: true, nested: false })
}
if (event.type === 'blur') {

View File

@@ -2,7 +2,6 @@
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/form-field'
import type { ComponentConfig } from '../types/utils'
import { createReusableTemplate } from '@vueuse/core'
type FormField = ComponentConfig<typeof theme, AppConfig, 'formField'>
@@ -21,8 +20,6 @@ export interface FormFieldProps {
help?: string
error?: string | boolean
hint?: string
variant?: 'default' | 'inline'
/**
* @defaultValue 'md'
*/
@@ -64,7 +61,6 @@ const appConfig = useAppConfig() as FormField['AppConfig']
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.formField || {}) })({
size: props.size,
variant: props.variant,
required: props.required
}))
@@ -91,28 +87,9 @@ provide(formFieldInjectionKey, computed(() => ({
help: props.help,
ariaId
}) as FormFieldInjectedOptions<FormFieldProps>))
const [DefineHintTemplate, ReuseHintTemplate] = createReusableTemplate()
const [DefineDescriptionTemplate, ReuseDescriptionTemplate] = createReusableTemplate()
</script>
<template>
<DefineHintTemplate>
<span v-if="(hint || !!slots.hint)" :id="`${ariaId}-hint`" :class="ui.hint({ class: props.ui?.hint })">
<slot name="hint" :hint="hint">
{{ hint }}
</slot>
</span>
</DefineHintTemplate>
<DefineDescriptionTemplate>
<p v-if="description || !!slots.description" :id="`${ariaId}-description`" :class="ui.description({ class: props.ui?.description })">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</DefineDescriptionTemplate>
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<div v-if="label || !!slots.label" :class="ui.labelWrapper({ class: props.ui?.labelWrapper })">
@@ -121,11 +98,19 @@ const [DefineDescriptionTemplate, ReuseDescriptionTemplate] = createReusableTemp
{{ label }}
</slot>
</Label>
<ReuseHintTemplate v-if="variant !== 'inline'" />
<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>
</div>
<ReuseDescriptionTemplate v-if="variant !== 'inline'" />
<p v-if="description || !!slots.description" :id="`${ariaId}-description`" :class="ui.description({ class: props.ui?.description })">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div :class="[(label || !!slots.label || description || !!slots.description) && ui.container({ class: props.ui?.container })]">
<slot :error="error" />

View File

@@ -46,6 +46,8 @@ export type FormData<S extends FormSchema, T extends boolean = true> = T extends
export type FormInputEvents = 'input' | 'blur' | 'change' | 'focus'
export type FormValidateOn = 'input' | 'blur' | 'change' | 'error-input'
export interface FormError<P extends string = string> {
name?: P
message: string

View File

@@ -18,14 +18,6 @@ export default {
lg: { root: 'text-sm' },
xl: { root: 'text-base' }
},
variant: {
inline: {
root: 'inline-flex',
label: 'mt-1.5 mx-2',
container: 'mt-0'
}
},
required: {
true: {
label: `after:content-['*'] after:ms-0.5 after:text-error`

View File

@@ -4,7 +4,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/checkbox'
import { renderForm } from '../utils/form'
import { mount, flushPromises } from '@vue/test-utils'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('Checkbox', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -58,7 +58,7 @@ describe('Checkbox', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -5,7 +5,7 @@ import theme from '#build/ui/checkbox-group'
import themeCheckbox from '#build/ui/checkbox'
import { flushPromises, mount } from '@vue/test-utils'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('CheckboxGroup', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -65,7 +65,7 @@ describe('CheckboxGroup', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,8 +4,8 @@ import Input, { type InputProps, type InputSlots } from '../../src/runtime/compo
import ComponentRender from '../component-render'
import theme from '#build/ui/input'
import type { FormValidateOn } from '~/src/module'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
describe('Input', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -104,7 +104,7 @@ describe('Input', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[], eagerValidation?: boolean) {
async function createForm(validateOn?: FormValidateOn[], eagerValidation?: boolean) {
const wrapper = await renderForm({
props: {
validateOn,
@@ -160,6 +160,18 @@ describe('Input', () => {
expect(wrapper.text()).not.toContain('Error message')
})
test('validate on error-input works', async () => {
const { input, wrapper } = await createForm(['error-input', 'blur'], true)
await input.setValue('value')
expect(wrapper.text()).not.toContain('Error message')
await input.trigger('blur')
expect(wrapper.text()).toContain('Error message')
await input.setValue('valid')
expect(wrapper.text()).not.toContain('Error message')
})
test('validate on input without eager validation works', async () => {
const { input, wrapper } = await createForm(['input'])

View File

@@ -4,7 +4,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/input'
import { renderForm } from '../utils/form'
import { flushPromises, mount } from '@vue/test-utils'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
import { expectEmitPayloadType } from '../utils/types'
describe('InputMenu', () => {
@@ -110,7 +110,7 @@ describe('InputMenu', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -5,7 +5,7 @@ import { reactive } from 'vue'
import InputNumber, { type InputNumberProps, type InputNumberSlots } from '../../src/runtime/components/InputNumber.vue'
import ComponentRender from '../component-render'
import theme from '#build/ui/input-number'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
import { renderForm } from '../utils/form'
describe('InputNumber', () => {
@@ -61,7 +61,7 @@ describe('InputNumber', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
state: reactive({ value: 0 }),
props: {

View File

@@ -5,7 +5,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/pin-input'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('PinInput', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -65,7 +65,7 @@ describe('PinInput', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,7 +4,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/radio-group'
import { flushPromises, mount } from '@vue/test-utils'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('RadioGroup', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -67,7 +67,7 @@ describe('RadioGroup', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,7 +4,7 @@ import Select, { type SelectProps, type SelectSlots } from '../../src/runtime/co
import ComponentRender from '../component-render'
import theme from '#build/ui/input'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
import { expectEmitPayloadType } from '../utils/types'
describe('Select', () => {
@@ -107,7 +107,7 @@ describe('Select', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,7 +4,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/input'
import { renderForm } from '../utils/form'
import { flushPromises, mount } from '@vue/test-utils'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
import { expectEmitPayloadType } from '../utils/types'
describe('SelectMenu', () => {
@@ -113,7 +113,7 @@ describe('SelectMenu', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,7 +4,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/slider'
import { flushPromises, mount } from '@vue/test-utils'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('Slider', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -52,7 +52,7 @@ describe('Slider', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,7 +4,7 @@ import ComponentRender from '../component-render'
import theme from '#build/ui/switch'
import { flushPromises, mount } from '@vue/test-utils'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('Switch', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -55,7 +55,7 @@ describe('Switch', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[]) {
async function createForm(validateOn?: FormValidateOn[]) {
const wrapper = await renderForm({
props: {
validateOn,

View File

@@ -4,7 +4,7 @@ import Textarea, { type TextareaProps, type TextareaSlots } from '../../src/runt
import ComponentRender from '../component-render'
import theme from '#build/ui/textarea'
import { renderForm } from '../utils/form'
import type { FormInputEvents } from '~/src/module'
import type { FormValidateOn } from '~/src/module'
describe('Textarea', () => {
const sizes = Object.keys(theme.variants.size) as any
@@ -107,7 +107,7 @@ describe('Textarea', () => {
})
describe('form integration', async () => {
async function createForm(validateOn?: FormInputEvents[], eagerValidation?: boolean) {
async function createForm(validateOn?: FormValidateOn[], eagerValidation?: boolean) {
const wrapper = await renderForm({
props: {
validateOn,