feat: add BackToTopButton component and enhance accessibility with custom hooks for keyboard navigation and screen reader announcements
This commit is contained in:
parent
b389effd5e
commit
f04fe3dfcb
|
|
@ -6,6 +6,7 @@ import Footer from '../components/layout/Footer';
|
||||||
import HomePage from '../pages/HomePage';
|
import HomePage from '../pages/HomePage';
|
||||||
import ImprintPage from '../pages/ImprintPage';
|
import ImprintPage from '../pages/ImprintPage';
|
||||||
import PrivacyPolicy from '../pages/PrivacyPolicy';
|
import PrivacyPolicy from '../pages/PrivacyPolicy';
|
||||||
|
import BackToTopButton from '../components/BackToTopButton';
|
||||||
import type { Theme } from '../data/types';
|
import type { Theme } from '../data/types';
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
|
|
@ -63,6 +64,7 @@ function AppRouter() {
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<BackToTopButton />
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,8 @@ export default function Navbar({
|
||||||
const sections = menuItems.map(item => item.section.toLowerCase());
|
const sections = menuItems.map(item => item.section.toLowerCase());
|
||||||
const scrollPosition = window.scrollY + 100; // Offset for navbar height
|
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);
|
const element = document.getElementById(section);
|
||||||
if (element && element.offsetTop <= scrollPosition) {
|
if (element && element.offsetTop <= scrollPosition) {
|
||||||
setActiveSection(section);
|
setActiveSection(section);
|
||||||
|
|
|
||||||
|
|
@ -91,15 +91,8 @@ export default function ContactSection(props: ContactSectionProps = {}) {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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 */}
|
{/* Contact Content Grid */}
|
||||||
<main className="contact-section__grid">
|
<main id="contact-form" className="contact-section__grid">
|
||||||
{/* Let's Connect Card */}
|
{/* Let's Connect Card */}
|
||||||
<div className="contact-section__connect-card" role="region" aria-labelledby="connect-heading">
|
<div className="contact-section__connect-card" role="region" aria-labelledby="connect-heading">
|
||||||
<div className="contact-section__connect-header">
|
<div className="contact-section__connect-header">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Download } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import type { StatItem } from '../../data/StatItem';
|
import type { StatItem } from '../../data/StatItem';
|
||||||
import { scrollToProjects } from '../../utils/scrollUtils';
|
import { scrollToProjects, scrollToSection } from '../../utils/scrollUtils';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
import resumePDF from '../../assets/CV_Sascha_Bach.pdf';
|
import resumePDF from '../../assets/CV_Sascha_Bach.pdf';
|
||||||
|
|
||||||
|
|
@ -85,13 +85,7 @@ export default function HeroSection(props: HeroSectionProps = {}) {
|
||||||
|
|
||||||
const handleGetInTouch = () => {
|
const handleGetInTouch = () => {
|
||||||
announceToScreenReader('Navigating to contact section');
|
announceToScreenReader('Navigating to contact section');
|
||||||
const contactSection = document.querySelector('.contact-section');
|
scrollToSection('contact', location.pathname, navigate);
|
||||||
if (contactSection) {
|
|
||||||
contactSection.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { GitBranch, ExternalLink } from 'lucide-react';
|
import { GitBranch, ExternalLink } from 'lucide-react';
|
||||||
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
import { projectsData } from '../../config/projects-config';
|
import { projectsData } from '../../config/projects-config';
|
||||||
import type { Project } from '../../data/Project';
|
import type { Project } from '../../data/Project';
|
||||||
|
import { useScreenReaderAnnouncements } from '../../hooks/useScreenReaderAnnouncements';
|
||||||
|
import { useProjectKeyboardNavigation } from '../../hooks/useProjectKeyboardNavigation';
|
||||||
|
|
||||||
|
|
||||||
interface ProjectsSectionProps {
|
interface ProjectsSectionProps {
|
||||||
|
|
@ -15,10 +16,6 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
const { texts: allTexts } = useLanguage();
|
const { texts: allTexts } = useLanguage();
|
||||||
const texts = allTexts.projects;
|
const texts = allTexts.projects;
|
||||||
const accessibilityTexts = allTexts.accessibility;
|
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
|
// Use centralized texts as defaults, allow props to override
|
||||||
const {
|
const {
|
||||||
|
|
@ -31,129 +28,21 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
})
|
})
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// Get available actions for a project
|
// Custom hooks for accessibility and keyboard navigation
|
||||||
const getProjectActions = (project: Project) => {
|
const { announce } = useScreenReaderAnnouncements();
|
||||||
const actions = [];
|
const {
|
||||||
if (project.github) actions.push({ type: 'github', url: project.github });
|
activeCardIndex,
|
||||||
if (project.live) actions.push({ type: 'live', url: project.live });
|
cardRefs,
|
||||||
return actions;
|
actionRefs,
|
||||||
};
|
handleCardSelect,
|
||||||
|
handleCardKeyDown,
|
||||||
// Handle card selection
|
handleActionKeyDown,
|
||||||
const handleCardSelect = (index: number) => {
|
getProjectActions,
|
||||||
setActiveCardIndex(index);
|
} = useProjectKeyboardNavigation({
|
||||||
setFocusedActionIndex(0);
|
projects,
|
||||||
|
announce,
|
||||||
// Announce to screen readers
|
useTabToNavigateText: accessibilityTexts.navigation.useTabToNavigate,
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="projects" className="projects-section">
|
<section id="projects" className="projects-section">
|
||||||
|
|
@ -214,7 +103,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
<div className="projects-section__image-container">
|
<div className="projects-section__image-container">
|
||||||
<img
|
<img
|
||||||
src={project.image || "/api/placeholder/400/200"}
|
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"
|
className="projects-section__image"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -224,12 +113,12 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
{/* Conditionally visible action buttons */}
|
{/* Conditionally visible action buttons */}
|
||||||
<fieldset
|
<fieldset
|
||||||
className={`projects-section__actions ${isActive ? 'projects-section__actions--visible' : ''}`}
|
className={`projects-section__actions ${isActive ? 'projects-section__actions--visible' : ''}`}
|
||||||
aria-label={`Actions for ${project.title}`}
|
|
||||||
aria-hidden={!isActive}
|
aria-hidden={!isActive}
|
||||||
>
|
>
|
||||||
|
<legend className="sr-only">{`Actions for ${project.title}`}</legend>
|
||||||
{project.github && (
|
{project.github && (
|
||||||
<a
|
<a
|
||||||
ref={(el) => { actionRefs.current[cardIndex * 2] = el }}
|
ref={(el) => { actionRefs.current[cardIndex * 2] = el; }}
|
||||||
href={project.github}
|
href={project.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|
@ -244,7 +133,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
)}
|
)}
|
||||||
{project.live && (
|
{project.live && (
|
||||||
<a
|
<a
|
||||||
ref={(el) => { actionRefs.current[cardIndex * 2 + 1] = el }}
|
ref={(el) => { actionRefs.current[cardIndex * 2 + 1] = el; }}
|
||||||
href={project.live}
|
href={project.live}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|
@ -272,7 +161,7 @@ export default function ProjectsSection(props: ProjectsSectionProps = {}) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add project metadata for screen readers */}
|
{/* 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}.
|
Project {cardIndex + 1} of {projects.length}.
|
||||||
{actions.length > 0 ? `${actions.length} actions available.` : 'No actions available.'}
|
{actions.length > 0 ? `${actions.length} actions available.` : 'No actions available.'}
|
||||||
{project.year && `Created in ${project.year}.`}
|
{project.year && `Created in ${project.year}.`}
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
|
||||||
<fieldset
|
<fieldset
|
||||||
key={category.title}
|
key={category.title}
|
||||||
className={`skills-section__category skills-section__category--${colorClass}`}
|
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">
|
<header className="skills-section__category-header">
|
||||||
<h3
|
<h3
|
||||||
id={`category-${categoryIndex}-title`}
|
id={`category-${categoryIndex}-title`}
|
||||||
|
|
@ -140,7 +140,7 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const projects = {
|
||||||
image: icaraceImage,
|
image: icaraceImage,
|
||||||
technologies: ['Angular 4', 'Typescript', 'HTML', 'CSS'],
|
technologies: ['Angular 4', 'Typescript', 'HTML', 'CSS'],
|
||||||
year: '2018',
|
year: '2018',
|
||||||
github: 'https://github.com/LuciusShadow/ErgoVR',
|
live: 'https://live.icarace.com/home',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
// All sections imported through index
|
// All sections imported through index
|
||||||
@use 'sections';
|
@use 'sections';
|
||||||
|
|
||||||
|
// Component styles
|
||||||
|
@use 'components/back-to-top';
|
||||||
|
|
||||||
// Import Geist font from Google Fonts or local files
|
// 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');
|
@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 {
|
body {
|
||||||
font-family: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
font-family:
|
||||||
'Roboto', sans-serif;
|
'Geist',
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
sans-serif;
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin: 0; // Explicitly remove body margin
|
margin: 0; // Explicitly remove body margin
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,18 +59,19 @@
|
||||||
top: -40px;
|
top: -40px;
|
||||||
left: 6px;
|
left: 6px;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-text-aaa-dark);
|
color: #000000;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: top 0.3s;
|
transition: top 0.3s;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
top: 6px;
|
top: 6px;
|
||||||
outline: 2px solid var(--color-focus-ring);
|
outline: 2px solid var(--color-focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,12 @@
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-background);
|
color: #000000;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-background);
|
color: #000000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
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(
|
export function scrollToSection(
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
currentPath: string,
|
currentPath: string,
|
||||||
|
|
@ -9,7 +12,13 @@ export function scrollToSection(
|
||||||
if (currentPath === '/') {
|
if (currentPath === '/') {
|
||||||
const element = document.getElementById(sectionId);
|
const element = document.getElementById(sectionId);
|
||||||
if (element) {
|
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) {
|
} else if (navigate) {
|
||||||
// If we're on another page, navigate to homepage with hash
|
// If we're on another page, navigate to homepage with hash
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue