"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; export type MorphSelectOption = { value: string; label: string; /** Small text on the right of the option. */ hint?: string; disabled?: boolean; }; type MorphSelectProps = { options: MorphSelectOption[]; /** Field name shown above the box. */ label: string; /** Picked value (controlled). */ value?: string; /** Picked value at start (uncontrolled). */ defaultValue?: string; /** Called with the new value when an option is picked. */ onChange?: (value: string) => void; /** Text shown when nothing is picked. */ placeholder?: string; /** Form field name, so the value is sent with a normal form. */ name?: string; /** Highlight and tick colour. */ accent?: string; /** Wrapper look (width etc.). */ className?: string; }; /* MorphSelect: a select box whose panel unfolds out of the button. A highlight glides between options, the tick draws itself, and the keyboard works like a native select. */ export function MorphSelect({ options, label, value, defaultValue, onChange, placeholder = "Choose…", name, accent = "#a78bfa", className = "w-full max-w-xs", }: MorphSelectProps) { const reduce = useReducedMotion(); const uid = useId(); const labelId = `${uid}-label`; const listId = `${uid}-list`; const optId = (i: number) => `${uid}-opt-${i}`; const button = useRef(null); const typed = useRef({ text: "", timer: 0 }); const [inner, setInner] = useState(defaultValue); const selected = value ?? inner; const selectedIndex = options.findIndex((o) => o.value === selected); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 480, damping: 36 }; useEffect(() => { if (open) document.getElementById(`${uid}-opt-${active}`)?.scrollIntoView({ block: "nearest" }); }, [open, active, uid]); const enabled = (i: number) => !!options[i] && !options[i].disabled; const firstEnabled = (from: number, dir: 1 | -1) => { for (let i = from; i >= 0 && i < options.length; i += dir) if (enabled(i)) return i; return -1; }; const step = (dir: 1 | -1) => { const next = firstEnabled(active + dir, dir); if (next !== -1) setActive(next); }; const show = (at = selectedIndex) => { setActive(enabled(at) ? at : firstEnabled(0, 1)); setOpen(true); }; const pick = (i: number) => { if (!enabled(i)) return; const v = options[i].value; if (value === undefined) setInner(v); if (v !== selected) onChange?.(v); setOpen(false); }; const typeahead = (key: string) => { const t = typed.current; window.clearTimeout(t.timer); t.text += key.toLowerCase(); t.timer = window.setTimeout(() => (t.text = ""), 500); const start = open ? active : selectedIndex; const from = t.text.length === 1 ? start + 1 : Math.max(start, 0); for (let k = 0; k < options.length; k++) { const i = (from + k) % options.length; if (enabled(i) && options[i].label.toLowerCase().startsWith(t.text)) { if (open) setActive(i); else show(i); return; } } }; const onKey = (e: KeyboardEvent) => { const { key } = e; if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && (key !== " " || typed.current.text)) { e.preventDefault(); return typeahead(key); } if (!open) { if (["ArrowDown", "ArrowUp", "Enter", " "].includes(key)) { e.preventDefault(); show(); } else if (key === "Home" || key === "End") { e.preventDefault(); show(key === "Home" ? firstEnabled(0, 1) : firstEnabled(options.length - 1, -1)); } return; } if (key === "ArrowDown" || key === "ArrowUp") { e.preventDefault(); if (e.altKey && key === "ArrowUp") return pick(active); step(key === "ArrowDown" ? 1 : -1); } else if (key === "Home" || key === "End") { e.preventDefault(); setActive(key === "Home" ? firstEnabled(0, 1) : firstEnabled(options.length - 1, -1)); } else if (key === "Enter" || key === " ") { e.preventDefault(); pick(active); } else if (key === "Escape") { e.preventDefault(); setOpen(false); } else if (key === "Tab") { setOpen(false); } }; const current = options[selectedIndex]; return (
button.current?.focus()} className="mb-2 block text-sm font-medium text-white/80"> {label}
{name && } {open && ( e.preventDefault()} initial={reduce ? { opacity: 0 } : { clipPath: "inset(0% 0% 100% 0% round 14px)", y: -6, opacity: 0.4 }} animate={reduce ? { opacity: 1 } : { clipPath: "inset(0% 0% 0% 0% round 14px)", y: 0, opacity: 1 }} exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { clipPath: "inset(0% 0% 100% 0% round 14px)", y: -6, opacity: 0, transition: { duration: 0.16, ease: "easeIn" } }} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }} className="absolute inset-x-0 top-full z-20 mt-2 max-h-64 overflow-auto rounded-[14px] border border-white/10 bg-[#141026] p-1.5 shadow-2xl shadow-black/50" > {options.map((o, i) => { const isActive = i === active; const isSelected = i === selectedIndex; return (
  • enabled(i) && !isActive && setActive(i)} onClick={() => pick(i)} className={`relative flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-[15px] ${ o.disabled ? "cursor-not-allowed text-white/25" : isSelected ? "text-white" : "text-white/70" }`} > {isActive && !o.disabled && ( )} {o.label} {o.hint && {o.hint}} {isSelected && ( )}
  • ); })}
    )}
    ); }