EMAILOUT / frontend /src /components /workspace /SearchableCountryPicker.jsx
Seth
update
9faed2a
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';
/**
* Table cell: shows flag only; opens searchable list of countries (full names).
*/
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;
// Last-column friendly: if trigger sits in the right part of the viewport,
// anchor the panel by its right edge to the trigger's right edge so the menu opens leftward.
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>
);
}