diff --git a/src/components/TipCarousel.tsx b/src/components/TipCarousel.tsx index f1633eca2..5fd158660 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,27 +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); }, 160); - }; + }, []); + // Manage auto-rotation interval based on pause state useEffect(() => { - // Auto-advancing content is motion the user didn't ask for; under - // reduced motion the tips change only when a dot is chosen. - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + 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; @@ -78,43 +108,75 @@ export default function TipCarousel() { timeoutRef.current = setTimeout(() => { setActiveIdx(idx); setIsFading(false); - if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + if (!isPaused) { intervalRef.current = setInterval(rotateTip, 6000); } }, 160); }; + 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-5 border-t border-[var(--border)] pt-4 text-left" + >
-
-
- {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..a2b769333 --- /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("Everything stays on this device")).toBeInTheDocument(); + expect(screen.getByText("Privacy")).toBeInTheDocument(); + + const dotButtons = screen.getAllByRole("button", { name: /Show 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("Everything stays on this device")).toBeInTheDocument(); + + // Advance 6000ms for rotation interval, plus 300ms for fade transition + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keep this tab in front")).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("Everything stays on this device")).toBeInTheDocument(); + + // Mouse leave to resume + fireEvent.mouseLeave(region); + + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keep this tab in front")).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("Everything stays on this device")).toBeInTheDocument(); + + // Blur out of carousel + fireEvent.blur(region, { relatedTarget: document.body }); + + act(() => { + vi.advanceTimersByTime(6000); + vi.advanceTimersByTime(300); + }); + + expect(screen.getByText("Keep this tab in front")).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("Everything stays on this device")).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 in front")).toBeInTheDocument(); + }); + + it("jumps to selected tip when clicking a pagination dot", () => { + render(); + const thirdDot = screen.getByRole("button", { name: /Show 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("Everything stays on this device")).toBeInTheDocument(); + }); +});