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
100 changes: 81 additions & 19 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,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<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);
}, 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;
Expand All @@ -78,50 +108,82 @@ 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 (
<div data-tip-carousel="" className="mt-5 border-t border-[var(--border)] pt-4 text-left">
<section
data-tip-carousel=""
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-5 border-t border-[var(--border)] pt-4 text-left"
>
<div
className={`min-h-[92px] transition-opacity duration-base ${isFading ? "opacity-0" : "opacity-100"}`}
aria-live="off"
aria-live={isPaused ? "polite" : "off"}
aria-atomic="true"
>
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--selected)]">
<IconComponent size={13} aria-hidden="true" />
{activeTip.category}
<div className="flex items-center justify-between gap-1.5 text-xs font-medium text-[var(--selected)]">
<div className="flex items-center gap-1.5">
<IconComponent size={13} aria-hidden="true" />
{activeTip.category}
</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-[var(--selected)] cursor-pointer"
>
{isManuallyPaused ? (
<Play size={12} aria-hidden="true" />
) : (
<Pause size={12} aria-hidden="true" />
)}
</button>
</div>
<h3 className="serif mt-1.5 text-lg leading-snug text-[var(--text)]">{activeTip.title}</h3>
<p className="mt-1 text-xs leading-relaxed text-[var(--muted)]">{activeTip.description}</p>
</div>

<div className="mt-3 flex items-center justify-center gap-1">
{TIPS.map((_, idx) => (
{TIPS.map((tip, idx) => (
<button
key={idx}
type="button"
onClick={() => handleDotClick(idx)}
aria-label={`Show tip ${idx + 1} of ${TIPS.length}`}
aria-label={`Show tip ${idx + 1} of ${TIPS.length}: ${tip.title}`}
aria-current={idx === activeIdx ? "true" : undefined}
className="flex h-6 w-6 items-center justify-center rounded-full"
>
<span
aria-hidden="true"
// Active tip: a short bar rather than a red dot (red is reserved).
className={`block h-[3px] rounded-full transition-[width,background-color] duration-base ${
idx === activeIdx ? "w-4 bg-[var(--text)]" : "w-1.5 bg-[var(--border-strong)]"
}`}
/>
</button>
))}
</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("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(<TipCarousel />);
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(<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("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(<TipCarousel />);
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(<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("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(<TipCarousel />);
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(<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("Everything stays on this device")).toBeInTheDocument();
});
});
Loading