diff --git a/.changeset/add-uncontrolled-form-error.md b/.changeset/add-uncontrolled-form-error.md new file mode 100644 index 00000000..406c4d92 --- /dev/null +++ b/.changeset/add-uncontrolled-form-error.md @@ -0,0 +1,5 @@ +--- +"@lambdacurry/forms": patch +--- + +Add uncontrolled mode to FormError component via a manual `message` prop. diff --git a/apps/docs/src/remix-hook-form/form-error-uncontrolled.stories.tsx b/apps/docs/src/remix-hook-form/form-error-uncontrolled.stories.tsx new file mode 100644 index 00000000..1b7909a1 --- /dev/null +++ b/apps/docs/src/remix-hook-form/form-error-uncontrolled.stories.tsx @@ -0,0 +1,79 @@ +import { FormError } from '@lambdacurry/forms'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; + +const UncontrolledFormErrorExample = ({ message }: { message?: string }) => { + const methods = useRemixForm({ + defaultValues: { + email: '', + }, + }); + + return ( + + + Uncontrolled Error + + This FormError is rendered with a manual message prop, bypassing the form state. + + + + + + + The error above is not coming from errors._form. It's passed directly as a prop. + + + + + ); +}; + +const meta: Meta = { + title: 'RemixHookForm/FormError/Uncontrolled', + component: FormError, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +The FormError component can be used in an "uncontrolled" mode by passing a \`message\` prop directly. +This is useful for displaying errors that aren't managed by the form's validation state, such as generic network errors or manual UI feedback. + +**Key Features of Uncontrolled Mode:** +- Takes precedence over form-state errors +- Does not require a \`name\` prop +- Maintains consistent styling and accessibility + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: () => , + }, + ], + }), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + message: 'A manual error message', + }, +}; + +export const CustomStyling: Story = { + args: { + message: 'A manual error message with custom styling', + className: 'bg-red-50 p-4 border border-red-200 rounded-md', + }, +}; diff --git a/apps/docs/src/remix-hook-form/form-error.test.tsx b/apps/docs/src/remix-hook-form/form-error.test.tsx index 25403293..d0533b3f 100644 --- a/apps/docs/src/remix-hook-form/form-error.test.tsx +++ b/apps/docs/src/remix-hook-form/form-error.test.tsx @@ -30,11 +30,13 @@ const TestFormWithError = ({ formErrorName = '_form', customComponents = {}, className = '', + message = '', }: { initialErrors?: Record; formErrorName?: string; customComponents?: { FormMessage?: React.ComponentType }; className?: string; + message?: string; }) => { const mockFetcher = { data: { errors: initialErrors }, @@ -55,7 +57,7 @@ const TestFormWithError = ({ return ( - + Submit @@ -313,6 +315,31 @@ describe('FormError Component', () => { }); }); + describe('Uncontrolled Mode', () => { + it('displays manual message when provided', () => { + render(); + + expect(screen.getByText('Manual error message')).toBeInTheDocument(); + }); + + it('manual message takes precedence over form state error', () => { + const errors = { + _form: { message: 'Form state error' }, + }; + + render(); + + expect(screen.getByText('Manual error message')).toBeInTheDocument(); + expect(screen.queryByText('Form state error')).not.toBeInTheDocument(); + }); + + it('renders even when form context is missing if message is provided', () => { + // Our implementation should be resilient. + render(); + expect(screen.getByText('Context-less error')).toBeInTheDocument(); + }); + }); + describe('Performance', () => { it('does not re-render unnecessarily when unrelated form state changes', () => { const renderSpy = jest.fn(); diff --git a/packages/components/src/remix-hook-form/form-error.tsx b/packages/components/src/remix-hook-form/form-error.tsx index 15c56bdf..396d28ed 100644 --- a/packages/components/src/remix-hook-form/form-error.tsx +++ b/packages/components/src/remix-hook-form/form-error.tsx @@ -4,14 +4,18 @@ import { FormErrorField } from '../ui/form-error-field'; export type FormErrorProps = { name?: string; + message?: string; className?: string; components?: Partial; }; -export function FormError({ name = '_form', ...props }: FormErrorProps) { - const { control } = useRemixFormContext(); +export function FormError({ name, message, ...props }: FormErrorProps) { + const context = useRemixFormContext(); + const control = context?.control; - return ; + const effectiveName = name ?? (message ? undefined : '_form'); + + return ; } FormError.displayName = 'FormError'; diff --git a/packages/components/src/ui/form-error-field.tsx b/packages/components/src/ui/form-error-field.tsx index 6ed90b10..ba9ba14a 100644 --- a/packages/components/src/ui/form-error-field.tsx +++ b/packages/components/src/ui/form-error-field.tsx @@ -7,7 +7,8 @@ export interface FormErrorFieldProps< TName extends FieldPath = FieldPath, > { control?: Control; - name: TName; + name?: TName; + message?: string; className?: string; components?: Partial; } @@ -18,9 +19,22 @@ export const FormErrorField = < >({ control, name, + message, className, components, }: FormErrorFieldProps) => { + if (message) { + return ( + + {message} + + ); + } + + if (!name) { + return null; + } + return (
+ This FormError is rendered with a manual message prop, bypassing the form state. +
+ The error above is not coming from errors._form. It's passed directly as a prop. +
errors._form