Procházet zdrojové kódy

[Phase 4 U7] Move GenreRollModal to dice/, add focus trap, fix mood union

Extracts the modal from src/components/landing/ to src/components/dice/
so the in-app roll flow (U8/U9) can import from a stable path. Adds an
inline focus trap (Tab/Shift-Tab cycle, initial focus on the search
input, focus restoration on unmount with a document.contains guard).

Changes the onRoll contract from `(genreIds: number[]) => void` to
`({ genreIds, moodKeys }) => void`. The modal no longer resolves mood
keys to genre IDs internally — resolution moves to the caller. The
landing carousel now unions BOTH primary and secondary mood IDs
(matching filterByGenresAndEmotions semantics), where previously only
primary IDs were used. PM has accepted this consistency fix.

Manual tab-through verifies focus cycles within the dialog and is
restored to the trigger button on close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User před 2 měsíci
rodič
revize
814866aa48

+ 58 - 15
src/components/landing/genre-roll-modal.tsx → src/components/dice/genre-roll-modal.tsx

@@ -1,8 +1,7 @@
 "use client";
 
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 
-import { EMOTION_TO_GENRE_MAP } from "@/lib/constants";
 import { TMDB_GENRE_MAP } from "@/types/tmdb";
 
 const CANONICAL_MOODS: { key: string; label: string; synonyms: string[] }[] = [
@@ -25,6 +24,9 @@ const GENRE_ENTRIES = Object.entries(TMDB_GENRE_MAP).map(([id, name]) => ({
   name,
 }));
 
+const FOCUSABLE_SELECTOR =
+  'button, input, [tabindex]:not([tabindex="-1"])';
+
 function chipClassName(selected: boolean, disabled: boolean): string {
   const base = "rounded-full px-3 py-1 text-sm";
   if (selected) return `${base} bg-foreground text-background cursor-pointer`;
@@ -32,9 +34,14 @@ function chipClassName(selected: boolean, disabled: boolean): string {
   return `${base} bg-foreground/10 text-foreground/70 cursor-pointer`;
 }
 
+export interface GenreRollPayload {
+  genreIds: number[];
+  moodKeys: string[];
+}
+
 interface GenreRollModalProps {
   onClose: () => void;
-  onRoll: (genreIds: number[]) => void;
+  onRoll: (payload: GenreRollPayload) => void;
   loading: boolean;
 }
 
@@ -43,6 +50,9 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
   const [selectedGenres, setSelectedGenres] = useState<Set<number>>(new Set());
   const [selectedMoods, setSelectedMoods] = useState<Set<string>>(new Set());
 
+  const dialogRef = useRef<HTMLDivElement>(null);
+  const searchInputRef = useRef<HTMLInputElement>(null);
+
   const totalSelected = selectedGenres.size + selectedMoods.size;
   const atMax = totalSelected >= MAX_SELECTIONS;
 
@@ -54,6 +64,45 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
     return () => document.removeEventListener("keydown", handleKeyDown);
   }, [onClose]);
 
+  useEffect(() => {
+    const previouslyFocused = document.activeElement as HTMLElement | null;
+    searchInputRef.current?.focus();
+
+    function handleTabKey(e: KeyboardEvent) {
+      if (e.key !== "Tab") return;
+      const container = dialogRef.current;
+      if (!container) return;
+      const focusables = Array.from(
+        container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
+      ).filter((el) => !el.hasAttribute("disabled"));
+      if (focusables.length === 0) return;
+
+      const first = focusables[0];
+      const last = focusables[focusables.length - 1];
+      const active = document.activeElement as HTMLElement | null;
+
+      if (e.shiftKey) {
+        if (active === first || !container.contains(active)) {
+          e.preventDefault();
+          last.focus();
+        }
+      } else {
+        if (active === last || !container.contains(active)) {
+          e.preventDefault();
+          first.focus();
+        }
+      }
+    }
+
+    document.addEventListener("keydown", handleTabKey);
+    return () => {
+      document.removeEventListener("keydown", handleTabKey);
+      if (previouslyFocused && document.contains(previouslyFocused)) {
+        previouslyFocused.focus();
+      }
+    };
+  }, []);
+
   const query = searchQuery.toLowerCase();
 
   const filteredGenres = query
@@ -93,18 +142,10 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
   }
 
   function handleRoll() {
-    const ids = new Set<number>(selectedGenres);
-
-    for (const moodKey of selectedMoods) {
-      const mapping = EMOTION_TO_GENRE_MAP[moodKey];
-      if (mapping) {
-        for (const id of mapping.primary) {
-          ids.add(id);
-        }
-      }
-    }
-
-    onRoll(Array.from(ids));
+    onRoll({
+      genreIds: Array.from(selectedGenres),
+      moodKeys: Array.from(selectedMoods),
+    });
   }
 
   function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
@@ -118,6 +159,7 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
       aria-modal="true"
       aria-labelledby="genre-roll-title"
       onClick={handleBackdropClick}
+      ref={dialogRef}
     >
       <div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
         <h2 id="genre-roll-title" className="text-lg font-semibold mb-4">
@@ -125,6 +167,7 @@ export function GenreRollModal({ onClose, onRoll, loading }: GenreRollModalProps
         </h2>
 
         <input
+          ref={searchInputRef}
           type="text"
           placeholder="Filter genres and moods..."
           value={searchQuery}

+ 14 - 3
src/components/landing/carousel-animation.tsx

@@ -4,7 +4,8 @@ import { useState, useEffect, useRef, useSyncExternalStore } from "react";
 import { getTMDBImageUrl } from "@/types/tmdb";
 import type { TMDBMovie } from "@/types/tmdb";
 import { TeaserCard } from "./teaser-card";
-import { GenreRollModal } from "./genre-roll-modal";
+import { GenreRollModal } from "@/components/dice/genre-roll-modal";
+import { EMOTION_TO_GENRE_MAP } from "@/lib/constants";
 
 interface ReelPoster {
   tmdb_id: number;
@@ -181,9 +182,19 @@ export function CarouselSection() {
 
   const handleRandomRoll = () => fetchAndRoll("/api/tmdb/popular");
 
-  const handleGenreRoll = (genreIds: number[]) => {
+  const handleGenreRoll = ({ genreIds, moodKeys }: { genreIds: number[]; moodKeys: string[] }) => {
     setModalOpen(false);
-    return fetchAndRoll(`/api/tmdb/discover?with_genres=${genreIds.join(",")}`);
+    // Resolve mood keys to genre IDs (both primary AND secondary, matching
+    // filterByGenresAndEmotions semantics) and union with explicit genre IDs.
+    const ids = new Set<number>(genreIds);
+    for (const moodKey of moodKeys) {
+      const mapping = EMOTION_TO_GENRE_MAP[moodKey];
+      if (mapping) {
+        for (const id of mapping.primary) ids.add(id);
+        for (const id of mapping.secondary) ids.add(id);
+      }
+    }
+    return fetchAndRoll(`/api/tmdb/discover?with_genres=${Array.from(ids).join(",")}`);
   };
 
   const handleModalClose = () => {