Skip to content

Commit 8349407

Browse files
authored
Merge pull request #140 from lambda-curry/codegen/lc-321-complete-implementation-guide-adding-scroll-to-error-to
2 parents 3ab0534 + a62e451 commit 8349407

File tree

11 files changed

+1972
-1244
lines changed

11 files changed

+1972
-1244
lines changed

apps/docs/src/remix-hook-form/scroll-to-error.stories.tsx

Lines changed: 642 additions & 0 deletions
Large diffs are not rendered by default.

packages/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lambdacurry/forms",
3-
"version": "0.19.7",
3+
"version": "0.20.0",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/components/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
// Main exports from both remix-hook-form and ui directories
22

3+
// Add scroll-to-error utilities
4+
export { scrollToFirstError } from './utils/scrollToError';
5+
export type { ScrollToErrorOptions } from './utils/scrollToError';
6+
37
// Export all components from remix-hook-form
48
export * from './remix-hook-form';
59

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type UseScrollToErrorOnSubmitOptions, useScrollToErrorOnSubmit } from '../hooks/useScrollToErrorOnSubmit';
2+
3+
export interface ScrollToErrorOnSubmitProps extends UseScrollToErrorOnSubmitOptions {
4+
className?: string;
5+
}
6+
7+
export const ScrollToErrorOnSubmit = ({ className, ...options }: ScrollToErrorOnSubmitProps) => {
8+
useScrollToErrorOnSubmit(options);
9+
10+
// Return null or hidden div - follows existing patterns
11+
return className ? <div className={className} aria-hidden="true" /> : null;
12+
};
13+
14+
ScrollToErrorOnSubmit.displayName = 'ScrollToErrorOnSubmit';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './ScrollToErrorOnSubmit';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useScrollToErrorOnSubmit';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useEffect, useMemo } from 'react';
2+
import type { FieldValues } from 'react-hook-form';
3+
import { useRemixFormContext } from 'remix-hook-form';
4+
import type { UseRemixFormReturn } from 'remix-hook-form';
5+
import { type ScrollToErrorOptions, scrollToFirstError } from '../../utils/scrollToError';
6+
7+
export interface UseScrollToErrorOnSubmitOptions extends ScrollToErrorOptions {
8+
delay?: number;
9+
enabled?: boolean;
10+
scrollOnServerErrors?: boolean;
11+
scrollOnMount?: boolean;
12+
methods?: UseRemixFormReturn<FieldValues>; // Optional methods parameter
13+
}
14+
15+
export const useScrollToErrorOnSubmit = (options: UseScrollToErrorOnSubmitOptions = {}) => {
16+
// Use provided methods or fall back to context
17+
const contextMethods = useRemixFormContext();
18+
const {
19+
methods,
20+
delay = 100,
21+
enabled = true,
22+
scrollOnServerErrors = true,
23+
scrollOnMount = true,
24+
...scrollOptions
25+
} = options;
26+
const formMethods = methods || contextMethods;
27+
28+
const { formState } = formMethods;
29+
30+
// Memoize scroll options to prevent unnecessary re-renders
31+
const { behavior, block, inline, offset, shouldFocus, retryAttempts, selectors } = scrollOptions;
32+
33+
// biome-ignore lint: Compare `selectors` by value via join to avoid unstable array identity.
34+
const memoizedScrollOptions = useMemo(
35+
() => ({
36+
behavior,
37+
block,
38+
inline,
39+
offset,
40+
shouldFocus,
41+
retryAttempts,
42+
selectors,
43+
}),
44+
[behavior, block, inline, offset, shouldFocus, retryAttempts, selectors?.join(',')],
45+
);
46+
47+
// Handle form submission errors
48+
useEffect(() => {
49+
if (!enabled) return;
50+
const hasErrors = Object.keys(formState.errors).length > 0;
51+
52+
// Scroll after submission attempt when errors exist
53+
if (!formState.isSubmitting && hasErrors) {
54+
const timeoutId = setTimeout(() => {
55+
scrollToFirstError(formState.errors, memoizedScrollOptions);
56+
}, delay);
57+
58+
return () => clearTimeout(timeoutId);
59+
}
60+
}, [formState.errors, formState.isSubmitting, enabled, delay, memoizedScrollOptions]);
61+
62+
// Handle server-side validation errors on mount (Remix SSR)
63+
useEffect(() => {
64+
if (!(enabled && scrollOnMount && scrollOnServerErrors)) return;
65+
const hasErrors = Object.keys(formState.errors).length > 0;
66+
67+
if (hasErrors && !formState.isSubmitting) {
68+
const timeoutId = setTimeout(() => {
69+
scrollToFirstError(formState.errors, memoizedScrollOptions);
70+
}, delay);
71+
72+
return () => clearTimeout(timeoutId);
73+
}
74+
}, [
75+
enabled,
76+
scrollOnMount,
77+
scrollOnServerErrors,
78+
formState.errors,
79+
formState.isSubmitting,
80+
delay,
81+
memoizedScrollOptions,
82+
]);
83+
};

packages/components/src/remix-hook-form/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
// Add scroll-to-error functionality
2+
export * from './hooks/useScrollToErrorOnSubmit';
3+
export * from './components/ScrollToErrorOnSubmit';
4+
5+
// Keep all existing exports
16
export * from './checkbox';
27
export * from './form';
38
export * from './form-error';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './scrollToError';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { FieldErrors } from 'react-hook-form';
2+
3+
export interface ScrollToErrorOptions {
4+
behavior?: ScrollBehavior;
5+
block?: ScrollLogicalPosition;
6+
inline?: ScrollLogicalPosition;
7+
offset?: number;
8+
shouldFocus?: boolean;
9+
retryAttempts?: number;
10+
selectors?: string[];
11+
}
12+
13+
const DEFAULT_ERROR_SELECTORS = [
14+
'[data-slot="form-message"]', // Target error message first (best UX)
15+
'[data-slot="form-control"][aria-invalid="true"]', // Input with error state
16+
];
17+
18+
const findFirstErrorElement = (selectors: string[]): HTMLElement | null => {
19+
for (const selector of selectors) {
20+
const element = document.querySelector(selector) as HTMLElement;
21+
if (element) {
22+
return element;
23+
}
24+
}
25+
return null;
26+
};
27+
28+
const scrollToElement = (element: HTMLElement, offset: number, behavior: ScrollBehavior): void => {
29+
const elementRect = element.getBoundingClientRect();
30+
const offsetTop = elementRect.top + window.pageYOffset - offset;
31+
32+
window.scrollTo({
33+
top: Math.max(0, offsetTop),
34+
behavior,
35+
});
36+
};
37+
38+
const focusElement = (element: HTMLElement, shouldFocus: boolean, behavior: ScrollBehavior): void => {
39+
if (shouldFocus && element.focus) {
40+
setTimeout(() => element.focus(), behavior === 'smooth' ? 300 : 0);
41+
}
42+
};
43+
44+
export const scrollToFirstError = (errors: FieldErrors, options: ScrollToErrorOptions = {}) => {
45+
const {
46+
behavior = 'smooth',
47+
offset = 80,
48+
shouldFocus = true,
49+
retryAttempts = 3,
50+
selectors = DEFAULT_ERROR_SELECTORS,
51+
} = options;
52+
53+
if (Object.keys(errors).length === 0) return false;
54+
55+
const attemptScroll = (attempt = 0): boolean => {
56+
const selectorList = selectors.length > 0 ? selectors : DEFAULT_ERROR_SELECTORS;
57+
const element = findFirstErrorElement(selectorList);
58+
if (element) {
59+
scrollToElement(element, offset, behavior);
60+
focusElement(element, shouldFocus, behavior);
61+
return true;
62+
}
63+
64+
// Retry for async rendering (common with Remix)
65+
if (attempt < retryAttempts) {
66+
setTimeout(() => attemptScroll(attempt + 1), 100);
67+
return true;
68+
}
69+
70+
console.warn('Could not find any form error elements to scroll to');
71+
return false;
72+
};
73+
74+
return attemptScroll();
75+
};

0 commit comments

Comments
 (0)