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
16 changes: 12 additions & 4 deletions docs/content/docs/2.components/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ props:
---
::

### HTML5 validation

When calling `form.submit()` programmatically, the Form component automatically triggers native HTML5 validation before submission.

::tip
This is particularly useful when the submit button is outside the form element, such as in a modal footer.
::

### Nesting forms

Use the `nested` prop to nest multiple Form components and link their validation functions. In this case, validating the parent form will automatically validate all the other forms inside it.
Expand Down Expand Up @@ -215,11 +223,11 @@ This will give you access to the following:

| Name | Type |
| ---- | ---- |
| `submit()`{lang="ts-type"} | `Promise<void>`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>Triggers form submission.</p> |
| `validate(opts: { name?: keyof T \| (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise<T>`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>Triggers form validation. Will raise any errors unless `opts.silent` is set to true.</p> |
| `submit()`{lang="ts-type"} | `Promise<void>`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>Triggers form submission with HTML5 validation.</p></div> |
| `validate(opts: { name?: keyof T \| (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise<T>`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>Triggers form validation. Will raise any errors unless `opts.silent` is set to true.</p></div> |
| `clear(path?: keyof T \| RegExp)`{lang="ts-type"} | `void` <br> <div class="text-toned mt-1"><p>Clears form errors associated with a specific path. If no path is provided, clears all form errors.</p></div> |
| `getErrors(path?: keyof T RegExp)`{lang="ts-type"} | `FormError[]`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.</p></div> |
| `setErrors(errors: FormError[], name?: keyof T RegExp)`{lang="ts-type"} | `void` <br> <div class="text-toned mt-1"><p>Sets form errors for a given path. If no path is provided, overrides all errors.</p></div> |
| `getErrors(path?: keyof T \| RegExp)`{lang="ts-type"} | `FormError[]`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.</p></div> |
| `setErrors(errors: FormError[], name?: keyof T \| RegExp)`{lang="ts-type"} | `void` <br> <div class="text-toned mt-1"><p>Sets form errors for a given path. If no path is provided, overrides all errors.</p></div> |
| `errors`{lang="ts-type"} | `Ref<FormError[]>`{lang="ts-type"} <br> <div class="text-toned mt-1"><p>A reference to the array containing validation errors. Use this to access or manipulate the error information.</p></div> |
| `disabled`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} |
| `dirty`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} `true` if at least one form field has been updated by the user. |
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/components/Form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const appConfig = useAppConfig() as FormConfig['AppConfig']
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.form || {}) }))

const formId = props.id ?? useId() as string
const formEl = ref<HTMLElement>()

const bus = useEventBus<FormEvent<I>>(`form-${formId}`)

Expand Down Expand Up @@ -399,6 +400,9 @@ const api = {
},

async submit() {
if (formEl.value instanceof HTMLFormElement && formEl.value.reportValidity() === false) {
return
}
await onSubmitWrapper(new Event('submit'))
},

Expand Down Expand Up @@ -448,6 +452,7 @@ defineExpose(api)
<component
:is="parentBus ? 'div' : 'form'"
:id="formId"
ref="formEl"
:class="ui({ class: props.class })"
@submit.prevent="onSubmitWrapper"
>
Expand Down
98 changes: 97 additions & 1 deletion test/components/Form.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { nextTick, watch } from 'vue'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { afterEach, describe, expect, it, beforeEach, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { flushPromises } from '@vue/test-utils'
import * as z from 'zod'
Expand Down Expand Up @@ -640,4 +640,100 @@ describe('Form', () => {
expect(wrapper.html()).toContain('Error on field2')
expect(wrapper.html()).toContain('General error')
})

describe('HTML5 validation', () => {
let reportValiditySpy: ReturnType<typeof vi.spyOn> | undefined

afterEach(() => {
reportValiditySpy?.mockRestore()
reportValiditySpy = undefined
})

it('programmatic submit() triggers HTML5 validation and prevents submission when invalid', async () => {
const onSubmit = vi.fn()
reportValiditySpy = vi.spyOn(HTMLFormElement.prototype, 'reportValidity').mockReturnValue(false)

const wrapper = await renderForm({
fixture: 'FormHtml5Validation',
props: { onSubmit }
})

const form = wrapper.setupState.form.value

// Call submit() programmatically (simulates usage in modals with footer buttons)
await form.submit()
await flushPromises()

// Verify reportValidity was called
expect(reportValiditySpy).toHaveBeenCalled()

// Verify form submission was prevented
expect(onSubmit).not.toHaveBeenCalled()
})

it('programmatic submit() proceeds when HTML5 validation passes', async () => {
const onSubmit = vi.fn()
reportValiditySpy = vi.spyOn(HTMLFormElement.prototype, 'reportValidity').mockReturnValue(true)

const wrapper = await renderForm({
fixture: 'FormHtml5Validation',
props: { onSubmit }
})

const form = wrapper.setupState.form.value
const state = wrapper.setupState.state

// Set valid values
state.email = 'test@example.com'
state.age = '25'
state.username = 'validuser'

// Call submit() programmatically
await form.submit()
await flushPromises()

// Verify reportValidity was called
expect(reportValiditySpy).toHaveBeenCalled()

// Verify form submission proceeded
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
data: {
email: 'test@example.com',
age: '25',
username: 'validuser'
}
}))
})

it('handles nested forms gracefully (renders as div, no HTML5 validation)', async () => {
const onSubmit = vi.fn()
// Create a nested form scenario
const wrapper = await renderForm({
fixture: 'FormNested',
props: { onSubmit }
})

const form = wrapper.setupState.form.value
const state = wrapper.setupState.state

// Set valid values for all fields
state.email = 'test@example.com'
state.password = 'strongpassword'
state.nested = { field: 'value' }

// Nested forms render as divs, so reportValidity should not be called
// and submit should work normally
await form.submit()
await flushPromises()

// Verify form submission proceeded (no HTML5 validation on div elements)
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
data: {
email: 'test@example.com',
password: 'strongpassword',
nested: { field: 'value' }
}
}))
})
})
})
34 changes: 34 additions & 0 deletions test/components/fixtures/FormHtml5Validation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { reactive, useTemplateRef } from 'vue'
import { UFormField, UInput, UForm } from '#components'

interface FormState {
email?: string
age?: string
username?: string
}

const state = reactive<FormState>({})
const form = useTemplateRef('form')
</script>

<template>
<UForm ref="form" :state="state" v-bind="$attrs">
<UFormField id="emailField" name="email">
<UInput id="email" v-model="state.email" type="email" required />
</UFormField>
<UFormField id="ageField" name="age">
<UInput
id="age"
v-model="state.age"
type="number"
min="18"
max="100"
required
/>
</UFormField>
<UFormField id="usernameField" name="username">
<UInput id="username" v-model="state.username" minlength="3" maxlength="20" required />
</UFormField>
</UForm>
</template>
Loading