Skip to content
Merged
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
88 changes: 65 additions & 23 deletions packages/web/lib/components/home/HeroDecodeBand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
import { Link2 } from "lucide-react";
import { Link2, Pause, Play } from "lucide-react";
import { ScanBracketGlyph } from "@/lib/design-system";
import { cn } from "@/lib/utils";
import type { HeroDecodeBandData, HeroSlide } from "./types";

const ROTATE_MS = 5000;
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

/**
* Decode-first hero: left column = headline + subcopy + Quick Decode card
Expand Down Expand Up @@ -71,25 +72,50 @@ export function HeroDecodeBand({
function HeroCarousel({ slides }: { slides: HeroSlide[] }) {
const t = useTranslations("Home");
const [active, setActive] = useState(0);
// WCAG 2.2.2 (Pause, Stop, Hide): content that auto-advances for 5s+ must
// be user-controllable. `paused` starts `false` so the first client render
// matches the server-rendered markup (reading matchMedia in a lazy
// initializer would make reduced-motion clients hydrate as `true` while
// the server always renders `false`, mismatching the toggle's aria-label
// and icon). The effect below reads the current preference right after
// mount and flips `paused` to `true` for reduced-motion users well before
// the first 5s rotation tick, so no rotation is ever visible. The visible
// toggle button lets anyone stop or resume it. `interacting` transiently
// pauses on hover/focus.
const [paused, setPaused] = useState(false);
const [interacting, setInteracting] = useState(false);
const n = slides.length;

useEffect(() => {
if (n <= 1) return;
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return;
}
if (typeof window === "undefined") return;
const mql = window.matchMedia(REDUCED_MOTION_QUERY);
if (mql.matches) setPaused(true);
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setPaused(true);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);

const isRotating = n > 1 && !paused && !interacting;

useEffect(() => {
if (!isRotating) return;
const rotateTimer = setInterval(
() => setActive((a) => (a + 1) % n),
ROTATE_MS
);
return () => clearInterval(rotateTimer);
}, [n]);
}, [isRotating, n]);

return (
<div className="mx-auto hidden w-[260px] max-w-full overflow-hidden md:block lg:mx-0 lg:justify-self-center">
<div
className="mx-auto hidden w-[260px] max-w-full overflow-hidden md:block lg:mx-0 lg:justify-self-center"
onMouseEnter={() => setInteracting(true)}
onMouseLeave={() => setInteracting(false)}
onFocus={() => setInteracting(true)}
onBlur={() => setInteracting(false)}
>
<div className="relative h-[300px]">
{slides.map((slide, i) => {
const rel = (((i - active) % n) + n) % n;
Expand All @@ -114,19 +140,35 @@ function HeroCarousel({ slides }: { slides: HeroSlide[] }) {
</div>

{n > 1 && (
<div className="mt-4 flex justify-center gap-1.5">
{slides.map((_, i) => (
<button
key={i}
type="button"
aria-label={t("hero.showLookAria", { index: i + 1 })}
onClick={() => setActive(i)}
className={cn(
"h-1.5 rounded-full transition-all",
i === active ? "w-5 bg-primary" : "w-1.5 bg-border"
)}
/>
))}
<div className="mt-4 flex items-center justify-center gap-3">
<div className="flex gap-1.5">
{slides.map((_, i) => (
<button
key={i}
type="button"
aria-label={t("hero.showLookAria", { index: i + 1 })}
onClick={() => setActive(i)}
className={cn(
"h-1.5 rounded-full transition-all",
i === active ? "w-5 bg-primary" : "w-1.5 bg-border"
)}
/>
))}
</div>
<button
type="button"
aria-label={
paused ? t("hero.playCarouselAria") : t("hero.pauseCarouselAria")
}
onClick={() => setPaused((p) => !p)}
className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent"
>
{paused ? (
<Play className="size-3" />
) : (
<Pause className="size-3" />
)}
</button>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { NextIntlClientProvider } from "next-intl";
import en from "@/messages/en.json";

// Sever the design-system barrel (it pulls the app-wide store/API graph);
// HeroDecodeBand only needs the ScanBracketGlyph icon.
vi.mock("@/lib/design-system", () => ({
ScanBracketGlyph: () => <svg />,
}));
vi.mock("next/image", () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img {...props} />
),
}));

import { HeroDecodeBand } from "../HeroDecodeBand";
import type { HeroSlide } from "../types";

function makeSlides(n: number): HeroSlide[] {
return Array.from({ length: n }, (_, i) => ({
imageUrl: `https://example.com/${i}.jpg`,
styleRead: null,
}));
}

function renderHero(slides: HeroSlide[]) {
return render(
<NextIntlClientProvider locale="en" messages={en}>
<HeroDecodeBand data={{ slides }} />
</NextIntlClientProvider>
);
}

/** Controllable `window.matchMedia` mock for `(prefers-reduced-motion: reduce)`. */
function mockMatchMedia(initialMatches: boolean) {
let matches = initialMatches;
const listeners = new Set<(e: MediaQueryListEvent) => void>();
const mql = {
get matches() {
return matches;
},
media: "(prefers-reduced-motion: reduce)",
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
listeners.add(cb);
},
removeEventListener: (
_type: string,
cb: (e: MediaQueryListEvent) => void
) => {
listeners.delete(cb);
},
dispatchEvent: () => true,
};
window.matchMedia = vi.fn().mockReturnValue(mql);
return {
setMatches(next: boolean) {
matches = next;
listeners.forEach((cb) => cb({ matches: next } as MediaQueryListEvent));
},
};
}

/** Active look = the dot button carrying the "current" width class. */
function activeDotIndex(): number {
const dots = screen.getAllByRole("button", { name: /^Show look \d+$/ });
return dots.findIndex((d) => d.className.includes("w-5"));
}

describe("HeroDecodeBand carousel auto-rotate pause/control (#848)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it("auto-rotates every 5s by default when motion is not reduced", () => {
mockMatchMedia(false);
renderHero(makeSlides(3));

expect(activeDotIndex()).toBe(0);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(activeDotIndex()).toBe(1);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(activeDotIndex()).toBe(2);
});

it("shows a visible pause toggle and stops rotation once paused", () => {
mockMatchMedia(false);
renderHero(makeSlides(3));

const pauseButton = screen.getByRole("button", { name: "Pause carousel" });
// APG rotation-control pattern: accessible-name swap alone communicates
// state; no aria-pressed (avoids "Play carousel, pressed" contradictions).
expect(pauseButton).not.toHaveAttribute("aria-pressed");

fireEvent.click(pauseButton);

const playButton = screen.getByRole("button", { name: "Play carousel" });
expect(playButton).not.toHaveAttribute("aria-pressed");

act(() => {
vi.advanceTimersByTime(15000);
});
expect(activeDotIndex()).toBe(0);
});

it("resumes rotation from the current slide when play is pressed again", () => {
mockMatchMedia(false);
renderHero(makeSlides(3));

fireEvent.click(screen.getByRole("button", { name: "Pause carousel" }));
fireEvent.click(screen.getByRole("button", { name: "Play carousel" }));

act(() => {
vi.advanceTimersByTime(5000);
});
expect(activeDotIndex()).toBe(1);
});

it("does not auto-start rotation when prefers-reduced-motion is set", () => {
mockMatchMedia(true);
renderHero(makeSlides(3));

// Starts paused: the toggle already reads "Play carousel".
expect(
screen.getByRole("button", { name: "Play carousel" })
).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(15000);
});
expect(activeDotIndex()).toBe(0);
});

it("pauses rotation on focus-within and resumes after blur", () => {
mockMatchMedia(false);
renderHero(makeSlides(3));

const pauseButton = screen.getByRole("button", { name: "Pause carousel" });
fireEvent.focus(pauseButton);

act(() => {
vi.advanceTimersByTime(15000);
});
expect(activeDotIndex()).toBe(0);

fireEvent.blur(pauseButton);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(activeDotIndex()).toBe(1);
});

it("does not render a pause toggle for a single slide (nothing to control)", () => {
mockMatchMedia(false);
renderHero(makeSlides(1));

expect(
screen.queryByRole("button", { name: "Pause carousel" })
).not.toBeInTheDocument();
});
});
4 changes: 3 additions & 1 deletion packages/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@
"decodeCta": "Decode a look",
"pasteUrlCta": "Paste URL",
"slideAlt": "Decoded look",
"showLookAria": "Show look {index}"
"showLookAria": "Show look {index}",
"pauseCarouselAria": "Pause carousel",
"playCarouselAria": "Play carousel"
},
"quickDecode": {
"aria": "Quick decode",
Expand Down
4 changes: 3 additions & 1 deletion packages/web/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@
"decodeCta": "룩 디코드하기",
"pasteUrlCta": "URL 붙여넣기",
"slideAlt": "디코드된 룩",
"showLookAria": "{index}번 룩 보기"
"showLookAria": "{index}번 룩 보기",
"pauseCarouselAria": "캐러셀 일시정지",
"playCarouselAria": "캐러셀 재생"
},
"quickDecode": {
"aria": "빠른 디코드",
Expand Down
Loading