>(
- ({ className, ...props }, ref) => (
-
+const CustomErrorMessage = (props: React.ComponentPropsWithoutRef) => {
+ const { className, ...rest } = props;
+ return (
+
⚠️ {props.children}
- ),
-);
+ );
+};
CustomErrorMessage.displayName = 'CustomErrorMessage';
// Example with custom checkbox components
@@ -237,228 +219,147 @@ type Story = StoryObj;
export const CustomCheckboxComponentExamples: Story = {
name: 'Custom Checkbox Component Examples',
decorators: [
- withRemixStubDecorator({
- root: {
- Component: AllCustomComponentsExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ 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
+### Checkbox Customization Examples
-The first approach customizes the visual appearance of the checkbox itself:
+This story demonstrates three different approaches to customizing the Checkbox component:
-\`\`\`tsx
-
-\`\`\`
+#### Custom Checkbox Components Example
-Where the custom components are defined as:
+The first example customizes the actual checkbox and its indicator component:
\`\`\`tsx
-// Custom checkbox component
-const PurpleCheckbox = React.forwardRef<
- HTMLButtonElement,
- React.ComponentPropsWithoutRef
->((props, ref) => (
+const PurpleCheckbox = (
+ props: React.ComponentPropsWithoutRef
+) => (
{props.children}
-));
+);
-// Custom indicator
-const PurpleIndicator = React.forwardRef<
- HTMLDivElement,
- React.ComponentPropsWithoutRef
->((props, ref) => (
+const PurpleIndicator = (
+ props: React.ComponentPropsWithoutRef
+) => (
✓
-));
-\`\`\`
-
-#### 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
+#### Custom Form Components Example
-The third approach combines both custom checkbox and form elements:
+The second example customizes the form label and error message components:
\`\`\`tsx
-// Create component objects for reuse
-const customCheckboxComponents = {
- Checkbox: PurpleCheckbox,
- CheckboxIndicator: PurpleIndicator,
+const CustomLabel = (
+ props: React.ComponentPropsWithoutRef
+) => {
+ const { className, htmlFor, ...rest } = props;
+ return (
+
+ );
};
-const customLabelComponents = {
- FormLabel: CustomLabel,
- FormMessage: CustomErrorMessage,
+const CustomErrorMessage = (
+ props: React.ComponentPropsWithoutRef
+) => {
+ const { className, ...rest } = props;
+ return (
+
+ ⚠️ {props.children}
+
+ );
};
-
-// 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) => (
+const PurpleCheckbox = (
+ props: React.ComponentPropsWithoutRef
+) => (
{props.children}
-));
+);
// Custom indicator
-const PurpleIndicator = React.forwardRef<
- HTMLDivElement,
- React.ComponentPropsWithoutRef
->((props, ref) => (
+const PurpleIndicator = (
+ props: React.ComponentPropsWithoutRef
+) => (
✓
-));
+);
// Custom form label component
-const CustomLabel = React.forwardRef<
- HTMLLabelElement,
- React.ComponentPropsWithoutRef
->(({ className, htmlFor, ...props }, ref) => (
-
-));
+const CustomLabel = (
+ props: React.ComponentPropsWithoutRef
+) => {
+ const { className, htmlFor, ...rest } = props;
+ return (
+
+ );
+};
// Custom error message component
-const CustomErrorMessage = React.forwardRef<
- HTMLParagraphElement,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
- ⚠️ {props.children}
-
-));
+const CustomErrorMessage = (
+ props: React.ComponentPropsWithoutRef
+) => {
+ const { className, ...rest } = props;
+ return (
+
+ ⚠️ {props.children}
+
+ );
+};
// Usage in form
-
-
-`,
+`,
},
},
},
@@ -506,4 +407,4 @@ const CustomErrorMessage = React.forwardRef<
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 b5e91f8b..706aef83 100644
--- a/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx
+++ b/apps/docs/src/remix-hook-form/checkbox-list.stories.tsx
@@ -2,16 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Checkbox } from '@lambdacurry/forms/remix-hook-form/checkbox';
import { Button } from '@lambdacurry/forms/ui/button';
import { FormMessage } from '@lambdacurry/forms/ui/form';
-import type { ActionFunctionArgs } from '@remix-run/node';
-import { useFetcher } from '@remix-run/react';
-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 { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
const AVAILABLE_COLORS = [
{ value: 'red', label: 'Red' },
@@ -30,21 +27,17 @@ 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 FullWidthLabel = ({ className, children, htmlFor, ...props }: React.ComponentPropsWithoutRef<'label'>) => {
+ return (
+
+ );
+};
const ControlledCheckboxListExample = () => {
const fetcher = useFetcher<{ message: string; selectedColors: string[] }>();
@@ -130,11 +123,14 @@ const meta: Meta = {
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
- withRemixStubDecorator({
- root: {
- Component: ControlledCheckboxListExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: ControlledCheckboxListExample,
+ action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
+ },
+ ],
}),
],
} satisfies Meta;
diff --git a/apps/docs/src/remix-hook-form/checkbox.stories.tsx b/apps/docs/src/remix-hook-form/checkbox.stories.tsx
index 50ef78f6..3a4b0b86 100644
--- a/apps/docs/src/remix-hook-form/checkbox.stories.tsx
+++ b/apps/docs/src/remix-hook-form/checkbox.stories.tsx
@@ -1,13 +1,12 @@
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 { type ActionFunctionArgs, useFetcher } from 'react-router';
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
const formSchema = z.object({
terms: z.boolean().refine((val) => val === true, 'You must accept the terms and conditions'),
@@ -70,11 +69,14 @@ const meta: Meta = {
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
- withRemixStubDecorator({
- root: {
- Component: ControlledCheckboxExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: ControlledCheckboxExample,
+ action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
+ },
+ ],
}),
],
} satisfies Meta;
diff --git a/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx
new file mode 100644
index 00000000..d24c5b72
--- /dev/null
+++ b/apps/docs/src/remix-hook-form/data-table-router-form.stories.tsx
@@ -0,0 +1,258 @@
+import { DataTableRouterForm } from '@lambdacurry/forms/remix-hook-form/data-table-router-form';
+import { dataTableRouterParsers } from '@lambdacurry/forms/remix-hook-form/data-table-router-parsers';
+import { DataTableColumnHeader } from '@lambdacurry/forms/ui/data-table/data-table-column-header';
+import type { Meta, StoryObj } from '@storybook/react';
+import type { ColumnDef } from '@tanstack/react-table';
+import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
+import { z } from 'zod';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
+
+// Define the data schema
+const userSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string().email(),
+ role: z.enum(['admin', 'user', 'editor']),
+ status: z.enum(['active', 'inactive', 'pending']),
+ createdAt: z.string().datetime(),
+});
+
+type User = z.infer;
+
+// Sample data
+const users: User[] = Array.from({ length: 100 }).map((_, i) => ({
+ id: `user-${i + 1}`,
+ name: `User ${i + 1}`,
+ email: `user${i + 1}@example.com`,
+ role: i % 3 === 0 ? 'admin' : i % 3 === 1 ? 'user' : 'editor',
+ status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending',
+ createdAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
+}));
+
+// Define response type
+interface DataResponse {
+ data: User[];
+ meta: {
+ total: number;
+ page: number;
+ pageSize: number;
+ pageCount: number;
+ };
+}
+
+// Define the columns
+const columns: ColumnDef[] = [
+ {
+ accessorKey: 'id',
+ header: ({ column }) => ,
+ cell: ({ row }) => {row.getValue('id')}
,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: 'name',
+ header: ({ column }) => ,
+ cell: ({ row }) => {row.getValue('name')}
,
+ },
+ {
+ accessorKey: 'email',
+ header: ({ column }) => ,
+ cell: ({ row }) => {row.getValue('email')}
,
+ },
+ {
+ accessorKey: 'role',
+ header: ({ column }) => ,
+ cell: ({ row }) => {row.getValue('role')}
,
+ enableColumnFilter: true,
+ filterFn: (row, id, value: string[]) => {
+ return value.includes(row.getValue(id));
+ },
+ },
+ {
+ accessorKey: 'status',
+ header: ({ column }) => ,
+ cell: ({ row }) => {row.getValue('status')}
,
+ enableColumnFilter: true,
+ filterFn: (row, id, value: string[]) => {
+ return value.includes(row.getValue(id));
+ },
+ },
+ {
+ accessorKey: 'createdAt',
+ header: ({ column }) => ,
+ cell: ({ row }) => {new Date(row.getValue('createdAt')).toLocaleDateString()}
,
+ },
+];
+
+// Component to display the data table with router form integration
+function DataTableRouterFormExample() {
+ const loaderData = useLoaderData();
+
+ // Ensure we have data even if loaderData is undefined
+ const data = loaderData?.data ?? [];
+ const pageCount = loaderData?.meta.pageCount ?? 0;
+
+ console.log('DataTableRouterFormExample - loaderData:', loaderData);
+
+ return (
+
+
Users Table (React Router Form Integration)
+
This example demonstrates integration with React Router forms, including:
+
+ - Form-based filtering with automatic submission
+ - Loading state while waiting for data
+ - Server-side filtering and pagination
+ - URL-based state management with React Router
+
+
+ columns={columns}
+ data={data}
+ pageCount={pageCount}
+ filterableColumns={[
+ {
+ id: 'role' as keyof User,
+ title: 'Role',
+ options: [
+ { label: 'Admin', value: 'admin' },
+ { label: 'User', value: 'user' },
+ { label: 'Editor', value: 'editor' },
+ ],
+ },
+ {
+ id: 'status' as keyof User,
+ title: 'Status',
+ options: [
+ { label: 'Active', value: 'active' },
+ { label: 'Inactive', value: 'inactive' },
+ { label: 'Pending', value: 'pending' },
+ ],
+ },
+ ]}
+ searchableColumns={[
+ {
+ id: 'name' as keyof User,
+ title: 'Name',
+ },
+ ]}
+ />
+
+ );
+}
+
+// Loader function to handle data fetching based on URL parameters
+const handleDataFetch = async ({ request }: LoaderFunctionArgs) => {
+ // Add a small delay to simulate network latency
+ await new Promise((resolve) => setTimeout(resolve, 300));
+
+ // Ensure we have a valid URL object
+ const url = request?.url ? new URL(request.url) : new URL('http://localhost?page=0&pageSize=10');
+ const params = url.searchParams;
+
+ console.log('handleDataFetch - URL:', url.toString());
+ console.log('handleDataFetch - Search Params:', Object.fromEntries(params.entries()));
+
+ // Use our custom parsers to parse URL search parameters
+ const page = dataTableRouterParsers.page.parse(params.get('page'));
+ const pageSize = dataTableRouterParsers.pageSize.parse(params.get('pageSize'));
+ const sortField = dataTableRouterParsers.sortField.parse(params.get('sortField'));
+ const sortOrder = dataTableRouterParsers.sortOrder.parse(params.get('sortOrder'));
+ const search = dataTableRouterParsers.search.parse(params.get('search'));
+ const parsedFilters = dataTableRouterParsers.filters.parse(params.get('filters'));
+
+ console.log('handleDataFetch - Parsed Parameters:', { page, pageSize, sortField, sortOrder, search, parsedFilters });
+
+ // Apply filters
+ let filteredData = [...users];
+
+ // 1. Apply global search filter
+ if (search) {
+ const searchLower = search.toLowerCase();
+ filteredData = filteredData.filter(
+ (user) => user.name.toLowerCase().includes(searchLower) || user.email.toLowerCase().includes(searchLower),
+ );
+ }
+
+ // 2. Apply faceted filters from the parsed 'filters' array
+ if (parsedFilters && parsedFilters.length > 0) {
+ // Check if parsedFilters is not null
+ parsedFilters.forEach((filter) => {
+ if (filter.id in users[0] && Array.isArray(filter.value) && filter.value.length > 0) {
+ const filterValues = filter.value as string[];
+ filteredData = filteredData.filter((user) => {
+ const userValue = user[filter.id as keyof User];
+ return filterValues.includes(userValue);
+ });
+ } else {
+ console.warn(`Invalid filter encountered: ${JSON.stringify(filter)}`);
+ }
+ });
+ }
+
+ // 3. Apply sorting
+ if (sortField && sortOrder && sortField in users[0]) {
+ filteredData.sort((a, b) => {
+ const aValue = a[sortField as keyof User];
+ const bValue = b[sortField as keyof User];
+ if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
+ if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
+ return 0;
+ });
+ }
+
+ // 4. Apply pagination
+ // Determine safe values for page and pageSize using defaultValue when params are missing
+ const safePage = params.has('page') ? page : dataTableRouterParsers.page.defaultValue;
+ const safePageSize = params.has('pageSize') ? pageSize : dataTableRouterParsers.pageSize.defaultValue;
+ const start = safePage * safePageSize;
+ const paginatedData = filteredData.slice(start, start + safePageSize);
+
+ // Log the data being returned for debugging
+ console.log(`Returning ${paginatedData.length} items, page ${safePage}, total ${filteredData.length}`);
+
+ // Return the data response
+ return {
+ data: paginatedData,
+ meta: {
+ total: filteredData.length,
+ page: safePage,
+ pageSize: safePageSize,
+ pageCount: Math.ceil(filteredData.length / safePageSize),
+ },
+ };
+};
+
+const meta = {
+ title: 'RemixHookForm/Data Table',
+ component: DataTableRouterForm,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: DataTableRouterFormExample,
+ loader: handleDataFetch,
+ },
+ ],
+ }),
+ ],
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ // biome-ignore lint/suspicious/noExplicitAny:
+ args: {} as any,
+ render: () => ,
+ parameters: {
+ docs: {
+ description: {
+ story: 'This is a description of the DataTableRouterForm component.',
+ },
+ },
+ },
+};
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 09a535dd..c0293447 100644
--- a/apps/docs/src/remix-hook-form/date-picker.stories.tsx
+++ b/apps/docs/src/remix-hook-form/date-picker.stories.tsx
@@ -1,73 +1,92 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { DatePicker } from '@lambdacurry/forms/remix-hook-form/date-picker';
import { Button } from '@lambdacurry/forms/ui/button';
-import type { ActionFunctionArgs } from '@remix-run/node';
-import { Form, useFetcher } from '@remix-run/react';
-import type { Meta, StoryContext, StoryObj } from '@storybook/react';
-import { expect, userEvent, waitFor, within } from '@storybook/test';
-import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
+import type { Meta, StoryObj } from '@storybook/react';
+import { expect, userEvent, within } from '@storybook/test';
+import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
+import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
const formSchema = z.object({
- eventDate: z.coerce.date(),
+ date: z.coerce.date({
+ required_error: 'Please select a date',
+ }),
});
type FormData = z.infer;
-const DatePickerExample = () => {
- const fetcher = useFetcher<{ message?: string }>();
+const ControlledDatePickerExample = () => {
+ const fetcher = useFetcher<{ message: string; date: string }>();
const methods = useRemixForm({
resolver: zodResolver(formSchema),
defaultValues: {
- eventDate: undefined,
+ date: undefined,
},
fetcher,
submitConfig: {
action: '/',
method: 'post',
},
+ submitHandlers: {
+ onValid: (data) => {
+ fetcher.submit(
+ createFormData({
+ date: data.date.toISOString(),
+ }),
+ {
+ method: 'post',
+ action: '/',
+ },
+ );
+ },
+ },
});
return (
);
};
-// Action function for form submission
const handleFormSubmission = async (request: Request) => {
- const { errors, receivedValues: defaultValues } = await getValidatedFormData(
- request,
- zodResolver(formSchema),
- );
+ const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema));
if (errors) {
- return { errors, defaultValues };
+ return { errors };
}
- return { message: 'Form submitted successfully' };
+ return { message: 'Date selected successfully', date: data.date.toISOString() };
};
-// Storybook configuration
const meta: Meta = {
title: 'RemixHookForm/Date Picker',
component: DatePicker,
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
- withRemixStubDecorator({
- root: {
- Component: DatePickerExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: ControlledDatePickerExample,
+ action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
+ },
+ ],
}),
],
} satisfies Meta;
@@ -75,48 +94,83 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
-const testDefaultValues = ({ canvas }: StoryContext) => {
- const datePickerButton = canvas.getByRole('button', { name: 'Event Date' });
- expect(datePickerButton).toHaveTextContent('Event Date');
-};
+// Helper function for sleep delays
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export const Default: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: 'A date picker component for selecting a date.',
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // Find all buttons in the form
+ const buttons = await canvas.findAllByRole('button');
+
+ // Select the date picker button (the one with aria-haspopup="dialog")
+ const datePickerButton = buttons.find((btn) => btn.getAttribute('aria-haspopup') === 'dialog');
+ if (!datePickerButton) {
+ throw new Error('Could not find date picker button');
+ }
+ expect(datePickerButton).toBeInTheDocument();
-const testDateSelection = async ({ canvas }: StoryContext) => {
- const datePickerButton = canvas.getByRole('button', { name: 'Event Date' });
- await userEvent.click(datePickerButton);
+ // Click to open the date picker
+ await userEvent.click(datePickerButton);
- await waitFor(async () => {
- const popover = document.querySelector('[role="dialog"]');
- expect(popover).not.toBeNull();
+ // Wait for date picker dialog to open
+ await sleep(200);
- if (popover) {
- const calendar = within(popover as HTMLElement).getByRole('grid');
- expect(calendar).toBeInTheDocument();
+ // Find the dialog popup using document.querySelector
+ const dialog = document.querySelector('[role="dialog"]');
- const dateCell = within(calendar).getByText('15');
- expect(dateCell).toBeInTheDocument();
- await userEvent.click(dateCell);
+ // Look for day buttons within the dialog using the dialog element's within scope
+ const dialogContent = within(dialog as HTMLElement);
+
+ // Find all day cells
+ const dayCells = await dialogContent.findAllByRole('gridcell');
+
+ // Click on a day that's not disabled
+ const dayToClick = dayCells.find((day) => day.textContent && /^\d+$/.test(day.textContent.trim()));
+
+ if (dayToClick) {
+ await userEvent.click(dayToClick);
+ } else {
+ // If we can't find a specific day, click the first day cell
+ await userEvent.click(dayCells[0]);
}
- });
- const dateToSelect = '15';
- await waitFor(() => {
- const updatedDatePickerButton = canvas.getByRole('button', { name: new RegExp(dateToSelect, 'i') });
- expect(updatedDatePickerButton).toBeInTheDocument();
- });
-};
+ // Wait for date picker to close
+ await sleep(100);
-const testSubmission = async ({ canvas }: StoryContext) => {
- const submitButton = canvas.getByRole('button', { name: 'Submit' });
- await userEvent.click(submitButton);
+ // Now click the submit button
+ const submitButton = buttons.find((btn) => btn.textContent?.includes('Submit'));
+ if (!submitButton) {
+ throw new Error('Could not find submit button');
+ }
- await expect(canvas.findByText('Form submitted successfully')).resolves.toBeInTheDocument();
-};
+ await userEvent.click(submitButton);
+
+ // Wait for form submission to complete
+ await sleep(200);
+
+ // After submission, the date picker button should now show a date value
+ // instead of "Select a date"
+ const updatedPickerButton = await canvas.findByRole('button', {
+ expanded: false,
+ });
+
+ // Check if this is the date picker button
+ expect(updatedPickerButton.getAttribute('aria-haspopup')).toBe('dialog');
+
+ expect(canvas.getByText('Submitted with date:')).toBeInTheDocument();
-// Stories
-export const Tests: Story = {
- play: async (storyContext) => {
- testDefaultValues(storyContext);
- await testDateSelection(storyContext);
- await testSubmission(storyContext);
+ // Verify the button's text is no longer just "Select a date"
+ const buttonText = updatedPickerButton.textContent || '';
+ expect(buttonText).not.toContain('Select a date');
+ expect(buttonText).toMatch(/\d/); // Should contain at least one digit
},
};
diff --git a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx b/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx
index 9e26be86..3d6d5b45 100644
--- a/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx
+++ b/apps/docs/src/remix-hook-form/dropdown-menu-select.stories.tsx
@@ -1,85 +1,109 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { DropdownMenuSelect } from '@lambdacurry/forms/remix-hook-form/dropdown-menu-select';
import { Button } from '@lambdacurry/forms/ui/button';
-import { DropdownMenuItem } from '@lambdacurry/forms/ui/dropdown-menu';
-import type { ActionFunctionArgs } from '@remix-run/node';
-import { Form, useFetcher } from '@remix-run/react';
-import type { Meta, StoryContext, StoryObj } from '@storybook/react';
-import { expect, userEvent, within } from '@storybook/test';
-import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
+import { DropdownMenuSelectItem } from '@lambdacurry/forms/ui/dropdown-menu-select-field';
+import type { Meta, StoryObj } from '@storybook/react';
+import { expect, screen, userEvent, within } from '@storybook/test';
+import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
+import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
+
+const AVAILABLE_FRUITS = [
+ { value: 'apple', label: 'Apple' },
+ { value: 'banana', label: 'Banana' },
+ { value: 'orange', label: 'Orange' },
+ { value: 'grape', label: 'Grape' },
+ { value: 'strawberry', label: 'Strawberry' },
+] as const;
-// Form schema definition
const formSchema = z.object({
- favoriteColor: z.string().min(1, 'Please select a color'),
+ fruit: z.string({
+ required_error: 'Please select a fruit',
+ }),
});
type FormData = z.infer;
-// Component for the form
-const DropdownMenuSelectExample = () => {
- const fetcher = useFetcher<{ message?: string }>();
+const ControlledDropdownMenuSelectExample = () => {
+ const fetcher = useFetcher<{ message: string; selectedFruit: string }>();
const methods = useRemixForm({
resolver: zodResolver(formSchema),
defaultValues: {
- favoriteColor: '',
+ fruit: '',
},
fetcher,
submitConfig: {
action: '/',
method: 'post',
},
+ submitHandlers: {
+ onValid: (data) => {
+ fetcher.submit(
+ createFormData({
+ fruit: data.fruit,
+ }),
+ {
+ method: 'post',
+ action: '/',
+ },
+ );
+ },
+ },
});
return (
);
};
-// Action function for form submission
const handleFormSubmission = async (request: Request) => {
- const { errors, receivedValues: defaultValues } = await getValidatedFormData(
- request,
- zodResolver(formSchema),
- );
+ const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema));
if (errors) {
- return { errors, defaultValues };
+ return { errors };
}
- return { message: 'Form submitted successfully' };
+ return { message: 'Fruit selected successfully', selectedFruit: data.fruit };
};
-// Storybook configuration
const meta: Meta = {
title: 'RemixHookForm/DropdownMenuSelect',
component: DropdownMenuSelect,
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
- withRemixStubDecorator({
- root: {
- Component: DropdownMenuSelectExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: ControlledDropdownMenuSelectExample,
+ action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
+ },
+ ],
}),
],
} satisfies Meta;
@@ -87,57 +111,30 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
-// Update the test functions to accept storyContext
-const testDefaultValues = ({ canvasElement }: StoryContext) => {
- const canvas = within(canvasElement);
- const dropdownButton = canvas.getByRole('button', { name: 'Select an option' });
- expect(dropdownButton).toHaveTextContent('Select an option');
-};
-
-const testInvalidSubmission = async ({ canvasElement }: StoryContext) => {
- const canvas = within(canvasElement);
- const submitButton = canvas.getByRole('button', { name: 'Submit' });
- await userEvent.click(submitButton);
- await expect(canvas.findByText('Please select a color')).resolves.toBeInTheDocument();
-};
-
-const testColorSelection = async ({ canvasElement }: StoryContext) => {
- const canvas = within(canvasElement);
- const dropdownButton = canvas.getByRole('button', { name: 'Select an option' });
- await userEvent.click(dropdownButton);
-
- const parentContainer = within(canvasElement.parentNode as HTMLElement);
-
- await expect(parentContainer.findByRole('menuitem', { name: 'Green' })).resolves.toBeInTheDocument();
-
- const greenOption = parentContainer.getByRole('menuitem', { name: 'Green' });
- await userEvent.click(greenOption);
-
- expect(dropdownButton).toHaveTextContent('Green');
-};
-
-const testValidSubmission = async ({ canvasElement }: StoryContext) => {
- const canvas = within(canvasElement);
-
- const dropdownButton = canvas.getByRole('button', { name: 'Green', hidden: true });
- await userEvent.click(dropdownButton);
-
- const parentContainer = within(canvasElement.parentNode as HTMLElement);
+export const Default: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: 'A dropdown menu select component for selecting a single option.',
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
- const blueOption = parentContainer.getByRole('menuitem', { name: 'Blue' });
- await userEvent.click(blueOption);
+ // Open the dropdown
+ const dropdownButton = canvas.getByRole('button', { name: 'Select an option' });
+ await userEvent.click(dropdownButton);
- const submitButton = canvas.getByRole('button', { name: 'Submit', hidden: true });
- await userEvent.click(submitButton);
+ // Select an option (portal renders outside the canvas)
+ const option = screen.getByRole('menuitem', { name: 'Banana' });
+ await userEvent.click(option);
- await expect(canvas.findByText('Form submitted successfully')).resolves.toBeInTheDocument();
-};
+ // Submit the form
+ const submitButton = canvas.getByRole('button', { name: 'Submit' });
+ await userEvent.click(submitButton);
-export const Tests: Story = {
- play: async (storyContext) => {
- testDefaultValues(storyContext);
- await testInvalidSubmission(storyContext);
- await testColorSelection(storyContext);
- await testValidSubmission(storyContext);
+ // Check if the selected option is displayed
+ await expect(await canvas.findByText('Banana')).toBeInTheDocument();
},
-};
\ No newline at end of file
+};
diff --git a/apps/docs/src/remix-hook-form/otp-input.stories.tsx b/apps/docs/src/remix-hook-form/otp-input.stories.tsx
index 2187f63b..644585c8 100644
--- a/apps/docs/src/remix-hook-form/otp-input.stories.tsx
+++ b/apps/docs/src/remix-hook-form/otp-input.stories.tsx
@@ -1,79 +1,90 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { OTPInput } from '@lambdacurry/forms/remix-hook-form/otp-input';
import { Button } from '@lambdacurry/forms/ui/button';
-import type { ActionFunctionArgs } from '@remix-run/node';
-import { Form, useFetcher } from '@remix-run/react';
-import type { Meta, StoryContext, StoryObj } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
-import type {} from '@testing-library/dom';
-import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
+import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
+import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
const formSchema = z.object({
- otp: z.string().length(6, 'Please enter a 6-digit code'),
+ otp: z.string().min(6, 'OTP must be 6 digits'),
});
type FormData = z.infer;
-const RemixOTPInputExample = () => {
- const fetcher = useFetcher<{ message?: string }>();
+const ControlledOtpInputExample = () => {
+ const fetcher = useFetcher<{
+ message: string;
+ otp: string;
+ }>();
+
const methods = useRemixForm({
resolver: zodResolver(formSchema),
defaultValues: {
otp: '',
},
- fetcher,
- submitConfig: {
- action: '/',
- method: 'post',
+ fetcher: fetcher,
+ submitHandlers: {
+ onValid: (data) => {
+ fetcher.submit(
+ createFormData({
+ otp: data.otp,
+ }),
+ {
+ method: 'post',
+ action: '/',
+ },
+ );
+ },
},
});
return (
);
};
-// Action function for form submission
const handleFormSubmission = async (request: Request) => {
- const { errors, receivedValues: defaultValues } = await getValidatedFormData(
- request,
- zodResolver(formSchema),
- );
+ const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema));
if (errors) {
- return { errors, defaultValues };
+ return { errors };
}
- return { message: 'OTP verified successfully' };
+ return { message: 'OTP submitted successfully', otp: data.otp };
};
-// Storybook configuration
const meta: Meta = {
title: 'RemixHookForm/OTPInput',
component: OTPInput,
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
- withRemixStubDecorator({
- root: {
- Component: RemixOTPInputExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: ControlledOtpInputExample,
+ action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
+ },
+ ],
}),
],
} satisfies Meta;
@@ -81,28 +92,28 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
-// Update the test functions to accept storyContext
-const testIncompleteSubmission = async ({ canvasElement }: StoryContext) => {
- const canvas = within(canvasElement);
- const submitButton = canvas.getByRole('button', { name: 'Submit' });
- const input = canvasElement.querySelector('input');
- await userEvent.type(input as HTMLInputElement, '123');
- await userEvent.click(submitButton);
- await expect(canvas.findByText('Please enter a 6-digit code')).resolves.toBeInTheDocument();
-};
+export const Default: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: 'An OTP input component for entering verification codes.',
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
-const testSubmission = async ({ canvasElement }: StoryContext) => {
- const canvas = within(canvasElement);
- const submitButton = canvas.getByRole('button', { name: 'Submit' });
- const input = canvasElement.querySelector('input');
- await userEvent.type(input as HTMLInputElement, '123456');
- await userEvent.click(submitButton);
- await expect(canvas.findByText('OTP verified successfully')).resolves.toBeInTheDocument();
-};
+ // Get the main OTP input
+ const otpInput = canvas.getByRole('textbox');
-export const Tests: Story = {
- play: async (storyContext) => {
- await testIncompleteSubmission(storyContext);
- await testSubmission(storyContext);
+ // Type the 6-digit OTP directly into the hidden input
+ await userEvent.type(otpInput, '123456');
+
+ // Submit the form
+ const submitButton = canvas.getByRole('button', { name: 'Submit' });
+ await userEvent.click(submitButton);
+
+ // Check if the submitted OTP is displayed
+ await expect(await canvas.findByText('123456')).toBeInTheDocument();
},
-};
\ 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
index 8588485e..31159318 100644
--- a/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx
+++ b/apps/docs/src/remix-hook-form/radio-group-custom.stories.tsx
@@ -1,18 +1,18 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { RadioGroup } from '@lambdacurry/forms/remix-hook-form/radio-group';
+import { RadioGroupItem } from '@lambdacurry/forms/remix-hook-form/radio-group-item';
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 type * as React from 'react';
+import type { ActionFunctionArgs } from 'react-router';
+import { Form, useFetcher } from 'react-router';
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
const formSchema = z.object({
plan: z.enum(['starter', 'pro', 'enterprise'], {
@@ -25,60 +25,64 @@ const formSchema = z.object({
type FormData = z.infer;
+// Custom FormLabel component that makes the entire area clickable
+const FullWidthCardLabel = ({ className, children, htmlFor, ...props }: React.ComponentPropsWithoutRef<'label'>) => {
+ return (
+
+ );
+};
+
// Custom radio group component
-const PurpleRadioGroup = React.forwardRef<
- React.ComponentRef,
- React.ComponentPropsWithoutRef
->((props, ref) => {
+const PurpleRadioGroup = (props: React.ComponentPropsWithoutRef) => {
return (
);
-});
+};
PurpleRadioGroup.displayName = 'PurpleRadioGroup';
// Custom radio group item component
-const PurpleRadioGroupItem = React.forwardRef<
- React.ComponentRef,
- React.ComponentPropsWithoutRef & {
+const PurpleRadioGroupItem = (
+ props: 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) => {
+const PurpleRadioGroupIndicator = (props: React.ComponentPropsWithoutRef) => {
return (
-
+
);
-});
+};
PurpleRadioGroupIndicator.displayName = 'PurpleRadioGroupIndicator';
// Custom radio group indicator with icon
-const IconRadioGroupIndicator = React.forwardRef<
- React.ComponentRef,
- React.ComponentPropsWithoutRef
->((props, ref) => {
+const IconRadioGroupIndicator = (props: React.ComponentPropsWithoutRef) => {
return (
-
+
);
-});
+};
IconRadioGroupIndicator.displayName = 'IconRadioGroupIndicator';
// Custom form label component
-const PurpleLabel = React.forwardRef>(
- ({ className, ...props }, ref) => ,
+const PurpleLabel = (props: React.ComponentPropsWithoutRef) => (
+
);
PurpleLabel.displayName = 'PurpleLabel';
// Custom error message component
-const PurpleErrorMessage = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
+const PurpleErrorMessage = (props: React.ComponentPropsWithoutRef) => (
+
);
PurpleErrorMessage.displayName = 'PurpleErrorMessage';
// Card-style radio group item component
-const CardRadioGroupItem = React.forwardRef<
- React.ComponentRef,
- React.ComponentPropsWithoutRef
->((props, ref) => {
+const CardRadioGroupItem = (props: React.ComponentPropsWithoutRef) => {
const { value, children, className, ...otherProps } = props;
return (
);
-});
+};
CardRadioGroupItem.displayName = 'CardRadioGroupItem';
const CustomRadioGroupExample = () => {
@@ -194,18 +192,9 @@ const CustomRadioGroupExample = () => {
FormMessage: PurpleErrorMessage,
}}
>
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -217,45 +206,36 @@ const CustomRadioGroupExample = () => {
description="Choose the plan that best fits your needs."
className="space-y-2"
>
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -267,45 +247,36 @@ const CustomRadioGroupExample = () => {
description="Choose the plan that best fits your needs."
className="space-y-2"
>
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
@@ -317,20 +288,59 @@ const CustomRadioGroupExample = () => {
description="Choose the plan that best fits your needs."
className="space-y-4"
>
-
- Starter
- Perfect for beginners
-
-
-
- Pro
- For professional users
-
-
-
- Enterprise
- For large organizations
-
+
+ Starter
+
+ Perfect for small projects
+
+
+ }
+ components={{
+ Label: FullWidthCardLabel,
+ }}
+ />
+
+
+ Pro
+
+ For professional developers
+
+
+ }
+ components={{
+ Label: FullWidthCardLabel,
+ }}
+ />
+
+
+ Enterprise
+
+ Advanced features for teams
+
+
+ }
+ components={{
+ Label: FullWidthCardLabel,
+ }}
+ />
@@ -340,24 +350,15 @@ const CustomRadioGroupExample = () => {
name="requiredPlan"
label="Select a required plan"
description="This field is required."
- className="space-y-2"
+ options={[
+ { value: 'starter', label: 'Starter', id: 'starter-5' },
+ { value: 'pro', label: 'Pro', id: 'pro-5' },
+ { value: 'enterprise', label: 'Enterprise', id: 'enterprise-5' },
+ ]}
components={{
FormMessage: PurpleErrorMessage,
}}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+ />
@@ -397,11 +398,14 @@ type Story = StoryObj;
export const CustomComponents: Story = {
render: () => ,
decorators: [
- withRemixStubDecorator({
- root: {
- Component: CustomRadioGroupExample,
- action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
- },
+ withReactRouterStubDecorator({
+ routes: [
+ {
+ path: '/',
+ Component: CustomRadioGroupExample,
+ action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
+ },
+ ],
}),
],
parameters: {
@@ -412,7 +416,7 @@ export const CustomComponents: Story = {
source: {
code: `
import { RadioGroup } from '@lambdacurry/forms/remix-hook-form/radio-group';
-import { RadioGroupItem } from '@lambdacurry/forms/ui/radio-group';
+import { RadioGroupItem } from '@lambdacurry/forms/remix-hook-form/radio-group-item';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import * as React from 'react';
import { cn } from '@lambdacurry/forms/ui/utils';
@@ -434,11 +438,9 @@ import { cn } from '@lambdacurry/forms/ui/utils';
FormMessage: PurpleErrorMessage,
}}
>
-
-
-
-
- {/* More radio items */}
+
+
+
/**
@@ -453,27 +455,23 @@ import { cn } from '@lambdacurry/forms/ui/utils';
description="Choose the plan that best fits your needs."
className="space-y-2"
>
-
-
-
-
+
{/* More radio items */}
/**
- * Example 3: Custom Radio Items with Icon
+ * Example 3: Using RadioGroupItem with built-in label and custom Label component
*
- * You can replace the default indicator with a custom icon.
- * This example uses an SVG checkmark instead of the default circle.
+ * Our RadioGroupItem component can use a custom Label component
*/
-
-
-
-
+
+
{/* More radio items */}
/**
- * Example 4: Card-Style Radio Buttons
+ * Example 4: Using options prop for automatic items with labels
*
- * 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.
+ * You can use the options prop to automatically generate radio items with labels
*/
-
-// Card-style radio group item component
-const CardRadioGroupItem = React.forwardRef((props, ref) => {
- const { value, children, className, ...otherProps } = props;
-
- return (
-
-
-
- );
-});
-
-// Usage with card-style radio buttons
-
- Starter
- Perfect for beginners
-
-
-
- Pro
- For professional users
-
-
-
- Enterprise
- For large organizations
-
-
+ options={[
+ { value: 'starter', label: 'Starter', id: 'starter-4' },
+ { value: 'pro', label: 'Pro', id: 'pro-4' },
+ { value: 'enterprise', label: 'Enterprise', id: 'enterprise-4' }
+ ]}
+ itemClassName="bg-purple-50 p-3 rounded-lg border border-purple-100 hover:bg-purple-100 transition-all"
+ labelClassName="text-purple-800 font-bold"
+/>
/**
- * Example 5: Required Radio Group
+ * Example 5: Required Radio Group with options prop
*
- * You can create a required radio group by using Zod validation.
- * This example shows how to display custom error messages when validation fails.
+ * You can create a required radio group with automatic items
*/
-
-
-
-
- {/* More radio items */}
-
+/>
// Zod schema with required field
const formSchema = z.object({
@@ -611,10 +555,17 @@ const formSchema = z.object({
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 1: Verify custom labels are rendered
+ const planLabels = canvas.getAllByText('Select a plan');
+ expect(planLabels.length).toBeGreaterThan(0);
+
+ // Find section with custom radio items
+ const customRadioItemSection = canvas.getByText('Custom Radio Items with Icon').closest('div');
+ expect(customRadioItemSection).toBeTruthy();
+
+ // Verify radio labels exist
+ const starterLabel = within(customRadioItemSection as HTMLElement).getByText('Starter');
+ expect(starterLabel).toBeInTheDocument();
// Test 2: Verify card-style radio buttons work
const cardStyleContainer = canvas.getByText('Card-Style Radio Buttons').closest('div');
@@ -622,16 +573,17 @@ const formSchema = z.object({
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');
+ // Find all radio inputs in the card-style container and select the one with "enterprise" value
+ // A more reliable approach than looking for text and closest button
+ const radioInputs = within(cardStyleContainer as HTMLElement).getAllByRole('radio');
+ const enterpriseOption = radioInputs.find((input) => input.getAttribute('value') === 'enterprise');
+
+ if (!enterpriseOption) {
+ throw new Error('Could not find Enterprise radio option');
}
- await userEvent.click(enterpriseCard);
- expect(enterpriseCard).toHaveAttribute('data-state', 'checked');
+ await userEvent.click(enterpriseOption);
+ expect(enterpriseOption).toBeChecked();
// Test 3: Verify required field validation
const submitButton = canvas.getByRole('button', { name: 'Submit' });
@@ -647,7 +599,14 @@ const formSchema = z.object({
throw new Error('Could not find required radio group container');
}
- const proOption = within(requiredContainer as HTMLElement).getByLabelText('Pro');
+ // Find all radio inputs in the required container and select the one for "Pro"
+ const requiredRadioInputs = within(requiredContainer as HTMLElement).getAllByRole('radio');
+ const proOption = requiredRadioInputs.find((input) => input.getAttribute('value') === 'pro');
+
+ if (!proOption) {
+ throw new Error('Could not find Pro option in required field');
+ }
+
await userEvent.click(proOption);
// Submit the form again
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 84c412a8..bc784422 100644
--- a/apps/docs/src/remix-hook-form/radio-group.stories.tsx
+++ b/apps/docs/src/remix-hook-form/radio-group.stories.tsx
@@ -1,29 +1,59 @@
import { zodResolver } from '@hookform/resolvers/zod';
-import { RadioGroup } from '@lambdacurry/forms/remix-hook-form/radio-group';
+import { RadioGroup, type RadioOption } from '@lambdacurry/forms/remix-hook-form/radio-group';
+import { RadioGroupItem } from '@lambdacurry/forms/remix-hook-form/radio-group-item';
import { Button } from '@lambdacurry/forms/ui/button';
-import { RadioGroupItem } from '@lambdacurry/forms/ui/radio-group';
-import type { ActionFunctionArgs } from '@remix-run/node';
-import { Form, useFetcher } from '@remix-run/react';
-import type { Meta, StoryContext, StoryObj } from '@storybook/react';
-import { expect, userEvent } from '@storybook/test';
+import { Label } from '@lambdacurry/forms/ui/label';
+import type { Meta, StoryObj } from '@storybook/react';
+import { expect, userEvent, within } from '@storybook/test';
+import type { ComponentPropsWithoutRef, ComponentType } from 'react';
+import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
-import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
+import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
+
+const AVAILABLE_SIZES: RadioOption[] = [
+ { value: 'xs', label: 'Extra Small' },
+ { value: 'sm', label: 'Small' },
+ { value: 'md', label: 'Medium' },
+ { value: 'lg', label: 'Large' },
+ { value: 'xl', label: 'Extra Large' },
+];
+
+const AVAILABLE_COLORS: RadioOption[] = [
+ { value: 'red', label: 'Red' },
+ { value: 'blue', label: 'Blue' },
+ { value: 'green', label: 'Green' },
+ { value: 'yellow', label: 'Yellow' },
+ { value: 'purple', label: 'Purple' },
+];
const formSchema = z.object({
- plan: z.enum(['starter', 'pro', 'enterprise'], {
- required_error: 'You need to select a plan',
+ size: z.enum(['xs', 'sm', 'md', 'lg', 'xl'], {
+ required_error: 'Please select a size',
+ }),
+ color: z.enum(['red', 'blue', 'green', 'yellow', 'purple'], {
+ required_error: 'Please select a color',
+ }),
+ design: z.enum(['modern', 'classic', 'vintage'], {
+ required_error: 'Please select a design',
}),
});
type FormData = z.infer;
-const RemixRadioGroupExample = () => {
- const fetcher = useFetcher<{ message?: string }>();
+// Custom label style component
+const CustomLabel: ComponentType> = (props) => {
+ return ;
+};
+CustomLabel.displayName = 'CustomLabel';
+
+// Radio group example with multiple usage patterns
+const RadioGroupExample = () => {
+ const fetcher = useFetcher<{ message: string; data?: FormData; errors?: Record }>();
const methods = useRemixForm({
resolver: zodResolver(formSchema),
defaultValues: {
- plan: undefined,
+ size: 'md',
},
fetcher,
submitConfig: {
@@ -35,46 +65,96 @@ const RemixRadioGroupExample = () => {
return (