feat: add LoginForm component with rendering tests
Implements the LoginForm composite with social login, email/password credentials, remember me, forgot password, and sign up sections. All sections conditionally render based on provided props. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
111
src/design-system/composites/LoginForm/LoginForm.module.css
Normal file
111
src/design-system/composites/LoginForm/LoginForm.module.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.loginForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-family: var(--font-body);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.socialSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.socialButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rememberRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forgotLink {
|
||||
font-size: 11px;
|
||||
color: var(--amber);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.forgotLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signUpText {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.signUpLink {
|
||||
color: var(--amber);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signUpLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
76
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
76
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { LoginForm } from './LoginForm'
|
||||
|
||||
const socialProviders = [
|
||||
{ label: 'Continue with Google', onClick: vi.fn() },
|
||||
{ label: 'Continue with GitHub', onClick: vi.fn() },
|
||||
]
|
||||
|
||||
const allProps = {
|
||||
logo: <div data-testid="logo">Logo</div>,
|
||||
title: 'Welcome back',
|
||||
socialProviders,
|
||||
onSubmit: vi.fn(),
|
||||
onForgotPassword: vi.fn(),
|
||||
onSignUp: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginForm', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all elements when all props provided', () => {
|
||||
render(<LoginForm {...allProps} />)
|
||||
expect(screen.getByTestId('logo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Welcome back')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
expect(screen.getByText('Continue with GitHub')).toBeInTheDocument()
|
||||
expect(screen.getByText('or')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default title when title prop omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides social section when socialProviders is empty', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} socialProviders={[]} />)
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides social section when socialProviders is omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides forgot password link when onForgotPassword omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides sign up link when onSignUp omitted', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides credentials section when onSubmit omitted (social only)', () => {
|
||||
render(<LoginForm socialProviders={socialProviders} />)
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Sign in' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('or')).not.toBeInTheDocument()
|
||||
// Social buttons should still render
|
||||
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows server error Alert when error prop set', () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />)
|
||||
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
223
src/design-system/composites/LoginForm/LoginForm.tsx
Normal file
223
src/design-system/composites/LoginForm/LoginForm.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type FormEvent } from 'react'
|
||||
import { Button } from '../../primitives/Button/Button'
|
||||
import { Input } from '../../primitives/Input/Input'
|
||||
import { Checkbox } from '../../primitives/Checkbox/Checkbox'
|
||||
import { FormField } from '../../primitives/FormField/FormField'
|
||||
import { Alert } from '../../primitives/Alert/Alert'
|
||||
import styles from './LoginForm.module.css'
|
||||
|
||||
export interface SocialProvider {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface LoginFormProps {
|
||||
logo?: ReactNode
|
||||
title?: string
|
||||
socialProviders?: SocialProvider[]
|
||||
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void
|
||||
onForgotPassword?: () => void
|
||||
onSignUp?: () => void
|
||||
error?: string
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface FieldErrors {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function validate(email: string, password: string): FieldErrors {
|
||||
const errors: FieldErrors = {}
|
||||
if (!email) {
|
||||
errors.email = 'Email is required'
|
||||
} else if (!EMAIL_REGEX.test(email)) {
|
||||
errors.email = 'Please enter a valid email address'
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required'
|
||||
} else if (password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
logo,
|
||||
title = 'Sign in',
|
||||
socialProviders,
|
||||
onSubmit,
|
||||
onForgotPassword,
|
||||
onSignUp,
|
||||
error,
|
||||
loading = false,
|
||||
className,
|
||||
}: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const emailRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-focus first input on mount
|
||||
useEffect(() => {
|
||||
emailRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Reset submitted flag when error prop changes (new server error from re-attempt)
|
||||
useEffect(() => {
|
||||
if (error) setSubmitted(false)
|
||||
}, [error])
|
||||
|
||||
// Server error is shown from prop, hidden after next submit attempt
|
||||
const showServerError = error && !submitted
|
||||
|
||||
const hasSocial = socialProviders && socialProviders.length > 0
|
||||
const hasCredentials = !!onSubmit
|
||||
const showDivider = hasSocial && hasCredentials
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitted(true)
|
||||
const errors = validate(email, password)
|
||||
setFieldErrors(errors)
|
||||
if (Object.keys(errors).length === 0) {
|
||||
onSubmit?.({ email, password, remember })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.loginForm} ${className ?? ''}`}>
|
||||
{logo && <div className={styles.logo}>{logo}</div>}
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
|
||||
{showServerError && (
|
||||
<div className={styles.error}>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSocial && (
|
||||
<div className={styles.socialSection}>
|
||||
{socialProviders.map((provider) => (
|
||||
<Button
|
||||
key={provider.label}
|
||||
variant="secondary"
|
||||
className={styles.socialButton}
|
||||
onClick={provider.onClick}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
{provider.icon}
|
||||
{provider.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDivider && (
|
||||
<div className={styles.divider}>
|
||||
<div className={styles.dividerLine} />
|
||||
<span className={styles.dividerText}>or</span>
|
||||
<div className={styles.dividerLine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredentials && (
|
||||
<form
|
||||
className={styles.fields}
|
||||
onSubmit={handleSubmit}
|
||||
aria-label="Sign in"
|
||||
noValidate
|
||||
>
|
||||
<FormField label="Email" htmlFor="login-email" required error={fieldErrors.email}>
|
||||
<Input
|
||||
ref={emailRef}
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password" required error={fieldErrors.password}>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.rememberRow}>
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
{onForgotPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.forgotLink}
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
{onSignUp && (
|
||||
<div className={styles.signUpText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signUpLink}
|
||||
onClick={onSignUp}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!hasCredentials && onSignUp && (
|
||||
<div className={styles.signUpText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signUpLink}
|
||||
onClick={onSignUp}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user