Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
## [Unreleased]

### Added
- Inline form validation in Auth page with real-time feedback and proper ARIA accessibility support (`aria-invalid`, `aria-describedby`, `role="alert"`).
- Dashboard skeleton loading state (`DashboardSkeleton`) to improve perceived performance during data fetch.
- Comprehensive `EmptyState` component for Groups and Friends pages to better guide new users.
- Toast notification system (`ToastContext`, `Toast` component) for providing non-blocking user feedback.
Expand Down
40 changes: 40 additions & 0 deletions .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,46 @@ addToast('Message', 'success|error|info');
- Auto-dismisses after 3 seconds
- Stacks vertically in bottom-right

### Form Validation Pattern

**Date:** 2026-01-01
**Context:** Implemented in Auth.tsx

```tsx
type FormErrors = { [key: string]: string };
const [fieldErrors, setFieldErrors] = useState<FormErrors>({});

// 1. Validation Logic
const validate = () => {
const newErrors: FormErrors = {};
if (!email) newErrors.email = 'Required';
setFieldErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

// 2. Clear on type
const clearFieldError = (field: string) => {
if (fieldErrors[field]) {
setFieldErrors(prev => ({ ...prev, [field]: undefined }));
}
};

// 3. Render with accessibility
<form onSubmit={handleSubmit} noValidate>
<Input
error={fieldErrors.email}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError('email');
}}
// Input component handles:
// aria-invalid={!!error}
// aria-describedby={`${id}-error`}
// Error message has id={`${id}-error`} and role="alert"
/>
</form>
```

---

## Mobile Patterns
Expand Down
12 changes: 5 additions & 7 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,6 @@

### Web

- [ ] **[ux]** Form validation with inline feedback
- Files: `web/pages/Auth.tsx`, `web/pages/GroupDetails.tsx`
- Context: Show real-time validation with error messages under inputs
- Impact: Users know immediately if input is valid
- Size: ~50 lines
- Added: 2026-01-01

- [ ] **[style]** Consistent hover/focus states across all buttons
- Files: `web/components/ui/Button.tsx`, usage across pages
- Context: Ensure all buttons have proper hover + focus-visible styles
Expand Down Expand Up @@ -153,4 +146,9 @@
- Files modified: `web/components/ui/EmptyState.tsx`, `web/pages/Groups.tsx`, `web/pages/Friends.tsx`
- Impact: Users now see a polished, illustrated empty state with clear CTAs when they have no groups or friends, instead of plain text.

- [x] **[ux]** Form validation with inline feedback
- Completed: 2026-01-01
- Files modified: `web/pages/Auth.tsx`
- Impact: Users know immediately if input is valid via inline error messages and red borders.

_No tasks completed yet. Move tasks here after completion._
5 changes: 4 additions & 1 deletion web/components/ui/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const Input: React.FC<InputProps> = ({ label, error, className = '', type
const [showPassword, setShowPassword] = useState(false);
const generatedId = useId();
const inputId = id || generatedId;
const errorId = `${inputId}-error`;

const isPassword = type === 'password';
const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;
Expand All @@ -37,6 +38,8 @@ export const Input: React.FC<InputProps> = ({ label, error, className = '', type
id={inputId}
type={inputType}
className={`${inputStyles} ${className}`}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{isPassword && (
Expand All @@ -54,7 +57,7 @@ export const Input: React.FC<InputProps> = ({ label, error, className = '', type
</button>
)}
</div>
{error && <span className="text-red-500 text-xs font-bold mt-1">{error}</span>}
{error && <span id={errorId} role="alert" className="text-red-500 text-xs font-bold mt-1">{error}</span>}
</div>
);
};
67 changes: 62 additions & 5 deletions web/pages/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
} from '../services/api';
import { signInWithGoogle } from '../services/firebase';

type FormErrors = {
email?: string;
password?: string;
name?: string;
};

export const Auth = () => {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
Expand All @@ -24,12 +30,36 @@ export const Auth = () => {
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<FormErrors>({});

const { login } = useAuth();
const { style, toggleStyle } = useTheme();
const { addToast } = useToast();
const navigate = useNavigate();

const validateForm = () => {
const newErrors: FormErrors = {};

if (!email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Please enter a valid email address';
}

if (!password) {
newErrors.password = 'Password is required';
} else if (password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}

if (!isLogin && !name) {
newErrors.name = 'Name is required';
}

setFieldErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleGoogleSignIn = async () => {
setError('');
setGoogleLoading(true);
Expand Down Expand Up @@ -66,6 +96,11 @@ export const Auth = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');

if (!validateForm()) {
return;
}

setLoading(true);

try {
Expand Down Expand Up @@ -96,6 +131,12 @@ export const Auth = () => {
}
};

const clearFieldError = (field: 'email' | 'password' | 'name') => {
if (fieldErrors[field]) {
setFieldErrors(prev => ({ ...prev, [field]: undefined }));
}
};

const isNeo = style === THEMES.NEOBRUTALISM;

return (
Expand Down Expand Up @@ -210,7 +251,7 @@ export const Auth = () => {
<div className="flex-grow border-t border-gray-200 dark:border-gray-700"></div>
</div>

<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<AnimatePresence mode="wait">
{!isLogin && (
<motion.div
Expand All @@ -221,8 +262,12 @@ export const Auth = () => {
<Input
placeholder="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value);
clearFieldError('name');
}}
required
error={fieldErrors.name}
className={isNeo ? 'rounded-none' : ''}
/>
</motion.div>
Expand All @@ -233,16 +278,24 @@ export const Auth = () => {
type="email"
placeholder="Email Address"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError('email');
}}
required
error={fieldErrors.email}
className={isNeo ? 'rounded-none' : ''}
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value);
clearFieldError('password');
}}
required
error={fieldErrors.password}
className={isNeo ? 'rounded-none' : ''}
/>

Expand All @@ -268,7 +321,11 @@ export const Auth = () => {
<div className="text-center pt-4">
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
onClick={() => {
setIsLogin(!isLogin);
setFieldErrors({});
setError('');
}}
className="text-sm font-bold hover:underline opacity-70 hover:opacity-100 transition-opacity"
>
{isLogin
Expand Down
Loading