diff --git a/docs/profile-performance.md b/docs/profile-performance.md new file mode 100644 index 00000000..be157bbc --- /dev/null +++ b/docs/profile-performance.md @@ -0,0 +1,19 @@ +# Profile Page Performance + +The profile route keeps its static shell server-rendered and limits client JavaScript to the tab interaction layer. + +## Implementation + +- `src/app/profile/page.tsx` is a server component that renders the page shell and profile header. +- `src/app/profile/components/ProfileTabs.tsx` owns the small client-side tab state. +- The default profile panel renders first, while settings and achievements are split into lazy-loaded tab panels. +- Shared profile, preference, and achievement data lives in `src/app/profile/profile-data.ts` to avoid recreating arrays during render. +- Tabs and switches use semantic roles and accessible names so the optimization does not trade away usability. + +## Validation + +Run the focused regression suite with: + +```bash +pnpm vitest run src/app/profile/__tests__/ProfileTabs.test.tsx +``` diff --git a/src/app/components/accessibility/README.md b/src/app/components/accessibility/README.md index 6612133a..6838498c 100644 --- a/src/app/components/accessibility/README.md +++ b/src/app/components/accessibility/README.md @@ -286,6 +286,14 @@ console.log(issues); 9. **Test with real assistive technologies** 10. **Include focus indicators** for all interactive elements +### Form Input Screen Reader Support + +- Use the shared `FormInput` components for new text fields, selects, and textareas where possible. +- Pass visible `label` text so the component can create an explicit `label`/`id` relationship. +- Pass `helperText` for persistent instructions; validation errors are linked to fields automatically. +- Use `required` for required fields so both browser validation and `aria-required` stay in sync. +- Keep decorative icons non-interactive; password visibility controls expose their current state to assistive technology. + ## Browser Support - Chrome/Edge 90+ diff --git a/src/app/components/auth/FormInput.tsx b/src/app/components/auth/FormInput.tsx index 30899178..a24e70c4 100644 --- a/src/app/components/auth/FormInput.tsx +++ b/src/app/components/auth/FormInput.tsx @@ -2,13 +2,14 @@ import { motion } from 'framer-motion'; import { Eye, EyeOff } from 'lucide-react'; -import { useState, InputHTMLAttributes, forwardRef } from 'react'; +import { useId, useState, InputHTMLAttributes, forwardRef } from 'react'; import { FieldError } from '../../../components/forms/FormError'; interface FormInputProps extends InputHTMLAttributes { label: string; error?: string; icon?: React.ReactNode; + helperText?: React.ReactNode; } export const FormInput = forwardRef( @@ -18,6 +19,10 @@ export const FormInput = forwardRef( error, icon, type, + id, + required, + helperText, + 'aria-describedby': ariaDescribedBy, onDrag, onDragStart, onDragEnd, @@ -32,10 +37,16 @@ export const FormInput = forwardRef( void onDragEnd; void onAnimationStart; void onAnimationEnd; + const generatedId = useId(); const [showPassword, setShowPassword] = useState(false); const [isFocused, setIsFocused] = useState(false); const inputType = type === 'password' && showPassword ? 'text' : type; + const inputId = id ?? `auth-input-${generatedId}`; + const helperTextId = helperText ? `${inputId}-helper` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = + [ariaDescribedBy, helperTextId, errorId].filter(Boolean).join(' ') || undefined; return ( ( transition={{ duration: 0.3 }} className="w-full" > - ); }, diff --git a/src/app/components/auth/__tests__/FormInput.test.tsx b/src/app/components/auth/__tests__/FormInput.test.tsx new file mode 100644 index 00000000..df97d530 --- /dev/null +++ b/src/app/components/auth/__tests__/FormInput.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FormInput } from '../FormInput'; + +describe('Auth FormInput', () => { + it('associates label, helper text, and required state with the input', () => { + render( + , + ); + + const input = screen.getByRole('textbox', { + name: 'Email', + description: 'Use the email connected to your account.', + }); + + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-required', 'true'); + expect(input).toHaveAttribute('aria-invalid', 'false'); + }); + + it('links validation errors to the input for screen readers', () => { + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Email' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid email address.'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription( + 'Use the email connected to your account. Enter a valid email address.', + ); + }); + + it('exposes password visibility controls with name, state, and controlled input', () => { + render(); + + const input = screen.getByLabelText('Password'); + const toggle = screen.getByRole('button', { name: 'Show Password' }); + + expect(input).toHaveAttribute('type', 'password'); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(toggle).toHaveAttribute('aria-controls', input.id); + + fireEvent.click(toggle); + + expect(input).toHaveAttribute('type', 'text'); + expect(screen.getByRole('button', { name: 'Hide Password' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); +}); diff --git a/src/app/profile/__tests__/ProfileTabs.test.tsx b/src/app/profile/__tests__/ProfileTabs.test.tsx new file mode 100644 index 00000000..ee3f5671 --- /dev/null +++ b/src/app/profile/__tests__/ProfileTabs.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import ProfileTabs from '../components/ProfileTabs'; + +describe('ProfileTabs', () => { + it('renders the profile panel first to keep initial work minimal', () => { + render(); + + expect(screen.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tabpanel', { name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByLabelText('Full Name')).toHaveValue('John Doe'); + expect(screen.queryByText('Dark Mode')).not.toBeInTheDocument(); + expect(screen.queryByText('First Course')).not.toBeInTheDocument(); + }); + + it('loads settings only when the settings tab is selected', async () => { + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('tab', { name: 'Settings' })); + + await waitFor(() => + expect(screen.getByRole('tabpanel', { name: 'Settings' })).toBeInTheDocument(), + ); + expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('switch', { name: 'Notifications' })).toBeChecked(); + + await user.click(screen.getByRole('switch', { name: 'Notifications' })); + expect(screen.getByRole('switch', { name: 'Notifications' })).not.toBeChecked(); + }); + + it('loads achievements only when the achievements tab is selected', async () => { + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('tab', { name: 'Achievements' })); + + await waitFor(() => + expect(screen.getByRole('tabpanel', { name: 'Achievements' })).toBeInTheDocument(), + ); + expect(screen.getByRole('tab', { name: 'Achievements' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByText('Web3 Master')).toBeInTheDocument(); + expect(screen.queryByLabelText('Full Name')).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/profile/components/AchievementsPanel.tsx b/src/app/profile/components/AchievementsPanel.tsx new file mode 100644 index 00000000..75faf61c --- /dev/null +++ b/src/app/profile/components/AchievementsPanel.tsx @@ -0,0 +1,30 @@ +import { achievements } from '../profile-data'; + +export default function AchievementsPanel() { + return ( +
+

Achievements

+ +
+ {achievements.map((achievement) => ( +
+ +

{achievement.title}

+

{achievement.description}

+

{achievement.earnedAt}

+
+ ))} +
+
+ ); +} diff --git a/src/app/profile/components/ProfileHeader.tsx b/src/app/profile/components/ProfileHeader.tsx new file mode 100644 index 00000000..fa4c5ef1 --- /dev/null +++ b/src/app/profile/components/ProfileHeader.tsx @@ -0,0 +1,26 @@ +import type { ProfileUser } from '../profile-data'; + +interface ProfileHeaderProps { + user: ProfileUser; +} + +export default function ProfileHeader({ user }: ProfileHeaderProps) { + return ( +
+
+
+

Profile

+
+ + {user.name} +
+
+
+
+ ); +} diff --git a/src/app/profile/components/ProfileInfoPanel.tsx b/src/app/profile/components/ProfileInfoPanel.tsx new file mode 100644 index 00000000..72b48f3f --- /dev/null +++ b/src/app/profile/components/ProfileInfoPanel.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { memo } from 'react'; +import { dailyLearningTimeOptions, learningGoalOptions, profileUser } from '../profile-data'; + +function ProfileInfoPanel() { + return ( +
+

Personal Information

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +