feat: add BackToTopButton component and enhance accessibility with custom hooks for keyboard navigation and screen reader announcements

This commit is contained in:
Sascha 2026-02-02 15:22:33 +01:00
parent b389effd5e
commit f04fe3dfcb
15 changed files with 344 additions and 160 deletions

View File

@ -6,6 +6,7 @@ import Footer from '../components/layout/Footer';
import HomePage from '../pages/HomePage';
import ImprintPage from '../pages/ImprintPage';
import PrivacyPolicy from '../pages/PrivacyPolicy';
import BackToTopButton from '../components/BackToTopButton';
import type { Theme } from '../data/types';
function AppRouter() {
@ -63,6 +64,7 @@ function AppRouter() {
</Routes>
</main>
<Footer />
<BackToTopButton />
</div>
</Router>
);

View File

@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
import { ArrowUp } from 'lucide-react';
export default function BackToTopButton() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const toggleVisibility = () => {
// Show button when page is scrolled down 300px
if (window.scrollY > 300) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
window.addEventListener('scroll', toggleVisibility);
return () => {
window.removeEventListener('scroll', toggleVisibility);
};
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<button
onClick={scrollToTop}
className={`back-to-top ${isVisible ? 'back-to-top--visible' : ''}`}
aria-label="Back to top"
type="button"
>
<ArrowUp className="back-to-top__icon" aria-hidden="true" />
</button>
);
}

View File

@ -36,7 +36,8 @@ export default function Navbar({
const sections = menuItems.map(item => item.section.toLowerCase());
const scrollPosition = window.scrollY + 100; // Offset for navbar height
for (const section of sections.reverse()) {
const reversedSections = [...sections].reverse();
for (const section of reversedSections) {
const element = document.getElementById(section);
if (element && element.offsetTop <= scrollPosition) {
setActiveSection(section);

View File

@ -91,15 +91,8 @@ export default function ContactSection(props: ContactSectionProps = {}) {
</p>
</header>
{/* Skip link for keyboard navigation */}
<div className="contact-section__skip">
<a href="#contact-form" className="sr-only sr-only-focusable">
Skip to contact form
</a>
</div>
{/* Contact Content Grid */}
<main className="contact-section__grid">
<main id="contact-form" className="contact-section__grid">
{/* Let's Connect Card */}
<div className="contact-section__connect-card" role="region" aria-labelledby="connect-heading">
<div className="contact-section__connect-header">

View File

@ -1,7 +1,7 @@
import { Download } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import type { StatItem } from '../../data/StatItem';
import { scrollToProjects } from '../../utils/scrollUtils';
import { scrollToProjects, scrollToSection } from '../../utils/scrollUtils';
import { useLanguage } from '../../contexts/LanguageContext';
import resumePDF from '../../assets/CV_Sascha_Bach.pdf';
@ -85,13 +85,7 @@ export default function HeroSection(props: HeroSectionProps = {}) {
const handleGetInTouch = () => {
announceToScreenReader('Navigating to contact section');
const contactSection = document.querySelector('.contact-section');
if (contactSection) {
contactSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
scrollToSection('contact', location.pathname, navigate);
};
return (

View File

@ -1,8 +1,9 @@
import { GitBranch, ExternalLink } from 'lucide-react';
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { projectsData } from '../../config/projects-config';
import type { Project } from '../../data/Project';
import { useScreenReaderAnnouncements } from '../../hooks/useScreenReaderAnnouncements';
import { useProjectKeyboardNavigation } from '../../hooks/useProjectKeyboardNavigation';
interface ProjectsSectionProps {
@ -15,10 +16,6 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
const { texts: allTexts } = useLanguage();
const texts = allTexts.projects;
const accessibilityTexts = allTexts.accessibility;
const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
const [focusedActionIndex, setFocusedActionIndex] = useState<number>(0);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
const actionRefs = useRef<(HTMLAnchorElement | null)[]>([]);
// Use centralized texts as defaults, allow props to override
const {
@ -31,129 +28,21 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
})
} = props;
// Get available actions for a project
const getProjectActions = (project: Project) => {
const actions = [];
if (project.github) actions.push({ type: 'github', url: project.github });
if (project.live) actions.push({ type: 'live', url: project.live });
return actions;
};
// Handle card selection
const handleCardSelect = (index: number) => {
setActiveCardIndex(index);
setFocusedActionIndex(0);
// Announce to screen readers
const project = projects[index];
const actions = getProjectActions(project);
const announcement = `${project.title} card selected. ${actions.length} actions available. ${accessibilityTexts.navigation.useTabToNavigate}, Escape to close.`;
announceToScreenReader(announcement);
};
// Handle keyboard navigation within cards
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, cardIndex: number) => {
const project = projects[cardIndex];
const actions = getProjectActions(project);
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
handleCardSelect(cardIndex);
break;
case 'Escape':
if (activeCardIndex === cardIndex) {
event.preventDefault();
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[cardIndex]?.focus();
announceToScreenReader(`${project.title} card closed.`);
}
break;
case 'Tab':
if (activeCardIndex === cardIndex && actions.length > 0) {
event.preventDefault();
const nextActionIndex = event.shiftKey
? (focusedActionIndex - 1 + actions.length) % actions.length
: (focusedActionIndex + 1) % actions.length;
setFocusedActionIndex(nextActionIndex);
// Focus the action button
const actionButton = actionRefs.current[cardIndex * 2 + nextActionIndex];
actionButton?.focus();
}
break;
case 'ArrowDown':
case 'ArrowUp':
if (activeCardIndex !== null) {
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const nextCardIndex = (cardIndex + direction + projects.length) % projects.length;
// Close current card and move to next
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[nextCardIndex]?.focus();
announceToScreenReader(`Moved to ${projects[nextCardIndex].title} card.`);
}
break;
}
};
// Handle action button keyboard navigation
const handleActionKeyDown = (event: KeyboardEvent<HTMLAnchorElement>, cardIndex: number, actionIndex: number) => {
const actions = getProjectActions(projects[cardIndex]);
switch (event.key) {
case 'Escape':
event.preventDefault();
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[cardIndex]?.focus();
announceToScreenReader(`${projects[cardIndex].title} card closed.`);
break;
case 'Tab':
if (event.shiftKey && actionIndex === 0) {
// Tab back to card
event.preventDefault();
cardRefs.current[cardIndex]?.focus();
} else if (!event.shiftKey && actionIndex === actions.length - 1) {
// Tab forward to next card
event.preventDefault();
const nextCardIndex = (cardIndex + 1) % projects.length;
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[nextCardIndex]?.focus();
announceToScreenReader(`Moved to ${projects[nextCardIndex].title} card.`);
}
break;
}
};
// Announce to screen readers
const announceToScreenReader = (message: string) => {
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.setAttribute('class', 'sr-only');
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => {
announcement.remove();
}, 1000);
};
// Focus management effect
useEffect(() => {
if (activeCardIndex !== null) {
const firstActionRef = actionRefs.current[activeCardIndex * 2];
if (firstActionRef) {
firstActionRef.focus();
}
}
}, [activeCardIndex]);
// Custom hooks for accessibility and keyboard navigation
const { announce } = useScreenReaderAnnouncements();
const {
activeCardIndex,
cardRefs,
actionRefs,
handleCardSelect,
handleCardKeyDown,
handleActionKeyDown,
getProjectActions,
} = useProjectKeyboardNavigation({
projects,
announce,
useTabToNavigateText: accessibilityTexts.navigation.useTabToNavigate,
});
return (
<section id="projects" className="projects-section">
@ -214,7 +103,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
<div className="projects-section__image-container">
<img
src={project.image || "/api/placeholder/400/200"}
alt={`Screenshot showing the user interface of ${project.title} project${project.description ? ': ' + project.description.substring(0, 50) + '...' : ''}`}
alt={`${project.title} project screenshot`}
className="projects-section__image"
/>
@ -224,12 +113,12 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
{/* Conditionally visible action buttons */}
<fieldset
className={`projects-section__actions ${isActive ? 'projects-section__actions--visible' : ''}`}
aria-label={`Actions for ${project.title}`}
aria-hidden={!isActive}
>
<legend className="sr-only">{`Actions for ${project.title}`}</legend>
{project.github && (
<a
ref={(el) => { actionRefs.current[cardIndex * 2] = el }}
ref={(el) => { actionRefs.current[cardIndex * 2] = el; }}
href={project.github}
target="_blank"
rel="noopener noreferrer"
@ -244,7 +133,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
)}
{project.live && (
<a
ref={(el) => { actionRefs.current[cardIndex * 2 + 1] = el }}
ref={(el) => { actionRefs.current[cardIndex * 2 + 1] = el; }}
href={project.live}
target="_blank"
rel="noopener noreferrer"
@ -272,7 +161,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
</p>
{/* Add project metadata for screen readers */}
<div className="projects-section__metadata sr-only">
<div id={`project-${project.id}-metadata`} className="projects-section__metadata sr-only">
Project {cardIndex + 1} of {projects.length}.
{actions.length > 0 ? `${actions.length} actions available.` : 'No actions available.'}
{project.year && `Created in ${project.year}.`}

View File

@ -70,8 +70,8 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
<fieldset
key={category.title}
className={`skills-section__category skills-section__category--${colorClass}`}
aria-labelledby={`category-${categoryIndex}-title`}
>
<legend className="sr-only">{category.title}</legend>
<header className="skills-section__category-header">
<h3
id={`category-${categoryIndex}-title`}
@ -140,7 +140,7 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
</div>
</div>
</li>
)
);
})}
</ul>

View File

@ -38,7 +38,7 @@ export const projects = {
image: icaraceImage,
technologies: ['Angular 4', 'Typescript', 'HTML', 'CSS'],
year: '2018',
github: 'https://github.com/LuciusShadow/ErgoVR',
live: 'https://live.icarace.com/home',
},
{
id: 4,

View File

@ -0,0 +1,159 @@
import { useState, useRef, useEffect, type KeyboardEvent, type RefObject } from 'react';
import type { Project } from '../data/Project';
interface ProjectAction {
type: 'github' | 'live';
url: string;
}
interface UseProjectKeyboardNavigationProps {
projects: Project[];
announce: (message: string) => void;
useTabToNavigateText: string;
}
interface UseProjectKeyboardNavigationReturn {
activeCardIndex: number | null;
focusedActionIndex: number;
cardRefs: RefObject<(HTMLElement | null)[]>;
actionRefs: RefObject<(HTMLAnchorElement | null)[]>;
handleCardSelect: (index: number) => void;
handleCardKeyDown: (event: KeyboardEvent<HTMLElement>, cardIndex: number) => void;
handleActionKeyDown: (event: KeyboardEvent<HTMLAnchorElement>, cardIndex: number, actionIndex: number) => void;
getProjectActions: (project: Project) => ProjectAction[];
}
/**
* Custom hook for managing keyboard navigation in project cards
* Handles card selection, focus management, and keyboard shortcuts
*/
export function useProjectKeyboardNavigation({
projects,
announce,
useTabToNavigateText,
}: UseProjectKeyboardNavigationProps): UseProjectKeyboardNavigationReturn {
const [activeCardIndex, setActiveCardIndex] = useState<number | null>(null);
const [focusedActionIndex, setFocusedActionIndex] = useState<number>(0);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
const actionRefs = useRef<(HTMLAnchorElement | null)[]>([]);
// Get available actions for a project
const getProjectActions = (project: Project): ProjectAction[] => {
const actions: ProjectAction[] = [];
if (project.github) actions.push({ type: 'github', url: project.github });
if (project.live) actions.push({ type: 'live', url: project.live });
return actions;
};
// Handle card selection
const handleCardSelect = (index: number) => {
setActiveCardIndex(index);
setFocusedActionIndex(0);
const project = projects[index];
const actions = getProjectActions(project);
const announcement = `${project.title} card selected. ${actions.length} actions available. ${useTabToNavigateText}, Escape to close.`;
announce(announcement);
};
// Handle keyboard navigation within cards
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, cardIndex: number) => {
const project = projects[cardIndex];
const actions = getProjectActions(project);
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
handleCardSelect(cardIndex);
break;
case 'Escape':
if (activeCardIndex === cardIndex) {
event.preventDefault();
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[cardIndex]?.focus();
announce(`${project.title} card closed.`);
}
break;
case 'Tab':
if (activeCardIndex === cardIndex && actions.length > 0) {
event.preventDefault();
const nextActionIndex = event.shiftKey
? (focusedActionIndex - 1 + actions.length) % actions.length
: (focusedActionIndex + 1) % actions.length;
setFocusedActionIndex(nextActionIndex);
// Focus the action button
const actionButton = actionRefs.current[cardIndex * 2 + nextActionIndex];
actionButton?.focus();
}
break;
case 'ArrowDown':
case 'ArrowUp':
if (activeCardIndex !== null) {
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const nextCardIndex = (cardIndex + direction + projects.length) % projects.length;
// Close current card and move to next
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[nextCardIndex]?.focus();
announce(`Moved to ${projects[nextCardIndex].title} card.`);
}
break;
}
};
// Handle action button keyboard navigation
const handleActionKeyDown = (event: KeyboardEvent<HTMLAnchorElement>, cardIndex: number, actionIndex: number) => {
const actions = getProjectActions(projects[cardIndex]);
switch (event.key) {
case 'Escape':
event.preventDefault();
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[cardIndex]?.focus();
announce(`${projects[cardIndex].title} card closed.`);
break;
case 'Tab':
if (event.shiftKey && actionIndex === 0) {
// Tab back to card
event.preventDefault();
cardRefs.current[cardIndex]?.focus();
} else if (!event.shiftKey && actionIndex === actions.length - 1) {
// Tab forward to next card
event.preventDefault();
const nextCardIndex = (cardIndex + 1) % projects.length;
setActiveCardIndex(null);
setFocusedActionIndex(0);
cardRefs.current[nextCardIndex]?.focus();
announce(`Moved to ${projects[nextCardIndex].title} card.`);
}
break;
}
};
// Focus management effect
useEffect(() => {
if (activeCardIndex !== null) {
const firstActionRef = actionRefs.current[activeCardIndex * 2];
if (firstActionRef) {
firstActionRef.focus();
}
}
}, [activeCardIndex]);
return {
activeCardIndex,
focusedActionIndex,
cardRefs,
actionRefs,
handleCardSelect,
handleCardKeyDown,
handleActionKeyDown,
getProjectActions,
};
}

View File

@ -0,0 +1,22 @@
import { useCallback } from 'react';
/**
* Custom hook for managing screen reader announcements
* Creates temporary live region elements to announce messages to assistive technologies
*/
export function useScreenReaderAnnouncements() {
const announce = useCallback((message: string) => {
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.setAttribute('class', 'sr-only');
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => {
announcement.remove();
}, 1000);
}, []);
return { announce };
}

View File

@ -9,6 +9,9 @@
// All sections imported through index
@use 'sections';
// Component styles
@use 'components/back-to-top';
// Import Geist font from Google Fonts or local files
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
@ -30,8 +33,14 @@
}
body {
font-family: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'Roboto', sans-serif;
font-family:
'Geist',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
sans-serif;
background-color: var(--color-background);
color: var(--color-text);
margin: 0; // Explicitly remove body margin

View File

@ -0,0 +1,63 @@
@use '../variables' as *;
@use '../globals';
.back-to-top {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--color-primary);
color: #000000;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
opacity: 0;
visibility: hidden;
transform: translateY(100px);
z-index: 999;
&--visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
&:active {
transform: translateY(-2px);
}
&__icon {
width: 1.5rem;
height: 1.5rem;
}
}
@media (max-width: 768px) {
.back-to-top {
bottom: 1rem;
right: 1rem;
width: 2.5rem;
height: 2.5rem;
&__icon {
width: 1.25rem;
height: 1.25rem;
}
}
}

View File

@ -59,18 +59,19 @@
top: -40px;
left: 6px;
background: var(--color-primary);
color: var(--color-text-aaa-dark);
color: #000000;
padding: 8px;
text-decoration: none;
z-index: 9999;
border-radius: 4px;
font-weight: 500;
font-weight: 600;
transition: top 0.3s;
&:focus {
top: 6px;
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
color: #000000;
}
}

View File

@ -37,11 +37,12 @@
&--active {
background: var(--color-primary);
color: var(--color-background);
color: #000000;
font-weight: 600;
&:hover {
background: var(--color-primary);
color: var(--color-background);
color: #000000;
}
}
}

View File

@ -1,5 +1,8 @@
import type { NavigateFunction } from 'react-router-dom';
// Offset to account for sticky navbar height (65px) plus some padding
const SCROLL_OFFSET = 65;
export function scrollToSection(
sectionId: string,
currentPath: string,
@ -9,7 +12,13 @@ export function scrollToSection(
if (currentPath === '/') {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - SCROLL_OFFSET;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
} else if (navigate) {
// If we're on another page, navigate to homepage with hash