Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions docs/profile-performance.md
Original file line number Diff line number Diff line change
@@ -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
```
8 changes: 8 additions & 0 deletions src/app/components/accessibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down
46 changes: 41 additions & 5 deletions src/app/components/auth/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement> {
label: string;
error?: string;
icon?: React.ReactNode;
helperText?: React.ReactNode;
}

export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
Expand All @@ -18,6 +19,10 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
error,
icon,
type,
id,
required,
helperText,
'aria-describedby': ariaDescribedBy,
onDrag,
onDragStart,
onDragEnd,
Expand All @@ -32,10 +37,16 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
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 (
<motion.div
Expand All @@ -44,16 +55,29 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
transition={{ duration: 0.3 }}
className="w-full"
>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{label}
</label>
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">{icon}</div>
<div
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
aria-hidden="true"
>
{icon}
</div>
)}
<motion.input
ref={ref}
id={inputId}
type={inputType}
required={required}
aria-invalid={!!error}
aria-required={required}
aria-describedby={describedBy}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
animate={{
Expand All @@ -72,13 +96,25 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? `Hide ${label}` : `Show ${label}`}
aria-pressed={showPassword}
aria-controls={inputId}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
{showPassword ? (
<EyeOff size={20} aria-hidden="true" />
) : (
<Eye size={20} aria-hidden="true" />
)}
</button>
)}
</div>
<FieldError error={error} />
{helperText && (
<p id={helperTextId} className="mt-1.5 text-sm text-gray-500 dark:text-gray-400 ml-1">
{helperText}
</p>
)}
<FieldError error={error} id={errorId} />
</motion.div>
);
},
Expand Down
65 changes: 65 additions & 0 deletions src/app/components/auth/__tests__/FormInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<FormInput
label="Email"
name="email"
type="email"
required
helperText="Use the email connected to your account."
/>,
);

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(
<FormInput
label="Email"
name="email"
type="email"
error="Enter a valid email address."
helperText="Use the email connected to your account."
/>,
);

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(<FormInput label="Password" name="password" type="password" />);

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',
);
});
});
49 changes: 49 additions & 0 deletions src/app/profile/__tests__/ProfileTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProfileTabs />);

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(<ProfileTabs />);
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(<ProfileTabs />);
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();
});
});
30 changes: 30 additions & 0 deletions src/app/profile/components/AchievementsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { achievements } from '../profile-data';

export default function AchievementsPanel() {
return (
<section
id="achievements-panel"
role="tabpanel"
aria-labelledby="achievements-tab"
className="rounded-lg bg-white p-6 shadow"
>
<h2 className="mb-6 text-xl font-semibold text-gray-900">Achievements</h2>

<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{achievements.map((achievement) => (
<article
key={achievement.id}
className="rounded-lg border border-gray-200 p-4 text-center"
>
<div className="mb-2 text-4xl" aria-hidden="true">
{achievement.icon}
</div>
<h3 className="font-semibold text-gray-900">{achievement.title}</h3>
<p className="text-sm text-gray-500">{achievement.description}</p>
<p className="mt-1 text-xs text-gray-400">{achievement.earnedAt}</p>
</article>
))}
</div>
</section>
);
}
26 changes: 26 additions & 0 deletions src/app/profile/components/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ProfileUser } from '../profile-data';

interface ProfileHeaderProps {
user: ProfileUser;
}

export default function ProfileHeader({ user }: ProfileHeaderProps) {
return (
<header className="border-b bg-white shadow-sm">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Profile</h1>
<div className="flex items-center space-x-2" aria-label={`Signed in as ${user.name}`}>
<div
className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 font-semibold text-white"
aria-hidden="true"
>
{user.initials}
</div>
<span className="text-gray-700">{user.name}</span>
</div>
</div>
</div>
</header>
);
}
Loading
Loading