/** * Minimal multi-select country chip picker. * * Self-contained inline render + bind helper. No dependency on the followed- * countries primitive (PR A) — alertRules.countries ships independently. * * Usage: * const picker = mountCountryChipPicker(rootEl, { initial: ['US', 'GB'] }); * picker.getValue(); // → ['US', 'GB'] * picker.onChange((next) => console.log(next)); * * Validation: input is normalized to uppercase ISO-3166 alpha-2 (regex * `^[A-Z]{2}$`); non-matching values are rejected with an inline error. */ import { toFlagEmoji } from '@/utils/country-flag'; import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; // Curated short-list of countries that show up on the picker by default. // Users can type any 2-letter ISO code into the input to add others; this // list just seeds the chip cloud so the user doesn't stare at an empty box. // Order ~loosely follows news-traffic frequency on WorldMonitor; not // comprehensive, deliberately so — the picker prioritizes "type-to-add" over // "scroll a 250-row list." const COMMON_COUNTRIES: ReadonlyArray<{ code: string; name: string }> = [ { code: 'US', name: 'United States' }, { code: 'GB', name: 'United Kingdom' }, { code: 'FR', name: 'France' }, { code: 'DE', name: 'Germany' }, { code: 'CN', name: 'China' }, { code: 'JP', name: 'Japan' }, { code: 'IN', name: 'India' }, { code: 'BR', name: 'Brazil' }, { code: 'CA', name: 'Canada' }, { code: 'AU', name: 'Australia' }, { code: 'RU', name: 'Russia' }, { code: 'UA', name: 'Ukraine' }, { code: 'IL', name: 'Israel' }, { code: 'IR', name: 'Iran' }, { code: 'SA', name: 'Saudi Arabia' }, { code: 'AE', name: 'UAE' }, { code: 'TR', name: 'Türkiye' }, { code: 'EG', name: 'Egypt' }, { code: 'MX', name: 'Mexico' }, { code: 'KR', name: 'South Korea' }, ]; export interface CountryChipPickerOptions { initial?: string[]; onChange?: (codes: string[]) => void; // When true, render the inline "Leave empty to receive alerts from all // countries" hint underneath the chip row. Default true. showAllHint?: boolean; } export interface CountryChipPickerHandle { getValue: () => string[]; setValue: (codes: string[]) => void; destroy: () => void; } const ISO2_RE = /^[A-Z]{2}$/; export const COUNTRY_CHIP_PICKER_MAX = 50; /** * Normalize one user-entered value to a valid ISO-3166 alpha-2 code, or null * if the shape doesn't match. Mirrors the server-side normalizeCountries * regex so the UI rejects what the server would silently drop. */ export function normalizeIso2(raw: string): string | null { if (typeof raw !== 'string') return null; const upper = raw.trim().toUpperCase(); return ISO2_RE.test(upper) ? upper : null; } function dedupe(codes: string[]): string[] { return [...new Set(codes)]; } function normalizeList(codes: string[]): string[] { return dedupe(codes.map(normalizeIso2).filter((c): c is string => c !== null)) .slice(0, COUNTRY_CHIP_PICKER_MAX); } export function mountCountryChipPicker( root: HTMLElement, opts: CountryChipPickerOptions = {}, ): CountryChipPickerHandle { let value: string[] = normalizeList(opts.initial ?? []); function emit(): void { if (opts.onChange) opts.onChange(value.slice()); } function render(): void { const showAllHint = opts.showAllHint !== false; const selectedSet = new Set(value); const chipRow = COMMON_COUNTRIES.map(({ code, name }) => { const on = selectedSet.has(code); // Selected chips carry a ✕ so removal is discoverable (clicking anywhere // on the chip toggles it off via the delegated handler). Decorative // (aria-hidden) — screen readers get the toggle state from aria-pressed. return ``; }).join(''); // Custom-added codes that aren't in COMMON_COUNTRIES — render them so the // user can deselect. Always selected, so always show the ✕ remove glyph. const extras = value.filter(code => !COMMON_COUNTRIES.some(c => c.code === code)); const extraChips = extras.map(code => ``, ).join(''); setTrustedHtml(root, trustedHtml(`