| import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; |
| import { createPortal } from 'react-dom'; |
| import { cn } from '@/lib/utils'; |
| import { getAllCountries, flagEmojiFromCode, matchCountry } from '@/lib/countries'; |
|
|
| |
| |
| |
| export function SearchableCountryPicker({ value, onChange, className = '' }) { |
| const countries = useMemo(() => getAllCountries(), []); |
| const [open, setOpen] = useState(false); |
| const [query, setQuery] = useState(''); |
| const triggerRef = useRef(null); |
| const menuRef = useRef(null); |
| const inputRef = useRef(null); |
| const [pos, setPos] = useState({ top: 0, left: 0, width: 320 }); |
|
|
| const matched = matchCountry(value || '', countries); |
| const flag = matched ? flagEmojiFromCode(matched.code) : '🏳️'; |
| const title = matched ? matched.name : value || 'Select country'; |
|
|
| 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) |
| ); |
| }, [countries, query]); |
|
|
| const computeMenuPosition = () => { |
| const trigger = triggerRef.current; |
| if (!trigger) return; |
| const r = trigger.getBoundingClientRect(); |
| const PAD = 12; |
| const MENU_W = 320; |
| const vw = window.innerWidth; |
| const vh = window.innerHeight; |
| |
| |
| const inRightZone = r.right > vw * 0.55; |
| let left = inRightZone ? r.right - MENU_W : r.left; |
| left = Math.max(PAD, Math.min(left, vw - MENU_W - PAD)); |
|
|
| const estPanelH = Math.min(vh * 0.72, 420); |
| let top = r.bottom + 6; |
| if (top + estPanelH > vh - PAD) { |
| top = Math.max(PAD, r.top - estPanelH - 6); |
| } |
|
|
| setPos({ top, left, width: MENU_W }); |
| }; |
|
|
| useLayoutEffect(() => { |
| if (!open || !triggerRef.current) return; |
| computeMenuPosition(); |
| }, [open]); |
|
|
| useEffect(() => { |
| if (!open) return; |
| const reposition = () => computeMenuPosition(); |
| window.addEventListener('scroll', reposition, true); |
| window.addEventListener('resize', reposition); |
| return () => { |
| window.removeEventListener('scroll', reposition, true); |
| window.removeEventListener('resize', reposition); |
| }; |
| }, [open]); |
|
|
| useEffect(() => { |
| if (!open) return; |
| const t = requestAnimationFrame(() => inputRef.current?.focus()); |
| return () => cancelAnimationFrame(t); |
| }, [open]); |
|
|
| useEffect(() => { |
| if (!open) return; |
| const onPointerDown = (e) => { |
| const tr = triggerRef.current; |
| const menu = menuRef.current; |
| if (tr?.contains(e.target) || menu?.contains(e.target)) return; |
| setOpen(false); |
| setQuery(''); |
| }; |
| document.addEventListener('pointerdown', onPointerDown); |
| return () => document.removeEventListener('pointerdown', onPointerDown); |
| }, [open]); |
|
|
| const pick = (row) => { |
| onChange(row.name); |
| setOpen(false); |
| setQuery(''); |
| }; |
|
|
| return ( |
| <div className={cn('relative inline-flex', className)}> |
| <button |
| ref={triggerRef} |
| type="button" |
| title={title} |
| aria-haspopup="listbox" |
| aria-expanded={open} |
| onClick={(e) => { |
| e.stopPropagation(); |
| setOpen((o) => !o); |
| }} |
| className={cn( |
| 'flex h-8 min-w-[2.25rem] items-center justify-center rounded-md border border-transparent', |
| 'bg-transparent text-xl leading-none hover:border-slate-200 hover:bg-white', |
| 'focus:outline-none focus:ring-2 focus:ring-violet-200 focus:ring-offset-0' |
| )} |
| > |
| <span aria-hidden>{flag}</span> |
| <span className="sr-only">{title}</span> |
| </button> |
| |
| {open && |
| createPortal( |
| <div |
| ref={menuRef} |
| role="listbox" |
| data-country-picker="true" |
| className="fixed z-[10050] flex max-h-[min(70vh,24rem)] flex-col rounded-md border border-slate-200 bg-white shadow-xl ring-1 ring-black/5" |
| style={{ |
| top: pos.top, |
| left: pos.left, |
| width: pos.width, |
| }} |
| onClick={(e) => e.stopPropagation()} |
| > |
| <div className="shrink-0 border-b border-slate-100 p-2"> |
| <input |
| ref={inputRef} |
| type="search" |
| role="combobox" |
| aria-autocomplete="list" |
| placeholder="Search countries…" |
| value={query} |
| onChange={(e) => setQuery(e.target.value)} |
| onKeyDown={(e) => e.stopPropagation()} |
| className="w-full rounded-md border border-slate-200 px-2 py-1.5 text-sm outline-none focus:border-violet-300 focus:ring-1 focus:ring-violet-200" |
| /> |
| </div> |
| <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1"> |
| <button |
| type="button" |
| role="option" |
| className="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-slate-600 hover:bg-slate-100" |
| onClick={() => { |
| onChange(''); |
| setOpen(false); |
| setQuery(''); |
| }} |
| > |
| <span className="text-base" aria-hidden> |
| 🏳️ |
| </span> |
| <span>Clear</span> |
| </button> |
| {filtered.map((c) => ( |
| <button |
| key={c.code} |
| type="button" |
| role="option" |
| className={cn( |
| 'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-slate-100', |
| matched?.code === c.code && 'bg-violet-50' |
| )} |
| onClick={() => pick(c)} |
| > |
| <span className="text-base leading-none" aria-hidden> |
| {flagEmojiFromCode(c.code)} |
| </span> |
| <span>{c.name}</span> |
| </button> |
| ))} |
| {filtered.length === 0 ? ( |
| <div className="px-2 py-3 text-center text-sm text-slate-500"> |
| No matches |
| </div> |
| ) : null} |
| </div> |
| </div>, |
| document.body |
| )} |
| </div> |
| ); |
| } |
|
|