Skip to content

Commit 7aa20cf

Browse files
authored
Merge pull request #22 from zerox80/beta
feat: improve navigation accessibility and footer quick links
2 parents a60667f + 7f88932 commit 7aa20cf

File tree

5 files changed

+245
-67
lines changed

5 files changed

+245
-67
lines changed

src/components/Footer.jsx

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,53 @@ const Footer = () => {
9292
return null
9393
}
9494
}, [])
95-
const navigationQuickLinks = useMemo(() => {
96-
const items = effectiveNavigationItems
97-
return items
95+
const quickLinks = useMemo(() => {
96+
const contentLinks = Array.isArray(footerContent?.quickLinks) ? footerContent.quickLinks : []
97+
const normalizedContentLinks = contentLinks
98+
.map((link) => {
99+
if (!link) return null
100+
if (link.target) {
101+
const href = buildTargetHref(link.target)
102+
return {
103+
label: link.label || link.target?.value || 'Link',
104+
target: link.target,
105+
href,
106+
}
107+
}
108+
if (link.href) {
109+
const safeHref = sanitizeExternalUrl(link.href)
110+
if (!safeHref) {
111+
console.warn('Blocked unsafe footer quick link:', link.href)
112+
return null
113+
}
114+
return {
115+
label: link.label || link.href,
116+
href: safeHref,
117+
}
118+
}
119+
if (link.path) {
120+
return {
121+
label: link.label || link.path,
122+
target: { type: 'route', value: link.path },
123+
href: link.path,
124+
}
125+
}
126+
if (link.slug) {
127+
const slug = link.slug.trim().replace(/^\//, '')
128+
if (!slug) return null
129+
return {
130+
label: link.label || slug,
131+
target: { type: 'route', value: `/pages/${slug}` },
132+
href: `/pages/${slug}`,
133+
}
134+
}
135+
return null
136+
})
137+
.filter(Boolean)
138+
if (normalizedContentLinks.length > 0) {
139+
return normalizedContentLinks
140+
}
141+
return effectiveNavigationItems
98142
.map((item) => {
99143
if (!item) return null
100144
if (item.target) {
@@ -142,10 +186,7 @@ const Footer = () => {
142186
return null
143187
})
144188
.filter(Boolean)
145-
}, [buildTargetHref, effectiveNavigationItems])
146-
const quickLinks = useMemo(() => {
147-
return navigationQuickLinks
148-
}, [navigationQuickLinks])
189+
}, [buildTargetHref, effectiveNavigationItems, footerContent?.quickLinks])
149190
const handleQuickLink = (event, link) => {
150191
// Early return for invalid or missing link data
151192
if (!link) return

src/components/Header.jsx

Lines changed: 123 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,75 @@ const Header = () => {
8282
scrollToSection(sectionId)
8383
}
8484
}
85+
const getNavHref = (item) => {
86+
if (!item) return '#'
87+
if (item.target) {
88+
const target = item.target
89+
const value = target.value ?? target.path ?? target.href ?? target.slug
90+
switch (target.type) {
91+
case 'section': {
92+
const sectionId = resolveSectionId({ ...item, ...target })
93+
return sectionId ? `#${sectionId.replace(/^#/, '')}` : '#'
94+
}
95+
case 'route':
96+
case 'page':
97+
return typeof value === 'string' && value.trim()
98+
? value.startsWith('/')
99+
? value
100+
: `/${value.trim().replace(/^\//, '')}`
101+
: '#'
102+
case 'external':
103+
case 'href': {
104+
const safeUrl = sanitizeExternalUrl(value)
105+
return safeUrl || '#'
106+
}
107+
default:
108+
return '#'
109+
}
110+
}
111+
if (item.href) {
112+
const safeUrl = sanitizeExternalUrl(item.href)
113+
return safeUrl || '#'
114+
}
115+
if ((item.type === 'route' || item.type === 'page') && item.path) {
116+
return item.path
117+
}
118+
if (item.type === 'section') {
119+
const sectionId = resolveSectionId(item)
120+
return sectionId ? `#${sectionId.replace(/^#/, '')}` : '#'
121+
}
122+
if (item.slug) {
123+
return `/pages/${item.slug}`
124+
}
125+
return '#'
126+
}
127+
const isExternalHref = (href) =>
128+
typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://'))
129+
const isSpecialProtocol = (href) =>
130+
typeof href === 'string' && (href.startsWith('mailto:') || href.startsWith('tel:'))
131+
const handleNavClick = (event, item) => {
132+
if (!item) return
133+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.button !== 0) {
134+
return
135+
}
136+
event.preventDefault()
137+
handleNavigation(item)
138+
setMobileMenuOpen(false)
139+
}
140+
const handleBrandClick = (event) => {
141+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.button !== 0) {
142+
return
143+
}
144+
event.preventDefault()
145+
setMobileMenuOpen(false)
146+
if (location.pathname === '/') {
147+
if (typeof window !== 'undefined') {
148+
window.scrollTo({ top: 0, behavior: 'smooth' })
149+
}
150+
return
151+
}
152+
navigate('/')
153+
}
85154
const handleCtaClick = () => {
86155
if (ctaContent.target) {
87156
navigateContentTarget(ctaContent.target, { navigate, location })
@@ -94,25 +163,44 @@ const Header = () => {
94163
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
95164
<div className="flex justify-between items-center h-20">
96165
{}
97-
<div className="flex items-center space-x-3">
166+
<a
167+
href="/"
168+
onClick={handleBrandClick}
169+
className="flex items-center space-x-3 rounded-xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
170+
aria-label="Zur Startseite"
171+
>
98172
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-white shadow-sm">
99173
<BrandIcon className="w-6 h-6" />
100174
</div>
101175
<span className="text-xl font-semibold text-gray-900 dark:text-gray-100">
102176
{headerContent?.brand?.name || 'Linux Tutorial'}
103177
</span>
104-
</div>
178+
</a>
105179
{}
106180
<div className="hidden md:flex items-center space-x-8">
107-
{computedNavItems.map((item, index) => (
108-
<button
109-
key={item.id || `${item.label ?? 'nav'}-${index}`}
110-
onClick={() => handleNavigation(item)}
111-
className="nav-link font-medium hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-400"
112-
>
113-
{item.label}
114-
</button>
115-
))}
181+
{computedNavItems.map((item, index) => {
182+
const href = getNavHref(item)
183+
const external = isExternalHref(href)
184+
const special = isSpecialProtocol(href)
185+
const ariaCurrent = !href || href.startsWith('#') || external || special
186+
? undefined
187+
: location.pathname === href
188+
? 'page'
189+
: undefined
190+
return (
191+
<a
192+
key={item.id || `${item.label ?? 'nav'}-${index}`}
193+
href={href}
194+
onClick={(event) => handleNavClick(event, item)}
195+
target={external ? '_blank' : undefined}
196+
rel={external ? 'noopener noreferrer' : undefined}
197+
className="nav-link font-medium hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-400"
198+
aria-current={ariaCurrent}
199+
>
200+
{item.label}
201+
</a>
202+
)
203+
})}
116204
<button
117205
onClick={() => setSearchOpen(true)}
118206
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
@@ -139,40 +227,44 @@ const Header = () => {
139227
<button
140228
className="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800"
141229
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
230+
aria-label={mobileMenuOpen ? 'Navigation schließen' : 'Navigation öffnen'}
142231
>
143-
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
232+
{mobileMenuOpen ? (
233+
<X className="w-5 h-5" />
234+
) : (
235+
<Menu className="w-5 h-5" />
236+
)}
144237
</button>
145238
</div>
146239
</div>
147-
{}
148240
{mobileMenuOpen && (
149241
<div className="md:hidden pb-4 space-y-2">
150-
{computedNavItems.map((item, index) => (
151-
<button
152-
key={item.id || `${item.label ?? 'nav'}-${index}`}
153-
onClick={() => {
154-
handleNavigation(item)
155-
setMobileMenuOpen(false)
156-
}}
157-
className="block w-full text-left px-4 py-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
158-
>
159-
{item.label}
160-
</button>
161-
))}
242+
{computedNavItems.map((item, index) => {
243+
const href = getNavHref(item)
244+
const external = isExternalHref(href)
245+
return (
246+
<a
247+
key={item.id || `${item.label ?? 'nav'}-${index}`}
248+
href={href}
249+
onClick={(event) => handleNavClick(event, item)}
250+
target={external ? '_blank' : undefined}
251+
rel={external ? 'noopener noreferrer' : undefined}
252+
className="block w-full text-left px-4 py-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400"
253+
>
254+
{item.label}
255+
</a>
256+
)
257+
})}
162258
<button
163-
onClick={() => {
164-
handleCtaClick()
165-
setMobileMenuOpen(false)
166-
}}
167-
className="btn-primary w-full justify-center"
259+
onClick={handleCtaClick}
260+
className="w-full flex items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-primary-600 to-primary-700 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:from-primary-700 hover:to-primary-800"
168261
>
169262
<CTAIcon className="w-4 h-4" />
170263
<span>{isAuthenticated ? ctaContent.authLabel || 'Admin' : ctaContent.guestLabel || 'Login'}</span>
171264
</button>
172265
</div>
173266
)}
174267
</nav>
175-
{}
176268
{searchOpen && <SearchBar onClose={() => setSearchOpen(false)} />}
177269
</header>
178270
)

src/components/SearchBar.jsx

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ const SearchBar = ({ onClose }) => {
1010
const [selectedTopic, setSelectedTopic] = useState('');
1111
const [isLoading, setIsLoading] = useState(false);
1212
const inputRef = useRef(null);
13+
const dialogRef = useRef(null);
14+
const previouslyFocusedElement = useRef(null);
1315
const navigate = useNavigate();
1416
useEffect(() => {
17+
previouslyFocusedElement.current = document.activeElement;
1518
inputRef.current?.focus();
1619
api.request('/search/topics', { cacheBust: false })
1720
.then((data) => setTopics(Array.isArray(data) ? data : []))
@@ -20,6 +23,51 @@ const SearchBar = ({ onClose }) => {
2023
setTopics([])
2124
})
2225
}, []);
26+
useEffect(() => {
27+
const handleKeyDown = (event) => {
28+
if (event.key === 'Escape') {
29+
event.preventDefault();
30+
if (onClose) {
31+
onClose();
32+
}
33+
return;
34+
}
35+
if (event.key === 'Tab' && dialogRef.current) {
36+
const focusableSelectors = [
37+
'a[href]:not([tabindex="-1"])',
38+
'button:not([disabled]):not([tabindex="-1"])',
39+
'textarea:not([disabled]):not([tabindex="-1"])',
40+
'input:not([disabled]):not([tabindex="-1"])',
41+
'select:not([disabled]):not([tabindex="-1"])',
42+
'[tabindex]:not([tabindex="-1"])',
43+
].join(',');
44+
const focusable = Array.from(dialogRef.current.querySelectorAll(focusableSelectors)).filter(
45+
(element) =>
46+
!element.hasAttribute('disabled') &&
47+
element.getAttribute('aria-hidden') !== 'true' &&
48+
(element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0),
49+
);
50+
if (!focusable.length) {
51+
return;
52+
}
53+
const first = focusable[0];
54+
const last = focusable[focusable.length - 1];
55+
const activeElement = document.activeElement;
56+
if (!event.shiftKey && activeElement === last) {
57+
event.preventDefault();
58+
first.focus();
59+
} else if (event.shiftKey && activeElement === first) {
60+
event.preventDefault();
61+
last.focus();
62+
}
63+
}
64+
};
65+
window.addEventListener('keydown', handleKeyDown);
66+
return () => {
67+
window.removeEventListener('keydown', handleKeyDown);
68+
previouslyFocusedElement.current?.focus?.();
69+
};
70+
}, [onClose]);
2371
useEffect(() => {
2472
if (query.trim().length < 1) {
2573
setResults([]);
@@ -49,31 +97,43 @@ const SearchBar = ({ onClose }) => {
4997
navigate(`/tutorials/${id}`);
5098
if (onClose) onClose();
5199
};
52-
const handleKeyDown = (e) => {
53-
if (e.key === 'Escape') {
54-
if (onClose) onClose();
55-
}
56-
};
57100
return (
58-
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 z-50 flex items-start justify-center pt-20 px-4">
59-
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
101+
<div
102+
className="fixed inset-0 bg-black/50 dark:bg-black/70 z-50 flex items-start justify-center pt-20 px-4"
103+
role="presentation"
104+
onMouseDown={(event) => {
105+
if (event.target === event.currentTarget && onClose) {
106+
onClose();
107+
}
108+
}}
109+
>
110+
<div
111+
ref={dialogRef}
112+
role="dialog"
113+
aria-modal="true"
114+
aria-labelledby="search-dialog-title"
115+
className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col"
116+
>
60117
{}
61118
<div className="p-4 border-b dark:border-gray-700">
119+
<h2 id="search-dialog-title" className="text-lg font-semibold text-gray-900 dark:text-gray-100">
120+
Tutorialsuche
121+
</h2>
62122
<div className="relative">
63123
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
64124
<input
65125
ref={inputRef}
66126
type="text"
67127
value={query}
68128
onChange={(e) => setQuery(e.target.value)}
69-
onKeyDown={handleKeyDown}
70129
placeholder="Tutorial suchen..."
71130
className="w-full pl-12 pr-12 py-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 dark:text-gray-100"
72131
/>
73132
{onClose && (
74133
<button
75134
onClick={onClose}
76135
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
136+
aria-label="Suche schließen"
77137
>
78138
<X className="w-5 h-5 text-gray-500" />
79139
</button>

0 commit comments

Comments
 (0)