diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c92f7a03..29245273 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,11 @@ jobs: - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - - name: Install Playwright Chromium - run: npx playwright install chromium + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build Storybook + run: yarn build-storybook - name: Run tests run: yarn test diff --git a/apps/docs/src/remix-hook-form/required-field-indicator.stories.tsx b/apps/docs/src/remix-hook-form/required-field-indicator.stories.tsx new file mode 100644 index 00000000..281894f4 --- /dev/null +++ b/apps/docs/src/remix-hook-form/required-field-indicator.stories.tsx @@ -0,0 +1,168 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field'; +import { Button } from '@lambdacurry/forms/ui/button'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { type ActionFunctionArgs, useFetcher } from 'react-router'; +import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email address'), + phone: z.string().optional(), + address: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + zipCode: z.string().min(1, 'Zip code is required'), +}); + +type FormData = z.infer; + +const RequiredFieldIndicatorExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const methods = useRemixForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + email: '', + phone: '', + address: '', + city: '', + state: '', + zipCode: '', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + return ( + + +
+

Required Field Indicator Example

+

+ This form demonstrates the required field indicator (asterisk) for required fields. + Notice that only Name, Email, and Zip Code have the asterisk indicator. +

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

Disable Required Indicator

+

+ You can disable the required indicator by setting showRequiredIndicator to false: +

+ + props.Component?.({ ...props, showRequiredIndicator: false }) + }} + /> +
+ + + + {fetcher.data?.message && ( +

{fetcher.data.message}

+ )} +
+
+
+
+ ); +}; + +const handleFormSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(formSchema)); + + if (errors) { + return { errors }; + } + + return { message: 'Form submitted successfully' }; +}; + +const meta: Meta = { + title: 'RemixHookForm/RequiredFieldIndicator', + component: TextField, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Demonstrates the required field indicator (asterisk) for required fields.' + } + } + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const RequiredFieldExample: Story = { + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: RequiredFieldIndicatorExample, + action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request), + }, + ], + }), + ], +}; + diff --git a/package.json b/package.json index 16f2631c..1a9afef4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "name": "forms", "version": "0.2.0", "private": true, - "workspaces": ["apps/*", "packages/*"], + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { "start": "yarn dev", "dev": "turbo run dev", diff --git a/packages/components/src/remix-hook-form/form.tsx b/packages/components/src/remix-hook-form/form.tsx index 4d7d50a2..8ba23a69 100644 --- a/packages/components/src/remix-hook-form/form.tsx +++ b/packages/components/src/remix-hook-form/form.tsx @@ -52,10 +52,9 @@ export const FormControl = React.forwardRef ); @@ -65,18 +64,25 @@ FormControl.displayName = 'FormControl'; export const FormDescription = (props: React.ComponentPropsWithoutRef) => { const { formDescriptionId } = useFormField(); - return ; + return ; }; FormDescription.displayName = 'FormDescription'; export const FormMessage = (props: React.ComponentPropsWithoutRef) => { const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message) : props.children; + + if (!body) { + return null; + } + return ( + > + {body} + ); }; FormMessage.displayName = 'FormMessage'; diff --git a/packages/components/src/ui/form.tsx b/packages/components/src/ui/form.tsx index 6d1f4ae9..445b98fd 100644 --- a/packages/components/src/ui/form.tsx +++ b/packages/components/src/ui/form.tsx @@ -1,28 +1,65 @@ -import type * as LabelPrimitive from '@radix-ui/react-label'; -import { Slot } from '@radix-ui/react-slot'; import * as React from 'react'; -import { Controller, type ControllerProps, type FieldPath, type FieldValues } from 'react-hook-form'; -import { Label } from './label'; -import type { InputProps } from './text-input'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'; + import { cn } from './utils'; +import { Label } from './label'; + +export type FieldComponents = { + FormControl: React.ComponentType>; + FormLabel: React.ComponentType>; + FormDescription: React.ComponentType>; + FormMessage: React.ComponentType>; +}; -export interface FieldComponents { - FormControl: React.ComponentType; - FormDescription: React.ComponentType; - FormLabel: React.ComponentType; - FormMessage: React.ComponentType; - Input?: React.ComponentType; -} +const Form = FormProvider; -export type FormFieldContextValue< +type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { name: TName; }; +// Export the context so it can be used by remix-hook-form export const FormFieldContext = React.createContext({} as FormFieldContextValue); +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +// Update the FormItemContextValue to include the IDs needed by remix-hook-form export type FormItemContextValue = { id: string; formItemId: string; @@ -30,156 +67,143 @@ export type FormItemContextValue = { formMessageId: string; }; +// Export the context so it can be used by remix-hook-form export const FormItemContext = React.createContext({} as FormItemContextValue); -export interface FormItemProps extends React.HTMLAttributes { - Component?: React.ComponentType; -} - -export function FormItem({ Component, className, ...props }: FormItemProps) { - const id = React.useId(); +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); - if (Component) { - return ; - } - - return ( - -
- - ); -} + formMessageId: `${id}-form-item-message` + }}> +
+ + ); + }, +); FormItem.displayName = 'FormItem'; -export interface FormLabelProps extends React.ComponentProps { - error?: string; - Component?: React.ComponentType; -} - -export function FormLabel({ Component, htmlFor, className, error, ...props }: FormLabelProps) { - const { formItemId } = React.useContext(FormItemContext); - - if (Component) { - return ( - - ); +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + Component?: React.ComponentType>; + showRequiredIndicator?: boolean; } +>(({ className, Component, showRequiredIndicator = true, ...props }, ref) => { + const { error, formItemId } = useFormField(); + const { formState } = useFormContext(); + + // Check if the field is required by examining the validation rules + const isFieldRequired = React.useMemo(() => { + if (!formState.defaultValues) return false; + + // Try to determine if the field is required by checking the validation rules + const fieldName = props.htmlFor?.toString() || ''; + + // Access the rules through the resolver or other means + // This is a simplified approach and may need to be adjusted based on your validation setup + const isRequired = fieldName && formState.errors && formState.errors[fieldName]?.type === 'required'; + + return isRequired; + }, [formState, props.htmlFor]); + + const LabelComponent = Component || Label; return ( -