// Currency combobox — instant search, keyboard nav, flag + symbol + code. // Usage: } compact /> function flagEmoji(iso) { if (!iso || iso.length !== 2) return "🌐"; const A = 0x1F1E6; const base = "A".charCodeAt(0); return String.fromCodePoint(A + iso.charCodeAt(0) - base) + String.fromCodePoint(A + iso.charCodeAt(1) - base); } function CurrencyCombobox({ value, onChange, onClose, autoFocus = true, maxListHeight = 360 }) { const [q, setQ] = React.useState(""); const [active, setActive] = React.useState(0); const listRef = React.useRef(null); const inputRef = React.useRef(null); React.useEffect(() => { if (autoFocus && inputRef.current) inputRef.current.focus(); }, [autoFocus]); // Build rich index: matches by code, name, symbol, country name const countryNamesByCcy = React.useMemo(() => { const out = {}; window.COUNTRIES.forEach(c => { out[c.ccy] = out[c.ccy] || []; out[c.ccy].push(c.name); }); return out; }, []); const results = React.useMemo(() => { const needle = q.trim().toLowerCase(); const items = window.CURRENCIES.map(c => ({ ...c, countries: countryNamesByCcy[c.code] || [], })); if (!needle) return items.slice(0, 40); // Score: code exact > code starts > name starts > country starts > name contains > country contains const scored = []; for (const c of items) { const code = c.code.toLowerCase(); const name = c.name.toLowerCase(); const cs = c.countries.map(n => n.toLowerCase()); let score = -1; if (code === needle) score = 100; else if (code.startsWith(needle)) score = 80; else if (name.startsWith(needle)) score = 60; else if (cs.some(n => n.startsWith(needle))) score = 55; else if (name.includes(needle)) score = 40; else if (cs.some(n => n.includes(needle))) score = 30; else if (c.symbol.toLowerCase() === needle) score = 20; if (score >= 0) scored.push({ ...c, _score: score }); } scored.sort((a, b) => b._score - a._score); return scored.slice(0, 40); }, [q, countryNamesByCcy]); React.useEffect(() => { setActive(0); }, [q]); // Ensure active item is visible React.useEffect(() => { const el = listRef.current?.querySelector(`[data-idx="${active}"]`); if (el) el.scrollIntoView({ block: "nearest" }); }, [active]); function onKey(e) { if (e.key === "ArrowDown") { e.preventDefault(); setActive(i => Math.min(i + 1, results.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(i => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); const pick = results[active]; if (pick) { onChange(pick.code); onClose && onClose(); } } else if (e.key === "Escape") { e.preventDefault(); onClose && onClose(); } else if (e.key === "Tab") { /* let it pass */ } } return (
setQ(e.target.value)} onKeyDown={onKey} placeholder="Search currency, code, or country…" style={cbxStyles.input} aria-autocomplete="list" /> ↑↓ ↵
{results.map((c, i) => { const isActive = i === active; const isSelected = c.code === value; return (
setActive(i)} onMouseDown={(e) => { e.preventDefault(); onChange(c.code); onClose && onClose(); }} style={{ ...cbxStyles.row, ...(isActive ? cbxStyles.rowActive : null), }} > {flagEmoji(c.flag)} {c.code} {c.symbol} {c.name} {c.countries.length > 0 && ( {" "}· {c.countries.slice(0, 2).join(", ")}{c.countries.length > 2 ? "…" : ""} )} {isSelected && CURRENT}
); })} {results.length === 0 && (
No currencies match "{q}"
)}
{results.length} result{results.length === 1 ? "" : "s"} Esc to close
); } const cbxStyles = { root: { background: "var(--bg-elev)", border: "1px solid var(--line)", width: "100%", maxWidth: 420, minWidth: 0, boxShadow: "0 24px 60px rgba(0,0,0,0.45)", display: "flex", flexDirection: "column", overflow: "hidden", }, searchRow: { display: "flex", alignItems: "center", gap: 10, padding: "12px 14px", borderBottom: "1px solid var(--line-soft)", color: "var(--fg)", }, input: { flex: 1, background: "transparent", border: "none", outline: "none", color: "var(--fg)", fontSize: 13, fontFamily: "inherit", }, hint: { fontFamily: "'JetBrains Mono', monospace", fontSize: 10, letterSpacing: "0.12em", color: "var(--fg-faint)", }, list: { maxHeight: 360, overflowY: "auto", }, row: { display: "grid", gridTemplateColumns: "28px 48px 22px 1fr auto", alignItems: "center", gap: 10, padding: "9px 14px", borderBottom: "1px solid var(--line-soft)", cursor: "pointer", color: "var(--fg)", transition: "background 80ms ease", }, rowActive: { background: "var(--accent-soft)", }, flag: { fontSize: 18, lineHeight: 1 }, code: { fontFamily: "'JetBrains Mono', monospace", fontSize: 12, fontWeight: 500, letterSpacing: "0.05em" }, symbol: { fontFamily: "'Instrument Serif', serif", fontSize: 16, color: "var(--accent)", textAlign: "center" }, name: { fontSize: 12, color: "var(--fg)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, nameSub:{ color: "var(--fg-faint)" }, selectedTag: { fontFamily: "'JetBrains Mono', monospace", fontSize: 8, letterSpacing: "0.14em", padding: "2px 6px", background: "var(--accent)", color: "oklch(0.18 0.01 250)", }, empty: { padding: 24, textAlign: "center", fontSize: 12, color: "var(--fg-faint)", }, footer: { display: "flex", justifyContent: "space-between", padding: "8px 14px", borderTop: "1px solid var(--line-soft)", fontFamily: "'JetBrains Mono', monospace", fontSize: 10, letterSpacing: "0.1em", color: "var(--fg-faint)", }, }; window.CurrencyCombobox = CurrencyCombobox; window.flagEmoji = flagEmoji; // FlagIcon — renders a country flag as an from flagcdn.com. // This works everywhere (Windows Chrome doesn't render regional-indicator flag // emoji, so we need an image). Size is pixel height; image is 4:3 aspect. // iso: 2-letter ISO country code (case-insensitive) function FlagIcon({ iso, size, style }) { if (!iso || iso.length !== 2) return React.createElement('span', { style: style }, '🌐'); var h = size || 12; var w = Math.round(h * 4 / 3); var code = iso.toLowerCase(); // flagcdn.com URL format: /WxH/CC.png. We request 2x pixel density for retina. return React.createElement('img', { src: 'https://flagcdn.com/' + (w * 2) + 'x' + (h * 2) + '/' + code + '.png', srcSet: 'https://flagcdn.com/' + (w * 4) + 'x' + (h * 4) + '/' + code + '.png 2x', width: w, height: h, alt: iso, loading: 'lazy', style: Object.assign({ display: 'inline-block', verticalAlign: 'middle', objectFit: 'cover', borderRadius: 1, flexShrink: 0, }, style || {}), onError: function(e){ e.target.style.display='none'; } }); } window.FlagIcon = FlagIcon;