|
|
@@ -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}
|