"use client"; import { useEffect, useRef, useState } from "react"; import { CONTROL_CLASS } from "./Field"; type CountryPickerProps = { value: string; onChange: (value: string) => void; countries: string[]; placeholder?: string; id?: string; "aria-describedby"?: string; "aria-invalid"?: true; }; const MAX_VISIBLE = 50; // Searchable text combobox constrained to a fixed list (UN member states) // rather than a Lookup-style "selected card" — a country is a single text // value, not an object with its own detail fields. // // Carries the full combobox contract: without role/aria-expanded/ // aria-activedescendant a screen reader announces a plain text box and never // mentions that a list appeared, and without the key handling the list is // reachable by mouse only. export function CountryPicker({ value, onChange, countries, placeholder = "Land suchen…", id, "aria-describedby": describedBy, "aria-invalid": invalid, }: CountryPickerProps) { const [query, setQuery] = useState(value); const [prevValue, setPrevValue] = useState(value); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const containerRef = useRef(null); const listRef = useRef(null); // Re-sync the local draft text when `value` changes externally (e.g. the // surrounding form loads a different employee). Adjusting state directly // during render — rather than in an effect — avoids an extra render pass // and the synchronous setState-in-effect this used to do. if (value !== prevValue) { setPrevValue(value); setQuery(value); } useEffect(() => { function onClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setQuery(value); } } document.addEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside); }, [value]); const filtered = (query.trim() ? countries.filter((c) => c.toLowerCase().includes(query.trim().toLowerCase())) : countries).slice( 0, MAX_VISIBLE ); const listId = id ? `${id}-listbox` : undefined; const activeId = id && filtered[activeIndex] ? `${id}-option-${activeIndex}` : undefined; function commit(country: string) { onChange(country); setQuery(country); setOpen(false); } // Keeps the highlighted row inside the scroll container as it moves. useEffect(() => { if (!open) return; listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" }); }, [activeIndex, open]); function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!open) { setOpen(true); setActiveIndex(0); return; } const delta = e.key === "ArrowDown" ? 1 : -1; setActiveIndex((i) => (filtered.length === 0 ? 0 : (i + delta + filtered.length) % filtered.length)); } else if (e.key === "Enter") { if (open && filtered[activeIndex]) { e.preventDefault(); commit(filtered[activeIndex]); } } else if (e.key === "Escape") { if (open) { // Stop here rather than letting it bubble — otherwise the dialog // containing this field closes along with the dropdown. e.stopPropagation(); setOpen(false); setQuery(value); } } } return (
{ setQuery(e.target.value); setActiveIndex(0); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} placeholder={placeholder} className={CONTROL_CLASS} /> {open && (
{filtered.length === 0 &&
Keine Treffer
} {filtered.map((c, i) => ( ))}
)}
); }