From 63ead2bf7f053bd4a0721402ed31695dfffed95b Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 28 May 2026 15:02:00 +0800 Subject: [PATCH 1/2] chore(repo): ignore playwright mcp artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bbc5717e..75f5c463 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ data/ .test token_estimator_test.go skills-lock.json +.playwright-mcp From e79cee1e9e9a0c1c679f144f1b57e9aa808dbf5e Mon Sep 17 00:00:00 2001 From: QuentinHsu Date: Thu, 28 May 2026 15:10:17 +0800 Subject: [PATCH 2/2] 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. --- web/default/src/components/ui/form.tsx | 100 ++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/web/default/src/components/ui/form.tsx b/web/default/src/components/ui/form.tsx index 16f804ed..db1a5365 100644 --- a/web/default/src/components/ui/form.tsx +++ b/web/default/src/components/ui/form.tsx @@ -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(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( + getFormScopedSelector(formContext.id, '[aria-invalid="true"]') + ) + const errorMessage = document.querySelector( + getFormScopedSelector(formContext.id, '[data-slot="form-message"]') + ) + const target = getFirstFormErrorTarget(invalidControl, errorMessage) + if (!target) return + + const formItem = target.closest( + getFormScopedSelector(formContext.id, '[data-slot="form-item"]') + ) + const scrollTarget = formItem ?? target + const focusTarget = + target === invalidControl + ? invalidControl + : (formItem?.querySelector( + '[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({ + children, + ...props +}: React.ComponentProps>) { + const reactId = React.useId() + const id = React.useMemo( + () => `form-${reactId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`, + [reactId] + ) + + return ( + + + + {children} + + + ) +} type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, @@ -90,11 +182,13 @@ const FormItemContext = React.createContext( function FormItem({ className, ...props }: React.ComponentProps<'div'>) { const id = React.useId() + const formContext = React.useContext(FormRootContext) return (
@@ -124,11 +218,13 @@ function FormControl({ ...props }: { children: React.ReactElement } & Record) { 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 (