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
74 changes: 74 additions & 0 deletions docs/components/NotFound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# NotFound Component

The `NotFound` component is the application's 404 page. It helps users recover from broken or expired URLs — such as stale contract detail links — by providing quick navigation to the three primary sections of TalentTrust.

## Overview

This is a Next.js App Router page component located at `src/app/not-found.tsx`. It has no props and renders automatically whenever a route is not matched.

## UI Sections

### 1. Decorative 404

A large `404` displayed purely for visual context. It is marked `aria-hidden="true"` so screen readers skip it.

### 2. Heading and Description

| Element | Content |
|---|---|
| `h1` | "Page Not Found" |
| `<p>` | "This page doesn't exist or the link may have expired. Here are a few places to get back on track." |

Copy follows the [Copywriting Guide](../COPYWRITING_GUIDE.md): direct, second-person, no technical jargon.

### 3. Quick Links (`<nav>`)

A `<nav aria-label="Quick links">` section with three links to the primary routes:

| Label | Route | Description shown |
|---|---|---|
| View Contracts | `/contracts` | "Pick up where you left off" |
| Track Milestones | `/milestones` | "See your project checkpoints" |
| My Reputation | `/reputation` | "Check your work history" |

### 4. Footer Actions

| Label | Target |
|---|---|
| Go Home | `/` |
| Contact Support | `mailto:support@talenttrust.io` |

## Accessibility

- **Heading hierarchy**: `h1` is the only top-level heading. The quick links section uses a visually hidden `h2` (`sr-only`) so screen reader users can navigate to it by heading.
- **Landmark navigation**: `<nav aria-label="Quick links">` creates a named navigation landmark.
- **Decorative content**: The `404` text has `aria-hidden="true"`.
- **Focus states**: All links include `focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2` for visible keyboard focus indicators (WCAG 2.1 AA — Success Criterion 2.4.7).
- **Keyboard navigation**: All interactive elements are native `<a>` elements, reachable via Tab in DOM order.

## Responsive Behaviour

- Quick links stack vertically on mobile; the separator (`—`) is hidden below `sm` breakpoint.
- Footer action buttons stack vertically on mobile (`flex-col`) and sit side by side from `sm` upward (`sm:flex-row`).

## Styling

Uses Tailwind CSS utility classes consistent with the rest of the app. No custom CSS. Background uses the global CSS variable `--background`.

## Testing

Tests live in `src/app/not-found.test.tsx` and cover:

| Test | What it verifies |
|---|---|
| `h1` heading renders | Correct heading level and text |
| Descriptive paragraph | Recovery copy is present |
| `aria-hidden` on 404 | Decorative element hidden from assistive tech |
| `<nav>` landmark | Named navigation region exists |
| Contracts link | `href="/contracts"` |
| Milestones link | `href="/milestones"` |
| Reputation link | `href="/reputation"` |
| Go Home link | `href="/"` |
| Contact Support link | `href="mailto:support@talenttrust.io"` |
| All links keyboard reachable | All 5 links are `<a>` elements |
| Snapshot | Regression guard on rendered output |
115 changes: 115 additions & 0 deletions docs/components/SettingsPanel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# SettingsPanel

A full-screen drawer that lets users manage their TalentTrust preferences. Opened by `SettingsTrigger`. Fully accessible (WCAG 2.1 AA): implements dialog semantics, focus trap, Escape key handling, and focus restoration.

---

## Props

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `isOpen` | `boolean` | Yes | Controls whether the panel is rendered and visible |
| `onClose` | `() => void` | Yes | Callback invoked when the panel should close (Escape, backdrop click, Close button, Done button) |

---

## Usage

```tsx
import { SettingsTrigger } from '@/components/settings/SettingsTrigger';

// SettingsTrigger manages isOpen state and renders SettingsPanel internally.
// Place it once at the app/layout level.
export default function Layout({ children }) {
return (
<>
{children}
<SettingsTrigger />
</>
);
}
```

To use `SettingsPanel` standalone:

```tsx
import { useState } from 'react';
import { SettingsPanel } from '@/components/settings/SettingsPanel';

export function MyComponent() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open settings</button>
<SettingsPanel isOpen={open} onClose={() => setOpen(false)} />
</>
);
}
```

---

## Keyboard Interactions

| Key | Action |
|-----|--------|
| `Tab` | Move focus to the next interactive element inside the panel. Wraps from the last element back to the first. |
| `Shift + Tab` | Move focus to the previous interactive element. Wraps from the first element to the last. |
| `Escape` | Close the panel and restore focus to the element that opened it. |
| `Enter` / `Space` | Activate the focused button or toggle. |

---

## ARIA Attributes

| Attribute | Value | Purpose |
|-----------|-------|---------|
| `role` | `"dialog"` | Identifies the drawer as a modal dialog to assistive technologies |
| `aria-modal` | `"true"` | Tells screen readers that content behind the dialog is inert |
| `aria-labelledby` | `"settings-panel-title"` | Associates the dialog with its visible "Settings" heading |

---

## Focus Management

### On open
- The close button (`aria-label="Close settings"`) receives focus immediately, so keyboard users know where they are.

### Focus trap
- `Tab` and `Shift+Tab` are intercepted via a `keydown` listener on `document`. Focus cycles through all non-disabled focusable elements inside the dialog and never reaches the page behind it.

### On close
- Managed by `SettingsTrigger`: a `useRef` holds a reference to the gear-icon trigger button. After the panel unmounts, `requestAnimationFrame` restores focus to that button, satisfying WCAG 2.4.3 Focus Order.

---

## Preferences Managed

| Preference | Options |
|------------|---------|
| Theme | `light` / `dark` / `system` |
| Currency Display | `usd` / `ngn` / `compact` |
| Toast Density | `relaxed` / `compact` |
| Quiet Mode | on / off (toggle) |

Preferences are persisted to `localStorage` under the key `talenttrust-user-preferences` via the `usePreferences` hook (`@/lib/preferences`).

---

## Testing

Tests live in `src/components/settings/__tests__/SettingsPanel.test.tsx` and cover:

- Visibility (renders nothing when closed, renders when open)
- All preference interactions and localStorage persistence
- Close via button, backdrop, Done button, and Escape key
- Dialog ARIA attributes (`role`, `aria-modal`, `aria-labelledby`)
- Focus trap (Tab wraps forward, Shift+Tab wraps backward)
- Initial focus on close button when panel opens
- `focus-visible` ring classes on all interactive controls

Run:

```bash
npm test -- --testPathPattern=SettingsPanel
```
127 changes: 127 additions & 0 deletions src/app/__snapshots__/not-found.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`NotFound page matches snapshot 1`] = `
<main
class="min-h-screen flex flex-col items-center justify-center p-8 bg-[var(--background)]"
>
<div
class="max-w-md w-full text-center space-y-8"
>
<div
aria-hidden="true"
class="text-6xl font-bold text-gray-200"
>
404
</div>
<div
class="space-y-3"
>
<h1
class="text-2xl font-bold text-gray-900"
>
Page Not Found
</h1>
<p
class="text-gray-600"
>
This page doesn't exist or the link may have expired. Here are a few places to get back on track.
</p>
</div>
<nav
aria-label="Quick links"
>
<h2
class="sr-only"
>
Where would you like to go?
</h2>
<ul
class="flex flex-col gap-3"
>
<li>
<a
class="flex flex-col items-center sm:flex-row sm:items-center gap-1 sm:gap-3 px-5 py-3 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
href="/contracts"
>
<span
class="font-medium text-gray-900"
>
View Contracts
</span>
<span
class="hidden sm:inline text-gray-400"
>
</span>
<span
class="text-sm text-gray-500"
>
Pick up where you left off
</span>
</a>
</li>
<li>
<a
class="flex flex-col items-center sm:flex-row sm:items-center gap-1 sm:gap-3 px-5 py-3 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
href="/milestones"
>
<span
class="font-medium text-gray-900"
>
Track Milestones
</span>
<span
class="hidden sm:inline text-gray-400"
>
</span>
<span
class="text-sm text-gray-500"
>
See your project checkpoints
</span>
</a>
</li>
<li>
<a
class="flex flex-col items-center sm:flex-row sm:items-center gap-1 sm:gap-3 px-5 py-3 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
href="/reputation"
>
<span
class="font-medium text-gray-900"
>
My Reputation
</span>
<span
class="hidden sm:inline text-gray-400"
>
</span>
<span
class="text-sm text-gray-500"
>
Check your work history
</span>
</a>
</li>
</ul>
</nav>
<div
class="flex flex-col sm:flex-row gap-3 justify-center"
>
<a
class="px-5 py-2 rounded-lg bg-gray-900 text-white font-medium hover:bg-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
href="/"
>
Go Home
</a>
<a
class="px-5 py-2 rounded-lg border border-gray-300 text-gray-700 font-medium hover:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2"
href="mailto:support@talenttrust.io"
>
Contact Support
</a>
</div>
</div>
</main>
`;
71 changes: 67 additions & 4 deletions src/app/not-found.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,73 @@ import { render, screen } from '@testing-library/react';
import NotFound from './not-found';

describe('NotFound page', () => {
it('renders 404 heading and navigation links', () => {
beforeEach(() => {
render(<NotFound />);
expect(screen.getByRole('heading', { name: /page not found/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /go home/i })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: /contact support/i })).toBeInTheDocument();
});

it('renders the h1 heading', () => {
expect(
screen.getByRole('heading', { level: 1, name: /page not found/i }),
).toBeInTheDocument();
});

it('renders the descriptive paragraph', () => {
expect(
screen.getByText(/this page doesn't exist or the link may have expired/i),
).toBeInTheDocument();
});

it('renders 404 as decorative and hidden from assistive technology', () => {
const decorative = screen.getByText('404');
expect(decorative).toHaveAttribute('aria-hidden', 'true');
});

it('renders the quick links nav with an accessible label', () => {
expect(screen.getByRole('navigation', { name: /quick links/i })).toBeInTheDocument();
});

it('renders a link to /contracts', () => {
expect(
screen.getByRole('link', { name: /view contracts/i }),
).toHaveAttribute('href', '/contracts');
});

it('renders a link to /milestones', () => {
expect(
screen.getByRole('link', { name: /track milestones/i }),
).toHaveAttribute('href', '/milestones');
});

it('renders a link to /reputation', () => {
expect(
screen.getByRole('link', { name: /my reputation/i }),
).toHaveAttribute('href', '/reputation');
});

it('renders the Go Home link pointing to /', () => {
expect(screen.getByRole('link', { name: /go home/i })).toHaveAttribute(
'href',
'/',
);
});

it('renders the Contact Support link', () => {
expect(
screen.getByRole('link', { name: /contact support/i }),
).toHaveAttribute('href', 'mailto:support@talenttrust.io');
});

it('all links are keyboard reachable (rendered as anchor elements)', () => {
const links = screen.getAllByRole('link');
// Go Home, Contact Support + 3 quick links = 5
expect(links.length).toBe(5);
links.forEach((link) => {
expect(link.tagName).toBe('A');
});
});

it('matches snapshot', () => {
const { container } = render(<NotFound />);
expect(container.firstChild).toMatchSnapshot();
});
});
Loading
Loading