diff --git a/.Jules/changelog.md b/.Jules/changelog.md index dcc2606..fd3f3c5 100644 --- a/.Jules/changelog.md +++ b/.Jules/changelog.md @@ -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. diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md index 3b01471..606cb38 100644 --- a/.Jules/knowledge.md +++ b/.Jules/knowledge.md @@ -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({}); + +// 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 +
+ { + 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" + /> +
+``` + --- ## Mobile Patterns diff --git a/.Jules/todo.md b/.Jules/todo.md index c169cac..310000d 100644 --- a/.Jules/todo.md +++ b/.Jules/todo.md @@ -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 @@ -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._ diff --git a/web/components/ui/Input.tsx b/web/components/ui/Input.tsx index fe401e9..80e7fff 100644 --- a/web/components/ui/Input.tsx +++ b/web/components/ui/Input.tsx @@ -13,6 +13,7 @@ export const Input: React.FC = ({ 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; @@ -37,6 +38,8 @@ export const Input: React.FC = ({ label, error, className = '', type id={inputId} type={inputType} className={`${inputStyles} ${className}`} + aria-invalid={!!error} + aria-describedby={error ? errorId : undefined} {...props} /> {isPassword && ( @@ -54,7 +57,7 @@ export const Input: React.FC = ({ label, error, className = '', type )} - {error && {error}} + {error && {error}} ); }; diff --git a/web/pages/Auth.tsx b/web/pages/Auth.tsx index e019721..4f35a28 100644 --- a/web/pages/Auth.tsx +++ b/web/pages/Auth.tsx @@ -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(''); @@ -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({}); 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); @@ -66,6 +96,11 @@ export const Auth = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); + + if (!validateForm()) { + return; + } + setLoading(true); try { @@ -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 ( @@ -210,7 +251,7 @@ export const Auth = () => {
-
+ {!isLogin && ( { setName(e.target.value)} + onChange={(e) => { + setName(e.target.value); + clearFieldError('name'); + }} required + error={fieldErrors.name} className={isNeo ? 'rounded-none' : ''} /> @@ -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' : ''} /> setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + clearFieldError('password'); + }} required + error={fieldErrors.password} className={isNeo ? 'rounded-none' : ''} /> @@ -251,6 +304,7 @@ export const Auth = () => { initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className={`p-3 text-red-600 text-sm font-medium border border-red-100 ${isNeo ? 'bg-red-100 border-2 border-black rounded-none' : 'bg-red-50 rounded-lg'}`} + role="alert" > {error} @@ -268,7 +322,11 @@ export const Auth = () => {