From 9bebe2bc0f9a66f461ef759d9cc9c51960f9fbd2 Mon Sep 17 00:00:00 2001 From: Chirag Jangir <184008125+chiragjangiir@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:29:02 +0530 Subject: [PATCH] fix(a11y): add pause controls and WCAG 2.2.2 carousel semantics to TipCarousel - Add pause-on-hover and pause-on-focus to satisfy WCAG 2.2.2 (Pause, Stop, Hide) - Add accessible play/pause toggle button with dynamic aria-label and aria-pressed - Respect prefers-reduced-motion media query by defaulting to paused state - Add carousel ARIA attributes (aria-roledescription, aria-label, aria-live, aria-current) - Add comprehensive unit test suite in src/components/__tests__/TipCarousel.test.tsx Closes #1773 --- src/components/TipCarousel.tsx | 108 +++++++++--- src/components/__tests__/TipCarousel.test.tsx | 161 ++++++++++++++++++ 2 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 src/components/__tests__/TipCarousel.test.tsx diff --git a/src/components/TipCarousel.tsx b/src/components/TipCarousel.tsx index 31ece14d9..02007e509 100644 --- a/src/components/TipCarousel.tsx +++ b/src/components/TipCarousel.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState, useRef } from "react"; -import { ShieldCheck, Zap, Keyboard, Cpu, SlidersHorizontal } from "lucide-react"; +import { useEffect, useState, useRef, useCallback } from "react"; +import { ShieldCheck, Zap, Keyboard, Cpu, SlidersHorizontal, Pause, Play } from "lucide-react"; interface Tip { category: string; @@ -46,24 +46,57 @@ const TIPS: Tip[] = [ export default function TipCarousel() { const [activeIdx, setActiveIdx] = useState(0); const [isFading, setIsFading] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [isManuallyPaused, setIsManuallyPaused] = useState(() => { + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + return false; + }); + const timeoutRef = useRef | null>(null); const intervalRef = useRef | null>(null); - const rotateTip = () => { + // Subscribe to prefers-reduced-motion media query changes + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const handleChange = (e: MediaQueryListEvent) => { + setIsManuallyPaused(e.matches); + }; + mediaQuery.addEventListener("change", handleChange); + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; + }, []); + + const isPaused = isManuallyPaused || isHovered || isFocused; + + const rotateTip = useCallback(() => { setIsFading(true); timeoutRef.current = setTimeout(() => { setActiveIdx((prev) => (prev + 1) % TIPS.length); setIsFading(false); }, 300); - }; + }, []); + // Manage auto-rotation interval based on pause state useEffect(() => { + if (isPaused) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + intervalRef.current = setInterval(rotateTip, 6000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; - }, []); + }, [isPaused, rotateTip]); const handleDotClick = (idx: number) => { if (idx === activeIdx || isFading) return; @@ -75,35 +108,69 @@ export default function TipCarousel() { timeoutRef.current = setTimeout(() => { setActiveIdx(idx); setIsFading(false); - intervalRef.current = setInterval(rotateTip, 6000); + if (!isPaused) { + intervalRef.current = setInterval(rotateTip, 6000); + } }, 300); }; + const togglePlayPause = () => { + setIsManuallyPaused((prev) => !prev); + }; + const activeTip = TIPS[activeIdx]; if (!activeTip) return null; - const IconComponent = activeTip.icon; + const IconComponent = activeTip.icon; return ( -
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onFocus={() => setIsFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false); + } + }} className="mt-6 p-4 rounded-xl border bg-[var(--surface)] border-[var(--border)] text-[var(--text)] text-left flex flex-col justify-between min-h-[142px] transition-all duration-300 shadow-sm relative overflow-hidden" > {/* Dynamic Slide Container */} -
-
- - - {activeTip.category} - +
+
+ + + {activeTip.category} + +
+ +
- +

{activeTip.title}

- +

{activeTip.description}

@@ -111,20 +178,21 @@ export default function TipCarousel() { {/* Pagination Indicators */}
- {TIPS.map((_, idx) => ( + {TIPS.map((tip, idx) => (
-
+ ); } diff --git a/src/components/__tests__/TipCarousel.test.tsx b/src/components/__tests__/TipCarousel.test.tsx new file mode 100644 index 000000000..d67d566aa --- /dev/null +++ b/src/components/__tests__/TipCarousel.test.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import TipCarousel from "../TipCarousel"; + +describe("TipCarousel Accessibility (WCAG 2.2.2)", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("renders with carousel region role and accessible label", () => { + render(); + const region = screen.getByRole("region", { name: "Workflow tips" }); + expect(region).toBeInTheDocument(); + expect(region).toHaveAttribute("aria-roledescription", "carousel"); + }); + + it("renders the first tip initially and exposes pagination indicators", () => { + render(); + expect(screen.getByText("100% Local Sandbox")).toBeInTheDocument(); + expect(screen.getByText("PRIVACY FIRST")).toBeInTheDocument(); + + const dotButtons = screen.getAllByRole("button", { name: /Go to tip/i }); + expect(dotButtons).toHaveLength(5); + expect(dotButtons[0]).toHaveAttribute("aria-current", "true"); + expect(dotButtons[1]).not.toHaveAttribute("aria-current"); + }); + + it("auto-advances to the next tip every 6000ms when unpaused", () => { + render(); + expect(screen.getByText("100% Local Sandbox")).toBeInTheDocument(); + + // Advance 6000ms for rotation interval, plus 300ms for fade transition + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keep This Tab Active")).toBeInTheDocument(); + }); + + it("pauses auto-rotation on mouse hover and resumes on mouse leave", () => { + render(); + const region = screen.getByRole("region", { name: "Workflow tips" }); + + // Hover to pause + fireEvent.mouseEnter(region); + + act(() => { + vi.advanceTimersByTime(12000); + }); + + // Should stay on the first tip + expect(screen.getByText("100% Local Sandbox")).toBeInTheDocument(); + + // Mouse leave to resume + fireEvent.mouseLeave(region); + + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keep This Tab Active")).toBeInTheDocument(); + }); + + it("pauses auto-rotation on focus and resumes on blur", () => { + render(); + const region = screen.getByRole("region", { name: "Workflow tips" }); + + // Focus into carousel + fireEvent.focus(region); + + act(() => { + vi.advanceTimersByTime(12000); + }); + + expect(screen.getByText("100% Local Sandbox")).toBeInTheDocument(); + + // Blur out of carousel + fireEvent.blur(region, { relatedTarget: document.body }); + + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keep This Tab Active")).toBeInTheDocument(); + }); + + it("toggles pause and play via the visible pause button", () => { + render(); + const pauseBtn = screen.getByRole("button", { name: /Pause auto-rotation/i }); + expect(pauseBtn).toHaveAttribute("aria-pressed", "false"); + + // Click pause button + fireEvent.click(pauseBtn); + expect(pauseBtn).toHaveAttribute("aria-label", "Resume auto-rotation"); + expect(pauseBtn).toHaveAttribute("aria-pressed", "true"); + + // Advancing time should not change tip + act(() => { + vi.advanceTimersByTime(12000); + }); + expect(screen.getByText("100% Local Sandbox")).toBeInTheDocument(); + + // Click resume button + fireEvent.click(pauseBtn); + expect(pauseBtn).toHaveAttribute("aria-label", "Pause auto-rotation"); + expect(pauseBtn).toHaveAttribute("aria-pressed", "false"); + + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + expect(screen.getByText("Keep This Tab Active")).toBeInTheDocument(); + }); + + it("jumps to selected tip when clicking a pagination dot", () => { + render(); + const thirdDot = screen.getByRole("button", { name: /Go to tip 3/i }); + + fireEvent.click(thirdDot); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keyboard Shortcuts")).toBeInTheDocument(); + expect(thirdDot).toHaveAttribute("aria-current", "true"); + }); + + it("initializes in paused state when prefers-reduced-motion is enabled", () => { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: query.includes("prefers-reduced-motion: reduce"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + render(); + const button = screen.getByRole("button", { name: /Resume auto-rotation/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("aria-pressed", "true"); + + act(() => { + vi.advanceTimersByTime(12000); + }); + expect(screen.getByText("100% Local Sandbox")).toBeInTheDocument(); + }); +});