Skip to content

Commit 1677837

Browse files
authored
Merge pull request #345 from wadexybiodun/feature-updated
Added new feature
2 parents cfb4fa7 + 67807b7 commit 1677837

14 files changed

Lines changed: 508 additions & 94 deletions

frontend/src/app/globals.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,50 @@ html:focus-within {
205205
::-webkit-scrollbar-thumb:hover {
206206
background: #555;
207207
}
208+
209+
/* ========================================
210+
Keyboard shortcut kbd styling
211+
======================================== */
212+
213+
kbd {
214+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
215+
'Liberation Mono', monospace;
216+
font-size: 0.75rem;
217+
line-height: 1rem;
218+
padding: 0.125rem 0.375rem;
219+
border-radius: 0.25rem;
220+
background: #f3f4f6;
221+
border: 1px solid #d1d5db;
222+
color: #374151;
223+
}
224+
225+
@media (prefers-color-scheme: dark) {
226+
kbd {
227+
background: #374151;
228+
border-color: #4b5563;
229+
color: #d1d5db;
230+
}
231+
}
232+
233+
/* ========================================
234+
Focus-visible for all interactive elements
235+
======================================== */
236+
237+
a:not([tabindex='-1']):focus-visible,
238+
button:not([tabindex='-1']):focus-visible,
239+
input:not([tabindex='-1']):focus-visible,
240+
select:not([tabindex='-1']):focus-visible,
241+
textarea:not([tabindex='-1']):focus-visible,
242+
[tabindex]:not([tabindex='-1']):focus-visible {
243+
outline: 3px solid var(--focus-ring-color);
244+
outline-offset: 2px;
245+
box-shadow: 0 0 0 4px var(--focus-ring-shadow);
246+
}
247+
248+
/* High contrast focus ring for dark mode */
249+
@media (prefers-color-scheme: dark) {
250+
:root {
251+
--focus-ring-color: #60a5fa;
252+
--focus-ring-shadow: rgba(96, 165, 250, 0.35);
253+
}
254+
}

frontend/src/app/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { RootErrorBoundary } from '@/components/providers/RootErrorBoundary';
1010
import '@/lib/performance-monitor';
1111
import PWAClientShell from '@/components/PWA/PWAClientShell';
1212
import MobileNavShell from '@/components/Mobile/MobileNavShell';
13+
import { PageTransition } from '@/components/PageTransition';
14+
import KeyboardShortcutsProvider from '@/components/providers/KeyboardShortcutsProvider';
1315

1416
const inter = Inter({ subsets: ['latin'] });
1517

@@ -82,6 +84,8 @@ export default function RootLayout({
8284
itself on md+ via its own `md:hidden` classes; the client shell
8385
supplies the current path + navigate callback from next/navigation. */}
8486
<MobileNavShell />
87+
{/* Global keyboard shortcuts listener + ? key help dialog */}
88+
<KeyboardShortcutsProvider />
8589
{/* Reserve space on mobile so the fixed hamburger (top) and bottom
8690
nav bar don't overlap page content; removed at md+ where the
8791
mobile nav is hidden. */}

frontend/src/components/Collaboration/WhiteboardShareDialog.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { useEffect, useState } from 'react';
1212
import { useTranslation } from 'react-i18next';
1313
import { Clipboard, Link as LinkIcon } from 'lucide-react';
14+
import { useFocusTrap } from '@/hooks/useFocusTrap';
1415

1516
interface WhiteboardShareDialogProps {
1617
open: boolean;
@@ -32,6 +33,11 @@ export function WhiteboardShareDialog({
3233
const { t } = useTranslation(['common', 'whiteboard']);
3334
const [copied, setCopied] = useState(false);
3435

36+
const focusTrapRef = useFocusTrap(open, {
37+
onEscape: onClose,
38+
initialFocusSelector: '[data-dialog-close]',
39+
});
40+
3541
useEffect(() => {
3642
if (!open) setCopied(false);
3743
}, [open]);
@@ -56,6 +62,7 @@ export function WhiteboardShareDialog({
5662
aria-modal="true"
5763
aria-label={t('whiteboard.shareDialogTitle', 'Share board')}
5864
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
65+
ref={focusTrapRef}
5966
>
6067
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6 space-y-4">
6168
<div className="flex items-center gap-2">
@@ -114,6 +121,7 @@ export function WhiteboardShareDialog({
114121
<button
115122
type="button"
116123
onClick={onClose}
124+
data-dialog-close
117125
className="px-3 py-2 rounded-md text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
118126
>
119127
{t('actions.close', 'Close')}

frontend/src/components/ErrorFallback.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export function ErrorFallback({
106106
{onRetry && (
107107
<button
108108
onClick={onRetry}
109+
tabIndex={0}
109110
className={`w-full px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 font-medium ${config.buttonClass}`}
110111
>
111112
<RefreshCw className="h-4 w-4" />
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use client';
2+
3+
import { useEffect, useState, useCallback, useRef } from 'react';
4+
import { Keyboard, X } from 'lucide-react';
5+
import { useFocusTrap } from '@/hooks/useFocusTrap';
6+
import { getAllShortcuts, type KeyboardShortcut } from '@/hooks/useKeyboardShortcuts';
7+
8+
function formatKey(shortcut: KeyboardShortcut): string {
9+
const parts: string[] = [];
10+
if (shortcut.ctrl) parts.push('Ctrl');
11+
if (shortcut.alt) parts.push('Alt');
12+
if (shortcut.meta) parts.push('Cmd');
13+
14+
let keyLabel = shortcut.key;
15+
if (keyLabel === ' ') keyLabel = 'Space';
16+
else if (keyLabel === 'Escape') keyLabel = 'Esc';
17+
else if (keyLabel === '?') keyLabel = '?';
18+
else if (keyLabel.length === 1) keyLabel = keyLabel.toUpperCase();
19+
20+
if (shortcut.shift && keyLabel !== '?') parts.push('Shift');
21+
22+
parts.push(keyLabel);
23+
return parts.join(' + ');
24+
}
25+
26+
export function KeyboardShortcutsDialog() {
27+
const [isOpen, setIsOpen] = useState(false);
28+
const [shortcuts, setShortcuts] = useState<KeyboardShortcut[]>([]);
29+
const previousFocusRef = useRef<HTMLElement | null>(null);
30+
31+
const handleOpen = useCallback(() => {
32+
previousFocusRef.current = document.activeElement as HTMLElement;
33+
setShortcuts(getAllShortcuts());
34+
setIsOpen(true);
35+
}, []);
36+
37+
const handleClose = useCallback(() => {
38+
setIsOpen(false);
39+
previousFocusRef.current?.focus();
40+
}, []);
41+
42+
useEffect(() => {
43+
const onShowHelp = () => handleOpen();
44+
const onClose = () => {
45+
if (isOpen) handleClose();
46+
};
47+
48+
window.addEventListener('keyboard-shortcut:show-help', onShowHelp);
49+
window.addEventListener('keyboard-shortcut:close', onClose);
50+
51+
return () => {
52+
window.removeEventListener('keyboard-shortcut:show-help', onShowHelp);
53+
window.removeEventListener('keyboard-shortcut:close', onClose);
54+
};
55+
}, [handleOpen, handleClose, isOpen]);
56+
57+
const focusTrapRef = useFocusTrap(isOpen, {
58+
onEscape: handleClose,
59+
initialFocusSelector: '[data-shortcuts-close]',
60+
});
61+
62+
if (!isOpen) return null;
63+
64+
const grouped = shortcuts.reduce<Record<string, KeyboardShortcut[]>>((acc, s) => {
65+
(acc[s.category] = acc[s.category] || []).push(s);
66+
return acc;
67+
}, {});
68+
69+
return (
70+
<div
71+
role="dialog"
72+
aria-modal="true"
73+
aria-label="Keyboard shortcuts"
74+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
75+
ref={focusTrapRef}
76+
>
77+
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] overflow-hidden">
78+
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
79+
<div className="flex items-center gap-2">
80+
<Keyboard size={20} className="text-blue-600 dark:text-blue-400" />
81+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
82+
Keyboard Shortcuts
83+
</h2>
84+
</div>
85+
<button
86+
type="button"
87+
onClick={handleClose}
88+
data-shortcuts-close
89+
className="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
90+
aria-label="Close keyboard shortcuts"
91+
>
92+
<X size={18} className="text-gray-500 dark:text-gray-400" />
93+
</button>
94+
</div>
95+
96+
<div className="p-4 overflow-y-auto max-h-[60vh]">
97+
{Object.entries(grouped).map(([category, categoryShortcuts]) => (
98+
<div key={category} className="mb-4 last:mb-0">
99+
<h3 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
100+
{category}
101+
</h3>
102+
<ul className="space-y-1" role="list">
103+
{categoryShortcuts.map((shortcut, i) => (
104+
<li
105+
key={`${category}-${i}`}
106+
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700/50"
107+
>
108+
<span className="text-sm text-gray-700 dark:text-gray-300">
109+
{shortcut.description}
110+
</span>
111+
<kbd className="inline-flex items-center gap-0.5 px-2 py-0.5 text-xs font-mono font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded">
112+
{formatKey(shortcut)}
113+
</kbd>
114+
</li>
115+
))}
116+
</ul>
117+
</div>
118+
))}
119+
</div>
120+
121+
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
122+
<p className="text-xs text-center text-gray-500 dark:text-gray-400">
123+
Press <kbd className="px-1.5 py-0.5 text-xs font-mono bg-gray-200 dark:bg-gray-700 rounded border border-gray-300 dark:border-gray-600">?</kbd> to toggle this dialog
124+
</p>
125+
</div>
126+
</div>
127+
</div>
128+
);
129+
}
130+
131+
export default KeyboardShortcutsDialog;

frontend/src/components/LanguageSwitcher.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRouter } from 'next/router';
66
import { ChevronDown, Globe, Check } from 'lucide-react';
77
import { isRTL, getFontFamily } from '../lib/rtl';
88
import { SUPPORTED_LOCALES, normalizeLocale, DEFAULT_LOCALE } from '../lib/i18n';
9+
import { useFocusTrap } from '../hooks/useFocusTrap';
910

1011
interface Language {
1112
code: string;
@@ -103,6 +104,11 @@ export function LanguageSwitcher({
103104
[searchTerm]
104105
);
105106

107+
const focusTrapRef = useFocusTrap(isOpen, {
108+
onEscape: () => setIsOpen(false),
109+
lockScroll: false,
110+
});
111+
106112
// Sanity check: every supported locale must have metadata so the UI
107113
// can render a label + flag. Run once on mount, not on every render.
108114
useEffect(() => {
@@ -119,6 +125,7 @@ export function LanguageSwitcher({
119125
<div className={`relative ${className}`}>
120126
<button
121127
onClick={() => setIsOpen(!isOpen)}
128+
tabIndex={0}
122129
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
123130
aria-label={t('language.switcherAria', 'Select language')}
124131
>
@@ -133,12 +140,14 @@ export function LanguageSwitcher({
133140
<div
134141
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"
135142
role="menu"
143+
ref={focusTrapRef}
136144
>
137145
<div className="max-h-64 overflow-y-auto">
138146
{filteredLanguages.map((language) => (
139147
<button
140148
key={language.code}
141149
onClick={() => handleLanguageChange(language)}
150+
tabIndex={0}
142151
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
143152
role="menuitemradio"
144153
aria-checked={selectedLanguage?.code === language.code}
@@ -181,6 +190,7 @@ export function LanguageSwitcher({
181190
<button
182191
key={language.code}
183192
onClick={() => handleLanguageChange(language)}
193+
tabIndex={0}
184194
className={`
185195
relative p-3 rounded-lg border-2 transition-all duration-200 text-center
186196
${
@@ -215,6 +225,7 @@ export function LanguageSwitcher({
215225
<div className={`relative ${className}`}>
216226
<button
217227
onClick={() => setIsOpen(!isOpen)}
228+
tabIndex={0}
218229
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
219230
aria-label={t('language.switcherAria', 'Select language')}
220231
aria-haspopup="menu"
@@ -238,6 +249,7 @@ export function LanguageSwitcher({
238249
<div
239250
className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"
240251
role="menu"
252+
ref={focusTrapRef}
241253
>
242254
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
243255
<div className="relative">
@@ -264,6 +276,7 @@ export function LanguageSwitcher({
264276
<button
265277
key={language.code}
266278
onClick={() => handleLanguageChange(language)}
279+
tabIndex={0}
267280
className={`
268281
w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
269282
${

frontend/src/components/LoadingFallback.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ export function LoadingFallback({ message = 'Loading...', size = 'md', className
1616
const sizeClasses = { sm: 'h-4 w-4', md: 'h-6 w-6', lg: 'h-8 w-8' };
1717
const textSizes = { sm: 'text-sm', md: 'text-base', lg: 'text-lg' };
1818
return (
19-
<div className={`flex items-center justify-center gap-2 ${className}`}>
20-
<Loader2 className={`animate-spin text-blue-600 ${sizeClasses[size]}`} />
19+
<div className={`flex items-center justify-center gap-2 ${className}`} role="status" aria-live="polite">
20+
<Loader2 className={`animate-spin text-blue-600 ${sizeClasses[size]}`} aria-hidden="true" />
2121
<span className={`text-gray-600 dark:text-gray-400 ${textSizes[size]}`}>{message}</span>
2222
</div>
2323
);
@@ -133,6 +133,7 @@ export function EmptyState({ icon, title, description, action, className }: Empt
133133
{action && (
134134
<button
135135
onClick={action.onClick}
136+
tabIndex={0}
136137
className="px-5 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
137138
>
138139
{action.label}
@@ -167,6 +168,7 @@ export function ErrorDisplay({ title = 'Something went wrong', message, details,
167168
<div className="mt-3">
168169
<button
169170
onClick={() => setShowDetails(v => !v)}
171+
tabIndex={0}
170172
className="text-xs text-red-600 dark:text-red-400 underline hover:no-underline"
171173
>
172174
{showDetails ? 'Hide details' : 'Show details'}
@@ -182,6 +184,7 @@ export function ErrorDisplay({ title = 'Something went wrong', message, details,
182184
{onRetry && (
183185
<button
184186
onClick={onRetry}
187+
tabIndex={0}
185188
className="mt-4 flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors"
186189
>
187190
<RefreshCw className="h-3.5 w-3.5" />

frontend/src/components/Notifications/NotificationCenter.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const NotificationCenter: React.FC = () => {
2525
const focusTrapRef = useFocusTrap(isOpen, {
2626
onEscape: () => setIsOpen(false),
2727
initialFocusSelector: '[data-notification-close]',
28+
lockScroll: false,
2829
});
2930

3031
// Close dropdown when clicking outside

frontend/src/components/Notifications/NotificationItem.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const NotificationItem: React.FC<NotificationItemProps> = ({
8787
<button
8888
type="button"
8989
onClick={handleRemove}
90+
tabIndex={0}
9091
className="absolute right-2 top-2 rounded-full p-1 transition-colors hover:bg-gray-200"
9192
aria-label={`Remove notification: ${notification.title}`}
9293
>
@@ -96,6 +97,7 @@ const NotificationItem: React.FC<NotificationItemProps> = ({
9697
<button
9798
type="button"
9899
onClick={handleActivate}
100+
tabIndex={0}
99101
className="flex w-full gap-3 pr-8 text-left outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
100102
aria-describedby={`notification-${notification.id}-meta notification-${notification.id}-message`}
101103
>

0 commit comments

Comments
 (0)