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..fcb457f0 --- /dev/null +++ b/src/components/Select.tsx @@ -0,0 +1,157 @@ +"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 allOptions = placeholder + ? [{ value: "", label: placeholder }, ...options] + : options; + + 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, allOptions.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setFocusIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (focusIndex >= 0 && focusIndex < allOptions.length) { + onChange(allOptions[focusIndex].value); + setOpen(false); + } + break; + case "Escape": + e.preventDefault(); + setOpen(false); + break; + } + }, + [disabled, open, allOptions, focusIndex, onChange], + ); + + return ( +
+ + + {open && ( +
    + {allOptions.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" + : opt.value === "" + ? "text-muted hover:bg-border/30" + : "text-foreground hover:bg-border/30" + }`} + > + {opt.label} +
  • + ))} +
+ )} +
+ ); +}