Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2bd7d49
feat: implement scroll-to-error functionality for @lambdacurry/forms
codegen-sh[bot] Sep 18, 2025
f9fcaf6
feat: add comprehensive Storybook examples for scroll-to-error functi…
codegen-sh[bot] Sep 18, 2025
06f8435
Fix RadioGroupItem import in scroll-to-error story
codegen-sh[bot] Sep 18, 2025
0a24c56
fix: allow passing methods to useScrollToErrorOnSubmit hook
codegen-sh[bot] Sep 18, 2025
693054d
Fix scroll-to-error story context issue
codegen-sh[bot] Sep 18, 2025
6ef7938
Fix scroll-to-error hook null reference error
codegen-sh[bot] Sep 18, 2025
ce180af
Fix ReferenceError: methods is not defined
codegen-sh[bot] Sep 18, 2025
8fc6ada
Fix methods reference in ScrollToErrorHookForm component
codegen-sh[bot] Sep 18, 2025
ddee63e
Refactor: streamline scroll-to-error story and remove unused test cases
jaruesink Sep 18, 2025
87daaa7
Merge pull request #141 from lambda-curry/codegen/lc-322-lets-create-…
jaruesink Sep 18, 2025
43aa58b
Fix: adjust spacing in RadioGroupItemField label for better a11y
lcmohsen Sep 8, 2025
62fd07f
chore: bump @lambdacurry/forms to v0.19.6
codegen-sh[bot] Sep 18, 2025
b5bc11d
Improve select dropdown width matching with ResizeObserver
codegen-sh[bot] Sep 18, 2025
7b9ac09
Add 500px width test story for select dropdown
codegen-sh[bot] Sep 18, 2025
899440b
Fix ResizeObserver compatibility for test environments
codegen-sh[bot] Sep 18, 2025
31ef9b5
Fix TypeScript error in ResizeObserver implementation
codegen-sh[bot] Sep 18, 2025
b16fb55
Fix ResizeObserver timing issues causing test failures
codegen-sh[bot] Sep 18, 2025
23623f1
Fix dropdown width by removing hardcoded w-72 class
codegen-sh[bot] Sep 18, 2025
b8fe440
Add width debugging and ensure inner content takes full width
codegen-sh[bot] Sep 18, 2025
08a6a15
Replace ResizeObserver with Radix CSS custom property
codegen-sh[bot] Sep 18, 2025
c3a2f81
Remove failing test story and cleanup test components
codegen-sh[bot] Sep 18, 2025
c4b7d5f
Remove unnecessary blank lines in select stories
jaruesink Sep 18, 2025
6aebf2f
feat: add keyboard navigation to Select component
codegen-sh[bot] Sep 17, 2025
79f9e62
Fix keyboard navigation timing issue in Select component
codegen-sh[bot] Sep 17, 2025
14eb20f
fix: Improve activeIndex initialization in Select component
codegen-sh[bot] Sep 17, 2025
187a97d
fix: Implement keyboard navigation for Select component
codegen-sh[bot] Sep 17, 2025
caf12ac
chore: remove changeset, update package version, and remove failing t…
codegen-sh[bot] Sep 18, 2025
241c976
chore: bump @lambdacurry/forms to v0.19.7
codegen-sh[bot] Sep 18, 2025
44e4540
chore: bump @lambdacurry/forms to v0.20.0
codegen-sh[bot] Sep 18, 2025
f910d40
Refactor: enhance useScrollToErrorOnSubmit hook with TypeScript types…
jaruesink Sep 18, 2025
869dad8
Merge branch 'codegen/lc-321-complete-implementation-guide-adding-scr…
jaruesink Sep 18, 2025
a62e451
Merge branch 'main' into codegen/lc-321-complete-implementation-guide…
jaruesink Sep 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
642 changes: 642 additions & 0 deletions apps/docs/src/remix-hook-form/scroll-to-error.stories.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lambdacurry/forms",
"version": "0.19.7",
"version": "0.20.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
4 changes: 4 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// Main exports from both remix-hook-form and ui directories

// Add scroll-to-error utilities
export { scrollToFirstError } from './utils/scrollToError';
export type { ScrollToErrorOptions } from './utils/scrollToError';

// Export all components from remix-hook-form
export * from './remix-hook-form';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { type UseScrollToErrorOnSubmitOptions, useScrollToErrorOnSubmit } from '../hooks/useScrollToErrorOnSubmit';

export interface ScrollToErrorOnSubmitProps extends UseScrollToErrorOnSubmitOptions {
className?: string;
}

export const ScrollToErrorOnSubmit = ({ className, ...options }: ScrollToErrorOnSubmitProps) => {
useScrollToErrorOnSubmit(options);

// Return null or hidden div - follows existing patterns
return className ? <div className={className} aria-hidden="true" /> : null;
};

ScrollToErrorOnSubmit.displayName = 'ScrollToErrorOnSubmit';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ScrollToErrorOnSubmit';
1 change: 1 addition & 0 deletions packages/components/src/remix-hook-form/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useScrollToErrorOnSubmit';
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useEffect, useMemo } from 'react';
import type { FieldValues } from 'react-hook-form';
import { useRemixFormContext } from 'remix-hook-form';
import type { UseRemixFormReturn } from 'remix-hook-form';
import { type ScrollToErrorOptions, scrollToFirstError } from '../../utils/scrollToError';

export interface UseScrollToErrorOnSubmitOptions extends ScrollToErrorOptions {
delay?: number;
enabled?: boolean;
scrollOnServerErrors?: boolean;
scrollOnMount?: boolean;
methods?: UseRemixFormReturn<FieldValues>; // Optional methods parameter
}

export const useScrollToErrorOnSubmit = (options: UseScrollToErrorOnSubmitOptions = {}) => {
// Use provided methods or fall back to context
const contextMethods = useRemixFormContext();
const {
methods,
delay = 100,
enabled = true,
scrollOnServerErrors = true,
scrollOnMount = true,
...scrollOptions
} = options;
const formMethods = methods || contextMethods;

const { formState } = formMethods;

// Memoize scroll options to prevent unnecessary re-renders
const { behavior, block, inline, offset, shouldFocus, retryAttempts, selectors } = scrollOptions;

// biome-ignore lint: Compare `selectors` by value via join to avoid unstable array identity.
const memoizedScrollOptions = useMemo(
() => ({
behavior,
block,
inline,
offset,
shouldFocus,
retryAttempts,
selectors,
}),
[behavior, block, inline, offset, shouldFocus, retryAttempts, selectors?.join(',')],
);

// Handle form submission errors
useEffect(() => {
if (!enabled) return;
const hasErrors = Object.keys(formState.errors).length > 0;

// Scroll after submission attempt when errors exist
if (!formState.isSubmitting && hasErrors) {
const timeoutId = setTimeout(() => {
scrollToFirstError(formState.errors, memoizedScrollOptions);
}, delay);

return () => clearTimeout(timeoutId);
}
}, [formState.errors, formState.isSubmitting, enabled, delay, memoizedScrollOptions]);

// Handle server-side validation errors on mount (Remix SSR)
useEffect(() => {
if (!(enabled && scrollOnMount && scrollOnServerErrors)) return;
const hasErrors = Object.keys(formState.errors).length > 0;

if (hasErrors && !formState.isSubmitting) {
const timeoutId = setTimeout(() => {
scrollToFirstError(formState.errors, memoizedScrollOptions);
}, delay);

return () => clearTimeout(timeoutId);
}
}, [
enabled,
scrollOnMount,
scrollOnServerErrors,
formState.errors,
formState.isSubmitting,
delay,
memoizedScrollOptions,
]);
};
5 changes: 5 additions & 0 deletions packages/components/src/remix-hook-form/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// Add scroll-to-error functionality
export * from './hooks/useScrollToErrorOnSubmit';
export * from './components/ScrollToErrorOnSubmit';

// Keep all existing exports
export * from './checkbox';
export * from './form';
export * from './form-error';
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './scrollToError';
75 changes: 75 additions & 0 deletions packages/components/src/utils/scrollToError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { FieldErrors } from 'react-hook-form';

export interface ScrollToErrorOptions {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
offset?: number;
shouldFocus?: boolean;
retryAttempts?: number;
selectors?: string[];
}

const DEFAULT_ERROR_SELECTORS = [
'[data-slot="form-message"]', // Target error message first (best UX)
'[data-slot="form-control"][aria-invalid="true"]', // Input with error state
];

const findFirstErrorElement = (selectors: string[]): HTMLElement | null => {
for (const selector of selectors) {
const element = document.querySelector(selector) as HTMLElement;
if (element) {
return element;
}
}
return null;
};

const scrollToElement = (element: HTMLElement, offset: number, behavior: ScrollBehavior): void => {
const elementRect = element.getBoundingClientRect();
const offsetTop = elementRect.top + window.pageYOffset - offset;

window.scrollTo({
top: Math.max(0, offsetTop),
behavior,
});
};

const focusElement = (element: HTMLElement, shouldFocus: boolean, behavior: ScrollBehavior): void => {
if (shouldFocus && element.focus) {
setTimeout(() => element.focus(), behavior === 'smooth' ? 300 : 0);
}
};

export const scrollToFirstError = (errors: FieldErrors, options: ScrollToErrorOptions = {}) => {
const {
behavior = 'smooth',
offset = 80,
shouldFocus = true,
retryAttempts = 3,
selectors = DEFAULT_ERROR_SELECTORS,
} = options;

if (Object.keys(errors).length === 0) return false;

const attemptScroll = (attempt = 0): boolean => {
const selectorList = selectors.length > 0 ? selectors : DEFAULT_ERROR_SELECTORS;
const element = findFirstErrorElement(selectorList);
if (element) {
scrollToElement(element, offset, behavior);
focusElement(element, shouldFocus, behavior);
return true;
}

// Retry for async rendering (common with Remix)
if (attempt < retryAttempts) {
setTimeout(() => attemptScroll(attempt + 1), 100);
return true;
}

console.warn('Could not find any form error elements to scroll to');
return false;
};

return attemptScroll();
};
Loading