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 1/4] 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?{' '} + +
+ )} +
+ ) +} From ec0db5a011fa471e1c61acbe6c600ff49df10241 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:14:05 +0100 Subject: [PATCH 2/4] test: add validation and interaction tests for LoginForm Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/LoginForm/LoginForm.test.tsx | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/design-system/composites/LoginForm/LoginForm.test.tsx b/src/design-system/composites/LoginForm/LoginForm.test.tsx index 33a8f60..a6b1e88 100644 --- a/src/design-system/composites/LoginForm/LoginForm.test.tsx +++ b/src/design-system/composites/LoginForm/LoginForm.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { LoginForm } from './LoginForm' const socialProviders = [ @@ -73,4 +74,120 @@ describe('LoginForm', () => { expect(screen.getByText('Invalid credentials')).toBeInTheDocument() }) }) + + describe('validation', () => { + it('validates required email', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(screen.getByText('Email is required')).toBeInTheDocument() + }) + + it('validates email format', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/email/i), 'notanemail') + await user.type(screen.getByLabelText(/password/i), 'password123') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument() + }) + + it('validates required password', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(screen.getByText('Password is required')).toBeInTheDocument() + }) + + it('validates password minimum length', async () => { + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + await user.type(screen.getByLabelText(/password/i), 'short') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument() + }) + + it('clears field errors on typing', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(screen.getByText('Email is required')).toBeInTheDocument() + await user.type(screen.getByLabelText(/email/i), 't') + expect(screen.queryByText('Email is required')).not.toBeInTheDocument() + }) + + it('calls onSubmit with credentials when valid', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render() + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + await user.type(screen.getByLabelText(/password/i), 'password123') + await user.click(screen.getByLabelText(/remember me/i)) + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(onSubmit).toHaveBeenCalledWith({ + email: 'test@example.com', + password: 'password123', + remember: true, + }) + }) + + it('does not call onSubmit when validation fails', async () => { + const onSubmit = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Sign in' })) + expect(onSubmit).not.toHaveBeenCalled() + }) + }) + + describe('loading state', () => { + it('disables form inputs when loading', () => { + render() + expect(screen.getByLabelText(/email/i)).toBeDisabled() + expect(screen.getByLabelText(/password/i)).toBeDisabled() + expect(screen.getByLabelText(/remember me/i)).toBeDisabled() + }) + + it('shows spinner on submit button when loading', () => { + render() + const submitBtn = screen.getByRole('button', { name: /sign in/i }) + expect(submitBtn).toBeDisabled() + // Button component renders Spinner when loading=true + expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument() + }) + + it('disables social buttons when loading', () => { + render() + expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Continue with GitHub' })).toBeDisabled() + }) + }) + + describe('callbacks', () => { + it('calls social provider onClick when clicked', async () => { + const onClick = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Continue with Google' })) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('calls onForgotPassword when link clicked', async () => { + const onForgotPassword = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByText(/forgot password/i)) + expect(onForgotPassword).toHaveBeenCalledOnce() + }) + + it('calls onSignUp when link clicked', async () => { + const onSignUp = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByText(/sign up/i)) + expect(onSignUp).toHaveBeenCalledOnce() + }) + }) }) From fd9b5e4fefc3749c29ff33919be6aaa05c122464 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:16:30 +0100 Subject: [PATCH 3/4] feat: add LoginDialog modal wrapper component Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/LoginForm/LoginDialog.test.tsx | 54 +++++++++++++++++++ .../composites/LoginForm/LoginDialog.tsx | 15 ++++++ 2 files changed, 69 insertions(+) create mode 100644 src/design-system/composites/LoginForm/LoginDialog.test.tsx create mode 100644 src/design-system/composites/LoginForm/LoginDialog.tsx diff --git a/src/design-system/composites/LoginForm/LoginDialog.test.tsx b/src/design-system/composites/LoginForm/LoginDialog.test.tsx new file mode 100644 index 0000000..6e486d0 --- /dev/null +++ b/src/design-system/composites/LoginForm/LoginDialog.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { LoginDialog } from './LoginDialog' + +const defaultProps = { + open: true, + onClose: vi.fn(), + onSubmit: vi.fn(), +} + +describe('LoginDialog', () => { + it('renders Modal with LoginForm when open', () => { + render() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('calls onClose on Esc', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render() + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('calls onClose on backdrop click', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByTestId('modal-backdrop')) + expect(onClose).toHaveBeenCalled() + }) + + it('passes LoginForm props through', () => { + render( + , + ) + expect(screen.getByText('Welcome')).toBeInTheDocument() + expect(screen.getByText('Continue with Google')).toBeInTheDocument() + expect(screen.getByText('Bad credentials')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/LoginForm/LoginDialog.tsx b/src/design-system/composites/LoginForm/LoginDialog.tsx new file mode 100644 index 0000000..cffd740 --- /dev/null +++ b/src/design-system/composites/LoginForm/LoginDialog.tsx @@ -0,0 +1,15 @@ +import { Modal } from '../Modal/Modal' +import { LoginForm, type LoginFormProps } from './LoginForm' + +export interface LoginDialogProps extends LoginFormProps { + open: boolean + onClose: () => void +} + +export function LoginDialog({ open, onClose, className, ...formProps }: LoginDialogProps) { + return ( + + + + ) +} From c1cb9fa5360b602f2a36660fa0fdd61609f9d8f9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:18:40 +0100 Subject: [PATCH 4/4] feat: export LoginForm and LoginDialog from composites barrel Co-Authored-By: Claude Opus 4.6 (1M context) --- src/design-system/composites/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index f2bb125..4a4ec9b 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -17,6 +17,10 @@ export { GroupCard } from './GroupCard/GroupCard' export type { FeedEvent } from './EventFeed/EventFeed' export { FilterBar } from './FilterBar/FilterBar' export { LineChart } from './LineChart/LineChart' +export { LoginDialog } from './LoginForm/LoginDialog' +export type { LoginDialogProps } from './LoginForm/LoginDialog' +export { LoginForm } from './LoginForm/LoginForm' +export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm' export { MenuItem } from './MenuItem/MenuItem' export { Modal } from './Modal/Modal' export { MultiSelect } from './MultiSelect/MultiSelect'