Compare commits
9 Commits
v0.0.2
...
a62b69b8e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62b69b8e2 | ||
|
|
ff4ba9bb91 | ||
|
|
c1cb9fa536 | ||
|
|
fd9b5e4fef | ||
|
|
ec0db5a011 | ||
|
|
bda0d11fde | ||
|
|
5c02b52cb0 | ||
|
|
be23161582 | ||
|
|
6521bbcf44 |
770
docs/superpowers/plans/2026-03-24-login-dialog.md
Normal file
770
docs/superpowers/plans/2026-03-24-login-dialog.md
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
# Login Dialog Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add composable `LoginForm` and `LoginDialog` components to the Cameleer3 design system with credential + social login support, client-side validation, and full dark mode compatibility.
|
||||||
|
|
||||||
|
**Architecture:** `LoginForm` is the core content component with all form logic, validation, and layout. `LoginDialog` is a thin wrapper that renders `LoginForm` inside `Modal size="sm"`. Both live in `src/design-system/composites/LoginForm/` and are exported from the composites barrel.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-login-dialog-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/design-system/composites/LoginForm/LoginForm.tsx` | Create | Core form component with validation, social providers, all layout |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginForm.module.css` | Create | All styles using design tokens |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginForm.test.tsx` | Create | 21 test cases for LoginForm |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginDialog.tsx` | Create | Thin Modal wrapper |
|
||||||
|
| `src/design-system/composites/LoginForm/LoginDialog.test.tsx` | Create | 5 test cases for LoginDialog |
|
||||||
|
| `src/design-system/composites/index.ts` | Modify | Add LoginForm, LoginDialog, and type exports |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: LoginForm — Rendering Tests & Basic Structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginForm.tsx`
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginForm.module.css`
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write rendering tests**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginForm.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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.getByText('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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create LoginForm component with basic rendering**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginForm.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginForm.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
Expected: 8 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/LoginForm/LoginForm.tsx \
|
||||||
|
src/design-system/composites/LoginForm/LoginForm.module.css \
|
||||||
|
src/design-system/composites/LoginForm/LoginForm.test.tsx
|
||||||
|
git commit -m "feat: add LoginForm component with rendering tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: LoginForm — Validation Tests & Behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add validation and interaction tests**
|
||||||
|
|
||||||
|
Append to the `describe('LoginForm')` block in `LoginForm.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
|
// Add these inside the existing describe('LoginForm') block, after the rendering describe:
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('validates required email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
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(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('disables form inputs when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
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(<LoginForm {...allProps} loading />)
|
||||||
|
const submitBtn = screen.getByRole('button', { name: 'Sign in' })
|
||||||
|
expect(submitBtn).toBeDisabled()
|
||||||
|
// Button component renders Spinner when loading=true
|
||||||
|
expect(submitBtn.querySelector('[class*="spinner"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables social buttons when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
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(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
|
||||||
|
await user.click(screen.getByText(/sign up/i))
|
||||||
|
expect(onSignUp).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginForm.test.tsx`
|
||||||
|
Expected: 21 tests PASS (8 rendering + 7 validation + 3 loading + 3 callbacks)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/LoginForm/LoginForm.test.tsx
|
||||||
|
git commit -m "test: add validation and interaction tests for LoginForm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: LoginDialog — Component & Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginDialog.tsx`
|
||||||
|
- Create: `src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write LoginDialog tests**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginDialog.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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(<LoginDialog {...defaultProps} />)
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Sign in')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<LoginDialog {...defaultProps} open={false} />)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on Esc', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on backdrop click', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.click(screen.getByTestId('modal-backdrop'))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes LoginForm props through', () => {
|
||||||
|
render(
|
||||||
|
<LoginDialog
|
||||||
|
{...defaultProps}
|
||||||
|
title="Welcome"
|
||||||
|
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
|
||||||
|
error="Bad credentials"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Welcome')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create LoginDialog component**
|
||||||
|
|
||||||
|
Create `src/design-system/composites/LoginForm/LoginDialog.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||||
|
<LoginForm {...formProps} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/LoginDialog.test.tsx`
|
||||||
|
Expected: 5 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/LoginForm/LoginDialog.tsx \
|
||||||
|
src/design-system/composites/LoginForm/LoginDialog.test.tsx
|
||||||
|
git commit -m "feat: add LoginDialog modal wrapper component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Barrel Exports & Full Test Suite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/design-system/composites/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add exports to barrel**
|
||||||
|
|
||||||
|
Add these lines to `src/design-system/composites/index.ts` in alphabetical position (after the `LineChart` export, before `MenuItem`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { LoginForm } from './LoginForm/LoginForm'
|
||||||
|
export type { LoginFormProps, SocialProvider } from './LoginForm/LoginForm'
|
||||||
|
export { LoginDialog } from './LoginForm/LoginDialog'
|
||||||
|
export type { LoginDialogProps } from './LoginForm/LoginDialog'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/design-system/composites/LoginForm/`
|
||||||
|
Expected: All tests PASS (21 LoginForm + 5 LoginDialog = 26 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full project test suite to check for regressions**
|
||||||
|
|
||||||
|
Run: `npx vitest run`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/composites/index.ts
|
||||||
|
git commit -m "feat: export LoginForm and LoginDialog from composites barrel"
|
||||||
|
```
|
||||||
173
docs/superpowers/specs/2026-03-24-login-dialog-design.md
Normal file
173
docs/superpowers/specs/2026-03-24-login-dialog-design.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Login Dialog Design Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A composable login component for the Cameleer3 design system. Provides a `LoginForm` content component and a `LoginDialog` wrapper that puts it inside a Modal. Supports username/password credentials, configurable social/SSO providers, and built-in client-side validation.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### LoginForm
|
||||||
|
|
||||||
|
Core form component. Lives in `src/design-system/composites/LoginForm/`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SocialProvider {
|
||||||
|
label: string // e.g. "Continue with Google"
|
||||||
|
icon?: ReactNode // SVG icon, optional
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
logo?: ReactNode
|
||||||
|
title?: string // Default: "Sign in"
|
||||||
|
socialProviders?: SocialProvider[] // Omit or [] to hide social section + divider
|
||||||
|
onSubmit?: (credentials: { email: string; password: string; remember: boolean }) => void // Omit to hide credentials section
|
||||||
|
onForgotPassword?: () => void // Omit to hide link
|
||||||
|
onSignUp?: () => void // Omit to hide "Don't have an account?"
|
||||||
|
error?: string // Server-side error, rendered as Alert
|
||||||
|
loading?: boolean // Disables form, spinner on submit button
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LoginDialog
|
||||||
|
|
||||||
|
Thin wrapper — passes all `LoginFormProps` through to `LoginForm`, adds Modal control.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface LoginDialogProps extends LoginFormProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `Modal` with `size="sm"` (400px).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Social-first ordering, top to bottom:
|
||||||
|
|
||||||
|
1. **Logo slot** — optional `ReactNode` rendered centered above title
|
||||||
|
2. **Title** — "Sign in" default, centered
|
||||||
|
3. **Server error** — `Alert variant="error"` shown when `error` prop is set, between title and social buttons
|
||||||
|
4. **Social buttons** — stacked vertically, each is a `Button variant="secondary"` with icon + label. Hidden when `socialProviders` is empty/omitted.
|
||||||
|
5. **Divider** — horizontal rule with "or" text, centered. Hidden when social section is hidden.
|
||||||
|
6. **Email field** — `FormField` + `Input`, required, placeholder "you@example.com"
|
||||||
|
7. **Password field** — `FormField` + `Input type="password"`, required, placeholder "••••••••"
|
||||||
|
8. **Remember me / Forgot password row** — `Checkbox` on the left, amber link on the right. Forgot password link hidden when `onForgotPassword` omitted.
|
||||||
|
9. **Submit button** — `Button variant="primary"`, full width, label "Sign in"
|
||||||
|
10. **Sign up link** — "Don't have an account? Sign up" centered below. Hidden when `onSignUp` omitted.
|
||||||
|
|
||||||
|
### Configuration Variants
|
||||||
|
|
||||||
|
The form adapts automatically based on props:
|
||||||
|
|
||||||
|
- **Full** — `socialProviders` + `onSubmit` both provided. Social buttons, divider, and credentials all shown.
|
||||||
|
- **Credentials only** — `onSubmit` provided, no `socialProviders`. Social section and divider hidden.
|
||||||
|
- **Social only** — `socialProviders` provided, `onSubmit` omitted. Credentials section (email, password, remember me, submit button) and divider hidden.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Client-side, triggered on form submit (not on blur):
|
||||||
|
|
||||||
|
| Field | Rule | Error message |
|
||||||
|
|----------|---------------------------------------------------|----------------------------------------|
|
||||||
|
| Email | Required | "Email is required" |
|
||||||
|
| Email | Basic format: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | "Please enter a valid email address" |
|
||||||
|
| Password | Required | "Password is required" |
|
||||||
|
| Password | Minimum 8 characters | "Password must be at least 8 characters" |
|
||||||
|
|
||||||
|
- `onSubmit` only fires when all validation passes
|
||||||
|
- Field errors displayed inline below each input using `FormField` error pattern (red border + message)
|
||||||
|
- Field errors clear when the user starts typing in that field
|
||||||
|
- Server `error` prop clears automatically on next submit attempt
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
When `loading={true}`:
|
||||||
|
- All inputs disabled
|
||||||
|
- All social buttons disabled
|
||||||
|
- Submit button shows `Spinner` component, text hidden (matches existing `Button loading` pattern)
|
||||||
|
- Form cannot be submitted
|
||||||
|
|
||||||
|
### Error
|
||||||
|
|
||||||
|
- Server error: `Alert variant="error"` rendered between title and social buttons
|
||||||
|
- Field errors: inline below each input via `FormField` error styling (red border, error text)
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- CSS Modules: `LoginForm.module.css`
|
||||||
|
- All colors via CSS custom properties from `tokens.css`
|
||||||
|
- Dark mode works automatically — no extra overrides needed
|
||||||
|
- Social buttons: `var(--bg-surface)` background, `var(--border)` border, hover uses `var(--bg-hover)`
|
||||||
|
- Divider: `var(--border)` line, `var(--text-muted)` "or" text
|
||||||
|
- Forgot password + Sign up links: `var(--amber)` color, `font-weight: 500`
|
||||||
|
- Form gap: 14px between fields
|
||||||
|
- Social button gap: 8px between buttons
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- `<form>` element with `aria-label="Sign in"`
|
||||||
|
- Labels tied to inputs via `htmlFor`/`id`
|
||||||
|
- Error messages linked with `aria-describedby`
|
||||||
|
- First input auto-focused on mount
|
||||||
|
- `LoginDialog` traps focus via Modal
|
||||||
|
- Social buttons are `<button>` elements, keyboard-navigable
|
||||||
|
- Alert uses `role="alert"` for screen readers
|
||||||
|
- Enter key submits form (standard `<form onSubmit>`)
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/design-system/composites/LoginForm/
|
||||||
|
LoginForm.tsx
|
||||||
|
LoginForm.module.css
|
||||||
|
LoginForm.test.tsx
|
||||||
|
LoginDialog.tsx
|
||||||
|
LoginDialog.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Exports added to `src/design-system/composites/index.ts`.
|
||||||
|
|
||||||
|
## Primitives Reused
|
||||||
|
|
||||||
|
- `FormField` — label + error display wrapper
|
||||||
|
- `Input` — email and password fields
|
||||||
|
- `Checkbox` — remember me
|
||||||
|
- `Button` — submit (primary) + social buttons (secondary)
|
||||||
|
- `Alert` — server error display
|
||||||
|
- `Spinner` — loading state in submit button
|
||||||
|
- `Modal` — LoginDialog wrapper
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests with Vitest + React Testing Library, wrapped in `ThemeProvider`.
|
||||||
|
|
||||||
|
### LoginForm tests:
|
||||||
|
- Renders all elements when all props provided
|
||||||
|
- Hides social section when `socialProviders` is empty
|
||||||
|
- Hides divider when no social providers
|
||||||
|
- Hides forgot password link when `onForgotPassword` omitted
|
||||||
|
- Hides sign up link when `onSignUp` omitted
|
||||||
|
- Shows server error Alert when `error` prop set
|
||||||
|
- Validates required email
|
||||||
|
- Validates email format
|
||||||
|
- Validates required password
|
||||||
|
- Validates password minimum length
|
||||||
|
- Clears field errors on typing
|
||||||
|
- Calls `onSubmit` with credentials when valid
|
||||||
|
- Does not call `onSubmit` when validation fails
|
||||||
|
- Disables form when `loading={true}`
|
||||||
|
- Shows spinner on submit button when loading
|
||||||
|
- Calls social provider `onClick` when clicked
|
||||||
|
- Calls `onForgotPassword` when link clicked
|
||||||
|
- Calls `onSignUp` when link clicked
|
||||||
|
|
||||||
|
### LoginDialog tests:
|
||||||
|
- Renders Modal with LoginForm when `open={true}`
|
||||||
|
- Does not render when `open={false}`
|
||||||
|
- Calls `onClose` on backdrop click / Esc
|
||||||
|
- Passes all LoginForm props through
|
||||||
54
src/design-system/composites/LoginForm/LoginDialog.test.tsx
Normal file
54
src/design-system/composites/LoginForm/LoginDialog.test.tsx
Normal file
@@ -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(<LoginDialog {...defaultProps} />)
|
||||||
|
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(<LoginDialog {...defaultProps} open={false} />)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on Esc', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on backdrop click', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginDialog {...defaultProps} onClose={onClose} />)
|
||||||
|
await user.click(screen.getByTestId('modal-backdrop'))
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes LoginForm props through', () => {
|
||||||
|
render(
|
||||||
|
<LoginDialog
|
||||||
|
{...defaultProps}
|
||||||
|
title="Welcome"
|
||||||
|
socialProviders={[{ label: 'Continue with Google', onClick: vi.fn() }]}
|
||||||
|
error="Bad credentials"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Welcome')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Continue with Google')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Bad credentials')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
15
src/design-system/composites/LoginForm/LoginDialog.tsx
Normal file
15
src/design-system/composites/LoginForm/LoginDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm" className={className}>
|
||||||
|
<LoginForm {...formProps} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
193
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
193
src/design-system/composites/LoginForm/LoginForm.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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 = [
|
||||||
|
{ 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('validates required email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
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(<LoginForm onSubmit={onSubmit} />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading state', () => {
|
||||||
|
it('disables form inputs when loading', () => {
|
||||||
|
render(<LoginForm {...allProps} loading />)
|
||||||
|
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(<LoginForm {...allProps} loading />)
|
||||||
|
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(<LoginForm {...allProps} loading />)
|
||||||
|
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(<LoginForm socialProviders={[{ label: 'Continue with Google', onClick }]} onSubmit={vi.fn()} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} onForgotPassword={onForgotPassword} />)
|
||||||
|
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(<LoginForm onSubmit={vi.fn()} onSignUp={onSignUp} />)
|
||||||
|
await user.click(screen.getByText(/sign up/i))
|
||||||
|
expect(onSignUp).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ export { GroupCard } from './GroupCard/GroupCard'
|
|||||||
export type { FeedEvent } from './EventFeed/EventFeed'
|
export type { FeedEvent } from './EventFeed/EventFeed'
|
||||||
export { FilterBar } from './FilterBar/FilterBar'
|
export { FilterBar } from './FilterBar/FilterBar'
|
||||||
export { LineChart } from './LineChart/LineChart'
|
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 { MenuItem } from './MenuItem/MenuItem'
|
||||||
export { Modal } from './Modal/Modal'
|
export { Modal } from './Modal/Modal'
|
||||||
export { MultiSelect } from './MultiSelect/MultiSelect'
|
export { MultiSelect } from './MultiSelect/MultiSelect'
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ const NAV_SECTIONS = [
|
|||||||
{ label: 'FilterBar', href: '#filterbar' },
|
{ label: 'FilterBar', href: '#filterbar' },
|
||||||
{ label: 'GroupCard', href: '#groupcard' },
|
{ label: 'GroupCard', href: '#groupcard' },
|
||||||
{ label: 'LineChart', href: '#linechart' },
|
{ label: 'LineChart', href: '#linechart' },
|
||||||
|
{ label: 'LoginDialog', href: '#logindialog' },
|
||||||
|
{ label: 'LoginForm', href: '#loginform' },
|
||||||
{ label: 'MenuItem', href: '#menuitem' },
|
{ label: 'MenuItem', href: '#menuitem' },
|
||||||
{ label: 'Modal', href: '#modal' },
|
{ label: 'Modal', href: '#modal' },
|
||||||
{ label: 'MultiSelect', href: '#multi-select' },
|
{ label: 'MultiSelect', href: '#multi-select' },
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
FilterBar,
|
FilterBar,
|
||||||
GroupCard,
|
GroupCard,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
LoginDialog,
|
||||||
|
LoginForm,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Modal,
|
Modal,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
@@ -219,6 +221,11 @@ export function CompositesSection() {
|
|||||||
// MultiSelect
|
// MultiSelect
|
||||||
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
const [multiValue, setMultiValue] = useState<string[]>(['admin'])
|
||||||
|
|
||||||
|
// LoginDialog
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||||
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
|
const [loginError, setLoginError] = useState<string | undefined>()
|
||||||
|
|
||||||
// 15. Modal
|
// 15. Modal
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -502,6 +509,59 @@ export function CompositesSection() {
|
|||||||
<LineChart series={CHART_SERIES} xLabel="Time (minutes)" yLabel="Count" width={420} height={180} />
|
<LineChart series={CHART_SERIES} xLabel="Time (minutes)" yLabel="Count" width={420} height={180} />
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* LoginDialog */}
|
||||||
|
<DemoCard
|
||||||
|
id="logindialog"
|
||||||
|
title="LoginDialog"
|
||||||
|
description="Modal login dialog wrapping LoginForm. Supports social providers, credentials, and error states."
|
||||||
|
>
|
||||||
|
<div className={styles.demoAreaRow}>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setLoginDialogOpen(true)}>
|
||||||
|
Open LoginDialog
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => { setLoginError('Invalid email or password.'); setLoginDialogOpen(true) }}>
|
||||||
|
With error
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<LoginDialog
|
||||||
|
open={loginDialogOpen}
|
||||||
|
onClose={() => { setLoginDialogOpen(false); setLoginError(undefined); setLoginLoading(false) }}
|
||||||
|
logo={<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--amber)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 700, fontSize: 16 }}>C</div>}
|
||||||
|
title="Sign in to Cameleer"
|
||||||
|
socialProviders={[
|
||||||
|
{ label: 'Continue with Google', onClick: () => {} },
|
||||||
|
{ label: 'Continue with GitHub', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
onSubmit={() => {
|
||||||
|
setLoginLoading(true)
|
||||||
|
setTimeout(() => { setLoginLoading(false); setLoginDialogOpen(false) }, 1500)
|
||||||
|
}}
|
||||||
|
onForgotPassword={() => {}}
|
||||||
|
onSignUp={() => {}}
|
||||||
|
error={loginError}
|
||||||
|
loading={loginLoading}
|
||||||
|
/>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* LoginForm */}
|
||||||
|
<DemoCard
|
||||||
|
id="loginform"
|
||||||
|
title="LoginForm"
|
||||||
|
description="Standalone login form with configurable social providers, validation, and loading state."
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: 360 }}>
|
||||||
|
<LoginForm
|
||||||
|
title="Sign in"
|
||||||
|
socialProviders={[
|
||||||
|
{ label: 'Continue with Google', onClick: () => {} },
|
||||||
|
]}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onForgotPassword={() => {}}
|
||||||
|
onSignUp={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 14. MenuItem */}
|
{/* 14. MenuItem */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="menuitem"
|
id="menuitem"
|
||||||
|
|||||||
Reference in New Issue
Block a user