diff --git a/src/components/a11y/LiveRegion.tsx b/src/components/a11y/LiveRegion.tsx new file mode 100644 index 00000000..64bf8ee9 --- /dev/null +++ b/src/components/a11y/LiveRegion.tsx @@ -0,0 +1,68 @@ +import React, { createContext, memo, useCallback, useContext, useState } from 'react'; + +interface LiveRegionContextValue { + announce: (message: string, politeness?: 'polite' | 'assertive') => void; +} + +const LiveRegionContext = createContext(null); + +export const LiveRegionProvider: React.FC<{ children: React.ReactNode }> = memo(({ children }) => { + const [politeMessage, setPoliteMessage] = useState(''); + const [assertiveMessage, setAssertiveMessage] = useState(''); + + const announce = useCallback((message: string, politeness: 'polite' | 'assertive' = 'polite') => { + if (politeness === 'assertive') { + setAssertiveMessage(''); + window.setTimeout(() => setAssertiveMessage(message), 30); + return; + } + + setPoliteMessage(''); + window.setTimeout(() => setPoliteMessage(message), 30); + }, []); + + return ( + + {children} +
+ {politeMessage} +
+
+ {assertiveMessage} +
+
+ ); +}); + +LiveRegionProvider.displayName = 'LiveRegionProvider'; + +export const useLiveRegion = (): LiveRegionContextValue['announce'] => { + const ctx = useContext(LiveRegionContext); + return ctx?.announce ?? (() => undefined); +}; diff --git a/src/components/builder/components/CommandPalette.tsx b/src/components/builder/components/CommandPalette.tsx index ed4d3576..71d8a846 100644 --- a/src/components/builder/components/CommandPalette.tsx +++ b/src/components/builder/components/CommandPalette.tsx @@ -27,6 +27,7 @@ import { Command, X, } from 'lucide-react'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; export interface PaletteCommand { id: string; @@ -60,6 +61,8 @@ const CommandPalette: React.FC = memo(({ isOpen, onClose, commands }) => const [recentIds, setRecentIds] = useState([]); const inputRef = useRef(null); const listRef = useRef(null); + const panelRef = useRef(null); + useFocusTrap(isOpen, panelRef, onClose); // FIX: Push state to history to intercept mobile back button useEffect(() => { @@ -235,6 +238,11 @@ const CommandPalette: React.FC = memo(({ isOpen, onClose, commands }) => `}
(({ isOpen, onClose, onLoad, anchorRef }) => { top: number; left: number; } | null>(null); + useFocusTrap(isOpen, popoverRef, onClose); useEffect(() => { if (!isOpen) return; @@ -70,6 +72,10 @@ const ImportDialog = memo(({ isOpen, onClose, onLoad, anchorRef }) => { return (
= memo(({ config, setConfig }) => { = memo(({ config, setConfig }) => {
} > - +
+ +
); }); diff --git a/src/components/builder/components/layout/MobileDock.tsx b/src/components/builder/components/layout/MobileDock.tsx index 82019b3e..5f11de22 100644 --- a/src/components/builder/components/layout/MobileDock.tsx +++ b/src/components/builder/components/layout/MobileDock.tsx @@ -58,7 +58,9 @@ const MobileDock: React.FC<{
diff --git a/src/components/dashboard/HeroSection.tsx b/src/components/dashboard/HeroSection.tsx index bfc2c9c9..23bddbd6 100644 --- a/src/components/dashboard/HeroSection.tsx +++ b/src/components/dashboard/HeroSection.tsx @@ -1,6 +1,7 @@ import { memo, useState, useCallback, useEffect, useRef } from 'react'; import { ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react'; import { API } from '@/lib/dashboard/constants'; +import { useAnimation } from '@/hooks/useAnimation'; interface HeroPoster { id: string; @@ -101,6 +102,7 @@ const CORNER_STYLE = (c: (typeof CORNERS)[number]): React.CSSProperties => ({ }); const CyclingPoster = memo(() => { + const animationsAllowed = useAnimation(); const [activeIdx, setActiveIdx] = useState(0); const [transitioning, setTransitioning] = useState(false); const [loaded, setLoaded] = useState>({}); @@ -159,6 +161,11 @@ const CyclingPoster = memo(() => { const doTransition = useCallback( (next: number) => { if (next === activeIdxRef.current || transitioningRef.current || !isPosterReady(next)) return; + if (!animationsAllowed) { + setActiveIdx(next); + finishTransition(); + return; + } clearTransitionTimers(); transitioningRef.current = true; setTransitioning(true); @@ -170,7 +177,7 @@ const CyclingPoster = memo(() => { }, 50); guardTimerRef.current = setTimeout(finishTransition, 1400); }, - [clearTransitionTimers, finishTransition, isPosterReady] + [animationsAllowed, clearTransitionTimers, finishTransition, isPosterReady] ); const goTo = useCallback( @@ -181,12 +188,13 @@ const CyclingPoster = memo(() => { ); const restartInterval = useCallback(() => { + if (!animationsAllowed) return; if (intervalRef.current) clearInterval(intervalRef.current); intervalRef.current = setInterval(() => { if (!isVisibleRef.current || transitioningRef.current) return; goTo((activeIdxRef.current + 1) % TOTAL); }, 4500); - }, [goTo]); + }, [animationsAllowed, goTo]); useEffect(() => { if (!loaded[0]) return; @@ -278,7 +286,7 @@ const CyclingPoster = memo(() => { display: 'block', opacity: i === activeIdx ? 1 : 0, willChange: transitioning ? 'opacity' : 'auto', - transition: 'opacity 0.35s ease', + transition: animationsAllowed ? 'opacity 0.35s ease' : 'none', pointerEvents: 'none', }} /> @@ -293,9 +301,9 @@ const CyclingPoster = memo(() => { zIndex: 1, background: 'linear-gradient(110deg,#151310 25%,#1e1b16 50%,#151310 75%)', backgroundSize: '200% 100%', - animation: 'shimmer 1.6s linear infinite', - }} - /> + animation: animationsAllowed ? 'shimmer 1.6s linear infinite' : 'none', + }} + /> )} {failed[activeIdx] && (
{ width: i === activeIdx ? 20 : 6, height: 6, borderRadius: 3, - transition: 'all 0.3s cubic-bezier(0.16,1,0.3,1)', - flexShrink: 0, - }} + transition: animationsAllowed ? 'all 0.3s cubic-bezier(0.16,1,0.3,1)' : 'none', + flexShrink: 0, + }} /> ))}
diff --git a/src/components/dashboard/Nav.tsx b/src/components/dashboard/Nav.tsx index 67c3b034..3760bdd7 100644 --- a/src/components/dashboard/Nav.tsx +++ b/src/components/dashboard/Nav.tsx @@ -1,5 +1,7 @@ import { memo, useState, useCallback, useEffect } from 'react'; import { Github, Menu, X } from 'lucide-react'; +import { useRef } from 'react'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; const NAV_LINKS = [ { label: 'Reel', href: '#reel' }, @@ -33,7 +35,9 @@ const Nav = memo(() => { const [visible, setVisible] = useState(false); const [scrolled, setScrolled] = useState(false); const [menuOpen, setMenuOpen] = useState(false); + const mobileMenuRef = useRef(null); const closeMenu = useCallback(() => setMenuOpen(false), []); + useFocusTrap(menuOpen && visible, mobileMenuRef, closeMenu); useEffect(() => { const update = () => { @@ -142,9 +146,10 @@ const Nav = memo(() => {