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
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
```
51 changes: 47 additions & 4 deletions src/components/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,73 @@
'use client';

import React from 'react';
import React, { useRef, useEffect } from 'react';
import { usePreferences, Theme, AmountFormat, ToastDensity } from '@/lib/preferences';

const FOCUSABLE_SELECTORS =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

interface SettingsPanelProps {
isOpen: boolean;
onClose: () => void;
}

export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
const { preferences, updatePreference } = usePreferences();
const panelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!isOpen) return;
const panel = panelRef.current;
if (!panel) return;

// Set initial focus to the close button
const closeBtn = panel.querySelector<HTMLElement>('[aria-label="Close settings"]');
closeBtn?.focus();

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab') {
const els = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS));
if (els.length === 0) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);

if (!isOpen) return null;

return (
<div className="fixed inset-0 z-50 flex justify-end overflow-hidden">
{/* Backdrop */}
<div
<div
className="absolute inset-0 bg-black/50 transition-opacity backdrop-blur-sm"
onClick={onClose}
/>

{/* Drawer */}
<div className="relative w-full max-w-md bg-[var(--background)] shadow-xl flex flex-col h-full border-l border-[var(--border)]">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="settings-panel-title"
className="relative w-full max-w-md bg-[var(--background)] shadow-xl flex flex-col h-full border-l border-[var(--border)]"
>
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
<h2 className="text-xl font-bold text-[var(--foreground)]">Settings</h2>
<h2 id="settings-panel-title" className="text-xl font-bold text-[var(--foreground)]">Settings</h2>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-[var(--accent)] text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2"
Expand Down
11 changes: 9 additions & 2 deletions src/components/settings/SettingsTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
'use client';

import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { SettingsPanel } from './SettingsPanel';

export function SettingsTrigger() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);

const handleClose = () => {
setIsOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};

return (
<>
<button
ref={triggerRef}
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 p-3 rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] shadow-lg hover:scale-110 transition-transform z-40 focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-2"
aria-label="Open Settings"
Expand All @@ -34,7 +41,7 @@ export function SettingsTrigger() {
</svg>
</button>

<SettingsPanel isOpen={isOpen} onClose={() => setIsOpen(false)} />
<SettingsPanel isOpen={isOpen} onClose={handleClose} />
</>
);
}
66 changes: 66 additions & 0 deletions src/components/settings/__tests__/SettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,70 @@ describe('SettingsPanel', () => {
expect(el.className).toMatch(/focus-visible/);
});
});

// --- Accessibility: dialog semantics ---

it('has role="dialog" when open', () => {
renderWithProvider(<SettingsPanel isOpen={true} onClose={() => {}} />);
expect(screen.getByRole('dialog')).toBeDefined();
});

it('has aria-modal="true" on the dialog', () => {
renderWithProvider(<SettingsPanel isOpen={true} onClose={() => {}} />);
expect(screen.getByRole('dialog').getAttribute('aria-modal')).toBe('true');
});

it('aria-labelledby points to the "Settings" heading', () => {
renderWithProvider(<SettingsPanel isOpen={true} onClose={() => {}} />);
const dialog = screen.getByRole('dialog');
const labelId = dialog.getAttribute('aria-labelledby');
expect(labelId).toBeTruthy();
const heading = document.getElementById(labelId!);
expect(heading).not.toBeNull();
expect(heading!.textContent).toBe('Settings');
});

// --- Accessibility: keyboard interactions ---

it('closes when Escape is pressed', () => {
const onClose = jest.fn();
renderWithProvider(<SettingsPanel isOpen={true} onClose={onClose} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});

it('sets initial focus on the close button when opened', () => {
renderWithProvider(<SettingsPanel isOpen={true} onClose={() => {}} />);
expect(document.activeElement).toBe(
screen.getByRole('button', { name: /close settings/i })
);
});

it('Tab on the last focusable element wraps focus to the first', () => {
renderWithProvider(<SettingsPanel isOpen={true} onClose={() => {}} />);
const dialog = screen.getByRole('dialog');
const focusable = Array.from(
dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
const last = focusable[focusable.length - 1];
last.focus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: false });
expect(document.activeElement).toBe(focusable[0]);
});

it('Shift+Tab on the first focusable element wraps focus to the last', () => {
renderWithProvider(<SettingsPanel isOpen={true} onClose={() => {}} />);
const dialog = screen.getByRole('dialog');
const focusable = Array.from(
dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
const first = focusable[0];
first.focus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(focusable[focusable.length - 1]);
});
});
Loading