/** * createCountryInput - the HEADLESS core behind : a searchable * country picker (search by name, dial code, or ISO code; roving active index + * keyboard) that emits the ISO 3166-1 alpha-2 code. Exposed as **prop-getters** * you spread onto YOUR OWN markup. No styles/DOM/portal - those stay in the * styled component. * * ```svelte * * * {#if ci.open} * * * {/if} * ``` */ import { COUNTRIES, COUNTRY_BY_CODE, type Country } from './countries' import { editorAria, type EditorAriaState } from './editor-contract' /** Reactive inputs are passed as getters so the core tracks live prop changes. */ export type CountryInputConfig = { value: () => string | null onChange?: (code: string) => void disabled?: () => boolean ariaLabel?: () => string | undefined /** Accessible label for the in-panel search field (localizable). */ searchLabel?: () => string | undefined /** DOM focus hook provided by the renderer; the core never touches the DOM. */ focusTrigger?: () => void // Editor contract (ARIA + validation) - folded into triggerProps(). id?: () => string | undefined invalid?: () => boolean required?: () => boolean error?: () => string | undefined hint?: () => string | undefined } let uid = 0 export function createCountryInput(config: CountryInputConfig) { const id = `sv-country-${uid++}` const listId = `${id}-list` const optionId = (i: number) => `${listId}-opt-${i}` const disabled = () => config.disabled?.() ?? false const ariaState = (): EditorAriaState => ({ id: config.id?.(), invalid: config.invalid?.(), required: config.required?.(), error: config.error?.(), hint: config.hint?.(), ariaLabel: config.ariaLabel?.(), }) let open = $state(false) let query = $state('') let active = $state(0) const selected = $derived(config.value() ? COUNTRY_BY_CODE.get(config.value()!) ?? null : null) const filtered = $derived( query.trim() === '' ? COUNTRIES : COUNTRIES.filter( (c) => c.name.toLowerCase().includes(query.toLowerCase()) || c.dial.includes(query) || c.code.toLowerCase() === query.toLowerCase(), ), ) function openPanel() { if (disabled() || open) return; open = true; query = ''; active = 0 } function close() { open = false; config.focusTrigger?.() } function toggle() { if (open) { open = false } else { openPanel() } } function pick(code: string) { config.onChange?.(code); open = false; config.focusTrigger?.() } function setQuery(v: string) { query = v; active = 0 } function onSearchInput(e: Event) { setQuery((e.currentTarget as HTMLInputElement).value) } /** The current active-descendant id (for the focused search input), or undefined. */ const activeDescendant = () => (open && filtered.length > 0 ? optionId(active) : undefined) function onKeydown(e: KeyboardEvent) { if (!open) return if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) } else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) } else if (e.key === 'Enter') { e.preventDefault(); const c = filtered[active]; if (c) pick(c.code) } else if (e.key === 'Escape') { e.preventDefault(); close() } } return { /** Panel open state. */ get open() { return open }, /** Highlighted country index into `filtered`. */ get activeIndex() { return active }, /** Current search query. */ get query() { return query }, /** Countries after the current search filter. */ get filtered() { return filtered }, /** The selected country, if any. */ get selected() { return selected }, /** Controlled value (ISO alpha-2 code). */ get value() { return config.value() }, isActive: (i: number) => i === active, isSelected: (code: string) => code === config.value(), setActive: (i: number) => { active = i }, setQuery, openPanel, close, toggle, pick, onKeydown, /** Spread onto the trigger