From ef65d6ae27918be5f47f2bb97a8e256940e33c0f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 14:35:27 +0000 Subject: [PATCH 1/2] [#171] Replace native selects with custom dark-themed dropdown New Select.tsx component with keyboard navigation (arrows, enter, escape), click-outside-to-close, hover/active states, and dark theme matching design system. Replaced all 3 native selects (chain page, register-agent genre + model). Removed unused select option CSS. Fixes #171 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/chain/page.tsx | 25 +++--- src/app/globals.css | 9 -- src/app/register-agent/page.tsx | 33 +++---- src/components/Select.tsx | 151 ++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 46 deletions(-) create mode 100644 src/components/Select.tsx diff --git a/src/app/chain/page.tsx b/src/app/chain/page.tsx index fb93ddce..996ae812 100644 --- a/src/app/chain/page.tsx +++ b/src/app/chain/page.tsx @@ -13,6 +13,7 @@ import { useChainPlot } from "../../hooks/useChainPlot"; import type { PublishState } from "../../hooks/usePublish"; import Link from "next/link"; import { ConnectWallet } from "../../components/ConnectWallet"; +import { Select } from "../../components/Select"; const STATE_LABELS: Record = { idle: "", @@ -120,22 +121,16 @@ export default function ChainPlotPage() {

) : ( - setStorylineId(v ? Number(v) : null)} disabled={busy} - className="border-border bg-surface text-foreground w-full rounded border px-3 pr-10 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50" - > - - {storylines.map((s) => ( - - ))} - + placeholder="Select a storyline" + options={storylines.map((s) => ({ + value: String(s.storyline_id), + label: `${s.title} (${s.plot_count} ${s.plot_count === 1 ? "plot" : "plots"})`, + }))} + /> )} diff --git a/src/app/globals.css b/src/app/globals.css index 85d98297..bf7a84b0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,12 +46,3 @@ select { padding-right: 2.5rem; } -select option { - background: var(--bg-surface); - color: var(--text); -} - -select option:checked { - background: var(--accent); - color: var(--bg); -} diff --git a/src/app/register-agent/page.tsx b/src/app/register-agent/page.tsx index 98e8b12e..6b3ac029 100644 --- a/src/app/register-agent/page.tsx +++ b/src/app/register-agent/page.tsx @@ -8,6 +8,7 @@ import { publicClient } from "../../../lib/rpc"; import { erc8004Abi } from "../../../lib/contracts/erc8004"; import { ERC8004_REGISTRY, BASE_CHAIN_ID, EXPLORER_URL } from "../../../lib/contracts/constants"; import { ConnectWallet } from "../../components/ConnectWallet"; +import { Select } from "../../components/Select"; // --------------------------------------------------------------------------- // Constants @@ -357,18 +358,12 @@ export default function RegisterAgentPage() { - + onChange={setGenre} + placeholder="Select genre..." + options={GENRES.map((g) => ({ value: g, label: g }))} + /> {/* LLM Model */} @@ -376,18 +371,12 @@ export default function RegisterAgentPage() { - + onChange={setLlmModel} + placeholder="Select model..." + options={LLM_MODELS.map((m) => ({ value: m, label: m }))} + /> {/* Metadata preview */} diff --git a/src/components/Select.tsx b/src/components/Select.tsx new file mode 100644 index 00000000..5ef5c95e --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; + +export interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + value: string; + onChange: (value: string) => void; + options: SelectOption[]; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function Select({ + value, + onChange, + options, + placeholder = "Select...", + disabled = false, + className = "", +}: SelectProps) { + const [open, setOpen] = useState(false); + const [focusIndex, setFocusIndex] = useState(-1); + const containerRef = useRef(null); + const listRef = useRef(null); + + const selectedLabel = + options.find((o) => o.value === value)?.label ?? placeholder; + + // 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 option into view + useEffect(() => { + if (!open || focusIndex < 0 || !listRef.current) return; + const el = listRef.current.children[focusIndex] as HTMLElement | undefined; + el?.scrollIntoView({ block: "nearest" }); + }, [open, focusIndex]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + + if (!open) { + if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { + e.preventDefault(); + setOpen(true); + setFocusIndex(0); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setFocusIndex((i) => Math.min(i + 1, options.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setFocusIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (focusIndex >= 0 && focusIndex < options.length) { + onChange(options[focusIndex].value); + setOpen(false); + } + break; + case "Escape": + e.preventDefault(); + setOpen(false); + break; + } + }, + [disabled, open, options, focusIndex, onChange], + ); + + return ( +
+ + + {open && ( +
    + {options.map((opt, i) => ( +
  • setFocusIndex(i)} + onClick={() => { + onChange(opt.value); + setOpen(false); + }} + className={`cursor-pointer px-3 py-2 text-sm ${ + opt.value === value + ? "bg-accent text-background" + : i === focusIndex + ? "bg-border/50 text-foreground" + : "text-foreground hover:bg-border/30" + }`} + > + {opt.label} +
  • + ))} +
+ )} +
+ ); +} From 4c1da55a85fb70172ad2a323b319a575d86decd2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 14:38:02 +0000 Subject: [PATCH 2/2] [#171] Include placeholder as selectable empty option in dropdown Prepend the placeholder text as an empty-value option so users can clear their selection back to blank, matching native select behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Select.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 5ef5c95e..fcb457f0 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -29,6 +29,10 @@ export function Select({ const containerRef = useRef(null); const listRef = useRef(null); + const allOptions = placeholder + ? [{ value: "", label: placeholder }, ...options] + : options; + const selectedLabel = options.find((o) => o.value === value)?.label ?? placeholder; @@ -67,7 +71,7 @@ export function Select({ switch (e.key) { case "ArrowDown": e.preventDefault(); - setFocusIndex((i) => Math.min(i + 1, options.length - 1)); + setFocusIndex((i) => Math.min(i + 1, allOptions.length - 1)); break; case "ArrowUp": e.preventDefault(); @@ -75,8 +79,8 @@ export function Select({ break; case "Enter": e.preventDefault(); - if (focusIndex >= 0 && focusIndex < options.length) { - onChange(options[focusIndex].value); + if (focusIndex >= 0 && focusIndex < allOptions.length) { + onChange(allOptions[focusIndex].value); setOpen(false); } break; @@ -86,7 +90,7 @@ export function Select({ break; } }, - [disabled, open, options, focusIndex, onChange], + [disabled, open, allOptions, focusIndex, onChange], ); return ( @@ -123,9 +127,9 @@ export function Select({ onKeyDown={handleKeyDown} className="border-border bg-surface absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded border py-1 shadow-lg" > - {options.map((opt, i) => ( + {allOptions.map((opt, i) => (
  • setFocusIndex(i)} @@ -138,7 +142,9 @@ export function Select({ ? "bg-accent text-background" : i === focusIndex ? "bg-border/50 text-foreground" - : "text-foreground hover:bg-border/30" + : opt.value === "" + ? "text-muted hover:bg-border/30" + : "text-foreground hover:bg-border/30" }`} > {opt.label}