+
Project {cardIndex + 1} of {projects.length}.
{actions.length > 0 ? `${actions.length} actions available.` : 'No actions available.'}
{project.year && `Created in ${project.year}.`}
diff --git a/src/components/sections/SkillsSection.tsx b/src/components/sections/SkillsSection.tsx
index 2006c51..215f6d1 100644
--- a/src/components/sections/SkillsSection.tsx
+++ b/src/components/sections/SkillsSection.tsx
@@ -70,8 +70,8 @@ export default function SkillsSection(props: SkillsSectionProps = {}) {
- )
+ );
})}
diff --git a/src/config/locales/en/projects.ts b/src/config/locales/en/projects.ts
index ad0ef8d..85506f3 100644
--- a/src/config/locales/en/projects.ts
+++ b/src/config/locales/en/projects.ts
@@ -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,
diff --git a/src/hooks/useProjectKeyboardNavigation.ts b/src/hooks/useProjectKeyboardNavigation.ts
new file mode 100644
index 0000000..742afbe
--- /dev/null
+++ b/src/hooks/useProjectKeyboardNavigation.ts
@@ -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
, cardIndex: number) => void;
+ handleActionKeyDown: (event: KeyboardEvent, 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(null);
+ const [focusedActionIndex, setFocusedActionIndex] = useState(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, 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, 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,
+ };
+}
diff --git a/src/hooks/useScreenReaderAnnouncements.ts b/src/hooks/useScreenReaderAnnouncements.ts
new file mode 100644
index 0000000..b9a79f3
--- /dev/null
+++ b/src/hooks/useScreenReaderAnnouncements.ts
@@ -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 };
+}
diff --git a/src/scss/App.scss b/src/scss/App.scss
index 345151f..6bfcf58 100644
--- a/src/scss/App.scss
+++ b/src/scss/App.scss
@@ -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
diff --git a/src/scss/components/back-to-top.scss b/src/scss/components/back-to-top.scss
new file mode 100644
index 0000000..6036915
--- /dev/null
+++ b/src/scss/components/back-to-top.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/scss/globals.scss b/src/scss/globals.scss
index 35c618f..87dd1bd 100644
--- a/src/scss/globals.scss
+++ b/src/scss/globals.scss
@@ -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;
}
}
diff --git a/src/scss/language-toggle.scss b/src/scss/language-toggle.scss
index f4f3009..91ef1d8 100644
--- a/src/scss/language-toggle.scss
+++ b/src/scss/language-toggle.scss
@@ -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;
}
}
}
diff --git a/src/utils/scrollUtils.ts b/src/utils/scrollUtils.ts
index dd8b26c..8f945bb 100644
--- a/src/utils/scrollUtils.ts
+++ b/src/utils/scrollUtils.ts
@@ -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