Skip to content

Commit 2bd7d49

Browse files
feat: implement scroll-to-error functionality for @lambdacurry/forms
- Add core scrollToFirstError utility with configurable options - Create useScrollToErrorOnSubmit hook for Remix form integration - Add ScrollToErrorOnSubmit component for declarative usage - Leverage existing data-slot selectors for seamless integration - Support both client-side and server-side validation errors - Include retry logic for async rendering scenarios - Export new functionality from main package entry points
1 parent 0abcba2 commit 2bd7d49

22 files changed

+189
-89
lines changed

apps/docs/src/remix-hook-form/phone-input.stories.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,7 @@ const ControlledPhoneInputExample = () => {
3737
<RemixFormProvider {...methods}>
3838
<fetcher.Form onSubmit={methods.handleSubmit}>
3939
<div className="grid gap-8">
40-
<PhoneInput
41-
name="usaPhone"
42-
label="Phone Number"
43-
description="Enter a US phone number"
44-
/>
40+
<PhoneInput name="usaPhone" label="Phone Number" description="Enter a US phone number" />
4541
<PhoneInput
4642
name="internationalPhone"
4743
label="International Phone Number"
Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,79 @@
1+
import type { StoryContext } from '@storybook/react';
12
import { expect } from '@storybook/test';
23
import { userEvent, within } from '@storybook/testing-library';
3-
import { StoryContext } from '@storybook/react';
44

55
// Test selecting a US state
66
export const testUSStateSelection = async ({ canvasElement }: StoryContext) => {
77
const canvas = within(canvasElement);
8-
8+
99
// Find and click the US state dropdown
1010
const stateDropdown = canvas.getByLabelText('US State');
1111
await userEvent.click(stateDropdown);
12-
12+
1313
// Select a state (e.g., California)
1414
const californiaOption = await canvas.findByText('California');
1515
await userEvent.click(californiaOption);
16-
16+
1717
// Verify the selection
1818
expect(stateDropdown).toHaveTextContent('California');
1919
};
2020

2121
// Test selecting a Canadian province
2222
export const testCanadaProvinceSelection = async ({ canvasElement }: StoryContext) => {
2323
const canvas = within(canvasElement);
24-
24+
2525
// Find and click the Canada province dropdown
2626
const provinceDropdown = canvas.getByLabelText('Canadian Province');
2727
await userEvent.click(provinceDropdown);
28-
28+
2929
// Select a province (e.g., Ontario)
3030
const ontarioOption = await canvas.findByText('Ontario');
3131
await userEvent.click(ontarioOption);
32-
32+
3333
// Verify the selection
3434
expect(provinceDropdown).toHaveTextContent('Ontario');
3535
};
3636

3737
// Test form submission
3838
export const testFormSubmission = async ({ canvasElement }: StoryContext) => {
3939
const canvas = within(canvasElement);
40-
40+
4141
// Select a state
4242
const stateDropdown = canvas.getByLabelText('US State');
4343
await userEvent.click(stateDropdown);
4444
const californiaOption = await canvas.findByText('California');
4545
await userEvent.click(californiaOption);
46-
46+
4747
// Select a province
4848
const provinceDropdown = canvas.getByLabelText('Canadian Province');
4949
await userEvent.click(provinceDropdown);
5050
const ontarioOption = await canvas.findByText('Ontario');
5151
await userEvent.click(ontarioOption);
52-
52+
5353
// Select a custom region
5454
const regionDropdown = canvas.getByLabelText('Custom Region');
5555
await userEvent.click(regionDropdown);
5656
const customOption = await canvas.findByText('New York');
5757
await userEvent.click(customOption);
58-
58+
5959
// Submit the form
6060
const submitButton = canvas.getByRole('button', { name: 'Submit' });
6161
await userEvent.click(submitButton);
62-
62+
6363
// Verify the submission (mock response would be shown)
6464
await expect(canvas.findByText('Selected regions:')).resolves.toBeInTheDocument();
6565
};
6666

6767
// Test validation errors
6868
export const testValidationErrors = async ({ canvasElement }: StoryContext) => {
6969
const canvas = within(canvasElement);
70-
70+
7171
// Submit the form without selecting anything
7272
const submitButton = canvas.getByRole('button', { name: 'Submit' });
7373
await userEvent.click(submitButton);
74-
74+
7575
// Verify error messages
7676
await expect(canvas.findByText('Please select a state')).resolves.toBeInTheDocument();
7777
await expect(canvas.findByText('Please select a province')).resolves.toBeInTheDocument();
7878
await expect(canvas.findByText('Please select a region')).resolves.toBeInTheDocument();
7979
};
80-

package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22
"name": "forms",
33
"version": "0.2.0",
44
"private": true,
5-
"workspaces": [
6-
"apps/*",
7-
"packages/*"
8-
],
5+
"workspaces": ["apps/*", "packages/*"],
96
"scripts": {
107
"start": "yarn dev",
118
"dev": "turbo run dev",

packages/components/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
"import": "./dist/ui/*.js"
2727
}
2828
},
29-
"files": [
30-
"dist"
31-
],
29+
"files": ["dist"],
3230
"scripts": {
3331
"prepublishOnly": "yarn run build",
3432
"build": "vite build",

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: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
import * as React from 'react';
2-
import { Select, type SelectProps } from './select';
31
import { CANADA_PROVINCES } from '../ui/data/canada-provinces';
2+
import { Select, type SelectProps } from './select';
43

54
export type CanadaProvinceSelectProps = Omit<SelectProps, 'options'>;
65

76
export function CanadaProvinceSelect(props: CanadaProvinceSelectProps) {
8-
return (
9-
<Select
10-
{...props}
11-
options={CANADA_PROVINCES}
12-
placeholder="Select a province"
13-
/>
14-
);
7+
return <Select {...props} options={CANADA_PROVINCES} placeholder="Select a province" />;
158
}
16-
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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect, useMemo } from 'react';
2+
import { useRemixFormContext } from 'remix-hook-form';
3+
import { type ScrollToErrorOptions, scrollToFirstError } from '../../utils/scrollToError';
4+
5+
export interface UseScrollToErrorOnSubmitOptions extends ScrollToErrorOptions {
6+
delay?: number;
7+
enabled?: boolean;
8+
scrollOnServerErrors?: boolean;
9+
scrollOnMount?: boolean;
10+
}
11+
12+
export const useScrollToErrorOnSubmit = (options: UseScrollToErrorOnSubmitOptions = {}) => {
13+
const { formState } = useRemixFormContext();
14+
const { delay = 100, enabled = true, scrollOnServerErrors = true, scrollOnMount = true, ...scrollOptions } = options;
15+
16+
// Memoize scroll options to prevent unnecessary re-renders
17+
const memoizedScrollOptions = useMemo(() => scrollOptions, [
18+
scrollOptions.behavior,
19+
scrollOptions.block,
20+
scrollOptions.inline,
21+
scrollOptions.offset,
22+
scrollOptions.shouldFocus,
23+
scrollOptions.retryAttempts,
24+
]);
25+
26+
// Handle form submission errors
27+
useEffect(() => {
28+
if (!enabled) return;
29+
const hasErrors = Object.keys(formState.errors).length > 0;
30+
31+
// Scroll after submission attempt when errors exist
32+
if (!formState.isSubmitting && hasErrors) {
33+
const timeoutId = setTimeout(() => {
34+
scrollToFirstError(formState.errors, memoizedScrollOptions);
35+
}, delay);
36+
37+
return () => clearTimeout(timeoutId);
38+
}
39+
}, [formState.errors, formState.isSubmitting, enabled, delay, memoizedScrollOptions]);
40+
41+
// Handle server-side validation errors on mount (Remix SSR)
42+
useEffect(() => {
43+
if (!(enabled && scrollOnMount) || !scrollOnServerErrors) return;
44+
const hasErrors = Object.keys(formState.errors).length > 0;
45+
46+
if (hasErrors && !formState.isSubmitting) {
47+
const timeoutId = setTimeout(() => {
48+
scrollToFirstError(formState.errors, memoizedScrollOptions);
49+
}, delay);
50+
51+
return () => clearTimeout(timeoutId);
52+
}
53+
}, [enabled, scrollOnMount, scrollOnServerErrors, formState.errors, formState.isSubmitting, delay, memoizedScrollOptions]);
54+
};

0 commit comments

Comments
 (0)