Skip to content

Commit 93bfc60

Browse files
feat: add password field component with reveal/hide functionality
- Add PasswordField UI component with eye/eye-off toggle icons - Add RemixHookForm wrapper for PasswordField - Create comprehensive Storybook story with create account form example - Include Zod schema validation for password confirmation - Add proper TypeScript types and accessibility features - Export components in index files for easy import
1 parent b004b8a commit 93bfc60

6 files changed

Lines changed: 384 additions & 1 deletion

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { PasswordField } from '@lambdacurry/forms/remix-hook-form/password-field';
3+
import { Button } from '@lambdacurry/forms/ui/button';
4+
import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite';
5+
import { expect, userEvent } from '@storybook/test';
6+
import { useRef } from 'react';
7+
import { type ActionFunctionArgs, useFetcher } from 'react-router';
8+
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
9+
import { z } from 'zod';
10+
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
11+
12+
const formSchema = z
13+
.object({
14+
password: z.string().min(8, 'Password must be at least 8 characters'),
15+
confirmPassword: z.string().min(1, 'Please confirm your password'),
16+
})
17+
.refine((data) => data.password === data.confirmPassword, {
18+
message: "Passwords don't match",
19+
path: ['confirmPassword'],
20+
});
21+
22+
type FormData = z.infer<typeof formSchema>;
23+
24+
const INITIAL_PASSWORD = 'test123456';
25+
const WEAK_PASSWORD = '123';
26+
const WEAK_PASSWORD_ERROR = 'Password must be at least 8 characters';
27+
const MISMATCH_PASSWORD_ERROR = "Passwords don't match";
28+
29+
const CreateAccountForm = () => {
30+
const fetcher = useFetcher<{ message: string; success: boolean }>();
31+
const methods = useRemixForm<FormData>({
32+
resolver: zodResolver(formSchema),
33+
defaultValues: {
34+
password: INITIAL_PASSWORD,
35+
confirmPassword: INITIAL_PASSWORD,
36+
},
37+
fetcher,
38+
submitConfig: {
39+
action: '/',
40+
method: 'post',
41+
},
42+
});
43+
44+
const ref = useRef<HTMLInputElement>(null);
45+
46+
return (
47+
<RemixFormProvider {...methods}>
48+
<fetcher.Form onSubmit={methods.handleSubmit}>
49+
<div className="space-y-6 max-w-md">
50+
<div className="text-center mb-6">
51+
<h2 className="text-2xl font-bold text-gray-900">Create Account</h2>
52+
<p className="text-gray-600 mt-2">Enter your password details to get started</p>
53+
</div>
54+
55+
<PasswordField
56+
name="password"
57+
label="Password"
58+
description="Must be at least 8 characters long"
59+
placeholder="Enter your password"
60+
/>
61+
62+
<PasswordField
63+
name="confirmPassword"
64+
label="Confirm Password"
65+
description="Re-enter your password to confirm"
66+
placeholder="Confirm your password"
67+
/>
68+
69+
<div className="flex gap-2 items-end">
70+
<PasswordField name="refExample" label="Ref Example" placeholder="This field has a ref" ref={ref} />
71+
<Button type="button" onClick={() => ref.current?.focus()}>
72+
Focus
73+
</Button>
74+
</div>
75+
76+
<Button type="submit" className="w-full mt-6">
77+
Create Account
78+
</Button>
79+
80+
{fetcher.data?.message && (
81+
<p className={`mt-2 text-center ${fetcher.data.success ? 'text-green-600' : 'text-red-600'}`}>
82+
{fetcher.data.message}
83+
</p>
84+
)}
85+
</div>
86+
</fetcher.Form>
87+
</RemixFormProvider>
88+
);
89+
};
90+
91+
const handleFormSubmission = async (request: Request) => {
92+
const { data, errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));
93+
94+
if (errors) {
95+
return { errors };
96+
}
97+
98+
// Simulate account creation - data is validated at this point
99+
console.log('Creating account with password length:', data.password.length);
100+
return {
101+
message: 'Account created successfully! Welcome aboard!',
102+
success: true,
103+
};
104+
};
105+
106+
const meta: Meta<typeof PasswordField> = {
107+
title: 'RemixHookForm/PasswordField',
108+
component: PasswordField,
109+
parameters: { layout: 'centered' },
110+
tags: ['autodocs'],
111+
};
112+
113+
export default meta;
114+
type Story = StoryObj<typeof meta>;
115+
116+
// Test scenarios
117+
const testDefaultValues = ({ canvas }: StoryContext) => {
118+
const passwordInput = canvas.getByLabelText('Password');
119+
const confirmInput = canvas.getByLabelText('Confirm Password');
120+
expect(passwordInput).toHaveValue(INITIAL_PASSWORD);
121+
expect(confirmInput).toHaveValue(INITIAL_PASSWORD);
122+
};
123+
124+
const testPasswordVisibilityToggle = async ({ canvas }: StoryContext) => {
125+
const passwordInput = canvas.getByLabelText('Password');
126+
const toggleButton = canvas.getByLabelText('Hide password');
127+
128+
// Initially password should be hidden (type="password")
129+
expect(passwordInput).toHaveAttribute('type', 'password');
130+
131+
// Click toggle to show password
132+
await userEvent.click(toggleButton);
133+
expect(passwordInput).toHaveAttribute('type', 'text');
134+
expect(canvas.getByLabelText('Show password')).toBeInTheDocument();
135+
136+
// Click toggle to hide password again
137+
await userEvent.click(canvas.getByLabelText('Show password'));
138+
expect(passwordInput).toHaveAttribute('type', 'password');
139+
expect(canvas.getByLabelText('Hide password')).toBeInTheDocument();
140+
};
141+
142+
const testWeakPasswordValidation = async ({ canvas }: StoryContext) => {
143+
const passwordInput = canvas.getByLabelText('Password');
144+
const submitButton = canvas.getByRole('button', { name: 'Create Account' });
145+
146+
await userEvent.click(passwordInput);
147+
await userEvent.clear(passwordInput);
148+
await userEvent.type(passwordInput, WEAK_PASSWORD);
149+
await userEvent.click(submitButton);
150+
151+
await expect(await canvas.findByText(WEAK_PASSWORD_ERROR)).toBeInTheDocument();
152+
};
153+
154+
const testPasswordMismatchValidation = async ({ canvas }: StoryContext) => {
155+
const passwordInput = canvas.getByLabelText('Password');
156+
const confirmInput = canvas.getByLabelText('Confirm Password');
157+
const submitButton = canvas.getByRole('button', { name: 'Create Account' });
158+
159+
await userEvent.click(passwordInput);
160+
await userEvent.clear(passwordInput);
161+
await userEvent.type(passwordInput, 'validpassword123');
162+
163+
await userEvent.click(confirmInput);
164+
await userEvent.clear(confirmInput);
165+
await userEvent.type(confirmInput, 'differentpassword123');
166+
167+
await userEvent.click(submitButton);
168+
169+
await expect(await canvas.findByText(MISMATCH_PASSWORD_ERROR)).toBeInTheDocument();
170+
};
171+
172+
const testValidSubmission = async ({ canvas }: StoryContext) => {
173+
const passwordInput = canvas.getByLabelText('Password');
174+
const confirmInput = canvas.getByLabelText('Confirm Password');
175+
const submitButton = canvas.getByRole('button', { name: 'Create Account' });
176+
177+
await userEvent.click(passwordInput);
178+
await userEvent.clear(passwordInput);
179+
await userEvent.type(passwordInput, 'validpassword123');
180+
181+
await userEvent.click(confirmInput);
182+
await userEvent.clear(confirmInput);
183+
await userEvent.type(confirmInput, 'validpassword123');
184+
185+
await userEvent.click(submitButton);
186+
187+
const successMessage = await canvas.findByText('Account created successfully! Welcome aboard!');
188+
expect(successMessage).toBeInTheDocument();
189+
};
190+
191+
const testRefFunctionality = async ({ canvas }: StoryContext) => {
192+
const refInput = canvas.getByLabelText('Ref Example');
193+
const focusButton = canvas.getByRole('button', { name: 'Focus' });
194+
195+
await userEvent.click(focusButton);
196+
expect(refInput).toHaveFocus();
197+
};
198+
199+
// Single story that contains all variants and tests
200+
export const CreateAccountExample: Story = {
201+
play: async (storyContext) => {
202+
testDefaultValues(storyContext);
203+
await testPasswordVisibilityToggle(storyContext);
204+
await testWeakPasswordValidation(storyContext);
205+
await testPasswordMismatchValidation(storyContext);
206+
await testValidSubmission(storyContext);
207+
await testRefFunctionality(storyContext);
208+
},
209+
decorators: [
210+
withReactRouterStubDecorator({
211+
routes: [
212+
{
213+
path: '/',
214+
Component: CreateAccountForm,
215+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
216+
},
217+
],
218+
}),
219+
],
220+
};

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"name": "forms",
33
"version": "0.2.0",
44
"private": true,
5-
"workspaces": ["apps/*", "packages/*"],
5+
"workspaces": [
6+
"apps/*",
7+
"packages/*"
8+
],
69
"scripts": {
710
"start": "yarn dev",
811
"dev": "turbo run dev",

packages/components/src/remix-hook-form/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './form-error';
44
export * from './date-picker';
55
export * from './dropdown-menu-select';
66
export * from './text-field';
7+
export * from './password-field';
78
export * from './radio-group';
89
export * from './radio-group-item';
910
export * from './switch';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type * as React from 'react';
2+
import {
3+
PasswordField as BasePasswordField,
4+
type PasswordInputProps as BasePasswordFieldProps,
5+
} from '../ui/password-field';
6+
import { FormControl, FormDescription, FormLabel, FormMessage } from './form';
7+
8+
import { useRemixFormContext } from 'remix-hook-form';
9+
10+
export type PasswordFieldProps = Omit<BasePasswordFieldProps, 'control'>;
11+
12+
export const PasswordField = function RemixPasswordField(
13+
props: PasswordFieldProps & { ref?: React.Ref<HTMLInputElement> },
14+
) {
15+
const { control } = useRemixFormContext();
16+
17+
// Merge the provided components with the default form components
18+
const defaultComponents = {
19+
FormControl,
20+
FormLabel,
21+
FormDescription,
22+
FormMessage,
23+
};
24+
25+
const components = {
26+
...defaultComponents,
27+
...props.components,
28+
};
29+
30+
return <BasePasswordField control={control} components={components} {...props} />;
31+
};
32+
33+
PasswordField.displayName = 'PasswordField';

packages/components/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './switch';
1717
export * from './switch-field';
1818
export * from './text-field';
1919
export * from './text-input';
20+
export * from './password-field';
2021
export * from './textarea-field';
2122
export * from './textarea';
2223
export * from './utils';

0 commit comments

Comments
 (0)