diff --git a/docs/components/SettingsPanel.md b/docs/components/SettingsPanel.md new file mode 100644 index 0000000..cf96d04 --- /dev/null +++ b/docs/components/SettingsPanel.md @@ -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} + + > + ); +} +``` + +To use `SettingsPanel` standalone: + +```tsx +import { useState } from 'react'; +import { SettingsPanel } from '@/components/settings/SettingsPanel'; + +export function MyComponent() { + const [open, setOpen] = useState(false); + return ( + <> + setOpen(true)}>Open settings + 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 +``` diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index cc67e39..4e02aba 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -1,8 +1,11 @@ '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; @@ -10,21 +13,61 @@ interface SettingsPanelProps { export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) { const { preferences, updatePreference } = usePreferences(); + const panelRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const panel = panelRef.current; + if (!panel) return; + + // Set initial focus to the close button + const closeBtn = panel.querySelector('[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(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 ( {/* Backdrop */} - {/* Drawer */} - + - Settings + Settings (null); + + const handleClose = () => { + setIsOpen(false); + requestAnimationFrame(() => triggerRef.current?.focus()); + }; return ( <> 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" @@ -34,7 +41,7 @@ export function SettingsTrigger() { - setIsOpen(false)} /> + > ); } diff --git a/src/components/settings/__tests__/SettingsPanel.test.tsx b/src/components/settings/__tests__/SettingsPanel.test.tsx index 7efe227..6d03274 100644 --- a/src/components/settings/__tests__/SettingsPanel.test.tsx +++ b/src/components/settings/__tests__/SettingsPanel.test.tsx @@ -177,4 +177,70 @@ describe('SettingsPanel', () => { expect(el.className).toMatch(/focus-visible/); }); }); + + // --- Accessibility: dialog semantics --- + + it('has role="dialog" when open', () => { + renderWithProvider( {}} />); + expect(screen.getByRole('dialog')).toBeDefined(); + }); + + it('has aria-modal="true" on the dialog', () => { + renderWithProvider( {}} />); + expect(screen.getByRole('dialog').getAttribute('aria-modal')).toBe('true'); + }); + + it('aria-labelledby points to the "Settings" heading', () => { + renderWithProvider( {}} />); + 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(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('sets initial focus on the close button when opened', () => { + renderWithProvider( {}} />); + expect(document.activeElement).toBe( + screen.getByRole('button', { name: /close settings/i }) + ); + }); + + it('Tab on the last focusable element wraps focus to the first', () => { + renderWithProvider( {}} />); + const dialog = screen.getByRole('dialog'); + const focusable = Array.from( + dialog.querySelectorAll( + '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( {}} />); + const dialog = screen.getByRole('dialog'); + const focusable = Array.from( + dialog.querySelectorAll( + '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]); + }); });