From 98dda9d53c0c059768e38e7853effb7d91772018 Mon Sep 17 00:00:00 2001 From: Rizky Djanuar Date: Wed, 3 Jun 2026 21:15:48 +0700 Subject: [PATCH 1/5] feat(Form): add focusOnError prop to automatically focus the first field with an error upon submission failure --- docs/content/docs/2.components/form.md | 6 +++++- src/runtime/components/Form.vue | 11 ++++++++++- src/runtime/utils/form.ts | 16 +++++++++++++++- test/components/Form.spec.ts | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/2.components/form.md b/docs/content/docs/2.components/form.md index 8d3beb394e..d3c3c4077d 100644 --- a/docs/content/docs/2.components/form.md +++ b/docs/content/docs/2.components/form.md @@ -158,7 +158,11 @@ You can listen to the `@error` event to handle errors. This event is triggered w - `name` - the `name` of the `FormField` - `message` - the error message to display. -Here's an example that focuses the first input element with an error after the form is submitted: +::tip +By default, the form automatically focuses the first field with an error when submission fails through the `focus-on-error` prop. You can disable this by passing `:focus-on-error="false"`. +:: + +Here's an example of using the `@error` event to add custom behavior, such as smoothly scrolling the focused element into view: ::component-example --- diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index 8ba83d55c4..3805e07a31 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -60,6 +60,10 @@ export type FormProps>) => void | Promise) | (() => void | Promise) @@ -82,7 +86,7 @@ import { useAppConfig } from '#imports' import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey, formErrorsInjectionKey, formStateInjectionKey } from '../composables/useFormField' import { tv } from '../utils/tv' import { useComponentProps } from '../composables/useComponentProps' -import { validateSchema, getAtPath, setAtPath } from '../utils/form' +import { validateSchema, getAtPath, setAtPath, scrollToErrorEl } from '../utils/form' import { FormValidationException } from '../types/form' type I = InferInput @@ -93,6 +97,7 @@ const _props = withDefaults(defineProps>(), { return ['input', 'blur', 'change'] as FormInputEvents[] }, validateOnInputDelay: 300, + focusOnError: true, transform: () => true as T, loadingAuto: true }) @@ -280,6 +285,10 @@ async function onSubmitWrapper(payload: Event) { throw error } + if (props.focusOnError) { + scrollToErrorEl(error.errors) + } + const errorEvent: FormErrorEvent = { ...event, errors: error.errors diff --git a/src/runtime/utils/form.ts b/src/runtime/utils/form.ts index 514deffc44..b0a848694e 100644 --- a/src/runtime/utils/form.ts +++ b/src/runtime/utils/form.ts @@ -1,6 +1,6 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' import type { Struct } from 'superstruct' -import type { FormSchema, ValidateReturnSchema } from '../types/form' +import type { FormErrorWithId, FormSchema, ValidateReturnSchema } from '../types/form' export function isSuperStructSchema(schema: any): schema is Struct { return ( @@ -114,3 +114,17 @@ export function setAtPath( return data } + +export function scrollToErrorEl(errors: FormErrorWithId[]) { + const error = errors[0] + if (error) { + const el = document.getElementById(error.id!) + let timeOut + if (el) { + if (timeOut) clearTimeout(timeOut) + timeOut = setTimeout(() => { + el.focus() + }, 0) + } + } +} diff --git a/test/components/Form.spec.ts b/test/components/Form.spec.ts index 05d02da0cc..f74899a1be 100644 --- a/test/components/Form.spec.ts +++ b/test/components/Form.spec.ts @@ -275,6 +275,24 @@ describe('Form', () => { expect(passwordField.text()).toBe('Invalid input: expected string, received undefined') }) + it('focuses on first error element on submit error', async () => { + const emailInputMock = { focus: vi.fn() } + const passwordInputMock = { focus: vi.fn() } + const getElementByIdSpy = vi.spyOn(document, 'getElementById').mockImplementation((id) => { + if (id === 'email') return emailInputMock as any + if (id === 'password') return passwordInputMock as any + return null + }) + + await form.submit() + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(emailInputMock.focus).toHaveBeenCalledTimes(1) + expect(passwordInputMock.focus).toHaveBeenCalledTimes(0) + + getElementByIdSpy.mockRestore() + }) + it('validate on submit works', async () => { state.email = 'bob@dylan.com' state.password = 'strongpassword' From b67b3f6c0ed1929e38af5cdcfd8e420be2fa2882 Mon Sep 17 00:00:00 2001 From: Rizky Djanuar Date: Wed, 3 Jun 2026 21:30:37 +0700 Subject: [PATCH 2/5] refactor: update form error example to display a toast notification instead of auto-focusing the element --- .../content/examples/form/FormExampleOnError.vue | 7 +++---- docs/content/docs/2.components/form.md | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/app/components/content/examples/form/FormExampleOnError.vue b/docs/app/components/content/examples/form/FormExampleOnError.vue index 46c142b94a..226421cfc1 100644 --- a/docs/app/components/content/examples/form/FormExampleOnError.vue +++ b/docs/app/components/content/examples/form/FormExampleOnError.vue @@ -22,10 +22,9 @@ async function onSubmit(event: FormSubmitEvent) { } async function onError(event: FormErrorEvent) { - if (event?.errors?.[0]?.id) { - const element = document.getElementById(event.errors[0].id) - element?.focus() - element?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + const error = event.errors[0] + if (error) { + toast.add({ title: 'Error', description: `Please check the form ${error.name}.`, color: 'error' }) } } diff --git a/docs/content/docs/2.components/form.md b/docs/content/docs/2.components/form.md index d3c3c4077d..ef9a2d9d8b 100644 --- a/docs/content/docs/2.components/form.md +++ b/docs/content/docs/2.components/form.md @@ -162,7 +162,7 @@ You can listen to the `@error` event to handle errors. This event is triggered w By default, the form automatically focuses the first field with an error when submission fails through the `focus-on-error` prop. You can disable this by passing `:focus-on-error="false"`. :: -Here's an example of using the `@error` event to add custom behavior, such as smoothly scrolling the focused element into view: +Here's an example of using the `@error` event to add custom behavior, such as displaying a toast notification when validation fails: ::component-example --- From 86509cf9fd0dc4aa9677b0db25b8096875b67eb1 Mon Sep 17 00:00:00 2001 From: Rizky Djanuar Date: Wed, 3 Jun 2026 21:41:33 +0700 Subject: [PATCH 3/5] up --- src/runtime/components/Form.vue | 3 ++- src/runtime/utils/form.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index 3805e07a31..a827cba5b3 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -61,7 +61,8 @@ export type FormProps( return data } +let timeOut: NodeJS.Timeout | null = null export function scrollToErrorEl(errors: FormErrorWithId[]) { const error = errors[0] if (error) { const el = document.getElementById(error.id!) - let timeOut if (el) { if (timeOut) clearTimeout(timeOut) timeOut = setTimeout(() => { From 9f0870e90ea444813bf46427b68dc407d57de237 Mon Sep 17 00:00:00 2001 From: Rizky Djanuar Date: Wed, 3 Jun 2026 23:27:27 +0700 Subject: [PATCH 4/5] up --- src/runtime/components/Form.vue | 9 +++++---- src/runtime/utils/form.ts | 6 +----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index a827cba5b3..c4ca94bfc7 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -286,10 +286,6 @@ async function onSubmitWrapper(payload: Event) { throw error } - if (props.focusOnError) { - scrollToErrorEl(error.errors) - } - const errorEvent: FormErrorEvent = { ...event, errors: error.errors @@ -298,6 +294,11 @@ async function onSubmitWrapper(payload: Event) { } finally { loading.value = false } + + if (props.focusOnError && errors.value.length > 0) { + await nextTick() + scrollToErrorEl(errors.value) + } } // eslint-disable-next-line vue/no-dupe-keys diff --git a/src/runtime/utils/form.ts b/src/runtime/utils/form.ts index 1393c311db..d5bbd45d6b 100644 --- a/src/runtime/utils/form.ts +++ b/src/runtime/utils/form.ts @@ -115,16 +115,12 @@ export function setAtPath( return data } -let timeOut: NodeJS.Timeout | null = null export function scrollToErrorEl(errors: FormErrorWithId[]) { const error = errors[0] if (error) { const el = document.getElementById(error.id!) if (el) { - if (timeOut) clearTimeout(timeOut) - timeOut = setTimeout(() => { - el.focus() - }, 0) + el.focus() } } } From f6e7caa57ef7bb4acea12108381a28d78bbb18f3 Mon Sep 17 00:00:00 2001 From: Rizky Djanuar Date: Wed, 3 Jun 2026 23:52:57 +0700 Subject: [PATCH 5/5] test: replace setTimeout with flushPromises in Form component tests --- test/components/Form.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/components/Form.spec.ts b/test/components/Form.spec.ts index f74899a1be..78af36c333 100644 --- a/test/components/Form.spec.ts +++ b/test/components/Form.spec.ts @@ -285,7 +285,7 @@ describe('Form', () => { }) await form.submit() - await new Promise(resolve => setTimeout(resolve, 0)) + await flushPromises() expect(emailInputMock.focus).toHaveBeenCalledTimes(1) expect(passwordInputMock.focus).toHaveBeenCalledTimes(0)