Skip to content

Commit 5b5d844

Browse files
Phase 2 Complete: Advanced keyboard navigation & screen reader support
Co-authored-by: rezwana-karim <126201034+rezwana-karim@users.noreply.github.com>
1 parent 57a0330 commit 5b5d844

5 files changed

Lines changed: 304 additions & 16 deletions

File tree

src/app/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import "./globals.css";
44
import { Header } from "@/components/layout/header";
55
import { Footer } from "@/components/layout/footer";
66
import { SkipLinks } from "@/components/ui/skip-links";
7+
import { KeyboardNavigation } from "@/components/ui/keyboard-navigation";
78
import { ThemeProvider } from "@/lib/theme-context";
89

910
const geistSans = Geist({
@@ -57,10 +58,11 @@ export default function RootLayout({
5758
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
5859
>
5960
<ThemeProvider>
61+
<KeyboardNavigation />
6062
<SkipLinks />
6163
<div className="flex min-h-screen flex-col">
6264
<Header />
63-
<main id="main-content" className="flex-1">{children}</main>
65+
<main id="main-content" className="flex-1" tabIndex={-1}>{children}</main>
6466
<Footer />
6567
</div>
6668
</ThemeProvider>

src/app/page.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,34 +33,44 @@ export default function Home() {
3333
{/* CTA Buttons */}
3434
<Stack direction="row" gap={4} className="flex-col sm:flex-row max-w-md w-full">
3535
<Button size="lg" className="w-full sm:w-auto" asChild>
36-
<a href="https://github.com/CodeStorm-Hub" target="_blank" rel="noopener noreferrer">
37-
<GitHubLogoIcon className="mr-2 h-4 w-4" />
36+
<a
37+
href="https://github.com/CodeStorm-Hub"
38+
target="_blank"
39+
rel="noopener noreferrer"
40+
aria-label="View CodeStorm Hub projects on GitHub (opens in new tab)"
41+
>
42+
<GitHubLogoIcon className="mr-2 h-4 w-4" aria-hidden="true" />
3843
View Projects
39-
<ArrowRightIcon className="ml-2 h-4 w-4" />
44+
<ArrowRightIcon className="ml-2 h-4 w-4" aria-hidden="true" />
4045
</a>
4146
</Button>
42-
<Button variant="outline" size="lg" className="w-full sm:w-auto">
47+
<Button
48+
variant="outline"
49+
size="lg"
50+
className="w-full sm:w-auto"
51+
aria-label="Join the CodeStorm Hub community"
52+
>
4353
Join Community
4454
</Button>
4555
</Stack>
4656

4757
{/* Features Grid */}
4858
<div className="mt-20 w-full max-w-5xl">
4959
<Grid cols={3} gap={8} className="items-start">
50-
<Stack align="center" gap={4} className="text-center">
51-
<div className="w-14 h-14 bg-primary/10 rounded-xl flex items-center justify-center shadow-sm">
52-
<GitHubLogoIcon className="h-7 w-7 text-primary" />
60+
<div className="text-center">
61+
<div className="w-14 h-14 bg-primary/10 rounded-xl flex items-center justify-center shadow-sm mx-auto mb-4" role="img" aria-label="Open source icon">
62+
<GitHubLogoIcon className="h-7 w-7 text-primary" aria-hidden="true" />
5363
</div>
5464
<div className="space-y-2">
5565
<Typography variant="h5" className="font-semibold">Open Source</Typography>
5666
<Typography variant="muted" className="text-sm leading-relaxed max-w-sm">
5767
Building transparent, accessible solutions for the community
5868
</Typography>
5969
</div>
60-
</Stack>
70+
</div>
6171

62-
<Stack align="center" gap={4} className="text-center">
63-
<div className="w-14 h-14 bg-primary/10 rounded-xl flex items-center justify-center shadow-sm">
72+
<div className="text-center">
73+
<div className="w-14 h-14 bg-primary/10 rounded-xl flex items-center justify-center shadow-sm mx-auto mb-4" role="img" aria-label="Community icon">
6474
<svg className="h-7 w-7 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
6575
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
6676
</svg>
@@ -71,10 +81,10 @@ export default function Home() {
7181
Connecting passionate developers and researchers worldwide
7282
</Typography>
7383
</div>
74-
</Stack>
84+
</div>
7585

76-
<Stack align="center" gap={4} className="text-center">
77-
<div className="w-14 h-14 bg-primary/10 rounded-xl flex items-center justify-center shadow-sm">
86+
<div className="text-center">
87+
<div className="w-14 h-14 bg-primary/10 rounded-xl flex items-center justify-center shadow-sm mx-auto mb-4" role="img" aria-label="Innovation icon">
7888
<svg className="h-7 w-7 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
7989
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
8090
</svg>
@@ -85,7 +95,7 @@ export default function Home() {
8595
Pushing boundaries with cutting-edge research and development
8696
</Typography>
8797
</div>
88-
</Stack>
98+
</div>
8999
</Grid>
90100
</div>
91101
</Stack>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client"
2+
3+
import { useEffect, useCallback, useMemo } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
6+
interface KeyboardShortcut {
7+
key: string
8+
ctrlKey?: boolean
9+
altKey?: boolean
10+
shiftKey?: boolean
11+
action: () => void
12+
description: string
13+
}
14+
15+
interface KeyboardNavigationProps {
16+
shortcuts?: KeyboardShortcut[]
17+
}
18+
19+
export function KeyboardNavigation({ shortcuts = [] }: KeyboardNavigationProps) {
20+
const router = useRouter()
21+
22+
// Default keyboard shortcuts for common actions
23+
const defaultShortcuts = useMemo<KeyboardShortcut[]>(() => [
24+
{
25+
key: 'h',
26+
altKey: true,
27+
action: () => router.push('/'),
28+
description: 'Go to homepage'
29+
},
30+
{
31+
key: 'p',
32+
altKey: true,
33+
action: () => router.push('/projects'),
34+
description: 'Go to projects page'
35+
},
36+
{
37+
key: 't',
38+
altKey: true,
39+
action: () => router.push('/team'),
40+
description: 'Go to team page'
41+
},
42+
{
43+
key: 'c',
44+
altKey: true,
45+
action: () => router.push('/contact'),
46+
description: 'Go to contact page'
47+
},
48+
{
49+
key: '/',
50+
action: () => {
51+
// Focus on search if available, otherwise skip to main content
52+
const searchInput = document.querySelector('input[type="search"]') as HTMLElement
53+
const mainContent = document.getElementById('main-content')
54+
if (searchInput) {
55+
searchInput.focus()
56+
} else if (mainContent) {
57+
mainContent.focus()
58+
}
59+
},
60+
description: 'Focus search or main content'
61+
}
62+
], [router])
63+
64+
// Memoize all shortcuts to prevent recreation
65+
const allShortcuts = useMemo(() => {
66+
const showHelpShortcut: KeyboardShortcut = {
67+
key: '?',
68+
shiftKey: true,
69+
action: () => {
70+
showKeyboardShortcuts([...defaultShortcuts, ...shortcuts])
71+
},
72+
description: 'Show keyboard shortcuts'
73+
}
74+
return [...defaultShortcuts, ...shortcuts, showHelpShortcut]
75+
}, [defaultShortcuts, shortcuts])
76+
77+
const handleKeyDown = useCallback((event: KeyboardEvent) => {
78+
// Don't trigger shortcuts when typing in inputs
79+
const target = event.target as HTMLElement
80+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
81+
// Allow some shortcuts even in inputs
82+
if (event.key === 'Escape') {
83+
target.blur()
84+
return
85+
}
86+
return
87+
}
88+
89+
for (const shortcut of allShortcuts) {
90+
if (
91+
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
92+
!!event.ctrlKey === !!shortcut.ctrlKey &&
93+
!!event.altKey === !!shortcut.altKey &&
94+
!!event.shiftKey === !!shortcut.shiftKey
95+
) {
96+
event.preventDefault()
97+
shortcut.action()
98+
break
99+
}
100+
}
101+
}, [allShortcuts])
102+
103+
useEffect(() => {
104+
document.addEventListener('keydown', handleKeyDown)
105+
return () => document.removeEventListener('keydown', handleKeyDown)
106+
}, [handleKeyDown])
107+
108+
return null // This is a behavior-only component
109+
}
110+
111+
function showKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
112+
// Create a modal dialog showing keyboard shortcuts
113+
const modal = document.createElement('div')
114+
modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'
115+
modal.setAttribute('role', 'dialog')
116+
modal.setAttribute('aria-modal', 'true')
117+
modal.setAttribute('aria-labelledby', 'shortcuts-title')
118+
119+
const content = document.createElement('div')
120+
content.className = 'bg-background border border-border rounded-lg p-6 max-w-md mx-4 shadow-lg'
121+
122+
const title = document.createElement('h2')
123+
title.id = 'shortcuts-title'
124+
title.className = 'text-lg font-semibold mb-4'
125+
title.textContent = 'Keyboard Shortcuts'
126+
127+
const shortcutsList = document.createElement('div')
128+
shortcutsList.className = 'space-y-2 mb-4'
129+
130+
shortcuts.forEach(shortcut => {
131+
const item = document.createElement('div')
132+
item.className = 'flex justify-between items-center text-sm'
133+
134+
const keys = document.createElement('kbd')
135+
keys.className = 'px-2 py-1 bg-muted rounded text-xs font-mono'
136+
137+
let keyText = ''
138+
if (shortcut.ctrlKey) keyText += 'Ctrl + '
139+
if (shortcut.altKey) keyText += 'Alt + '
140+
if (shortcut.shiftKey) keyText += 'Shift + '
141+
keyText += shortcut.key.toUpperCase()
142+
143+
keys.textContent = keyText
144+
145+
const description = document.createElement('span')
146+
description.className = 'text-muted-foreground'
147+
description.textContent = shortcut.description
148+
149+
item.appendChild(keys)
150+
item.appendChild(description)
151+
shortcutsList.appendChild(item)
152+
})
153+
154+
const closeButton = document.createElement('button')
155+
closeButton.className = 'w-full bg-primary text-primary-foreground px-4 py-2 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
156+
closeButton.textContent = 'Close'
157+
closeButton.onclick = () => document.body.removeChild(modal)
158+
159+
// Close on escape or click outside
160+
const closeModal = (e: Event) => {
161+
if (e.target === modal || (e as KeyboardEvent).key === 'Escape') {
162+
document.body.removeChild(modal)
163+
document.removeEventListener('keydown', closeModal)
164+
}
165+
}
166+
167+
document.addEventListener('keydown', closeModal)
168+
modal.onclick = closeModal
169+
content.onclick = (e) => e.stopPropagation()
170+
171+
content.appendChild(title)
172+
content.appendChild(shortcutsList)
173+
content.appendChild(closeButton)
174+
modal.appendChild(content)
175+
document.body.appendChild(modal)
176+
177+
// Focus the close button
178+
closeButton.focus()
179+
}
180+
181+
export type { KeyboardShortcut }

src/components/ui/live-region.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use client"
2+
3+
import { useEffect, useRef } from 'react'
4+
5+
interface LiveRegionProps {
6+
children: React.ReactNode
7+
priority?: 'polite' | 'assertive'
8+
atomic?: boolean
9+
className?: string
10+
}
11+
12+
export function LiveRegion({
13+
children,
14+
priority = 'polite',
15+
atomic = true,
16+
className = "sr-only"
17+
}: LiveRegionProps) {
18+
const regionRef = useRef<HTMLDivElement>(null)
19+
20+
return (
21+
<div
22+
ref={regionRef}
23+
aria-live={priority}
24+
aria-atomic={atomic}
25+
className={className}
26+
role="status"
27+
>
28+
{children}
29+
</div>
30+
)
31+
}
32+
33+
// Hook for announcing messages to screen readers
34+
export function useAnnouncement() {
35+
const timeoutRef = useRef<NodeJS.Timeout>()
36+
37+
const announce = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
38+
// Clear any existing timeout
39+
if (timeoutRef.current) {
40+
clearTimeout(timeoutRef.current)
41+
}
42+
43+
// Create or find live region
44+
let liveRegion = document.getElementById('live-announcements')
45+
if (!liveRegion) {
46+
liveRegion = document.createElement('div')
47+
liveRegion.id = 'live-announcements'
48+
liveRegion.setAttribute('aria-live', priority)
49+
liveRegion.setAttribute('aria-atomic', 'true')
50+
liveRegion.className = 'sr-only'
51+
document.body.appendChild(liveRegion)
52+
}
53+
54+
// Update the aria-live attribute if priority changed
55+
liveRegion.setAttribute('aria-live', priority)
56+
57+
// Clear and then set the message
58+
liveRegion.textContent = ''
59+
60+
timeoutRef.current = setTimeout(() => {
61+
if (liveRegion) {
62+
liveRegion.textContent = message
63+
}
64+
}, 100) // Small delay ensures screen readers catch the change
65+
}
66+
67+
useEffect(() => {
68+
return () => {
69+
if (timeoutRef.current) {
70+
clearTimeout(timeoutRef.current)
71+
}
72+
}
73+
}, [])
74+
75+
return announce
76+
}
77+
78+
// Component for managing theme change announcements
79+
export function ThemeAnnouncement({ theme }: { theme: 'light' | 'dark' }) {
80+
const announce = useAnnouncement()
81+
82+
useEffect(() => {
83+
announce(`Switched to ${theme} mode`, 'polite')
84+
}, [theme, announce])
85+
86+
return null
87+
}

src/components/ui/theme-toggle.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import React from 'react'
44
import { SunIcon, MoonIcon } from '@radix-ui/react-icons'
55
import { Button } from '@/components/ui/button'
66
import { useTheme } from '@/hooks/use-theme'
7+
import { useAnnouncement } from '@/components/ui/live-region'
78

89
export function ThemeToggle() {
910
const { theme, toggleTheme, mounted } = useTheme()
11+
const announce = useAnnouncement()
12+
13+
const handleToggle = () => {
14+
toggleTheme()
15+
const newTheme = theme === 'dark' ? 'light' : 'dark'
16+
announce(`Switched to ${newTheme} mode`)
17+
}
1018

1119
if (!mounted) {
1220
return (
@@ -26,7 +34,7 @@ export function ThemeToggle() {
2634
<Button
2735
variant="ghost"
2836
size="sm"
29-
onClick={toggleTheme}
37+
onClick={handleToggle}
3038
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
3139
className="h-9 w-9 px-0"
3240
>

0 commit comments

Comments
 (0)