From 0c529b7a5cb8a6693d167aea2ac41530ff1777ac Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 16:31:20 +0000 Subject: [PATCH] [#272] Custom styled dropdown for genre and language filters - Add DropdownSelect component: dark bg, monospace, custom arrow, click-outside close, keyboard nav (arrows/Enter/Escape), scrollable - Replace native in create page genre/language form - Reusable with sm/md size variants Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/create/page.tsx | 33 +++--- src/components/DropdownSelect.tsx | 140 +++++++++++++++++++++++++ src/components/GenreLanguageFilter.tsx | 49 +++++---- 3 files changed, 183 insertions(+), 39 deletions(-) create mode 100644 src/components/DropdownSelect.tsx diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index e84868ce..144af1ed 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -13,8 +13,15 @@ import { STORY_FACTORY } from "../../../lib/contracts/constants"; import { decodeEventLog, encodeEventTopics } from "viem"; import Link from "next/link"; import { ConnectWallet } from "../../components/ConnectWallet"; +import { DropdownSelect } from "../../components/DropdownSelect"; import { GENRES, LANGUAGES } from "../../../lib/genres"; +const genreOptions = [ + { value: "", label: "Select genre..." }, + ...GENRES.map((g) => ({ value: g, label: g })), +]; +const languageOptions = LANGUAGES.map((l) => ({ value: l, label: l })); + const STORYLINE_CREATED_TOPIC = encodeEventTopics({ abi: [storylineCreatedEvent], eventName: "StorylineCreated", @@ -145,30 +152,22 @@ export default function CreateStorylinePage() {
- + />
- + />
diff --git a/src/components/DropdownSelect.tsx b/src/components/DropdownSelect.tsx new file mode 100644 index 00000000..c83cc0b2 --- /dev/null +++ b/src/components/DropdownSelect.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; + +interface DropdownSelectProps { + value: string; + onChange: (value: string) => void; + options: readonly { value: string; label: string }[]; + placeholder?: string; + disabled?: boolean; + className?: string; + size?: "sm" | "md"; +} + +export function DropdownSelect({ + value, + onChange, + options, + placeholder, + disabled, + className, + size = "md", +}: DropdownSelectProps) { + const [open, setOpen] = useState(false); + const [focusIdx, setFocusIdx] = useState(-1); + const containerRef = useRef(null); + const listRef = useRef(null); + + const selected = options.find((o) => o.value === value); + const label = selected?.label ?? placeholder ?? "Select..."; + + // Close on click outside + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Scroll focused item into view + useEffect(() => { + if (!open || focusIdx < 0 || !listRef.current) return; + const items = listRef.current.children; + if (items[focusIdx]) { + (items[focusIdx] as HTMLElement).scrollIntoView({ block: "nearest" }); + } + }, [focusIdx, open]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + if (!open) { + if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { + e.preventDefault(); + setOpen(true); + setFocusIdx(options.findIndex((o) => o.value === value)); + } + return; + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setFocusIdx((i) => Math.min(i + 1, options.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setFocusIdx((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (focusIdx >= 0 && focusIdx < options.length) { + onChange(options[focusIdx].value); + setOpen(false); + } + break; + case "Escape": + e.preventDefault(); + setOpen(false); + break; + } + }, + [open, focusIdx, options, value, onChange, disabled], + ); + + const sizeClasses = + size === "sm" + ? "px-2 py-1 text-xs" + : "px-3 py-2 text-sm"; + + return ( +
+ + + {open && ( +
    + {options.map((opt, i) => ( +
  • setFocusIdx(i)} + onClick={() => { + onChange(opt.value); + setOpen(false); + }} + className={`cursor-pointer ${sizeClasses} transition-colors ${ + opt.value === value + ? "text-accent font-medium" + : focusIdx === i + ? "bg-accent/10 text-foreground" + : "text-muted hover:text-foreground" + }`} + > + {opt.label} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/GenreLanguageFilter.tsx b/src/components/GenreLanguageFilter.tsx index bb533f70..eb4fac0b 100644 --- a/src/components/GenreLanguageFilter.tsx +++ b/src/components/GenreLanguageFilter.tsx @@ -1,45 +1,50 @@ "use client"; import { GENRES, LANGUAGES } from "../../lib/genres"; +import { DropdownSelect } from "./DropdownSelect"; + +const genreOptions = [ + { value: "all", label: "All genres" }, + ...GENRES.map((g) => ({ value: g, label: g })), +]; + +const languageOptions = [ + { value: "all", label: "All languages" }, + ...LANGUAGES.map((l) => ({ value: l, label: l })), +]; export function GenreFilter({ active, tab, writer, lang }: { active: string; tab: string; writer: string; lang: string }) { return ( - + options={genreOptions} + size="sm" + className="w-32" + /> ); } export function LanguageFilter({ active, tab, writer, genre }: { active: string; tab: string; writer: string; genre: string }) { return ( - + options={languageOptions} + size="sm" + className="w-36" + /> ); }