From 6410659920a452764cc6e17358050f8a114acd7c Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Fri, 14 Mar 2025 22:51:20 -0500 Subject: [PATCH 01/13] feat: enhance checkbox components with custom label support and improve form structure - Added FullWidthLabel component for clickable label areas in checkbox list stories. - Updated Checkbox component to accept custom components for flexibility. - Refactored checkbox stories to utilize new features and improve layout. - Adjusted form structure for better spacing and usability in checkbox examples. - Added noNamespaceImport rule to biome configuration for better compatibility. --- ai/CustomInputsProject.md | 629 ++++++++++++++++++ .../remix-hook-form/checkbox-list.stories.tsx | 63 +- .../src/remix-hook-form/checkbox.stories.tsx | 82 +-- .../custom-checkbox.stories.tsx | 373 +++++++++++ biome.json | 3 +- .../src/remix-hook-form/checkbox.tsx | 26 +- packages/components/src/ui/checkbox-field.tsx | 94 +-- packages/components/src/ui/form.tsx | 1 - 8 files changed, 1178 insertions(+), 93 deletions(-) create mode 100644 ai/CustomInputsProject.md create mode 100644 apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx diff --git a/ai/CustomInputsProject.md b/ai/CustomInputsProject.md new file mode 100644 index 00000000..c5b7aa05 --- /dev/null +++ b/ai/CustomInputsProject.md @@ -0,0 +1,629 @@ +# 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>; +} +``` + +### 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); +}; +``` + +## 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/src/remix-hook-form/checkbox-list.stories.tsx b/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx index 71994d77..3d283113 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 }) => ( - + ))}
@@ -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/custom-checkbox.stories.tsx b/apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx new file mode 100644 index 00000000..66032077 --- /dev/null +++ b/apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx @@ -0,0 +1,373 @@ +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/CustomCheckbox', + 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: 'Examples of custom checkbox components with different styling options.', + }, + 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(); + } + }, +}; diff --git a/biome.json b/biome.json index b8268c48..058d760f 100644 --- a/biome.json +++ b/biome.json @@ -30,7 +30,8 @@ "noImplicitBoolean": "off", "noDefaultExport": "off", "noUnusedTemplateLiteral": "off", - "useFilenamingConvention": "off" + "useFilenamingConvention": "off", + "noNamespaceImport": "off" }, "complexity": { "all": true, diff --git a/packages/components/src/remix-hook-form/checkbox.tsx b/packages/components/src/remix-hook-form/checkbox.tsx index 719cb820..b0bf0967 100644 --- a/packages/components/src/remix-hook-form/checkbox.tsx +++ b/packages/components/src/remix-hook-form/checkbox.tsx @@ -1,18 +1,28 @@ import { useRemixFormContext } from 'remix-hook-form'; -import { Checkbox as BaseCheckbox, type CheckboxProps as BaseCheckboxProps } from '../ui/checkbox-field'; -import { FormControl , FormDescription, FormLabel, FormMessage } from './form'; +import { + Checkbox as BaseCheckbox, + type CheckboxProps as BaseCheckboxProps, + type CheckboxFieldComponents, +} from '../ui/checkbox-field'; +import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; export type CheckboxProps = Omit; export function Checkbox(props: CheckboxProps) { const { control } = useRemixFormContext(); - const components = { - FormDescription: FormDescription, - FormControl: FormControl, - FormLabel: FormLabel, - FormMessage: FormMessage, + // Destructure components from props to avoid it being overridden + const { components: customComponents, ...restProps } = props; + + // Create a new components object that merges the default components with any custom components + const mergedComponents: Partial = { + FormDescription, + FormControl, + FormLabel, + FormMessage, + ...customComponents, // Merge user components }; - return ; + // Pass the merged components to the BaseCheckbox + return ; } diff --git a/packages/components/src/ui/checkbox-field.tsx b/packages/components/src/ui/checkbox-field.tsx index 10a36289..f9af3f7f 100644 --- a/packages/components/src/ui/checkbox-field.tsx +++ b/packages/components/src/ui/checkbox-field.tsx @@ -1,7 +1,5 @@ -// biome-ignore lint/style/noNamespaceImport: fromRadix import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import { Check } from 'lucide-react'; -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import * as React from 'react'; import type { Control, FieldPath, FieldValues } from 'react-hook-form'; @@ -16,6 +14,11 @@ import { } from './form'; import { cn } from './utils'; +export interface CheckboxFieldComponents extends FieldComponents { + Checkbox?: React.ComponentType>; + CheckboxIndicator?: React.ComponentType>; +} + export interface CheckboxProps< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, @@ -25,48 +28,63 @@ export interface CheckboxProps< label?: React.ReactNode; description?: string; className?: string; - components?: Partial; + components?: Partial; indicatorClassName?: string; checkClassName?: string; } const CheckboxField = React.forwardRef( - ({ control, name, className, label, description, components, indicatorClassName, checkClassName, ...props }, ref) => ( - ( - - - - { + // Extract custom components with fallbacks + const CheckboxComponent = components?.Checkbox || CheckboxPrimitive.Root; + const IndicatorComponent = components?.CheckboxIndicator || CheckboxPrimitive.Indicator; + + // Determine if we're using custom components + const isCustomCheckbox = components?.Checkbox !== undefined; + const isCustomIndicator = components?.CheckboxIndicator !== undefined; + + // Default checkbox className + const defaultCheckboxClassName = + 'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground'; + + // Default indicator className + const defaultIndicatorClassName = cn('flex items-center justify-center text-current', indicatorClassName); + + return ( + ( + + + - - - - -
- {label && ( - - {label} - - )} - {description && {description}} - {fieldState.error && ( - {fieldState.error.message} - )} -
-
- )} - /> - ), + + + + + +
+ {label && ( + + {label} + + )} + {description && {description}} + {fieldState.error && ( + {fieldState.error.message} + )} +
+ + )} + /> + ); + }, ); CheckboxField.displayName = CheckboxPrimitive.Root.displayName; diff --git a/packages/components/src/ui/form.tsx b/packages/components/src/ui/form.tsx index 27997abc..7c28a6bc 100644 --- a/packages/components/src/ui/form.tsx +++ b/packages/components/src/ui/form.tsx @@ -1,6 +1,5 @@ import type * as LabelPrimitive from '@radix-ui/react-label'; import { Slot } from '@radix-ui/react-slot'; -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import * as React from 'react'; import { Controller, type ControllerProps, type FieldPath, type FieldValues } from 'react-hook-form'; import { Label } from './label'; From 511dc4937fe54516311d64ec7311078b34df5484 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Fri, 14 Mar 2025 23:22:02 -0500 Subject: [PATCH 02/13] chore: update package dependencies and configuration - Removed outdated dependency "@date-fns/tz" from yarn.lock. - Upgraded "@radix-ui/react-slot" to version 1.1.2 in package.json and yarn.lock. - Downgraded "react-day-picker" to version 8.10.1 in package.json and yarn.lock. - Updated components alias in components.json for improved path resolution. - Refactored button component styles for better layout and spacing. - Cleaned up imports in date-picker-field component. --- packages/components/components.json | 2 +- packages/components/package.json | 4 +- packages/components/src/ui/button.tsx | 3 +- .../components/src/ui/date-picker-field.tsx | 1 - yarn.lock | 42 +++++++++++-------- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/components/components.json b/packages/components/components.json index d1400af1..283b1e3f 100644 --- a/packages/components/components.json +++ b/packages/components/components.json @@ -10,7 +10,7 @@ "cssVariables": true }, "aliases": { - "components": "@/src", + "components": "src", "utils": "@/lib/utils" } } diff --git a/packages/components/package.json b/packages/components/package.json index dd3c0c12..d1624ea4 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -50,7 +50,7 @@ "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", "@remix-run/node": "^2.15.1", @@ -62,7 +62,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "next-themes": "^0.4.4", - "react-day-picker": "9.4.4", + "react-day-picker": "8.10.1", "react-hook-form": "^7.53.1", "remix-hook-form": "5.1.1", "sonner": "^1.7.1", diff --git a/packages/components/src/ui/button.tsx b/packages/components/src/ui/button.tsx index b09044cc..41242b6e 100644 --- a/packages/components/src/ui/button.tsx +++ b/packages/components/src/ui/button.tsx @@ -1,11 +1,10 @@ import { Slot } from '@radix-ui/react-slot'; import { type VariantProps, cva } from 'class-variance-authority'; -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import * as React from 'react'; import { cn } from './utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { diff --git a/packages/components/src/ui/date-picker-field.tsx b/packages/components/src/ui/date-picker-field.tsx index d60b1324..7830f4e5 100644 --- a/packages/components/src/ui/date-picker-field.tsx +++ b/packages/components/src/ui/date-picker-field.tsx @@ -1,6 +1,5 @@ import { format } from 'date-fns'; import { Calendar as CalendarIcon } from 'lucide-react'; -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import * as React from 'react'; import type { Control, FieldPath, FieldValues } from 'react-hook-form'; import { Button } from './button'; diff --git a/yarn.lock b/yarn.lock index 66f042ed..9be7ebe4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -875,13 +875,6 @@ __metadata: languageName: node linkType: hard -"@date-fns/tz@npm:^1.2.0": - version: 1.2.0 - resolution: "@date-fns/tz@npm:1.2.0" - checksum: 10c0/411e9d4303b10951f6fd0189d18fb845f0d934a575df2176bc10daf664282c765fb6b057a977e446bbb1229151d89e7788978600a019f1fc24b5c75276d496bd - languageName: node - linkType: hard - "@emotion/hash@npm:^0.9.0": version: 0.9.2 resolution: "@emotion/hash@npm:0.9.2" @@ -2006,7 +1999,7 @@ __metadata: "@radix-ui/react-popover": "npm:^1.1.4" "@radix-ui/react-radio-group": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2" - "@radix-ui/react-slot": "npm:^1.1.1" + "@radix-ui/react-slot": "npm:^1.1.2" "@radix-ui/react-switch": "npm:^1.1.2" "@radix-ui/react-tooltip": "npm:^1.1.6" "@remix-run/dev": "npm:^2.15.1" @@ -2029,7 +2022,7 @@ __metadata: next-themes: "npm:^0.4.4" postcss: "npm:^8.4.49" react: "npm:^19.0.0" - react-day-picker: "npm:9.4.4" + react-day-picker: "npm:8.10.1" react-hook-form: "npm:^7.53.1" remix-hook-form: "npm:5.1.1" sonner: "npm:^1.7.1" @@ -2829,7 +2822,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:1.1.1, @radix-ui/react-slot@npm:^1.1.1": +"@radix-ui/react-slot@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/react-slot@npm:1.1.1" dependencies: @@ -2844,6 +2837,21 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slot@npm:^1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-slot@npm:1.1.2" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/81d45091806c52b507cec80b4477e4f31189d76ffcd7845b382eb3a034e6cf1faef71b881612028d5893f7580bf9ab59daa18fbf2792042dccd755c99a18df67 + languageName: node + linkType: hard + "@radix-ui/react-switch@npm:^1.1.2": version: 1.1.2 resolution: "@radix-ui/react-switch@npm:1.1.2" @@ -11625,15 +11633,13 @@ __metadata: languageName: node linkType: hard -"react-day-picker@npm:9.4.4": - version: 9.4.4 - resolution: "react-day-picker@npm:9.4.4" - dependencies: - "@date-fns/tz": "npm:^1.2.0" - date-fns: "npm:^4.1.0" +"react-day-picker@npm:8.10.1": + version: 8.10.1 + resolution: "react-day-picker@npm:8.10.1" peerDependencies: - react: ">=16.8.0" - checksum: 10c0/0a8cfa99863854538cd58bfe28fc68febcf7c7f72e14be2e1a4f108ec9e803838bf5dae946e3f2b1900a6ddef520daf666a49bac4a551de35e8e37a347f694cb + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/a0ff28c4b61b3882e6a825b19e5679e2fdf3256cf1be8eb0a0c028949815c1ae5a6561474c2c19d231c010c8e0e0b654d3a322610881e0655abca05a2e03d9df languageName: node linkType: hard From c6be1850660704a3a55864cba070b3c7b9c561b7 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sat, 15 Mar 2025 00:08:16 -0500 Subject: [PATCH 03/13] feat: enhance radio group and checkbox components with custom implementations and examples - Added comprehensive Storybook examples for custom checkbox and radio group components. - Implemented custom components for checkbox, including PurpleCheckbox, PurpleIndicator, CustomLabel, and CustomErrorMessage. - Updated RadioGroup to support custom components and improved structure for better flexibility. - Enhanced RadioGroupField to accept custom components and added className support for styling. - Included interactive tests to verify custom component functionality and styling. - Documented key takeaways for component overriding to ensure type safety and accessibility. --- ai/CustomInputsProject.md | 180 +++++ .../custom-radio-group.stories.tsx | 660 ++++++++++++++++++ .../remix-hook-form/radio-group.stories.tsx | 2 +- .../src/remix-hook-form/radio-group.tsx | 17 +- .../components/src/ui/radio-group-field.tsx | 25 +- packages/components/src/ui/radio-group.tsx | 29 +- 6 files changed, 898 insertions(+), 15 deletions(-) create mode 100644 apps/docs/src/remix-hook-form/custom-radio-group.stories.tsx diff --git a/ai/CustomInputsProject.md b/ai/CustomInputsProject.md index c5b7aa05..cd694458 100644 --- a/ai/CustomInputsProject.md +++ b/ai/CustomInputsProject.md @@ -622,6 +622,186 @@ const testAccessibility = async ({ canvas }: StoryContext) => { }; ``` +## 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 and ensuring accessibility. + ## 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. diff --git a/apps/docs/src/remix-hook-form/custom-radio-group.stories.tsx b/apps/docs/src/remix-hook-form/custom-radio-group.stories.tsx new file mode 100644 index 00000000..aa16cca3 --- /dev/null +++ b/apps/docs/src/remix-hook-form/custom-radio-group.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/CustomRadioGroup', + 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..7f4a904f 100644 --- a/apps/docs/src/remix-hook-form/radio-group.stories.tsx +++ b/apps/docs/src/remix-hook-form/radio-group.stories.tsx @@ -121,4 +121,4 @@ export const Tests: Story = { await testRadioGroupSelection(storyContext); await testSubmission(storyContext); }, -}; \ No newline at end of file +}; diff --git a/packages/components/src/remix-hook-form/radio-group.tsx b/packages/components/src/remix-hook-form/radio-group.tsx index 9bb5db88..757b5c8d 100644 --- a/packages/components/src/remix-hook-form/radio-group.tsx +++ b/packages/components/src/remix-hook-form/radio-group.tsx @@ -1,10 +1,23 @@ import { useRemixFormContext } from 'remix-hook-form'; -import { RadioGroupField as BaseRadioGroupField, type RadioGroupFieldProps as BaseRadioGroupFieldProps } from '../ui/radio-group-field'; +import { FormControl, FormDescription, FormLabel, FormMessage } from '../ui/form'; +import { + RadioGroupField as BaseRadioGroupField, + type RadioGroupFieldProps as BaseRadioGroupFieldProps, + type RadioGroupFieldComponents, +} from '../ui/radio-group-field'; export type RadioGroupFieldProps = Omit; export function RadioGroup(props: RadioGroupFieldProps) { const { control } = useRemixFormContext(); - return ; + const components: Partial = { + FormControl, + FormDescription, + FormLabel, + FormMessage, + ...props.components, + }; + + return ; } diff --git a/packages/components/src/ui/radio-group-field.tsx b/packages/components/src/ui/radio-group-field.tsx index 5ab16b73..c83e5ce1 100644 --- a/packages/components/src/ui/radio-group-field.tsx +++ b/packages/components/src/ui/radio-group-field.tsx @@ -1,4 +1,4 @@ -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library +import type * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; import * as React from 'react'; import type { Control, FieldPath, FieldValues } from 'react-hook-form'; import { @@ -11,6 +11,11 @@ import { FormMessage, } from './form'; import { RadioGroup } from './radio-group'; +import { cn } from './utils'; + +export interface RadioGroupFieldComponents extends FieldComponents { + RadioGroup?: React.ComponentType>; +} export interface RadioGroupFieldProps< TFieldValues extends FieldValues = FieldValues, @@ -20,11 +25,15 @@ export interface RadioGroupFieldProps< name: TName; label?: string; description?: string; - components?: Partial; + components?: Partial; + radioGroupClassName?: string; } const RadioGroupField = React.forwardRef( - ({ control, name, label, description, className, components, ...props }, ref) => { + ({ control, name, label, description, className, radioGroupClassName, components, children, ...props }, ref) => { + // Extract custom components with fallbacks + const RadioGroupComponent = components?.RadioGroup || RadioGroup; + return ( ( {label && {label}} - + + {children} + {description && {description}} {fieldState.error && ( diff --git a/packages/components/src/ui/radio-group.tsx b/packages/components/src/ui/radio-group.tsx index 1bd482c9..d34ef548 100644 --- a/packages/components/src/ui/radio-group.tsx +++ b/packages/components/src/ui/radio-group.tsx @@ -1,10 +1,17 @@ -// biome-ignore lint/style/noNamespaceImport: from Radix import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; import { Circle } from 'lucide-react'; -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library import * as React from 'react'; import { cn } from './utils'; +export interface RadioGroupItemComponents { + RadioGroupItem?: React.ComponentType< + React.ComponentPropsWithoutRef & { + indicator?: React.ReactNode; + } + >; + RadioGroupIndicator?: React.ComponentType>; +} + const RadioGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -17,10 +24,18 @@ const RadioGroupItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { indicator?: React.ReactNode; + components?: Partial; } ->(({ className, indicator, ...props }, ref) => { +>(({ className, indicator, components, ...props }, ref) => { + // Extract custom components with fallbacks + const RadioItem = components?.RadioGroupItem || RadioGroupPrimitive.Item; + const RadioIndicator = components?.RadioGroupIndicator || RadioGroupPrimitive.Indicator; + + // Determine the indicator content + const indicatorContent = indicator || ; + return ( - - - {indicator || } - - + {indicatorContent} + ); }); RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; From bf932a1915e96e6b4f54c5608479a5b9121a10ec Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sat, 15 Mar 2025 00:13:20 -0500 Subject: [PATCH 04/13] feat: enhance radio group and checkbox components with new props and examples - Added `radioGroupClassName` prop to `RadioGroupFieldProps` for custom container styling. - Expanded documentation with implementation examples for various customization approaches in radio groups. - Updated Storybook titles for better readability and consistency. - Removed deprecated custom checkbox and radio group stories to streamline examples. --- ai/CustomInputsProject.md | 128 +++++++++++++++++- ...tories.tsx => checkbox-custom.stories.tsx} | 2 +- .../remix-hook-form/checkbox-list.stories.tsx | 2 +- .../remix-hook-form/date-picker.stories.tsx | 4 +- ...ies.tsx => radio-group-custom.stories.tsx} | 2 +- .../remix-hook-form/radio-group.stories.tsx | 2 +- 6 files changed, 133 insertions(+), 7 deletions(-) rename apps/docs/src/remix-hook-form/{custom-checkbox.stories.tsx => checkbox-custom.stories.tsx} (99%) rename apps/docs/src/remix-hook-form/{custom-radio-group.stories.tsx => radio-group-custom.stories.tsx} (99%) diff --git a/ai/CustomInputsProject.md b/ai/CustomInputsProject.md index cd694458..585e684a 100644 --- a/ai/CustomInputsProject.md +++ b/ai/CustomInputsProject.md @@ -223,6 +223,132 @@ export interface RadioGroupFieldComponents extends FieldComponents { 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 @@ -800,7 +926,7 @@ play: async ({ canvasElement }) => { 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 and ensuring accessibility. +This approach provides a flexible and consistent way for users to customize any part of our form components while maintaining a clean API. ## Conclusion diff --git a/apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx similarity index 99% rename from apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx rename to apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx index 66032077..cd5fec2e 100644 --- a/apps/docs/src/remix-hook-form/custom-checkbox.stories.tsx +++ b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx @@ -225,7 +225,7 @@ const handleFormSubmission = async (request: Request) => { }; const meta: Meta = { - title: 'RemixHookForm/CustomCheckbox', + title: 'RemixHookForm/Checkbox Customized', component: Checkbox, parameters: { layout: 'centered' }, tags: ['autodocs'], 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 3d283113..b5e91f8b 100644 --- a/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx +++ b/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx @@ -125,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'], 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/custom-radio-group.stories.tsx b/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx similarity index 99% rename from apps/docs/src/remix-hook-form/custom-radio-group.stories.tsx rename to apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx index aa16cca3..8588485e 100644 --- a/apps/docs/src/remix-hook-form/custom-radio-group.stories.tsx +++ b/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx @@ -385,7 +385,7 @@ const handleFormSubmission = async (request: Request) => { }; const meta: Meta = { - title: 'RemixHookForm/CustomRadioGroup', + title: 'RemixHookForm/Radio Group Customized', component: RadioGroup, parameters: { layout: 'centered' }, tags: ['autodocs'], 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 7f4a904f..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'], From 5e766e1c6e914e26a1507321193f285c2f999352 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sat, 15 Mar 2025 00:40:42 -0500 Subject: [PATCH 05/13] feat: enhance Switch component with customizable components and improved structure - Updated Switch component to accept custom components for enhanced flexibility. - Introduced default styled Switch and Switch Thumb components for consistent styling. - Refactored SwitchField to utilize new component structure, improving maintainability. - Ensured proper integration with Remix form context for better form handling. --- .../remix-hook-form/switch-custom.stories.tsx | 361 ++++++++++++++++++ .../components/src/remix-hook-form/switch.tsx | 18 +- packages/components/src/ui/switch-field.tsx | 95 +++-- 3 files changed, 442 insertions(+), 32 deletions(-) create mode 100644 apps/docs/src/remix-hook-form/switch-custom.stories.tsx 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/packages/components/src/remix-hook-form/switch.tsx b/packages/components/src/remix-hook-form/switch.tsx index d03dadef..82fcb1c0 100644 --- a/packages/components/src/remix-hook-form/switch.tsx +++ b/packages/components/src/remix-hook-form/switch.tsx @@ -1,6 +1,6 @@ import type * as React from 'react'; import { useRemixFormContext } from 'remix-hook-form'; -import { SwitchField as BaseSwitchField } from '../ui/switch-field'; +import { SwitchField as BaseSwitchField, type SwitchFieldComponents } from '../ui/switch-field'; import { FormControl, FormDescription, FormLabel, FormMessage } from './form'; export interface SwitchProps extends Omit, 'control'> { @@ -9,14 +9,15 @@ export interface SwitchProps extends Omit = { + FormDescription, + FormControl, + FormLabel, + FormMessage, + ...components, }; return ( @@ -25,7 +26,8 @@ export function Switch({ name, label, description, className, ...props }: Switch name={name} label={label} description={description} - components={components} + components={mergedComponents} + className={className} {...props} /> ); diff --git a/packages/components/src/ui/switch-field.tsx b/packages/components/src/ui/switch-field.tsx index 3b62d29f..a7fe9906 100644 --- a/packages/components/src/ui/switch-field.tsx +++ b/packages/components/src/ui/switch-field.tsx @@ -1,43 +1,90 @@ -// biome-ignore lint/style/noNamespaceImport: prevents React undefined errors when exporting as a component library +import * as SwitchPrimitives from '@radix-ui/react-switch'; import * as React from 'react'; import type { Control, FieldPath, FieldValues } from 'react-hook-form'; import { type FieldComponents, FormControl, FormDescription, FormField, FormItem, FormLabel } from './form'; -import { Switch } from './switch'; +import type { Switch } from './switch'; import { cn } from './utils'; +// Default styled Switch component +const DefaultSwitchPrimitive = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + + {props.children} + +)); +DefaultSwitchPrimitive.displayName = 'DefaultSwitchPrimitive'; + +// Default styled Switch Thumb component +const DefaultSwitchThumbPrimitive = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); +DefaultSwitchThumbPrimitive.displayName = 'DefaultSwitchThumbPrimitive'; + +export interface SwitchFieldComponents extends FieldComponents { + Switch?: React.ComponentType>; + SwitchThumb?: React.ComponentType>; +} + export interface SwitchProps< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, -> extends React.ComponentPropsWithoutRef { +> extends Omit, 'checked' | 'defaultChecked' | 'onCheckedChange'> { control?: Control; name: TName; label?: React.ReactNode; description?: string; className?: string; - components?: Partial; + components?: Partial; } const SwitchField = React.forwardRef( - ({ control, name, className, label, description, components, ...props }, ref) => ( - ( - -
- {label && {label}} - {description && {description}} -
- - - -
- )} - /> - ), + ({ control, name, className, label, description, components, ...props }, ref) => { + // Extract custom components with fallbacks + const SwitchComponent = components?.Switch || DefaultSwitchPrimitive; + const SwitchThumbComponent = components?.SwitchThumb || DefaultSwitchThumbPrimitive; + + return ( + ( + +
+ {label && {label}} + {description && {description}} +
+ + + + + +
+ )} + /> + ); + }, ); SwitchField.displayName = 'SwitchField'; From b67816c92059ae866ccac1c3f96ad11489ccaf93 Mon Sep 17 00:00:00 2001 From: Jake Ruesink Date: Sat, 15 Mar 2025 00:51:03 -0500 Subject: [PATCH 06/13] feat: enhance textarea and text field components with customizable structure - Updated Textarea and TextField components to support custom component props for greater flexibility. - Introduced TextareaFieldComponents and TextFieldComponents interfaces for improved type safety. - Refactored components to utilize custom components if provided, enhancing reusability. - Cleaned up imports and ensured consistent structure across form components. --- .../text-field-custom.stories.tsx | 226 +++++++++++++++ .../textarea-custom.stories.tsx | 261 ++++++++++++++++++ .../src/remix-hook-form/textarea.tsx | 17 +- packages/components/src/ui/text-field.tsx | 11 +- packages/components/src/ui/text-input.tsx | 1 - packages/components/src/ui/textarea-field.tsx | 13 +- packages/components/src/ui/textarea.tsx | 36 ++- 7 files changed, 539 insertions(+), 26 deletions(-) create mode 100644 apps/docs/src/remix-hook-form/text-field-custom.stories.tsx create mode 100644 apps/docs/src/remix-hook-form/textarea-custom.stories.tsx 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..a531c146 --- /dev/null +++ b/apps/docs/src/remix-hook-form/text-field-custom.stories.tsx @@ -0,0 +1,226 @@ +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< + HTMLInputElement, + React.InputHTMLAttributes & { icon?: React.ReactNode } +>(({ icon, ...props }, ref) => ( +
+ {icon &&
{icon}
} + +
+)); +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 */} + ( + + Lock + + + } + /> + ), + }} + /> +
+ + {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: + +1. **Default Styling**: The first text field uses the default styling with no customization needed. + +2. **Custom Styling**: The second text field customizes the Input, FormLabel, and FormMessage components with purple styling. +\`\`\`tsx + +\`\`\` + +3. **Icon Input**: The third text field demonstrates how to create a custom input with an icon. +\`\`\`tsx + ( + } + /> + ), + }} +/> +\`\`\` + +The \`components\` prop allows you to override any of the internal components used by the TextField component, giving you complete control over the styling and behavior. +`, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Fill in the form fields + const usernameInput = canvas.getByLabelText('Username'); + const emailInput = canvas.getByLabelText('Email'); + const passwordInput = canvas.getByLabelText('Password'); + + 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/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) => ( +