feat(a11y): implement screen reader support and accessibility optimizations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
const pathname = usePathname();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
@@ -76,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
}, [isOpen, currentIndex, updateUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen) {
|
||||
if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture previous focus
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus close button on open
|
||||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'ArrowLeft') prevImage();
|
||||
if (e.key === 'ArrowRight') nextImage();
|
||||
|
||||
// Focus Trap
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = document.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const modalElements = Array.from(focusableElements).filter((el) =>
|
||||
document.querySelector('[role="dialog"]')?.contains(el),
|
||||
);
|
||||
|
||||
if (modalElements.length === 0) return;
|
||||
|
||||
const firstElement = modalElements[0] as HTMLElement;
|
||||
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lock scroll
|
||||
@@ -101,7 +141,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -116,6 +160,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ delay: 0.1, duration: 0.4 }}
|
||||
ref={closeButtonRef}
|
||||
onClick={handleClose}
|
||||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||||
aria-label="Close lightbox"
|
||||
|
||||
Reference in New Issue
Block a user