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:
hsiegeln
2026-03-24 11:07:13 +01:00
parent 5c02b52cb0
commit bda0d11fde
3 changed files with 410 additions and 0 deletions

View 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;
}

View 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()
})
})
})

View 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&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</form>
)}
{!hasCredentials && onSignUp && (
<div className={styles.signUpText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.signUpLink}
onClick={onSignUp}
>
Sign up
</button>
</div>
)}
</div>
)
}