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