buildmymcpserver/apps/web/components/country-picker.tsx
Marco Sadjadi a8e6f4fabd
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
fix(web): UserMenu + CountryPicker dropdowns frosted (Tailwind v4 bug)
Same Tailwind-v4 bracket-arbitrary issue we hit on the marketing burger
menu: bg-[--color-bg-elevated] compiles to `background-color:
--color-bg-elevated` (no var() wrap → invalid color → transparent).
Both dropdowns were rendering see-through against the dashboard.

Switch both to the proven pattern: backdrop-blur-md class + inline
style for backgroundColor + borderColor using color-mix() and explicit
var(). 88% elevated-panel fill gives a clear frosted-glass look while
keeping the menu items readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:04:02 +02:00

177 lines
6.3 KiB
TypeScript

'use client';
import { Check, ChevronDown, Search } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
export interface Country {
code: string; // ISO-3166 alpha-2
name: string;
dial: string; // E.164 dial prefix incl. leading '+'
}
/** Compute the regional-indicator-symbol flag emoji from an ISO-3166 alpha-2
* code. 🇨🇭 from "CH", 🇺🇸 from "US" etc. — no asset files needed. */
function flagFor(code: string): string {
if (code.length !== 2) return '';
const A = 0x1f1e6 - 65;
return String.fromCodePoint(
...code
.toUpperCase()
.split('')
.map((c) => A + c.charCodeAt(0)),
);
}
/**
* Custom country picker — opens DOWNWARD always (native <select> defers the
* direction to the browser and frequently opens upward on mobile, hiding
* countries behind the keyboard). Built-in search filter so a 150-entry list
* stays usable.
*/
export function CountryPicker({
countries,
value,
onChange,
}: {
countries: Country[];
value: string;
onChange: (code: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const wrap = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const selected = countries.find((c) => c.code === value);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return countries;
return countries.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.code.toLowerCase().includes(q) ||
c.dial.replace('+', '').includes(q.replace('+', '')),
);
}, [countries, query]);
// Outside-click + Escape close
useEffect(() => {
if (!open) return;
function onDocMouseDown(e: MouseEvent) {
if (!wrap.current?.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
// Autofocus the search input when the dropdown opens.
useEffect(() => {
if (open) {
const t = setTimeout(() => searchRef.current?.focus(), 0);
return () => clearTimeout(t);
}
}, [open]);
function select(code: string) {
onChange(code);
setOpen(false);
setQuery('');
}
return (
<div ref={wrap} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[13px] text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong] focus:border-[--color-accent] focus:outline-none focus:ring-1 focus:ring-[--color-accent]"
>
<span className="flex min-w-0 items-center gap-2">
{selected && (
<span aria-hidden className="text-[14px] leading-none">
{flagFor(selected.code)}
</span>
)}
<span className="truncate">
{selected ? `${selected.name} (${selected.dial})` : 'Select country'}
</span>
</span>
<ChevronDown size={13} className="shrink-0 text-[--color-fg-subtle]" />
</button>
{open && (
<div
role="listbox"
// Background + border via inline style for the same reason as UserMenu:
// Tailwind v4's `bg-[--color-X]` bracket syntax produces invalid CSS,
// so the dropdown was rendering transparent. Frosted glass via
// backdrop-blur + 88% elevated-panel fill.
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border shadow-lg shadow-black/40 backdrop-blur-md"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 88%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<div className="flex items-center gap-2 border-b border-[--color-border] px-2.5">
<Search size={13} className="text-[--color-fg-subtle]" />
<input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search country or code…"
className="h-9 w-full bg-transparent text-[13px] text-[--color-fg] placeholder:text-[--color-fg-subtle] focus:outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered.length > 0) {
e.preventDefault();
select(filtered[0]!.code);
}
}}
/>
</div>
<ul className="max-h-60 overflow-y-auto py-1">
{filtered.length === 0 && (
<li className="px-3 py-2 text-[12px] text-[--color-fg-subtle]">No match.</li>
)}
{filtered.map((c) => {
const isSelected = c.code === value;
return (
<li key={c.code}>
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => select(c.code)}
className={`flex w-full items-center gap-2.5 px-2.5 py-1.5 text-left text-[12.5px] transition-colors ${
isSelected
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]'
}`}
>
<span aria-hidden className="text-[14px] leading-none">
{flagFor(c.code)}
</span>
<span className="flex-1 truncate">{c.name}</span>
<span className="mono text-[11px] text-[--color-fg-subtle]">{c.dial}</span>
{isSelected && (
<Check size={12} className="text-[--color-accent]" />
)}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}