Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 88 additions & 20 deletions src/components/TipCarousel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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;
Expand All @@ -75,56 +108,91 @@ 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 (
<div
<section
aria-roledescription="carousel"
aria-label="Workflow tips"
onMouseEnter={() => 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 */}
<div
<div
aria-live={isPaused ? "polite" : "off"}
aria-atomic="true"
className={`flex-1 transition-all duration-300 transform ${
isFading ? "opacity-0 -translate-y-1.5 scale-[0.99]" : "opacity-100 translate-y-0 scale-100"
}`}
>
<div className="flex items-center gap-2">
<IconComponent className="text-film-600" size={14} />
<span className="text-[10px] font-heading font-bold uppercase tracking-widest text-film-600">
{activeTip.category}
</span>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<IconComponent className="text-film-600" size={14} />
<span className="text-[10px] font-heading font-bold uppercase tracking-widest text-film-600">
{activeTip.category}
</span>
</div>

<button
type="button"
onClick={togglePlayPause}
aria-label={isManuallyPaused ? "Resume auto-rotation" : "Pause auto-rotation"}
aria-pressed={isManuallyPaused}
className="p-1 rounded text-[var(--muted)] hover:text-[var(--text)] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-film-600 cursor-pointer"
>
{isManuallyPaused ? (
<Play size={12} aria-hidden="true" />
) : (
<Pause size={12} aria-hidden="true" />
)}
</button>
</div>

<h4 className="text-xs font-heading font-bold mt-1 text-[var(--text)] tracking-wide uppercase">
{activeTip.title}
</h4>

<p className="text-[11px] leading-relaxed text-[var(--muted)] mt-1.5">
{activeTip.description}
</p>
</div>

{/* Pagination Indicators */}
<div className="flex items-center justify-center gap-1.5 mt-4">
{TIPS.map((_, idx) => (
{TIPS.map((tip, idx) => (
<button
key={idx}
type="button"
onClick={() => handleDotClick(idx)}
aria-label={`Go to tip ${idx + 1}`}
aria-label={`Go to tip ${idx + 1}: ${tip.title}`}
aria-current={idx === activeIdx ? "true" : undefined}
className={`h-1.5 rounded-full transition-all duration-300 ease-out cursor-pointer ${
idx === activeIdx
idx === activeIdx
? "w-4 bg-film-600"
: "w-1.5 bg-[var(--border)] hover:opacity-80"
}`}
/>
))}
</div>
</div>
</section>
);
}
161 changes: 161 additions & 0 deletions src/components/__tests__/TipCarousel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TipCarousel />);
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(<TipCarousel />);
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(<TipCarousel />);
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(<TipCarousel />);
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(<TipCarousel />);
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(<TipCarousel />);
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(<TipCarousel />);
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(<TipCarousel />);
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();
});
});
Loading