fix(web): improve form validation error focus #5163

Merge pull request #5163 from QuantumNous/fix/form-validation-focus
This commit is contained in:
同語
2026-05-28 23:34:02 +08:00
committed by GitHub
2 changed files with 100 additions and 1 deletions
+1
View File
@@ -35,3 +35,4 @@ data/
.test
token_estimator_test.go
skills-lock.json
.playwright-mcp
+99 -1
View File
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
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<
TFieldValues extends FieldValues = FieldValues,
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
const formContext = React.useContext(FormRootContext)
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
data-form-root={formContext?.id}
className={cn('grid gap-2', className)}
{...props}
/>
@@ -124,11 +218,13 @@ function FormControl({
...props
}: { children: React.ReactElement } & Record<string, unknown>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
const formContext = React.useContext(FormRootContext)
return useRender({
render: children,
props: {
'data-slot': 'form-control',
'data-form-root': formContext?.id,
id: formItemId,
'aria-describedby': !error
? `${formDescriptionId}`
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const formContext = React.useContext(FormRootContext)
const { t } = useTranslation()
const body = error ? String(error?.message ?? '') : props.children
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='form-message'
data-form-root={formContext?.id}
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}