city-selector.ts raw
1 // Client-side city selector: a popup list with search input that supports
2 // approximate cyrillic/latin equivalence. Works the same on mobile and
3 // desktop (native datalist is unreliable on mobile browsers).
4
5 import { toSearch } from './translit';
6
7 let citiesPromise: Promise<string[]> | null = null;
8
9 export function fetchCities(): Promise<string[]> {
10 if (!citiesPromise) {
11 citiesPromise = fetch('/api/cities')
12 .then(r => r.json())
13 .then(d => (Array.isArray(d) ? (d as string[]) : []))
14 .catch(() => []);
15 }
16 return citiesPromise;
17 }
18
19 export { toSearch };
20
21 function escapeHtml(s: string): string {
22 const d = document.createElement('div');
23 d.textContent = s;
24 return d.innerHTML;
25 }
26
27 function setupCitySelector(input: HTMLInputElement): void {
28 const wrap = document.createElement('div');
29 wrap.className = 'city-select-wrap';
30 input.parentNode!.insertBefore(wrap, input);
31 wrap.appendChild(input);
32
33 const dd = document.createElement('div');
34 dd.className = 'city-select-dd';
35 dd.style.display = 'none';
36 wrap.appendChild(dd);
37
38 let cities: string[] = [];
39
40 function render() {
41 const q = toSearch(input.value);
42 const matches = q
43 ? cities.filter(c => toSearch(c).includes(q))
44 : cities;
45 dd.innerHTML = matches.slice(0, 60)
46 .map(c => `<div class="city-opt" data-v="${escapeHtml(c)}">${escapeHtml(c)}</div>`)
47 .join('') || `<div class="city-none">Няма резултати</div>`;
48 dd.querySelectorAll<HTMLElement>('.city-opt').forEach(el => {
49 el.addEventListener('click', () => {
50 input.value = el.dataset.v!;
51 // fire both so listeners on 'input' (live refresh) and 'change' run
52 input.dispatchEvent(new Event('input', { bubbles: true }));
53 input.dispatchEvent(new Event('change', { bubbles: true }));
54 dd.style.display = 'none';
55 });
56 });
57 }
58
59 function show() {
60 dd.style.display = 'block';
61 render();
62 }
63
64 input.addEventListener('focus', show);
65 input.addEventListener('input', show);
66 document.addEventListener('click', (e) => {
67 if (!wrap.contains(e.target as Node)) dd.style.display = 'none';
68 });
69
70 fetchCities().then(cs => {
71 cities = cs;
72 if (dd.style.display === 'block') render();
73 });
74 }
75
76 export function initCitySelectors(root: ParentNode = document): void {
77 root.querySelectorAll<HTMLInputElement>('input[data-city-selector]').forEach(input => {
78 if ((input as any).__citySelectorInit) return;
79 (input as any).__citySelectorInit = true;
80 setupCitySelector(input);
81 });
82 }
83