From 9186be7e3ee17046e63e6aa5a2e0dc94b9b41516 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Wed, 18 Feb 2026 13:52:40 +0000 Subject: [PATCH] Added aria attributes/keyboard nav to carousel --- src/components/tiles/ProjectsTile.tsx | 93 +++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/src/components/tiles/ProjectsTile.tsx b/src/components/tiles/ProjectsTile.tsx index 47062e3..b8f21b5 100644 --- a/src/components/tiles/ProjectsTile.tsx +++ b/src/components/tiles/ProjectsTile.tsx @@ -13,6 +13,12 @@ interface ProjectItemProps { slideWidth: string cardMinHeight: number onClick: () => void + index: number + total: number + cardRef?: (el: HTMLDivElement | null) => void + onArrowKey?: (direction: -1 | 1) => void + onEscape?: () => void + isInert?: boolean } function ProjectItem({ @@ -20,6 +26,12 @@ function ProjectItem({ slideWidth, cardMinHeight, onClick, + index, + total, + cardRef, + onArrowKey, + onEscape, + isInert, }: ProjectItemProps) { const dotColor = PROJECT_STATUS_COLORS[project.status] const livePillLabel = project.demoUrl ? 'Live Demo' : project.externalUrl ? 'Live' : null @@ -30,15 +42,28 @@ function ProjectItem({ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onClick() + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + onArrowKey?.(-1) + } else if (e.key === 'ArrowRight') { + e.preventDefault() + onArrowKey?.(1) + } else if (e.key === 'Escape') { + e.preventDefault() + onEscape?.() } }, - [onClick], + [onClick, onArrowKey, onEscape], ) const maxVisibleResults = 4 return (
>(new Map()) + const [emblaRef, emblaApi] = useEmblaCarousel( { loop: true, align: 'start' }, [Autoplay({ delay: 4000, stopOnInteraction: false, stopOnMouseEnter: true })], @@ -435,17 +463,41 @@ function EmblaProjectsCarousel() { } }, [emblaApi, onSelect]) + const handleArrowKey = useCallback((currentIndex: number, direction: -1 | 1) => { + const nextIndex = (currentIndex + direction + investigations.length) % investigations.length + cardRefs.current.get(nextIndex)?.focus() + emblaApi?.scrollTo(nextIndex) + }, [emblaApi]) + + const handleEscape = useCallback(() => { + wrapperRef.current?.focus() + }, []) + return ( -
+
- {investigations.map((project) => ( + {investigations.map((project, i) => ( openPanel({ type: 'project', investigation: project })} + index={i} + total={investigations.length} + cardRef={(el) => { + if (el) cardRefs.current.set(i, el) + else cardRefs.current.delete(i) + }} + onArrowKey={(dir) => handleArrowKey(i, dir)} + onEscape={handleEscape} /> ))}
@@ -502,6 +554,8 @@ function ContinuousScrollCarousel() { : false, ) const resumeTimeoutRef = useRef(0) + const containerRef = useRef(null) + const cardRefs = useRef>(new Map()) const jumpByCards = useCallback((direction: 1 | -1) => { const trackEl = trackRef.current @@ -620,6 +674,16 @@ function ContinuousScrollCarousel() { isPausedRef.current = value } + const handleArrowKey = useCallback((currentIndex: number, direction: -1 | 1) => { + const nextIndex = (currentIndex + direction + investigations.length) % investigations.length + cardRefs.current.get(nextIndex)?.focus() + jumpByCards(direction) + }, [jumpByCards]) + + const handleEscape = useCallback(() => { + containerRef.current?.focus() + }, []) + const arrowStyle: React.CSSProperties = { position: 'absolute', top: '50%', @@ -642,7 +706,14 @@ function ContinuousScrollCarousel() { } return ( -
+
- {investigations.map((project) => ( + {investigations.map((project, i) => ( openPanel({ type: 'project', investigation: project })} + index={i} + total={investigations.length} + isInert={setIndex === 1} + cardRef={setIndex === 0 ? (el) => { + if (el) cardRefs.current.set(i, el) + else cardRefs.current.delete(i) + } : undefined} + onArrowKey={setIndex === 0 ? (dir) => handleArrowKey(i, dir) : undefined} + onEscape={setIndex === 0 ? handleEscape : undefined} /> ))}