Download frontend/src/components/workspace/SearchableCountryPicker.jsx from Seth0330/EMAILOUT: direct link, hf CLI and curl.
- Browser
- Download file 8.09 kB
-
https://huggingface.co/spaces/Seth0330/EMAILOUT/resolve/main/frontend/src/components/workspace/SearchableCountryPicker.jsx
- Command line
-
hf download hf://spaces/Seth0330/EMAILOUT/frontend/src/components/workspace/SearchableCountryPicker.jsx
-
curl -L -o SearchableCountryPicker.jsx https://huggingface.co/spaces/Seth0330/EMAILOUT/resolve/main/frontend/src/components/workspace/SearchableCountryPicker.jsx
8.09 kB
| 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> | |
| ); | |
| } | |