From 703b29f1082873f79d215291c6735ef19cd41184 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 08:13:19 +0000 Subject: [PATCH 1/3] Add required field asterisk indicator --- .../required-field-indicator.stories.tsx | 168 +++++++++++ packages/components/src/ui/form.tsx | 267 +++++++++--------- 2 files changed, 309 insertions(+), 126 deletions(-) create mode 100644 apps/docs/src/remix-hook-form/required-field-indicator.stories.tsx 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/packages/components/src/ui/form.tsx b/packages/components/src/ui/form.tsx index 6d1f4ae9..9ad7de78 100644 --- a/packages/components/src/ui/form.tsx +++ b/packages/components/src/ui/form.tsx @@ -1,185 +1,200 @@ -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 const FormFieldContext = React.createContext({} as FormFieldContextValue); +const FormFieldContext = React.createContext({} as FormFieldContextValue); -export type FormItemContextValue = { - id: string; - formItemId: string; - formDescriptionId: string; - formMessageId: string; +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); }; -export const FormItemContext = React.createContext({} as FormItemContextValue); - -export interface FormItemProps extends React.HTMLAttributes { - Component?: React.ComponentType; -} +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); -export function FormItem({ Component, className, ...props }: FormItemProps) { - const id = React.useId(); + const fieldState = getFieldState(fieldContext.name, formState); - if (Component) { - return ; + if (!fieldContext) { + throw new Error('useFormField should be used within '); } - return ( - -
- - ); -} -FormItem.displayName = 'FormItem'; + const { id } = itemContext; -export interface FormLabelProps extends React.ComponentProps { - error?: string; - Component?: React.ComponentType; -} + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext({} as FormItemContextValue); -export function FormLabel({ Component, htmlFor, className, error, ...props }: FormLabelProps) { - const { formItemId } = React.useContext(FormItemContext); +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); - if (Component) { return ( - + +
+ ); + }, +); +FormItem.displayName = 'FormItem'; + +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(); + const { defaultValues } = formState; + + // Check if the field is required by examining the defaultValues and 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() || ''; + const fieldRules = formState._defaultValues?.[fieldName]?.rules; + + if (fieldRules) { + return fieldRules.required !== undefined; + } + + return false; + }, [formState, props.htmlFor]); + + const LabelComponent = Component || Label; return ( -