From ba2b3aa68db7a5b2de627ca3b30b97771cecf169 Mon Sep 17 00:00:00 2001 From: thxforall <113906780+thxforall@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:38:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20Hero=20=EC=BA=90=EB=9F=AC?= =?UTF-8?q?=EC=85=80=20=EC=9E=90=EB=8F=99=ED=9A=8C=EC=A0=84=20=EC=A0=95?= =?UTF-8?q?=EC=A7=80/=EC=A0=9C=EC=96=B4=20=EC=B6=94=EA=B0=80=20(WCAG=202.2?= =?UTF-8?q?.2)=20(#848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HeroDecodeBand의 HeroCarousel이 5초 간격으로 무한 자동회전하지만 사용자가 멈출 방법이 없었다(WCAG 2.2.2 Pause, Stop, Hide 위반). - 도트 옆에 보이는 pause/play 토글 버튼 추가 (aria-label + aria-pressed) - prefers-reduced-motion: reduce 시 기본 정지 + 런타임 변경 리스닝 - 포커스/호버 시(focus-within, hover) 자동회전 일시정지 - 회전 로직은 그대로(5s interval), 정지 조건만 추가 이 커밋은 #848 중 Hero-pause만; 개인화 피드·타이틀·streaming은 별도(#840 머지 후). 리뷰 반영: hydration 안전 초기화(paused 초기값 false + 마운트 후 matchMedia 반영) + APG aria(aria-pressed 제거, aria-label swap만 유지) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/components/home/HeroDecodeBand.tsx | 88 ++++++--- .../__tests__/HeroDecodeBand.pause.test.tsx | 175 ++++++++++++++++++ packages/web/messages/en.json | 4 +- packages/web/messages/ko.json | 4 +- 4 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx diff --git a/packages/web/lib/components/home/HeroDecodeBand.tsx b/packages/web/lib/components/home/HeroDecodeBand.tsx index f4d07d32..4e3a60d2 100644 --- a/packages/web/lib/components/home/HeroDecodeBand.tsx +++ b/packages/web/lib/components/home/HeroDecodeBand.tsx @@ -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 @@ -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 ( -
+
setInteracting(true)} + onMouseLeave={() => setInteracting(false)} + onFocus={() => setInteracting(true)} + onBlur={() => setInteracting(false)} + >
{slides.map((slide, i) => { const rel = (((i - active) % n) + n) % n; @@ -114,19 +140,35 @@ function HeroCarousel({ slides }: { slides: HeroSlide[] }) {
{n > 1 && ( -
- {slides.map((_, i) => ( -
+
)}
diff --git a/packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx b/packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx new file mode 100644 index 00000000..6ca30fe4 --- /dev/null +++ b/packages/web/lib/components/home/__tests__/HeroDecodeBand.pause.test.tsx @@ -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: () => , +})); +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + // eslint-disable-next-line @next/next/no-img-element + + ), +})); + +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( + + + + ); +} + +/** 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(); + }); +}); diff --git a/packages/web/messages/en.json b/packages/web/messages/en.json index 9285dcd1..66bfb0a1 100644 --- a/packages/web/messages/en.json +++ b/packages/web/messages/en.json @@ -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", diff --git a/packages/web/messages/ko.json b/packages/web/messages/ko.json index 5837ae76..bf641218 100644 --- a/packages/web/messages/ko.json +++ b/packages/web/messages/ko.json @@ -87,7 +87,9 @@ "decodeCta": "룩 디코드하기", "pasteUrlCta": "URL 붙여넣기", "slideAlt": "디코드된 룩", - "showLookAria": "{index}번 룩 보기" + "showLookAria": "{index}번 룩 보기", + "pauseCarouselAria": "캐러셀 일시정지", + "playCarouselAria": "캐러셀 재생" }, "quickDecode": { "aria": "빠른 디코드",