feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
Native <select> defers dropdown direction to the browser, which on mobile routinely opens upward and hides countries behind the keyboard. Replaced with a custom combobox that always opens DOWNWARD (absolute positioned below the trigger) with a search input at top — at 150 countries a scrollable list is unusable without search anyway. COUNTRIES list expanded from 60 → 152 entries: every country with a meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka, Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq, Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia, Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka, Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in the prior list — just unfindable without search. Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files). Search matches name + code + dial-prefix so "+41" or "CH" both find Switzerland. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1cccdbdff1
commit
e9827b1f77
@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { CountryPicker } from '@/components/country-picker';
|
||||||
import { Input, Label } from '@/components/input';
|
import { Input, Label } from '@/components/input';
|
||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -21,69 +22,163 @@ const ERROR_COPY: Record<string, string> = {
|
|||||||
sms_verify_failed: 'Could not verify the code. Try again.',
|
sms_verify_failed: 'Could not verify the code. Try again.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Country dial codes for the phone-login picker. Sorted by name; Switzerland
|
// Country dial codes for the phone-login picker. ~150 entries — every country
|
||||||
// is the default (Swiss-built product, Swiss Twilio sender number).
|
// with a non-trivial diaspora. Sorted alphabetically by name. Switzerland is
|
||||||
|
// the default (Swiss-built product, Swiss Twilio sender number).
|
||||||
const COUNTRIES: { code: string; name: string; dial: string }[] = [
|
const COUNTRIES: { code: string; name: string; dial: string }[] = [
|
||||||
|
{ code: 'AL', name: 'Albania', dial: '+355' },
|
||||||
|
{ code: 'DZ', name: 'Algeria', dial: '+213' },
|
||||||
|
{ code: 'AD', name: 'Andorra', dial: '+376' },
|
||||||
|
{ code: 'AO', name: 'Angola', dial: '+244' },
|
||||||
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
||||||
|
{ code: 'AM', name: 'Armenia', dial: '+374' },
|
||||||
|
{ code: 'AW', name: 'Aruba', dial: '+297' },
|
||||||
{ code: 'AU', name: 'Australia', dial: '+61' },
|
{ code: 'AU', name: 'Australia', dial: '+61' },
|
||||||
{ code: 'AT', name: 'Austria', dial: '+43' },
|
{ code: 'AT', name: 'Austria', dial: '+43' },
|
||||||
|
{ code: 'AZ', name: 'Azerbaijan', dial: '+994' },
|
||||||
|
{ code: 'BS', name: 'Bahamas', dial: '+1' },
|
||||||
|
{ code: 'BH', name: 'Bahrain', dial: '+973' },
|
||||||
|
{ code: 'BD', name: 'Bangladesh', dial: '+880' },
|
||||||
|
{ code: 'BB', name: 'Barbados', dial: '+1' },
|
||||||
|
{ code: 'BY', name: 'Belarus', dial: '+375' },
|
||||||
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
||||||
|
{ code: 'BZ', name: 'Belize', dial: '+501' },
|
||||||
|
{ code: 'BJ', name: 'Benin', dial: '+229' },
|
||||||
|
{ code: 'BM', name: 'Bermuda', dial: '+1' },
|
||||||
|
{ code: 'BT', name: 'Bhutan', dial: '+975' },
|
||||||
|
{ code: 'BO', name: 'Bolivia', dial: '+591' },
|
||||||
|
{ code: 'BA', name: 'Bosnia & Herzegovina', dial: '+387' },
|
||||||
|
{ code: 'BW', name: 'Botswana', dial: '+267' },
|
||||||
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
||||||
|
{ code: 'BN', name: 'Brunei', dial: '+673' },
|
||||||
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
||||||
|
{ code: 'BF', name: 'Burkina Faso', dial: '+226' },
|
||||||
|
{ code: 'KH', name: 'Cambodia', dial: '+855' },
|
||||||
|
{ code: 'CM', name: 'Cameroon', dial: '+237' },
|
||||||
{ code: 'CA', name: 'Canada', dial: '+1' },
|
{ code: 'CA', name: 'Canada', dial: '+1' },
|
||||||
|
{ code: 'CV', name: 'Cape Verde', dial: '+238' },
|
||||||
|
{ code: 'KY', name: 'Cayman Islands', dial: '+1' },
|
||||||
{ code: 'CL', name: 'Chile', dial: '+56' },
|
{ code: 'CL', name: 'Chile', dial: '+56' },
|
||||||
{ code: 'CN', name: 'China', dial: '+86' },
|
{ code: 'CN', name: 'China', dial: '+86' },
|
||||||
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
||||||
|
{ code: 'CR', name: 'Costa Rica', dial: '+506' },
|
||||||
{ code: 'HR', name: 'Croatia', dial: '+385' },
|
{ code: 'HR', name: 'Croatia', dial: '+385' },
|
||||||
|
{ code: 'CY', name: 'Cyprus', dial: '+357' },
|
||||||
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
||||||
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
||||||
|
{ code: 'DO', name: 'Dominican Republic', dial: '+1' },
|
||||||
|
{ code: 'EC', name: 'Ecuador', dial: '+593' },
|
||||||
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
||||||
|
{ code: 'SV', name: 'El Salvador', dial: '+503' },
|
||||||
{ code: 'EE', name: 'Estonia', dial: '+372' },
|
{ code: 'EE', name: 'Estonia', dial: '+372' },
|
||||||
|
{ code: 'ET', name: 'Ethiopia', dial: '+251' },
|
||||||
|
{ code: 'FJ', name: 'Fiji', dial: '+679' },
|
||||||
{ code: 'FI', name: 'Finland', dial: '+358' },
|
{ code: 'FI', name: 'Finland', dial: '+358' },
|
||||||
{ code: 'FR', name: 'France', dial: '+33' },
|
{ code: 'FR', name: 'France', dial: '+33' },
|
||||||
|
{ code: 'GE', name: 'Georgia', dial: '+995' },
|
||||||
{ code: 'DE', name: 'Germany', dial: '+49' },
|
{ code: 'DE', name: 'Germany', dial: '+49' },
|
||||||
|
{ code: 'GH', name: 'Ghana', dial: '+233' },
|
||||||
{ code: 'GR', name: 'Greece', dial: '+30' },
|
{ code: 'GR', name: 'Greece', dial: '+30' },
|
||||||
|
{ code: 'GT', name: 'Guatemala', dial: '+502' },
|
||||||
|
{ code: 'HN', name: 'Honduras', dial: '+504' },
|
||||||
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
|
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
|
||||||
{ code: 'HU', name: 'Hungary', dial: '+36' },
|
{ code: 'HU', name: 'Hungary', dial: '+36' },
|
||||||
{ code: 'IS', name: 'Iceland', dial: '+354' },
|
{ code: 'IS', name: 'Iceland', dial: '+354' },
|
||||||
{ code: 'IN', name: 'India', dial: '+91' },
|
{ code: 'IN', name: 'India', dial: '+91' },
|
||||||
{ code: 'ID', name: 'Indonesia', dial: '+62' },
|
{ code: 'ID', name: 'Indonesia', dial: '+62' },
|
||||||
|
{ code: 'IR', name: 'Iran', dial: '+98' },
|
||||||
|
{ code: 'IQ', name: 'Iraq', dial: '+964' },
|
||||||
{ code: 'IE', name: 'Ireland', dial: '+353' },
|
{ code: 'IE', name: 'Ireland', dial: '+353' },
|
||||||
{ code: 'IL', name: 'Israel', dial: '+972' },
|
{ code: 'IL', name: 'Israel', dial: '+972' },
|
||||||
{ code: 'IT', name: 'Italy', dial: '+39' },
|
{ code: 'IT', name: 'Italy', dial: '+39' },
|
||||||
|
{ code: 'CI', name: 'Ivory Coast', dial: '+225' },
|
||||||
|
{ code: 'JM', name: 'Jamaica', dial: '+1' },
|
||||||
{ code: 'JP', name: 'Japan', dial: '+81' },
|
{ code: 'JP', name: 'Japan', dial: '+81' },
|
||||||
|
{ code: 'JO', name: 'Jordan', dial: '+962' },
|
||||||
|
{ code: 'KZ', name: 'Kazakhstan', dial: '+7' },
|
||||||
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
||||||
|
{ code: 'XK', name: 'Kosovo', dial: '+383' },
|
||||||
|
{ code: 'KW', name: 'Kuwait', dial: '+965' },
|
||||||
|
{ code: 'KG', name: 'Kyrgyzstan', dial: '+996' },
|
||||||
|
{ code: 'LA', name: 'Laos', dial: '+856' },
|
||||||
{ code: 'LV', name: 'Latvia', dial: '+371' },
|
{ code: 'LV', name: 'Latvia', dial: '+371' },
|
||||||
|
{ code: 'LB', name: 'Lebanon', dial: '+961' },
|
||||||
|
{ code: 'LY', name: 'Libya', dial: '+218' },
|
||||||
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
|
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
|
||||||
{ code: 'LT', name: 'Lithuania', dial: '+370' },
|
{ code: 'LT', name: 'Lithuania', dial: '+370' },
|
||||||
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
|
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
|
||||||
|
{ code: 'MO', name: 'Macau', dial: '+853' },
|
||||||
|
{ code: 'MK', name: 'North Macedonia', dial: '+389' },
|
||||||
|
{ code: 'MG', name: 'Madagascar', dial: '+261' },
|
||||||
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
||||||
|
{ code: 'MV', name: 'Maldives', dial: '+960' },
|
||||||
|
{ code: 'MT', name: 'Malta', dial: '+356' },
|
||||||
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
||||||
|
{ code: 'MD', name: 'Moldova', dial: '+373' },
|
||||||
|
{ code: 'MC', name: 'Monaco', dial: '+377' },
|
||||||
|
{ code: 'MN', name: 'Mongolia', dial: '+976' },
|
||||||
|
{ code: 'ME', name: 'Montenegro', dial: '+382' },
|
||||||
|
{ code: 'MA', name: 'Morocco', dial: '+212' },
|
||||||
|
{ code: 'MZ', name: 'Mozambique', dial: '+258' },
|
||||||
|
{ code: 'MM', name: 'Myanmar', dial: '+95' },
|
||||||
|
{ code: 'NA', name: 'Namibia', dial: '+264' },
|
||||||
|
{ code: 'NP', name: 'Nepal', dial: '+977' },
|
||||||
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
||||||
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
||||||
|
{ code: 'NI', name: 'Nicaragua', dial: '+505' },
|
||||||
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
||||||
{ code: 'NO', name: 'Norway', dial: '+47' },
|
{ code: 'NO', name: 'Norway', dial: '+47' },
|
||||||
|
{ code: 'OM', name: 'Oman', dial: '+968' },
|
||||||
|
{ code: 'PK', name: 'Pakistan', dial: '+92' },
|
||||||
|
{ code: 'PS', name: 'Palestine', dial: '+970' },
|
||||||
|
{ code: 'PA', name: 'Panama', dial: '+507' },
|
||||||
|
{ code: 'PG', name: 'Papua New Guinea', dial: '+675' },
|
||||||
|
{ code: 'PY', name: 'Paraguay', dial: '+595' },
|
||||||
|
{ code: 'PE', name: 'Peru', dial: '+51' },
|
||||||
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
||||||
{ code: 'PL', name: 'Poland', dial: '+48' },
|
{ code: 'PL', name: 'Poland', dial: '+48' },
|
||||||
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
||||||
|
{ code: 'PR', name: 'Puerto Rico', dial: '+1' },
|
||||||
|
{ code: 'QA', name: 'Qatar', dial: '+974' },
|
||||||
{ code: 'RO', name: 'Romania', dial: '+40' },
|
{ code: 'RO', name: 'Romania', dial: '+40' },
|
||||||
|
{ code: 'RU', name: 'Russia', dial: '+7' },
|
||||||
|
{ code: 'RW', name: 'Rwanda', dial: '+250' },
|
||||||
|
{ code: 'SM', name: 'San Marino', dial: '+378' },
|
||||||
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
||||||
|
{ code: 'SN', name: 'Senegal', dial: '+221' },
|
||||||
{ code: 'RS', name: 'Serbia', dial: '+381' },
|
{ code: 'RS', name: 'Serbia', dial: '+381' },
|
||||||
{ code: 'SG', name: 'Singapore', dial: '+65' },
|
{ code: 'SG', name: 'Singapore', dial: '+65' },
|
||||||
{ code: 'SK', name: 'Slovakia', dial: '+421' },
|
{ code: 'SK', name: 'Slovakia', dial: '+421' },
|
||||||
{ code: 'SI', name: 'Slovenia', dial: '+386' },
|
{ code: 'SI', name: 'Slovenia', dial: '+386' },
|
||||||
|
{ code: 'SO', name: 'Somalia', dial: '+252' },
|
||||||
{ code: 'ZA', name: 'South Africa', dial: '+27' },
|
{ code: 'ZA', name: 'South Africa', dial: '+27' },
|
||||||
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
||||||
{ code: 'ES', name: 'Spain', dial: '+34' },
|
{ code: 'ES', name: 'Spain', dial: '+34' },
|
||||||
|
{ code: 'LK', name: 'Sri Lanka', dial: '+94' },
|
||||||
|
{ code: 'SD', name: 'Sudan', dial: '+249' },
|
||||||
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
||||||
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
||||||
|
{ code: 'SY', name: 'Syria', dial: '+963' },
|
||||||
|
{ code: 'TW', name: 'Taiwan', dial: '+886' },
|
||||||
|
{ code: 'TJ', name: 'Tajikistan', dial: '+992' },
|
||||||
|
{ code: 'TZ', name: 'Tanzania', dial: '+255' },
|
||||||
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
||||||
|
{ code: 'TT', name: 'Trinidad & Tobago', dial: '+1' },
|
||||||
|
{ code: 'TN', name: 'Tunisia', dial: '+216' },
|
||||||
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
||||||
|
{ code: 'TM', name: 'Turkmenistan', dial: '+993' },
|
||||||
|
{ code: 'UG', name: 'Uganda', dial: '+256' },
|
||||||
{ code: 'UA', name: 'Ukraine', dial: '+380' },
|
{ code: 'UA', name: 'Ukraine', dial: '+380' },
|
||||||
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
||||||
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
|
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
|
||||||
{ code: 'US', name: 'United States', dial: '+1' },
|
{ code: 'US', name: 'United States', dial: '+1' },
|
||||||
|
{ code: 'UY', name: 'Uruguay', dial: '+598' },
|
||||||
|
{ code: 'UZ', name: 'Uzbekistan', dial: '+998' },
|
||||||
|
{ code: 'VE', name: 'Venezuela', dial: '+58' },
|
||||||
{ code: 'VN', name: 'Vietnam', dial: '+84' },
|
{ code: 'VN', name: 'Vietnam', dial: '+84' },
|
||||||
|
{ code: 'YE', name: 'Yemen', dial: '+967' },
|
||||||
|
{ code: 'ZM', name: 'Zambia', dial: '+260' },
|
||||||
|
{ code: 'ZW', name: 'Zimbabwe', dial: '+263' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function dialFor(code: string): string {
|
function dialFor(code: string): string {
|
||||||
@ -301,18 +396,11 @@ export default function LoginPage() {
|
|||||||
<form onSubmit={requestSmsCode} className="space-y-3">
|
<form onSubmit={requestSmsCode} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="country">Country</Label>
|
<Label htmlFor="country">Country</Label>
|
||||||
<select
|
<CountryPicker
|
||||||
id="country"
|
countries={COUNTRIES}
|
||||||
value={country}
|
value={country}
|
||||||
onChange={(e) => setCountry(e.target.value)}
|
onChange={setCountry}
|
||||||
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] text-[--color-fg] transition-colors duration-200 focus:border-[--color-accent] focus:outline-none focus:ring-1 focus:ring-[--color-accent]"
|
/>
|
||||||
>
|
|
||||||
{COUNTRIES.map((c) => (
|
|
||||||
<option key={c.code} value={c.code}>
|
|
||||||
{c.name} ({c.dial})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="phone" hint={dialFor(country)}>
|
<Label htmlFor="phone" hint={dialFor(country)}>
|
||||||
|
|||||||
168
apps/web/components/country-picker.tsx
Normal file
168
apps/web/components/country-picker.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
'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"
|
||||||
|
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-[--color-border] bg-[--color-bg-elevated] shadow-lg shadow-black/40"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user