All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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>
177 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|