From bda0d11fde068ea904267e3455240a15fbe60535 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:07:13 +0100 Subject: [PATCH] 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) --- .../composites/LoginForm/LoginForm.module.css | 111 +++++++++ .../composites/LoginForm/LoginForm.test.tsx | 76 ++++++ .../composites/LoginForm/LoginForm.tsx | 223 ++++++++++++++++++ 3 files changed, 410 insertions(+) create mode 100644 src/design-system/composites/LoginForm/LoginForm.module.css create mode 100644 src/design-system/composites/LoginForm/LoginForm.test.tsx create mode 100644 src/design-system/composites/LoginForm/LoginForm.tsx diff --git a/src/design-system/composites/LoginForm/LoginForm.module.css b/src/design-system/composites/LoginForm/LoginForm.module.css new file mode 100644 index 0000000..5c61959 --- /dev/null +++ b/src/design-system/composites/LoginForm/LoginForm.module.css @@ -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; +} diff --git a/src/design-system/composites/LoginForm/LoginForm.test.tsx b/src/design-system/composites/LoginForm/LoginForm.test.tsx new file mode 100644 index 0000000..33a8f60 --- /dev/null +++ b/src/design-system/composites/LoginForm/LoginForm.test.tsx @@ -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:
Logo
, + 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() + 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() + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + }) + + it('hides social section when socialProviders is empty', () => { + render() + expect(screen.queryByText('or')).not.toBeInTheDocument() + }) + + it('hides social section when socialProviders is omitted', () => { + render() + expect(screen.queryByText('or')).not.toBeInTheDocument() + }) + + it('hides forgot password link when onForgotPassword omitted', () => { + render() + expect(screen.queryByText(/forgot password/i)).not.toBeInTheDocument() + }) + + it('hides sign up link when onSignUp omitted', () => { + render() + expect(screen.queryByText(/sign up/i)).not.toBeInTheDocument() + }) + + it('hides credentials section when onSubmit omitted (social only)', () => { + render() + 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() + expect(screen.getByText('Invalid credentials')).toBeInTheDocument() + }) + }) +}) diff --git a/src/design-system/composites/LoginForm/LoginForm.tsx b/src/design-system/composites/LoginForm/LoginForm.tsx new file mode 100644 index 0000000..c4d9ed0 --- /dev/null +++ b/src/design-system/composites/LoginForm/LoginForm.tsx @@ -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({}) + const [submitted, setSubmitted] = useState(false) + const emailRef = useRef(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 ( +
+ {logo &&
{logo}
} +

{title}

+ + {showServerError && ( +
+ {error} +
+ )} + + {hasSocial && ( +
+ {socialProviders.map((provider) => ( + + ))} +
+ )} + + {showDivider && ( +
+
+ or +
+
+ )} + + {hasCredentials && ( +
+ + { + setEmail(e.target.value) + if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined })) + }} + disabled={loading} + /> + + + + { + setPassword(e.target.value) + if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined })) + }} + disabled={loading} + /> + + +
+ setRemember(e.target.checked)} + disabled={loading} + /> + {onForgotPassword && ( + + )} +
+ + + + {onSignUp && ( +
+ Don't have an account?{' '} + +
+ )} +
+ )} + + {!hasCredentials && onSignUp && ( +
+ Don't have an account?{' '} + +
+ )} +
+ ) +}