diff --git a/ai/CustomInputsProject.md b/ai/CustomInputsProject.md new file mode 100644 index 00000000..585e684a --- /dev/null +++ b/ai/CustomInputsProject.md @@ -0,0 +1,935 @@ +# Custom Form Components Project + +## Overview + +This document outlines our approach to allowing users of our form library to inject their own custom components while maintaining a consistent API across all form elements. This feature enables users to fully customize the appearance and behavior of form components while preserving the form handling functionality. + +## High-Level Approach + +We use a component injection pattern that allows users to replace any part of our form components with their own implementations. This is achieved through a consistent `components` prop that accepts custom component implementations for various parts of the form element. + +### Key Principles + +1. **Consistency**: All form components follow the same pattern for customization +2. **Flexibility**: Users can replace any part of a form component +3. **Type Safety**: All component replacements are properly typed +4. **Defaults**: Default components are provided if no custom components are specified +5. **Backward Compatibility**: Existing implementations continue to work without changes + +## Implementation Details + +### Component Interface Extensions + +For each form component, we extend the base `FieldComponents` interface to include component-specific elements: + +```typescript +// Base interface (already exists) +export interface FieldComponents { + FormControl: React.ForwardRefExoticComponent>; + FormDescription: React.ForwardRefExoticComponent>; + FormLabel: React.ForwardRefExoticComponent>; + FormMessage: React.ForwardRefExoticComponent>; +} + +// Extended interface for specific components (e.g., Checkbox) +export interface CheckboxFieldComponents extends FieldComponents { + Checkbox?: React.ComponentType>; + CheckboxIndicator?: React.ComponentType>; +} +``` + +### Component Implementation + +In the component implementation, we extract custom components from the `components` prop with fallbacks to the default components: + +```typescript +const CheckboxField = React.forwardRef( + ({ control, name, className, label, description, components, ...props }, ref) => { + // Extract custom components with fallbacks + const CheckboxComponent = components?.Checkbox || CheckboxPrimitive.Root; + const IndicatorComponent = components?.CheckboxIndicator || CheckboxPrimitive.Indicator; + + return ( + ( + + + + + + + + + {/* Rest of the component */} + + )} + /> + ); + } +); +``` + +### Remix Hook Form Wrapper + +In the Remix Hook Form wrapper, we merge any user-provided components with the default form components: + +```typescript +export function Checkbox(props: CheckboxProps) { + const { control } = useRemixFormContext(); + + const components: Partial = { + FormDescription, + FormControl, + FormLabel, + FormMessage, + ...props.components, // Merge user components + }; + + return ; +} +``` + +## Usage Examples + +### Basic Usage (No Customization) + +```tsx +import { Checkbox } from '@lambdacurry/forms'; + +function MyForm() { + return ( + + ); +} +``` + +### Custom Checkbox Component + +```tsx +import { Checkbox } from '@lambdacurry/forms'; +import { CheckIcon } from 'lucide-react'; + +// Custom checkbox component +const MyCustomCheckbox = React.forwardRef((props, ref) => ( +
props.onCheckedChange(!props.checked)} + {...props} + > + {props.children} +
+)); + +// Custom indicator component +const MyCustomIndicator = ({ className, children, ...props }) => ( +
+ +
+); + +function MyForm() { + return ( + + ); +} +``` + +### Custom Form Elements + +```tsx +import { Checkbox } from '@lambdacurry/forms'; + +// Custom form label +const MyCustomLabel = ({ className, children, ...props }) => ( + +); + +// Custom error message +const MyCustomMessage = ({ className, children, ...props }) => ( +
+ + {children} +
+); + +function MyForm() { + return ( + + ); +} +``` + +## Components to Update + +The following form components should be updated to support component injection: + +### 1. Checkbox +```typescript +export interface CheckboxFieldComponents extends FieldComponents { + Checkbox?: React.ComponentType>; + CheckboxIndicator?: React.ComponentType>; +} +``` + +### 2. TextField/TextInput +```typescript +export interface TextFieldComponents extends FieldComponents { + Input?: React.ComponentType>; +} +``` + +### 3. TextArea +```typescript +export interface TextAreaFieldComponents extends FieldComponents { + TextArea?: React.ComponentType>; +} +``` + +### 4. RadioGroup +```typescript +export interface RadioGroupFieldComponents extends FieldComponents { + RadioGroup?: React.ComponentType>; + RadioGroupItem?: React.ComponentType>; + RadioGroupIndicator?: React.ComponentType>; +} + +// Props interface should include radioGroupClassName for styling the container +export interface RadioGroupFieldProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> extends Omit, 'onValueChange'> { + control?: Control; + name: TName; + label?: string; + description?: string; + components?: Partial; + radioGroupClassName?: string; // Added prop for styling the RadioGroup container +} +``` + +#### RadioGroup Implementation Examples + +Our implementation supports several customization approaches: + +1. **Container Styling**: Use the `radioGroupClassName` prop to style the RadioGroup container without changing its behavior. + +```tsx + + {/* Radio items */} + +``` + +2. **Custom Radio Items**: Customize individual radio items by passing custom components through the `components` prop of `RadioGroupItem`. + +```tsx + +``` + +3. **Custom Icons**: Replace the default indicator with a custom SVG icon. + +```tsx + +``` + +4. **Card-Style Radio Buttons**: Completely transform the appearance of radio buttons by creating a custom component that uses `RadioGroupPrimitive.Item`. + +```tsx +// Card-style radio group item component +const CardRadioGroupItem = React.forwardRef((props, ref) => { + const { value, children, className, ...otherProps } = props; + + return ( + +
+
+ {children} +
+
+ + + +
+
+
+ ); +}); + +// Usage with card-style radio buttons + + +
Starter
+
Perfect for beginners
+
+ + {/* More card items */} +
+``` + +### 5. Switch +```typescript +export interface SwitchFieldComponents extends FieldComponents { + Switch?: React.ComponentType>; + SwitchThumb?: React.ComponentType>; +} +``` + +### 6. DatePicker +```typescript +export interface DatePickerFieldComponents extends FieldComponents { + DatePicker?: React.ComponentType>; + DatePickerTrigger?: React.ComponentType>; + DatePickerContent?: React.ComponentType>; + Calendar?: React.ComponentType>; +} +``` + +### 7. OTPInput +```typescript +export interface OTPInputFieldComponents extends FieldComponents { + OTPInput?: React.ComponentType>; + OTPInputSlot?: React.ComponentType>; + OTPInputChar?: React.ComponentType>; +} +``` + +### 8. DropdownMenuSelect +```typescript +export interface DropdownMenuSelectFieldComponents extends FieldComponents { + DropdownMenu?: React.ComponentType>; + DropdownMenuTrigger?: React.ComponentType>; + DropdownMenuContent?: React.ComponentType>; + DropdownMenuItem?: React.ComponentType>; +} +``` + +## Implementation Priority + +1. **High Priority**: + - Checkbox + - TextField + - RadioGroup + - Switch + +2. **Medium Priority**: + - TextArea + - DropdownMenuSelect + +3. **Low Priority**: + - DatePicker + - OTPInput + +## Implementation Template + +Here's a template for implementing component injection for each form component: + +```typescript +// 1. Define component-specific interface +export interface [Component]FieldComponents extends FieldComponents { + [Component]?: React.ComponentType>; + // Add any additional sub-components +} + +// 2. Update props interface +export interface [Component]Props< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> extends [BaseProps] { + // Existing props + components?: Partial<[Component]FieldComponents>; +} + +// 3. Update component implementation +const [Component]Field = React.forwardRef( + ({ control, name, components, ...props }, ref) => { + // Extract custom components with fallbacks + const CustomComponent = components?.[Component] || [Component]Primitive.Root; + + return ( + ( + + {/* Use custom components */} + + {/* Rest of the component */} + + )} + /> + ); + } +); + +// 4. Update Remix wrapper +export function [Component](props: [Component]Props) { + const { control } = useRemixFormContext(); + + const components: Partial<[Component]FieldComponents> = { + FormDescription, + FormControl, + FormLabel, + FormMessage, + ...props.components, + }; + + return ; +} +``` + +## Testing Strategy + +When implementing custom component injection, follow these testing steps: + + +## 1. Storybook Examples + +Based on our existing checkbox.stories.tsx, we should create stories that demonstrate both default and custom component usage. Here's how to enhance our stories: + +#### Example: Custom Checkbox Story Implementation + +```tsx +import { zodResolver } from '@hookform/resolvers/zod'; +import { Checkbox } from '@lambdacurry/forms/remix-hook-form/checkbox'; +import { Button } from '@lambdacurry/forms/ui/button'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { useFetcher } from '@remix-run/react'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react'; +import { expect, userEvent } from '@storybook/test'; +import * as React from 'react'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; + +// Custom checkbox component +const CustomCheckbox = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { checked?: boolean; onCheckedChange?: (checked: boolean) => void } +>((props, ref) => { + const { checked, onCheckedChange, children, className, ...rest } = props; + + return ( +
onCheckedChange?.(!checked)} + {...rest} + > + {children} +
+ ); +}); +CustomCheckbox.displayName = 'CustomCheckbox'; + +// Custom indicator component +const CustomIndicator = ({ className, ...props }: React.HTMLAttributes) => ( +
+ ✓ +
+); + +// Form schema (same as original) +const formSchema = z.object({ + terms: z.boolean().refine((val) => val === true, 'You must accept the terms and conditions'), + marketing: z.boolean().optional(), + required: z.boolean().refine((val) => val === true, 'This field is required'), +}); + +type FormData = z.infer; + +// Example with custom components +const CustomCheckboxExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + terms: false as true, + marketing: false, + required: false as true, + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+ + + +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +// Handler function (same as original) +const handleFormSubmission = async (request: Request) => { + const { errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { message: 'Form submitted successfully' }; +}; + +// Story definition +export const CustomComponents: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Example of checkbox with custom components.', + }, + source: { + code: ` +import { Checkbox } from '@lambdacurry/forms/remix-hook-form/checkbox'; + +// Custom checkbox component +const CustomCheckbox = React.forwardRef((props, ref) => { + const { checked, onCheckedChange, children, className, ...rest } = props; + + return ( +
onCheckedChange?.(!checked)} + {...rest} + > + {children} +
+ ); +}); + +// Custom indicator component +const CustomIndicator = ({ className, ...props }) => ( +
+ ✓ +
+); + +// Usage in form +`, + }, + }, + }, + play: async (storyContext) => { + const { canvas } = storyContext; + + // Test 1: Verify custom checkboxes are rendered + const customCheckboxes = canvas.getAllByRole('checkbox'); + expect(customCheckboxes.length).toBe(3); + + // Test 2: Verify custom checkboxes can be checked + const termsCheckbox = canvas.getByLabelText('Accept terms and conditions'); + const requiredCheckbox = canvas.getByLabelText('This is a required checkbox'); + + await userEvent.click(termsCheckbox); + await userEvent.click(requiredCheckbox); + + expect(termsCheckbox).toHaveAttribute('aria-checked', 'true'); + expect(requiredCheckbox).toHaveAttribute('aria-checked', 'true'); + + // Test 3: Verify form submission works with custom components + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + await expect(await canvas.findByText('Form submitted successfully')).toBeInTheDocument(); + }, +}; +``` + +### 4. Testing Custom Form Elements + +In addition to testing custom input components, we should also test custom form elements like labels and error messages: + +```tsx +// Custom form elements example +const CustomFormElementsExample = () => { + // ... form setup code ... + + return ( + + +
+ ( + + ), + FormMessage: ({ children, ...props }) => ( +
+ ⚠️ {children} +
+ ) + }} + /> + {/* ... other form fields ... */} +
+ +
+
+ ); +}; + +// Tests for custom form elements +const testCustomFormElements = async ({ canvas }: StoryContext) => { + // Test 1: Verify custom label is rendered + const customLabel = canvas.getByText(/Accept terms and conditions ★/); + expect(customLabel).toHaveClass('custom-label'); + + // Test 2: Verify custom error message is rendered after invalid submission + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + const errorMessage = await canvas.findByText('You must accept the terms and conditions'); + expect(errorMessage.parentElement).toHaveClass('custom-error'); + expect(errorMessage.parentElement?.querySelector('span')?.textContent).toBe('⚠️'); +}; +``` + +### 5. Accessibility Testing + +When implementing custom components, it's crucial to maintain accessibility. Here are specific tests to include: + +```tsx +const testAccessibility = async ({ canvas }: StoryContext) => { + // Test 1: Verify custom checkbox has proper ARIA attributes + const checkbox = canvas.getByLabelText('Accept terms and conditions'); + expect(checkbox).toHaveAttribute('role', 'checkbox'); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + + // Test 2: Verify checkbox can be toggled with keyboard + checkbox.focus(); + await userEvent.keyboard(' '); // Space key + expect(checkbox).toHaveAttribute('aria-checked', 'true'); + + // Test 3: Verify form label is properly associated with the checkbox + const label = canvas.getByText('Accept terms and conditions'); + expect(label).toHaveAttribute('for', checkbox.id); +}; +``` + +## Implementation Examples + +### Custom Checkbox Story Implementation + +We've created a comprehensive Storybook example that demonstrates how to override components in our checkbox field. The example can be found in `apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx`. + +Here's how we implemented custom components for the checkbox: + +```tsx +// Custom checkbox component +const PurpleCheckbox = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); +PurpleCheckbox.displayName = 'PurpleCheckbox'; + +// Custom indicator +const PurpleIndicator = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + ✓ + +)); +PurpleIndicator.displayName = 'PurpleIndicator'; + +// Custom form label component +const CustomLabel = React.forwardRef>( + ({ className, htmlFor, ...props }, ref) => ( + + ), +); +CustomLabel.displayName = 'CustomLabel'; + +// Custom error message component +const CustomErrorMessage = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ⚠️ {props.children} +

+ ), +); +CustomErrorMessage.displayName = 'CustomErrorMessage'; +``` + +### Component Overriding Examples + +The story demonstrates three different ways to override components: + +#### 1. Overriding Just the Checkbox Components + +```tsx + +``` + +#### 2. Overriding Just the Form Components + +```tsx + +``` + +#### 3. Overriding All Components + +```tsx +// Create component objects for reuse +const customCheckboxComponents = { + Checkbox: PurpleCheckbox, + CheckboxIndicator: PurpleIndicator, +}; + +const customLabelComponents = { + FormLabel: CustomLabel, + FormMessage: CustomErrorMessage, +}; + +// Use spread operator to combine them + +``` + +### Testing Custom Components + +Our story includes interactive tests that verify: + +1. Custom styling is applied correctly +2. Component functionality works as expected +3. Form validation still works with custom components + +```tsx +play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find all checkboxes + const checkboxElements = canvas.getAllByRole('checkbox', { hidden: true }); + + // Get all button checkboxes + const checkboxButtons = Array.from(checkboxElements) + .map((checkbox) => checkbox.closest('button')) + .filter((button) => button !== null) as HTMLButtonElement[]; + + // Find the custom purple checkbox + const purpleCheckbox = checkboxButtons.find( + (button) => button.classList.contains('rounded-full') && button.classList.contains('border-purple-500'), + ); + + if (purpleCheckbox) { + // Verify custom checkbox styling + expect(purpleCheckbox).toHaveClass('rounded-full'); + expect(purpleCheckbox).toHaveClass('border-purple-500'); + + // Check the terms checkbox + await userEvent.click(purpleCheckbox); + expect(purpleCheckbox).toHaveAttribute('data-state', 'checked'); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify successful submission + const successMessage = await canvas.findByText('Form submitted successfully'); + expect(successMessage).toBeInTheDocument(); + } +} +``` + +### Key Takeaways for Component Overriding + +1. **Type Safety**: All custom components are properly typed using React's `forwardRef` and `ComponentPropsWithoutRef` +2. **Prop Forwarding**: Custom components forward all necessary props to maintain functionality +3. **Ref Handling**: Refs are properly forwarded to maintain form control integration +4. **Composition**: Components can be overridden individually or as groups +5. **Accessibility**: Custom components maintain proper ARIA attributes and keyboard navigation + +This approach provides a flexible and consistent way for users to customize any part of our form components while maintaining a clean API. + +## Conclusion + +This component injection pattern provides a flexible and consistent way for users to customize any part of our form components while maintaining a clean API. By following this approach across all form components, we ensure a unified experience for library users. + +The implementation should be done incrementally, starting with the high-priority components, and ensuring backward compatibility at each step. diff --git a/apps/docs/package.json b/apps/docs/package.json index 7cfd2a9f..6012b2f5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -24,12 +24,14 @@ "@remix-run/testing": "^2.15.1", "@storybook/test-runner": "^0.20.1", "@storybook/testing-library": "^0.2.2", - "@types/react": "^19.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", - "react": "^19.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", "vite": "^5.4.11", diff --git a/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx new file mode 100644 index 00000000..558420cc --- /dev/null +++ b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx @@ -0,0 +1,509 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Checkbox } from '@lambdacurry/forms/remix-hook-form/checkbox'; +import type { FormLabel, FormMessage } from '@lambdacurry/forms/remix-hook-form/form'; +import { Button } from '@lambdacurry/forms/ui/button'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { useFetcher } from '@remix-run/react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; +import * as React from 'react'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; + +const formSchema = z.object({ + terms: z.boolean().refine((val) => val === true, 'You must accept the terms and conditions'), + marketing: z.boolean().optional(), + required: z.boolean().refine((val) => val === true, 'This field is required'), +}); + +type FormData = z.infer; + +// Custom checkbox component +const PurpleCheckbox = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); +PurpleCheckbox.displayName = 'PurpleCheckbox'; + +// Custom indicator +const PurpleIndicator = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + ✓ + +)); +PurpleIndicator.displayName = 'PurpleIndicator'; + +// Custom form label component +const CustomLabel = React.forwardRef>( + ({ className, htmlFor, ...props }, ref) => ( + + ), +); +CustomLabel.displayName = 'CustomLabel'; + +// Custom error message component +const CustomErrorMessage = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ⚠️ {props.children} +

+ ), +); +CustomErrorMessage.displayName = 'CustomErrorMessage'; + +// Example with custom checkbox components +const PurpleCheckboxExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + terms: false as true, + marketing: false, + required: false as true, + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+ +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +// Example with custom label components +const CustomLabelExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + terms: false as true, + marketing: false, + required: false as true, + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+ +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +// Example with all custom components +const AllCustomComponentsExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + terms: false as true, + marketing: false, + required: false as true, + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + const customCheckboxComponents = { + Checkbox: PurpleCheckbox, + CheckboxIndicator: PurpleIndicator, + }; + + const customLabelComponents = { + FormLabel: CustomLabel, + FormMessage: CustomErrorMessage, + }; + + return ( + + +
+ + + +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { message: 'Form submitted successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/Checkbox Customized', + component: Checkbox, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CustomCheckboxComponentExamples: Story = { + name: 'Custom Checkbox Component Examples', + decorators: [ + withRemixStubDecorator({ + root: { + Component: AllCustomComponentsExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + }), + ], + parameters: { + docs: { + description: { + story: ` +### Checkbox Component Customization + +This example demonstrates three different approaches to customizing the Checkbox component with complete control over styling and behavior. + +#### 1. Custom Checkbox Appearance + +The first approach customizes the visual appearance of the checkbox itself: + +\`\`\`tsx + +\`\`\` + +Where the custom components are defined as: + +\`\`\`tsx +// Custom checkbox component +const PurpleCheckbox = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); + +// Custom indicator +const PurpleIndicator = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + ✓ + +)); +\`\`\` + +#### 2. Custom Form Elements + +The second approach customizes the form elements (label and error message) while keeping the default checkbox: + +\`\`\`tsx + +\`\`\` + +With the custom form components defined as: + +\`\`\`tsx +// Custom form label component +const CustomLabel = React.forwardRef< + HTMLLabelElement, + React.ComponentPropsWithoutRef +>(({ className, htmlFor, ...props }, ref) => ( + +)); + +// Custom error message component +const CustomErrorMessage = React.forwardRef< + HTMLParagraphElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +

+ ⚠️ {props.children} +

+)); +\`\`\` + +#### 3. Combining Custom Components + +The third approach combines both custom checkbox and form elements: + +\`\`\`tsx +// Create component objects for reuse +const customCheckboxComponents = { + Checkbox: PurpleCheckbox, + CheckboxIndicator: PurpleIndicator, +}; + +const customLabelComponents = { + FormLabel: CustomLabel, + FormMessage: CustomErrorMessage, +}; + +// Use spread operator to combine them + +\`\`\` + +### Key Points + +- Always use React.forwardRef when creating custom components +- Make sure to spread the props to pass all necessary attributes +- Include the ref to maintain form functionality +- Add a displayName to your component for better debugging +- The components prop accepts replacements for Checkbox, CheckboxIndicator, FormLabel, FormMessage, and FormDescription +- You can mix and match different custom components as needed +`, + }, + source: { + code: ` +// Custom checkbox component +const PurpleCheckbox = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); + +// Custom indicator +const PurpleIndicator = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + ✓ + +)); + +// Custom form label component +const CustomLabel = React.forwardRef< + HTMLLabelElement, + React.ComponentPropsWithoutRef +>(({ className, htmlFor, ...props }, ref) => ( + +)); + +// Custom error message component +const CustomErrorMessage = React.forwardRef< + HTMLParagraphElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +

+ ⚠️ {props.children} +

+)); + +// Usage in form + + +`, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find all checkboxes + const checkboxElements = canvas.getAllByRole('checkbox', { hidden: true }); + + // Get all button checkboxes + const checkboxButtons = Array.from(checkboxElements) + .map((checkbox) => checkbox.closest('button')) + .filter((button) => button !== null) as HTMLButtonElement[]; + + // We should have at least one custom checkbox button + expect(checkboxButtons.length).toBeGreaterThan(0); + + // Find the custom purple checkbox (the one with rounded-full class) + const purpleCheckbox = checkboxButtons.find( + (button) => button.classList.contains('rounded-full') && button.classList.contains('border-purple-500'), + ); + + if (purpleCheckbox) { + // Verify custom checkbox styling + expect(purpleCheckbox).toHaveClass('rounded-full'); + expect(purpleCheckbox).toHaveClass('border-purple-500'); + + // Check the terms checkbox + await userEvent.click(purpleCheckbox); + expect(purpleCheckbox).toHaveAttribute('data-state', 'checked'); + + // Find the required checkbox (we'll just check all remaining checkboxes) + for (const button of checkboxButtons) { + if (button !== purpleCheckbox) { + await userEvent.click(button); + } + } + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify successful submission + const successMessage = await canvas.findByText('Form submitted successfully'); + expect(successMessage).toBeInTheDocument(); + } + }, +}; \ No newline at end of file diff --git a/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx b/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx index 71994d77..b5e91f8b 100644 --- a/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx +++ b/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx @@ -8,6 +8,7 @@ import { Form } from '@remix-run/react'; import type { Meta, StoryContext, StoryObj } from '@storybook/react'; import { expect, userEvent } from '@storybook/test'; import type {} from '@testing-library/dom'; +import * as React from 'react'; import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; @@ -28,6 +29,23 @@ const formSchema = z.object({ type FormData = z.infer; +// Custom FormLabel component that makes the entire area clickable +const FullWidthLabel = React.forwardRef>( + ({ className, children, htmlFor, ...props }, ref) => { + return ( + + ); + }, +); +FullWidthLabel.displayName = 'FullWidthLabel'; + const ControlledCheckboxListExample = () => { const fetcher = useFetcher<{ message: string; selectedColors: string[] }>(); const methods = useRemixForm({ @@ -58,8 +76,6 @@ const ControlledCheckboxListExample = () => { }, }); - console.log(methods.formState); - return (
@@ -67,7 +83,15 @@ const ControlledCheckboxListExample = () => {

Select your favorite colors:

{AVAILABLE_COLORS.map(({ value, label }) => ( - + ))}
@@ -101,7 +125,7 @@ const handleFormSubmission = async (request: Request) => { }; const meta: Meta = { - title: 'RemixHookForm/CheckboxList', + title: 'RemixHookForm/Checkbox List', component: Checkbox, parameters: { layout: 'centered' }, tags: ['autodocs'], @@ -153,7 +177,36 @@ export const Tests: Story = { parameters: { docs: { description: { - story: 'A checkbox list component for selecting multiple colors.', + story: 'A checkbox list component for selecting multiple colors with full-width clickable area.', + }, + source: { + code: ` +// Custom FormLabel component that makes the entire area clickable +const FullWidthLabel = React.forwardRef>( + ({ className, children, htmlFor, ...props }, ref) => { + return ( + + ); + }, +); + +// Usage in your component + +`, }, }, }, @@ -162,4 +215,4 @@ export const Tests: Story = { await testErrorState(storyContext); await testColorSelection(storyContext); }, -}; \ No newline at end of file +}; diff --git a/apps/docs/src/remix-hook-form/checkbox.stories.tsx b/apps/docs/src/remix-hook-form/checkbox.stories.tsx index 0b44fa67..50ef78f6 100644 --- a/apps/docs/src/remix-hook-form/checkbox.stories.tsx +++ b/apps/docs/src/remix-hook-form/checkbox.stories.tsx @@ -5,7 +5,6 @@ import type { ActionFunctionArgs } from '@remix-run/node'; import { useFetcher } from '@remix-run/react'; import type { Meta, StoryContext, StoryObj } from '@storybook/react'; import { expect, userEvent } from '@storybook/test'; -import type {} from '@testing-library/dom'; import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; import { z } from 'zod'; import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; @@ -37,17 +36,16 @@ const ControlledCheckboxExample = () => { return ( -
- +
+ - +
- {fetcher.data?.message &&

{fetcher.data.message}

} @@ -112,7 +110,7 @@ const testValidSubmission = async ({ canvas }: StoryContext) => { await expect(await canvas.findByText('Form submitted successfully')).toBeInTheDocument(); }; -export const Tests: Story = { +export const Default: Story = { parameters: { docs: { description: { @@ -120,40 +118,44 @@ export const Tests: Story = { }, source: { code: ` - const formSchema = z.object({ - terms: z.boolean().optional().refine(val => val === true, 'You must accept the terms and conditions'), - marketing: z.boolean().optional(), - required: z.boolean().optional().refine(val => val === true, 'This field is required'), +const formSchema = z.object({ + terms: z.boolean().refine(val => val === true, 'You must accept the terms and conditions'), + marketing: z.boolean().optional(), + required: z.boolean().refine(val => val === true, 'This field is required'), +}); + +const ControlledCheckboxExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + terms: false as true, // Note: ZOD Schema expects a true value + marketing: false, + required: false as true //Note: ZOD Schema expects a true value + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, }); - const ControlledCheckboxExample = () => { - const fetcher = useFetcher<{ message: string }>(); - const methods = useRemixForm({ - resolver: zodResolver(formSchema), - defaultValues: { - terms: false as true, // Note: ZOD Schema expects a true value - marketing: false, - required: false as true //Note: ZOD Schema expects a true value - }, - fetcher, - }); - - return ( - - -
- - - -
- - {fetcher.data?.message &&

{fetcher.data.message}

} -
-
- ); - };`, + return ( + + +
+ + + +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +};`, }, }, }, @@ -162,4 +164,4 @@ export const Tests: Story = { await testInvalidSubmission(storyContext); await testValidSubmission(storyContext); }, -}; \ No newline at end of file +}; diff --git a/apps/docs/src/remix-hook-form/date-picker.stories.tsx b/apps/docs/src/remix-hook-form/date-picker.stories.tsx index 4d81e456..09a535dd 100644 --- a/apps/docs/src/remix-hook-form/date-picker.stories.tsx +++ b/apps/docs/src/remix-hook-form/date-picker.stories.tsx @@ -58,7 +58,7 @@ const handleFormSubmission = async (request: Request) => { // Storybook configuration const meta: Meta = { - title: 'RemixHookForm/DatePicker', + title: 'RemixHookForm/Date Picker', component: DatePicker, parameters: { layout: 'centered' }, tags: ['autodocs'], @@ -119,4 +119,4 @@ export const Tests: Story = { await testDateSelection(storyContext); await testSubmission(storyContext); }, -}; \ No newline at end of file +}; diff --git a/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx b/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx new file mode 100644 index 00000000..8588485e --- /dev/null +++ b/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx @@ -0,0 +1,660 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { RadioGroup } from '@lambdacurry/forms/remix-hook-form/radio-group'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { FormLabel, FormMessage } from '@lambdacurry/forms/ui/form'; +import { RadioGroupItem } from '@lambdacurry/forms/ui/radio-group'; +import { cn } from '@lambdacurry/forms/ui/utils'; +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { Form, useFetcher } from '@remix-run/react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; +import * as React from 'react'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; + +const formSchema = z.object({ + plan: z.enum(['starter', 'pro', 'enterprise'], { + required_error: 'You need to select a plan', + }), + requiredPlan: z.enum(['starter', 'pro', 'enterprise'], { + required_error: 'This field is required', + }), +}); + +type FormData = z.infer; + +// Custom radio group component +const PurpleRadioGroup = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + return ( + + ); +}); +PurpleRadioGroup.displayName = 'PurpleRadioGroup'; + +// Custom radio group item component +const PurpleRadioGroupItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + indicator?: React.ReactNode; + } +>((props, ref) => { + return ( + + {props.children} + + ); +}); +PurpleRadioGroupItem.displayName = 'PurpleRadioGroupItem'; + +// Custom radio group indicator component +const PurpleRadioGroupIndicator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + return ( + +
+ + ); +}); +PurpleRadioGroupIndicator.displayName = 'PurpleRadioGroupIndicator'; + +// Custom radio group indicator with icon +const IconRadioGroupIndicator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + return ( + + + + ); +}); +IconRadioGroupIndicator.displayName = 'IconRadioGroupIndicator'; + +// Custom form label component +const PurpleLabel = React.forwardRef>( + ({ className, ...props }, ref) => , +); +PurpleLabel.displayName = 'PurpleLabel'; + +// Custom error message component +const PurpleErrorMessage = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +PurpleErrorMessage.displayName = 'PurpleErrorMessage'; + +// Card-style radio group item component +const CardRadioGroupItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => { + const { value, children, className, ...otherProps } = props; + + return ( + +
+
{children}
+
+ + + +
+
+
+ ); +}); +CardRadioGroupItem.displayName = 'CardRadioGroupItem'; + +const CustomRadioGroupExample = () => { + const fetcher = useFetcher<{ message?: string; errors?: Record }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + plan: undefined, + requiredPlan: undefined, + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+
+

Custom Radio Group Container

+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Custom Radio Items

+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Custom Radio Items with Icon

+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Card-Style Radio Buttons

+ + +
Starter
+
Perfect for beginners
+
+ + +
Pro
+
For professional users
+
+ + +
Enterprise
+
For large organizations
+
+
+
+ +
+

Required Radio Group

+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + {fetcher.data?.message &&

{fetcher.data.message}

} + +
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { + errors, + data, + receivedValues: defaultValues, + } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors, defaultValues }; + } + + return { message: 'Plan selected successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/Radio Group Customized', + component: RadioGroup, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CustomComponents: Story = { + render: () => , + decorators: [ + withRemixStubDecorator({ + root: { + Component: CustomRadioGroupExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + }), + ], + parameters: { + docs: { + description: { + story: 'Examples of different ways to customize radio group components.', + }, + source: { + code: ` +import { RadioGroup } from '@lambdacurry/forms/remix-hook-form/radio-group'; +import { RadioGroupItem } from '@lambdacurry/forms/ui/radio-group'; +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import * as React from 'react'; +import { cn } from '@lambdacurry/forms/ui/utils'; + +/** + * Example 1: Custom Container Styling + * + * You can customize the container by using the radioGroupClassName prop. + * This applies custom styles to the RadioGroup container without changing its behavior. + */ + +
+ + +
+ {/* More radio items */} +
+ +/** + * Example 2: Custom Radio Items + * + * You can customize the radio items by passing custom components through + * the components prop of RadioGroupItem. + */ + +
+ + +
+ {/* More radio items */} +
+ +/** + * Example 3: Custom Radio Items with Icon + * + * You can replace the default indicator with a custom icon. + * This example uses an SVG checkmark instead of the default circle. + */ + +
+ + +
+ {/* More radio items */} +
+ +/** + * Example 4: Card-Style Radio Buttons + * + * You can completely transform the appearance of radio buttons + * by creating a custom component that uses RadioGroupPrimitive.Item. + * This example creates card-style radio buttons with rich content. + */ + +// Card-style radio group item component +const CardRadioGroupItem = React.forwardRef((props, ref) => { + const { value, children, className, ...otherProps } = props; + + return ( + +
+
+ {children} +
+
+ + + +
+
+
+ ); +}); + +// Usage with card-style radio buttons + + +
Starter
+
Perfect for beginners
+
+ + +
Pro
+
For professional users
+
+ + +
Enterprise
+
For large organizations
+
+
+ +/** + * Example 5: Required Radio Group + * + * You can create a required radio group by using Zod validation. + * This example shows how to display custom error messages when validation fails. + */ + +
+ + +
+ {/* More radio items */} +
+ +// Zod schema with required field +const formSchema = z.object({ + plan: z.enum(['starter', 'pro', 'enterprise'], { + required_error: 'You need to select a plan', + }), + requiredPlan: z.enum(['starter', 'pro', 'enterprise'], { + required_error: 'This field is required', + }), +});`, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Test 1: Verify custom label is rendered with purple styling + const customLabel = canvas.getAllByText('Select a plan')[0]; + expect(customLabel).toHaveClass('text-purple-700'); + expect(customLabel).toHaveClass('font-bold'); + + // Test 2: Verify card-style radio buttons work + const cardStyleContainer = canvas.getByText('Card-Style Radio Buttons').closest('div'); + if (!cardStyleContainer) { + throw new Error('Could not find card-style container'); + } + + // Find and click the Enterprise card option + const enterpriseCard = within(cardStyleContainer as HTMLElement) + .getByText('Enterprise') + .closest('button'); + if (!enterpriseCard) { + throw new Error('Could not find Enterprise card option'); + } + + await userEvent.click(enterpriseCard); + expect(enterpriseCard).toHaveAttribute('data-state', 'checked'); + + // Test 3: Verify required field validation + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Should show error message for required field + const errorMessage = await canvas.findByText('This field is required'); + expect(errorMessage).toBeInTheDocument(); + + // Test 4: Select an option in the required field and submit + const requiredContainer = canvas.getByText('Required Radio Group').closest('div'); + if (!requiredContainer) { + throw new Error('Could not find required radio group container'); + } + + const proOption = within(requiredContainer as HTMLElement).getByLabelText('Pro'); + await userEvent.click(proOption); + + // Submit the form again + await userEvent.click(submitButton); + + // Wait for success message + const successMessage = await canvas.findByText('Plan selected successfully'); + expect(successMessage).toBeInTheDocument(); + }, +}; diff --git a/apps/docs/src/remix-hook-form/radio-group.stories.tsx b/apps/docs/src/remix-hook-form/radio-group.stories.tsx index 249828be..84c412a8 100644 --- a/apps/docs/src/remix-hook-form/radio-group.stories.tsx +++ b/apps/docs/src/remix-hook-form/radio-group.stories.tsx @@ -78,7 +78,7 @@ const handleFormSubmission = async (request: Request) => { }; const meta: Meta = { - title: 'RemixHookForm/RadioGroup', + title: 'RemixHookForm/Radio Group', component: RadioGroup, parameters: { layout: 'centered' }, tags: ['autodocs'], @@ -121,4 +121,4 @@ export const Tests: Story = { await testRadioGroupSelection(storyContext); await testSubmission(storyContext); }, -}; \ No newline at end of file +}; diff --git a/apps/docs/src/remix-hook-form/switch-custom.stories.tsx b/apps/docs/src/remix-hook-form/switch-custom.stories.tsx new file mode 100644 index 00000000..8d6d42fa --- /dev/null +++ b/apps/docs/src/remix-hook-form/switch-custom.stories.tsx @@ -0,0 +1,361 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Switch } from '@lambdacurry/forms/remix-hook-form/switch'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { FormLabel, FormMessage } from '@lambdacurry/forms/ui/form'; +import * as SwitchPrimitives from '@radix-ui/react-switch'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { useFetcher } from '@remix-run/react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; +import * as React from 'react'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; + +const formSchema = z.object({ + notifications: z.boolean().default(false), + darkMode: z.boolean().default(false), + premium: z.boolean().default(false), +}); + +type FormData = z.infer; + +// Custom Switch component +const PurpleSwitch = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); +PurpleSwitch.displayName = 'PurpleSwitch'; + +// Custom Switch Thumb component +const PurpleSwitchThumb = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + + ON + + + OFF + + +)); +PurpleSwitchThumb.displayName = 'PurpleSwitchThumb'; + +// Custom Form Label component +const PurpleLabel = React.forwardRef>( + ({ className, ...props }, ref) => , +); +PurpleLabel.displayName = 'PurpleLabel'; + +// Custom Form Message component +const PurpleMessage = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +PurpleMessage.displayName = 'PurpleMessage'; + +// Green Switch component +const GreenSwitch = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); +GreenSwitch.displayName = 'GreenSwitch'; + +// Green Switch Thumb component +const GreenSwitchThumb = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + + Check mark + + + +)); +GreenSwitchThumb.displayName = 'GreenSwitchThumb'; + +const CustomSwitchExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + notifications: false, + darkMode: false, + premium: false, + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+ {/* Default Switch */} + + + {/* Custom Switch with purple styling */} + + + {/* Custom Switch with green styling and custom form components */} + +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { message: 'Settings updated successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/Switch Customized', + component: Switch, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + withRemixStubDecorator({ + root: { + Component: CustomSwitchExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + }), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CustomComponents: Story = { + parameters: { + docs: { + description: { + story: ` +### Switch Component Customization + +This example demonstrates three different approaches to customizing the Switch component: + +1. **Default Styling**: The first switch uses the default styling with no customization needed. + +2. **Custom Switch Elements**: The second switch customizes only the Switch and SwitchThumb components with purple styling and ON/OFF text indicators. +\`\`\`tsx + +\`\`\` + +3. **Fully Customized**: The third switch demonstrates comprehensive customization of all components, including form elements and a green switch with a checkmark icon. +\`\`\`tsx + +\`\`\` + +The \`components\` prop allows you to override any of the internal components used by the Switch component, giving you complete control over the styling and behavior. +`, + }, + source: { + code: ` +// APPROACH 1: Default Switch (no customization needed) + + +// APPROACH 2: Custom Switch with purple styling and ON/OFF text +const PurpleSwitch = React.forwardRef((props, ref) => ( + + {props.children} + +)); + +const PurpleSwitchThumb = React.forwardRef((props, ref) => ( + + + ON + + + OFF + + +)); + + + +// APPROACH 3: Fully customized switch with green styling, checkmark icon, and custom form components +const GreenSwitch = React.forwardRef((props, ref) => ( + + {props.children} + +)); + +const GreenSwitchThumb = React.forwardRef((props, ref) => ( + + + + + +)); + +const PurpleLabel = React.forwardRef((props, ref) => ( + +)); + +const PurpleMessage = React.forwardRef((props, ref) => ( + +)); + +`, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find all switches + const switches = canvas.getAllByRole('switch'); + expect(switches.length).toBe(3); + + // Toggle the custom switches + const darkModeSwitch = canvas.getByLabelText('Dark mode'); + const premiumSwitch = canvas.getByLabelText('Premium features'); + + await userEvent.click(darkModeSwitch); + await userEvent.click(premiumSwitch); + + expect(darkModeSwitch).toBeChecked(); + expect(premiumSwitch).toBeChecked(); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify successful submission + await expect(await canvas.findByText('Settings updated successfully')).toBeInTheDocument(); + }, +}; diff --git a/apps/docs/src/remix-hook-form/text-field-custom.stories.tsx b/apps/docs/src/remix-hook-form/text-field-custom.stories.tsx new file mode 100644 index 00000000..005b97f9 --- /dev/null +++ b/apps/docs/src/remix-hook-form/text-field-custom.stories.tsx @@ -0,0 +1,289 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { FormLabel, FormMessage } from '@lambdacurry/forms/ui/form'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { useFetcher } from '@remix-run/react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; +import * as React from 'react'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; + +const formSchema = z.object({ + username: z.string().min(3, 'Username must be at least 3 characters'), + email: z.string().email('Please enter a valid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), +}); + +type FormData = z.infer; + +// Custom Input component +const PurpleInput = React.forwardRef>((props, ref) => ( + +)); +PurpleInput.displayName = 'PurpleInput'; + +// Custom Form Label component +const PurpleLabel = React.forwardRef>( + ({ className, ...props }, ref) => , +); +PurpleLabel.displayName = 'PurpleLabel'; + +// Custom Form Message component +const PurpleMessage = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +PurpleMessage.displayName = 'PurpleMessage'; + +// Custom Input with icon +const IconInput = React.forwardRef>( + ({ ...props }, ref) => ( +
+
+ + Lock + + +
+ +
+ ), +); +IconInput.displayName = 'IconInput'; + +const CustomTextFieldExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + username: '', + email: '', + password: '', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+ {/* Default TextField */} + + + {/* Custom TextField with purple styling */} + + + {/* Custom TextField with icon */} + +
+ + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { message: 'Form submitted successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/TextField Customized', + component: TextField, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + withRemixStubDecorator({ + root: { + Component: CustomTextFieldExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + }), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CustomComponents: Story = { + parameters: { + docs: { + description: { + story: ` +### TextField Component Customization + +This example demonstrates three different approaches to customizing the TextField component with complete control over styling and behavior. + +#### 1. Default Styling + +The first text field uses the default styling with no customization needed: + +\`\`\`tsx + +\`\`\` + +#### 2. Custom Styling with Purple Theme + +The second text field customizes the Input, FormLabel, and FormMessage components with purple styling: + +\`\`\`tsx + +\`\`\` + +Where the custom components are defined as: + +\`\`\`tsx +// Custom Input component +const PurpleInput = React.forwardRef>((props, ref) => ( + +)); + +// Custom Form Label component +const PurpleLabel = React.forwardRef>( + ({ className, ...props }, ref) => , +); + +// Custom Form Message component +const PurpleMessage = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +\`\`\` + +#### 3. Icon Input + +The third text field demonstrates how to create a custom input with an icon: + +\`\`\`tsx + +\`\`\` + +With the IconInput component defined as: + +\`\`\`tsx +const IconInput = React.forwardRef>( + ({ ...props }, ref) => ( +
+
+ + Lock + + +
+ +
+ ), +); +\`\`\` + +### Key Points + +- Always use React.forwardRef when creating custom components +- Make sure to spread the props to pass all necessary attributes +- Include the ref to maintain form functionality +- Add a displayName to your component for better debugging +- The components prop accepts replacements for Input, FormLabel, FormMessage, and FormDescription +`, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in the form fields + const usernameInput = canvas.getByPlaceholderText('Enter your username'); + const emailInput = canvas.getByPlaceholderText('Enter your email'); + const passwordInput = canvas.getByPlaceholderText('Enter your password'); + + // Type values + await userEvent.type(usernameInput, 'johndoe'); + await userEvent.type(emailInput, 'john@example.com'); + await userEvent.type(passwordInput, 'password123'); + + // Submit the form + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + await userEvent.click(submitButton); + + // Verify successful submission + await expect(await canvas.findByText('Form submitted successfully')).toBeInTheDocument(); + }, +}; diff --git a/apps/docs/src/remix-hook-form/text-field.stories.tsx b/apps/docs/src/remix-hook-form/text-field.stories.tsx index 52cfa183..512d1f25 100644 --- a/apps/docs/src/remix-hook-form/text-field.stories.tsx +++ b/apps/docs/src/remix-hook-form/text-field.stories.tsx @@ -12,6 +12,9 @@ import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; const formSchema = z.object({ username: z.string().min(3, 'Username must be at least 3 characters'), + price: z.string().min(1, 'Price is required'), + email: z.string().email('Invalid email address'), + measurement: z.string().min(1, 'Measurement is required'), }); type FormData = z.infer; @@ -26,6 +29,9 @@ const ControlledTextFieldExample = () => { resolver: zodResolver(formSchema), defaultValues: { username: INITIAL_USERNAME, + price: '10.00', + email: 'user@example.com', + measurement: '10', }, fetcher, submitConfig: { @@ -37,11 +43,28 @@ const ControlledTextFieldExample = () => { return ( - - - {fetcher.data?.message &&

{fetcher.data.message}

} +
+ + + + + + + + + + {fetcher.data?.message &&

{fetcher.data.message}

} +
); @@ -80,20 +103,7 @@ const meta: Meta = { component: TextField, parameters: { layout: 'centered' }, tags: ['autodocs'], - decorators: [ - withRemixStubDecorator({ - root: { - Component: ControlledTextFieldExample, - }, - routes: [ - { - path: '/username', - action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), - }, - ], - }), - ], -} satisfies Meta; +}; export default meta; type Story = StoryObj; @@ -147,12 +157,25 @@ const testValidSubmission = async ({ canvas }: StoryContext) => { expect(successMessage).toBeInTheDocument(); }; -// Stories -export const Tests: Story = { +// Single story that contains all variants +export const Examples: Story = { play: async (storyContext) => { testDefaultValues(storyContext); await testInvalidSubmission(storyContext); await testUsernameTaken(storyContext); await testValidSubmission(storyContext); }, -}; \ No newline at end of file + decorators: [ + withRemixStubDecorator({ + root: { + Component: ControlledTextFieldExample, + }, + routes: [ + { + path: '/username', + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], + }), + ], +}; diff --git a/apps/docs/src/remix-hook-form/textarea-custom.stories.tsx b/apps/docs/src/remix-hook-form/textarea-custom.stories.tsx new file mode 100644 index 00000000..3023088c --- /dev/null +++ b/apps/docs/src/remix-hook-form/textarea-custom.stories.tsx @@ -0,0 +1,261 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Textarea } from '@lambdacurry/forms/remix-hook-form/textarea'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { FormControl, FormItem, FormLabel, FormMessage } from '@lambdacurry/forms/ui/form'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { useFetcher } from '@remix-run/react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; +import * as React from 'react'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withRemixStubDecorator } from '../lib/storybook/remix-stub'; + +const formSchema = z.object({ + feedback: z.string().min(10, 'Feedback must be at least 10 characters'), + bio: z.string().min(20, 'Bio must be at least 20 characters'), + notes: z.string().optional(), +}); + +type FormData = z.infer; + +// Custom Textarea component +const PurpleTextarea = React.forwardRef>( + (props, ref) => ( +