Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 45 additions & 22 deletions apps/docs/src/remix-hook-form/text-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { withRemixStubDecorator } from '../lib/storybook/remix-stub';

const formSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
price: z.string().min(1, 'Price is required'),
email: z.string().email('Invalid email address'),
measurement: z.string().min(1, 'Measurement is required'),
});

type FormData = z.infer<typeof formSchema>;
Expand All @@ -26,6 +29,9 @@ const ControlledTextFieldExample = () => {
resolver: zodResolver(formSchema),
defaultValues: {
username: INITIAL_USERNAME,
price: '10.00',
email: 'user@example.com',
measurement: '10',
},
fetcher,
submitConfig: {
Expand All @@ -37,11 +43,28 @@ const ControlledTextFieldExample = () => {
return (
<RemixFormProvider {...methods}>
<fetcher.Form onSubmit={methods.handleSubmit}>
<TextField name="username" label="Username" description="Enter a unique username" />
<Button type="submit" className="mt-4">
Submit
</Button>
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
<div className="space-y-6">
<TextField name="username" label="Username" description="Enter a unique username" />

<TextField name="price" label="Price" description="Enter the price" prefix="$" />

<TextField name="email" label="Email" description="Enter your email address" suffix="@example.com" />

<TextField
type="number"
name="measurement"
step={0.1}
label="Measurement"
description="Enter a measurement"
prefix="~"
suffix="cm"
/>

<Button type="submit" className="mt-4">
Submit
</Button>
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
</div>
</fetcher.Form>
</RemixFormProvider>
);
Expand Down Expand Up @@ -80,20 +103,7 @@ const meta: Meta<typeof TextField> = {
component: TextField,
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
withRemixStubDecorator({
root: {
Component: ControlledTextFieldExample,
},
routes: [
{
path: '/username',
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
},
],
}),
],
} satisfies Meta<typeof TextField>;
};

export default meta;
type Story = StoryObj<typeof meta>;
Expand Down Expand Up @@ -147,12 +157,25 @@ const testValidSubmission = async ({ canvas }: StoryContext) => {
expect(successMessage).toBeInTheDocument();
};

// Stories
export const Tests: Story = {
// Single story that contains all variants
export const Examples: Story = {
play: async (storyContext) => {
testDefaultValues(storyContext);
await testInvalidSubmission(storyContext);
await testUsernameTaken(storyContext);
await testValidSubmission(storyContext);
},
};
decorators: [
withRemixStubDecorator({
root: {
Component: ControlledTextFieldExample,
},
routes: [
{
path: '/username',
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
},
],
}),
],
};
108 changes: 81 additions & 27 deletions packages/components/src/ui/text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,96 @@ import {
FormMessage,
} from './form';
import { TextInput } from './text-input';
import { cn } from './utils';

export interface TextFieldComponents extends FieldComponents {
Input?: React.ComponentType<React.InputHTMLAttributes<HTMLInputElement>>;
}
export const FieldPrefix = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
'flex h-full text-base items-center pl-3 pr-0 text-gray-500 group-focus-within:text-gray-700 transition-colors duration-200 border-y border-l border-input rounded-l-md bg-background',
className,
)}
>
<span className="whitespace-nowrap">{children}</span>
</div>
);
};

export interface TextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'> {
control?: Control<TFieldValues>;
name: TName;
export const FieldSuffix = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
'flex h-full text-base items-center pr-3 pl-0 text-gray-500 group-focus-within:text-gray-700 transition-colors duration-200 border-y border-r border-input rounded-r-md bg-background',
className,
)}
>
<span className="whitespace-nowrap">{children}</span>
</div>
);
};

// Create a specific interface for the input props that includes className explicitly
type TextInputProps = React.ComponentPropsWithRef<typeof TextInput> & {
control?: Control<FieldValues>;
name: FieldPath<FieldValues>;
label?: string;
description?: string;
components?: Partial<TextFieldComponents>;
}

export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
({ control, name, label, description, className, components, ...props }, ref) => {
const InputComponent = components?.Input || TextInput;
components?: Partial<FieldComponents>;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
className?: string;
};

export const TextField = React.forwardRef<HTMLDivElement, TextInputProps>(
({ control, name, label, description, className, components, prefix, suffix, ...props }, ref) => {
return (
<FormField
control={control}
name={name}
render={({ field, fieldState }) => (
<FormItem className={className} ref={ref}>
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
<FormControl Component={components?.FormControl}>
<InputComponent {...field} {...props} ref={field.ref} />
</FormControl>
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
{fieldState.error && (
<FormMessage Component={components?.FormMessage}>{fieldState.error.message}</FormMessage>
)}
</FormItem>
)}
render={({ field, fieldState }) => {
return (
<FormItem className={className} ref={ref}>
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
<FormControl Component={components?.FormControl}>
<div
className={cn('flex group transition-all duration-200 rounded-md', {
'field__input--with-prefix': prefix,
'field__input--with-suffix': suffix,
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background': true,
})}
>
{prefix && <FieldPrefix>{prefix}</FieldPrefix>}
<TextInput
{...field}
{...props}
ref={field.ref}
className={cn('focus-visible:ring-0 focus-visible:ring-offset-0', {
'rounded-l-none border-l-0': prefix,
'rounded-r-none border-r-0': suffix,
})}
/>
{suffix && <FieldSuffix>{suffix}</FieldSuffix>}
</div>
</FormControl>
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
{fieldState.error && (
<FormMessage Component={components?.FormMessage}>{fieldState.error.message}</FormMessage>
)}
</FormItem>
);
}}
/>
);
},
Expand Down