Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions src/app/chain/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublishState, string> = {
idle: "",
Expand Down Expand Up @@ -120,22 +121,16 @@ export default function ChainPlotPage() {
</Link>
</p>
) : (
<select
value={storylineId ?? ""}
onChange={(e) =>
setStorylineId(e.target.value ? Number(e.target.value) : null)
}
<Select
value={storylineId != null ? String(storylineId) : ""}
onChange={(v) => 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"
>
<option value="">Select a storyline</option>
{storylines.map((s) => (
<option key={s.id} value={s.storyline_id}>
{s.title} ({s.plot_count}{" "}
{s.plot_count === 1 ? "plot" : "plots"})
</option>
))}
</select>
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"})`,
}))}
/>
)}
</div>

Expand Down
9 changes: 0 additions & 9 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
33 changes: 11 additions & 22 deletions src/app/register-agent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -357,37 +358,25 @@ export default function RegisterAgentPage() {
<label className="text-foreground mb-2 block text-sm">
Primary Genre
</label>
<select
<Select
value={genre}
onChange={(e) => setGenre(e.target.value)}
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"
>
<option value="">Select genre...</option>
{GENRES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
onChange={setGenre}
placeholder="Select genre..."
options={GENRES.map((g) => ({ value: g, label: g }))}
/>
</div>

{/* LLM Model */}
<div>
<label className="text-foreground mb-2 block text-sm">
LLM Model
</label>
<select
<Select
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
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"
>
<option value="">Select model...</option>
{LLM_MODELS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
onChange={setLlmModel}
placeholder="Select model..."
options={LLM_MODELS.map((m) => ({ value: m, label: m }))}
/>
</div>

{/* Metadata preview */}
Expand Down
157 changes: 157 additions & 0 deletions src/components/Select.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);

const allOptions = placeholder

Check warning on line 32 in src/components/Select.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

The 'allOptions' conditional could make the dependencies of useCallback Hook (at line 93) change on every render. To fix this, wrap the initialization of 'allOptions' in its own useMemo() Hook
? [{ 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 (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
onClick={() => !disabled && setOpen((o) => !o)}
onKeyDown={handleKeyDown}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
className="border-border bg-surface text-foreground w-full rounded border px-3 pr-10 py-2 text-left text-sm focus:border-accent focus:outline-none disabled:opacity-50"
>
<span className={value ? "" : "text-muted"}>{selectedLabel}</span>
<svg
className="text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>

{open && (
<ul
ref={listRef}
role="listbox"
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"
>
{allOptions.map((opt, i) => (
<li
key={opt.value === "" ? "__placeholder__" : opt.value}
role="option"
aria-selected={opt.value === value}
onMouseEnter={() => 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}
</li>
))}
</ul>
)}
</div>
);
}
Loading