perf(form): focus first validation error on submit
- scope validation queries with a form root id so feedback stays inside the submitted form. - scroll to the earliest invalid control or message and move focus without fighting scroll position. - avoid handling the same failed submit twice to keep retries from jumping unexpectedly.
This commit is contained in:
+99
-1
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
const Form = FormProvider
|
type FormRootContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormRootContext = React.createContext<FormRootContextValue | null>(null)
|
||||||
|
|
||||||
|
function getFormScopedSelector(formId: string, selector: string): string {
|
||||||
|
return `[data-form-root="${formId}"]${selector}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFormErrors(errors: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof errors === 'object' &&
|
||||||
|
errors !== null &&
|
||||||
|
Object.keys(errors).length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstFormErrorTarget(
|
||||||
|
invalidControl: HTMLElement | null,
|
||||||
|
errorMessage: HTMLElement | null
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (!invalidControl) return errorMessage
|
||||||
|
if (!errorMessage) return invalidControl
|
||||||
|
|
||||||
|
const position = invalidControl.compareDocumentPosition(errorMessage)
|
||||||
|
return position & Node.DOCUMENT_POSITION_PRECEDING
|
||||||
|
? errorMessage
|
||||||
|
: invalidControl
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormValidationFocus() {
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
|
const { control } = useFormContext()
|
||||||
|
const { errors, submitCount } = useFormState({ control })
|
||||||
|
const handledSubmitCountRef = React.useRef(0)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!formContext || submitCount === 0 || !hasFormErrors(errors)) return
|
||||||
|
if (handledSubmitCountRef.current === submitCount) return
|
||||||
|
|
||||||
|
handledSubmitCountRef.current = submitCount
|
||||||
|
|
||||||
|
const animationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
const invalidControl = document.querySelector<HTMLElement>(
|
||||||
|
getFormScopedSelector(formContext.id, '[aria-invalid="true"]')
|
||||||
|
)
|
||||||
|
const errorMessage = document.querySelector<HTMLElement>(
|
||||||
|
getFormScopedSelector(formContext.id, '[data-slot="form-message"]')
|
||||||
|
)
|
||||||
|
const target = getFirstFormErrorTarget(invalidControl, errorMessage)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
const formItem = target.closest<HTMLElement>(
|
||||||
|
getFormScopedSelector(formContext.id, '[data-slot="form-item"]')
|
||||||
|
)
|
||||||
|
const scrollTarget = formItem ?? target
|
||||||
|
const focusTarget =
|
||||||
|
target === invalidControl
|
||||||
|
? invalidControl
|
||||||
|
: (formItem?.querySelector<HTMLElement>(
|
||||||
|
'[aria-invalid="true"], input, textarea, select, button, [tabindex]:not([tabindex="-1"])'
|
||||||
|
) ?? null)
|
||||||
|
|
||||||
|
scrollTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
focusTarget?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(animationFrameId)
|
||||||
|
}, [errors, formContext, submitCount])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Form<TFieldValues extends FieldValues = FieldValues>({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof FormProvider<TFieldValues>>) {
|
||||||
|
const reactId = React.useId()
|
||||||
|
const id = React.useMemo(
|
||||||
|
() => `form-${reactId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`,
|
||||||
|
[reactId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormRootContext.Provider value={{ id }}>
|
||||||
|
<FormProvider {...props}>
|
||||||
|
<FormValidationFocus />
|
||||||
|
{children}
|
||||||
|
</FormProvider>
|
||||||
|
</FormRootContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type FormFieldContextValue<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
|
|||||||
|
|
||||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div
|
<div
|
||||||
data-slot='form-item'
|
data-slot='form-item'
|
||||||
|
data-form-root={formContext?.id}
|
||||||
className={cn('grid gap-2', className)}
|
className={cn('grid gap-2', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -124,11 +218,13 @@ function FormControl({
|
|||||||
...props
|
...props
|
||||||
}: { children: React.ReactElement } & Record<string, unknown>) {
|
}: { children: React.ReactElement } & Record<string, unknown>) {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
|
|
||||||
return useRender({
|
return useRender({
|
||||||
render: children,
|
render: children,
|
||||||
props: {
|
props: {
|
||||||
'data-slot': 'form-control',
|
'data-slot': 'form-control',
|
||||||
|
'data-form-root': formContext?.id,
|
||||||
id: formItemId,
|
id: formItemId,
|
||||||
'aria-describedby': !error
|
'aria-describedby': !error
|
||||||
? `${formDescriptionId}`
|
? `${formDescriptionId}`
|
||||||
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|||||||
|
|
||||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField()
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const body = error ? String(error?.message ?? '') : props.children
|
const body = error ? String(error?.message ?? '') : props.children
|
||||||
|
|
||||||
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot='form-message'
|
data-slot='form-message'
|
||||||
|
data-form-root={formContext?.id}
|
||||||
id={formMessageId}
|
id={formMessageId}
|
||||||
className={cn('text-destructive text-sm', className)}
|
className={cn('text-destructive text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user