Skip to content

Commit 6329095

Browse files
authored
Merge pull request #41 from lambda-curry/feature/mkt-159-text-field-prefix-suffix
Add prefix and suffix props to TextField component
2 parents 9573a46 + 07f423c commit 6329095

2 files changed

Lines changed: 126 additions & 49 deletions

File tree

apps/docs/src/remix-hook-form/text-field.stories.tsx

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
1212

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

1720
type FormData = z.infer<typeof formSchema>;
@@ -26,6 +29,9 @@ const ControlledTextFieldExample = () => {
2629
resolver: zodResolver(formSchema),
2730
defaultValues: {
2831
username: INITIAL_USERNAME,
32+
price: '10.00',
33+
email: 'user@example.com',
34+
measurement: '10',
2935
},
3036
fetcher,
3137
submitConfig: {
@@ -37,11 +43,28 @@ const ControlledTextFieldExample = () => {
3743
return (
3844
<RemixFormProvider {...methods}>
3945
<fetcher.Form onSubmit={methods.handleSubmit}>
40-
<TextField name="username" label="Username" description="Enter a unique username" />
41-
<Button type="submit" className="mt-4">
42-
Submit
43-
</Button>
44-
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
46+
<div className="space-y-6">
47+
<TextField name="username" label="Username" description="Enter a unique username" />
48+
49+
<TextField name="price" label="Price" description="Enter the price" prefix="$" />
50+
51+
<TextField name="email" label="Email" description="Enter your email address" suffix="@example.com" />
52+
53+
<TextField
54+
type="number"
55+
name="measurement"
56+
step={0.1}
57+
label="Measurement"
58+
description="Enter a measurement"
59+
prefix="~"
60+
suffix="cm"
61+
/>
62+
63+
<Button type="submit" className="mt-4">
64+
Submit
65+
</Button>
66+
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
67+
</div>
4568
</fetcher.Form>
4669
</RemixFormProvider>
4770
);
@@ -80,20 +103,7 @@ const meta: Meta<typeof TextField> = {
80103
component: TextField,
81104
parameters: { layout: 'centered' },
82105
tags: ['autodocs'],
83-
decorators: [
84-
withRemixStubDecorator({
85-
root: {
86-
Component: ControlledTextFieldExample,
87-
},
88-
routes: [
89-
{
90-
path: '/username',
91-
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
92-
},
93-
],
94-
}),
95-
],
96-
} satisfies Meta<typeof TextField>;
106+
};
97107

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

150-
// Stories
151-
export const Tests: Story = {
160+
// Single story that contains all variants
161+
export const Examples: Story = {
152162
play: async (storyContext) => {
153163
testDefaultValues(storyContext);
154164
await testInvalidSubmission(storyContext);
155165
await testUsernameTaken(storyContext);
156166
await testValidSubmission(storyContext);
157167
},
158-
};
168+
decorators: [
169+
withRemixStubDecorator({
170+
root: {
171+
Component: ControlledTextFieldExample,
172+
},
173+
routes: [
174+
{
175+
path: '/username',
176+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
177+
},
178+
],
179+
}),
180+
],
181+
};

packages/components/src/ui/text-field.tsx

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,96 @@ import {
1010
FormMessage,
1111
} from './form';
1212
import { TextInput } from './text-input';
13+
import { cn } from './utils';
1314

14-
export interface TextFieldComponents extends FieldComponents {
15-
Input?: React.ComponentType<React.InputHTMLAttributes<HTMLInputElement>>;
16-
}
15+
export const FieldPrefix = ({
16+
children,
17+
className,
18+
}: {
19+
children: React.ReactNode;
20+
className?: string;
21+
}) => {
22+
return (
23+
<div
24+
className={cn(
25+
'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',
26+
className,
27+
)}
28+
>
29+
<span className="whitespace-nowrap">{children}</span>
30+
</div>
31+
);
32+
};
1733

18-
export interface TextFieldProps<
19-
TFieldValues extends FieldValues = FieldValues,
20-
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
21-
> extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'> {
22-
control?: Control<TFieldValues>;
23-
name: TName;
34+
export const FieldSuffix = ({
35+
children,
36+
className,
37+
}: {
38+
children: React.ReactNode;
39+
className?: string;
40+
}) => {
41+
return (
42+
<div
43+
className={cn(
44+
'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',
45+
className,
46+
)}
47+
>
48+
<span className="whitespace-nowrap">{children}</span>
49+
</div>
50+
);
51+
};
52+
53+
// Create a specific interface for the input props that includes className explicitly
54+
type TextInputProps = React.ComponentPropsWithRef<typeof TextInput> & {
55+
control?: Control<FieldValues>;
56+
name: FieldPath<FieldValues>;
2457
label?: string;
2558
description?: string;
26-
components?: Partial<TextFieldComponents>;
27-
}
28-
29-
export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
30-
({ control, name, label, description, className, components, ...props }, ref) => {
31-
const InputComponent = components?.Input || TextInput;
59+
components?: Partial<FieldComponents>;
60+
prefix?: React.ReactNode;
61+
suffix?: React.ReactNode;
62+
className?: string;
63+
};
3264

65+
export const TextField = React.forwardRef<HTMLDivElement, TextInputProps>(
66+
({ control, name, label, description, className, components, prefix, suffix, ...props }, ref) => {
3367
return (
3468
<FormField
3569
control={control}
3670
name={name}
37-
render={({ field, fieldState }) => (
38-
<FormItem className={className} ref={ref}>
39-
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
40-
<FormControl Component={components?.FormControl}>
41-
<InputComponent {...field} {...props} ref={field.ref} />
42-
</FormControl>
43-
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
44-
{fieldState.error && (
45-
<FormMessage Component={components?.FormMessage}>{fieldState.error.message}</FormMessage>
46-
)}
47-
</FormItem>
48-
)}
71+
render={({ field, fieldState }) => {
72+
return (
73+
<FormItem className={className} ref={ref}>
74+
{label && <FormLabel Component={components?.FormLabel}>{label}</FormLabel>}
75+
<FormControl Component={components?.FormControl}>
76+
<div
77+
className={cn('flex group transition-all duration-200 rounded-md', {
78+
'field__input--with-prefix': prefix,
79+
'field__input--with-suffix': suffix,
80+
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background': true,
81+
})}
82+
>
83+
{prefix && <FieldPrefix>{prefix}</FieldPrefix>}
84+
<TextInput
85+
{...field}
86+
{...props}
87+
ref={field.ref}
88+
className={cn('focus-visible:ring-0 focus-visible:ring-offset-0', {
89+
'rounded-l-none border-l-0': prefix,
90+
'rounded-r-none border-r-0': suffix,
91+
})}
92+
/>
93+
{suffix && <FieldSuffix>{suffix}</FieldSuffix>}
94+
</div>
95+
</FormControl>
96+
{description && <FormDescription Component={components?.FormDescription}>{description}</FormDescription>}
97+
{fieldState.error && (
98+
<FormMessage Component={components?.FormMessage}>{fieldState.error.message}</FormMessage>
99+
)}
100+
</FormItem>
101+
);
102+
}}
49103
/>
50104
);
51105
},

0 commit comments

Comments
 (0)