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
10 changes: 7 additions & 3 deletions src/app/components/accessibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,16 @@ return <div ref={containerRef}>{/* content */}</div>;

### useFocusTrap

Traps focus within a container (for modals/dialogs).
Traps focus within an active container, moves focus to an optional initial target,
and restores focus to the opener when the dialog closes. New dialogs should import
the shared hook directly.

```tsx
import { useFocusTrap } from '@/hooks/useAccessibility';
import { useRef } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';

const containerRef = useFocusTrap(isModalOpen);
const initialFocusRef = useRef<HTMLInputElement>(null);
const containerRef = useFocusTrap(isModalOpen, { initialFocusRef });
return <div ref={containerRef}>{/* modal content */}</div>;
```

Expand Down
40 changes: 36 additions & 4 deletions src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { PollCreationModal, type PollDraft } from '@/components/polls/PollCreationModal';
import { useSettingsStore } from '@/lib/settings/store';
import { useToast } from '@/context/ToastContext';
Expand All @@ -12,6 +12,7 @@ import {
useKeyboardShortcuts,
} from '@/hooks/useKeyboardShortcuts';
import { createLogger } from '@/lib/logging';
import { useFocusTrap } from '@/hooks/useFocusTrap';
const logger = createLogger('CommandPalette');

function navigateTo(path: string): void {
Expand Down Expand Up @@ -92,6 +93,13 @@ export function CommandPalette() {
const [query, setQuery] = useState('');
const { theme, setTheme } = useTheme();
const [pollModalOpen, setPollModalOpen] = useState(false);
const commandInputRef = useRef<HTMLInputElement>(null);
const commandPaletteRef = useFocusTrap<HTMLDivElement>(open && !showHelp, {
initialFocusRef: commandInputRef,
});
const shortcutHelpRef = useFocusTrap<HTMLDivElement>(showHelp);
const commandPaletteTitleId = useId();
const shortcutHelpTitleId = useId();

const settings = useSettingsStore((s) => s.settings);
const { info: toastInfo } = useToast();
Expand Down Expand Up @@ -179,6 +187,20 @@ export function CommandPalette() {
);
}, [commands, query]);

useEffect(() => {
if (!open && !showHelp) return;

const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
if (showHelp) setShowHelp(false);
else setOpen(false);
};

document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open, showHelp]);

return (
<>
{open ? (
Expand All @@ -189,12 +211,15 @@ export function CommandPalette() {
aria-hidden="true"
/>
<div
ref={commandPaletteRef}
role="dialog"
aria-modal="true"
aria-label="Command palette"
aria-labelledby={commandPaletteTitleId}
aria-hidden={showHelp || undefined}
className="fixed left-1/2 top-20 z-[12001] w-[min(100vw-2rem,44rem)] -translate-x-1/2 rounded-xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<input
ref={commandInputRef}
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
Expand All @@ -204,6 +229,9 @@ export function CommandPalette() {
placeholder="Type a command..."
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
/>
<h2 id={commandPaletteTitleId} className="sr-only">
Command palette
</h2>

<div className="mt-3 max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
{filtered.map((command) => {
Expand Down Expand Up @@ -258,13 +286,17 @@ export function CommandPalette() {
aria-hidden="true"
/>
<div
ref={shortcutHelpRef}
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts help"
aria-labelledby={shortcutHelpTitleId}
className="fixed left-1/2 top-1/2 z-[12011] max-h-[80vh] w-[min(100vw-2rem,54rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
<h2
id={shortcutHelpTitleId}
className="text-lg font-semibold text-gray-900 dark:text-gray-100"
>
Keyboard shortcuts
</h2>
<button
Expand Down
22 changes: 20 additions & 2 deletions src/components/ConflictResolver.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import React, { useState } from 'react';
import React, { useEffect, useId, useState } from 'react';
import { ConflictRecord, ResolutionStrategy } from '@/lib/conflict/types';
import { motion, AnimatePresence } from 'framer-motion';
import { X, AlertTriangle, ArrowRight, Save, History, Check } from 'lucide-react';
import { useFocusTrap } from '@/hooks/useFocusTrap';

interface ConflictResolverProps {
conflict: ConflictRecord<any>;
Expand All @@ -18,6 +19,16 @@ export const ConflictResolver: React.FC<ConflictResolverProps> = ({
}) => {
const [selectedStrategy, setSelectedStrategy] = useState<ResolutionStrategy>('manual');
const [showHistory, setShowHistory] = useState(false);
const dialogRef = useFocusTrap<HTMLDivElement>(true);
const titleId = useId();

useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);

const localItems = Object.entries(conflict.localData).filter(
([key]) => !['updatedAt', 'version', 'id'].includes(key),
Expand All @@ -31,6 +42,10 @@ export const ConflictResolver: React.FC<ConflictResolverProps> = ({
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
Expand All @@ -43,14 +58,17 @@ export const ConflictResolver: React.FC<ConflictResolverProps> = ({
<AlertTriangle size={24} />
</div>
<div>
<h3 className="text-xl font-bold text-white">Conflict Detected</h3>
<h3 id={titleId} className="text-xl font-bold text-white">
Conflict Detected
</h3>
<p className="text-sm text-gray-400">
Resolution required for {conflict.entityType}
</p>
</div>
</div>
<button
onClick={onClose}
aria-label="Close conflict resolver"
className="p-2 hover:bg-white/5 rounded-lg text-gray-400 transition-colors"
>
<X size={20} />
Expand Down
66 changes: 6 additions & 60 deletions src/components/CookieConsentBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,30 @@
'use client';

import { useEffect, useRef, useId } from 'react';
import { useEffect, useId } from 'react';
import { Shield } from 'lucide-react';
import { useGdprConsent } from '@/hooks/useGdprConsent';
import { useScreenReaderAnnouncement } from '@/hooks/useAccessibility';
import { useFocusTrap } from '@/hooks/useFocusTrap';

/**
* GDPR Cookie Consent Banner with full focus management:
* - Traps focus inside the banner while visible
* - Restores focus to the previously focused element on dismiss
* GDPR Cookie Consent Banner with keyboard focus management:
* - Traps focus inside the banner and restores it on dismissal
* - Announces appearance to screen readers
* - Keyboard: Tab/Shift+Tab cycle within banner; Enter/Space activate buttons
*/
export function CookieConsentBanner() {
const { showBanner, accept, reject } = useGdprConsent();
const bannerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const bannerRef = useFocusTrap<HTMLDivElement>(showBanner);
const announce = useScreenReaderAnnouncement();
const titleId = useId();

// Save the element that had focus before the banner appeared, then focus the banner
// Announce appearance to screen readers. useFocusTrap moves focus to the first control.
useEffect(() => {
if (!showBanner) return;

previousFocusRef.current = document.activeElement as HTMLElement;
announce('Cookie consent banner appeared. Please choose your cookie preferences.', 'assertive');

// Focus the first interactive element inside the banner
const raf = requestAnimationFrame(() => {
const first = bannerRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
first?.focus();
});

return () => cancelAnimationFrame(raf);
}, [showBanner, announce]);

// Restore focus when banner is dismissed
useEffect(() => {
if (showBanner) return;
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, [showBanner]);

// Trap focus inside the banner while it is visible
useEffect(() => {
if (!showBanner) return;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !bannerRef.current) return;

const focusable = Array.from(
bannerRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute('disabled'));

if (focusable.length === 0) return;

const first = focusable[0];
const last = focusable[focusable.length - 1];

if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};

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

if (!showBanner) return null;

return (
Expand Down
36 changes: 33 additions & 3 deletions src/components/courses/VideoPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';

interface VideoPreviewProps {
videoUrl?: string;
Expand All @@ -13,8 +14,26 @@ export default function VideoPreview({
videoUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ',
thumbnailUrl = 'https://static.vecteezy.com/system/resources/previews/053/715/379/non_2x/abstract-green-digital-rain-with-matrix-code-in-futuristic-cyber-background-perfect-for-technology-and-data-themed-visuals-png.png',
duration = '5:30',
onClose,
}: VideoPreviewProps) {
const [isOpen, setIsOpen] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const dialogRef = useFocusTrap<HTMLDivElement>(isOpen, { initialFocusRef: closeButtonRef });
const titleId = useId();

const closePreview = () => {
setIsOpen(false);
onClose?.();
};

useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') closePreview();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);

return (
<>
Expand All @@ -38,12 +57,22 @@ export default function VideoPreview({

{isOpen && (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={() => setIsOpen(false)}
onClick={closePreview}
>
<div className="relative w-full max-w-4xl" onClick={(e) => e.stopPropagation()}>
<h2 id={titleId} className="sr-only">
Video preview
</h2>
<button
onClick={() => setIsOpen(false)}
ref={closeButtonRef}
type="button"
onClick={closePreview}
aria-label="Close video preview"
className="absolute -top-12 right-0 text-white hover:text-[#00C2FF] transition-colors"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand All @@ -57,6 +86,7 @@ export default function VideoPreview({
</button>
<iframe
src={videoUrl}
title="Video preview"
className="w-full aspect-video rounded-xl"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
Expand Down
Loading
Loading