Выбор стран и городов

Связка «страна → город» относится к классу зависимых селектов, где выбор в первом поле определяет доступный набор значений во втором. В интерфейсах это реализуется через каскадную фильтрацию данных, асинхронную подгрузку и нормализацию справочников. Библиотека Tom Select позволяет строить такие сценарии за счёт комбинации кастомных источников данных, динамических опций и событийного API.

Ключевая особенность подобных интерфейсов — необходимость поддерживать три состояния данных:

  • исходный список стран (часто статический или кэшируемый)
  • динамически изменяемый список городов
  • синхронизацию выбранных значений между зависимыми селектами

При этом важно учитывать задержки сети, частичную загрузку и необходимость очищать зависимые поля при изменении родительского значения.


Базовая структура данных для стран и городов

На уровне модели данных используется иерархическая структура:

const countries = [
  { id: "kz", name: "Казахстан" },
  { id: "ru", name: "Россия" },
  { id: "tr", name: "Турция" }
];

const cities = {
  kz: [
    { id: "karaganda", name: "Караганда" },
    { id: "astana", name: "Астана" }
  ],
  ru: [
    { id: "moscow", name: "Москва" },
    { id: "spb", name: "Санкт-Петербург" }
  ],
  tr: [
    { id: "istanbul", name: "Стамбул" },
    { id: "ankara", name: "Анкара" }
  ]
};

Такой формат позволяет быстро фильтровать данные на клиенте, но в реальных приложениях чаще используется API, возвращающий города по country_id.


Инициализация селекта стран

Первый уровень (страны) обычно инициализируется как статический список:

const countrySelect = new TomSelect("#country", {
  valueField: "id",
  labelField: "name",
  searchField: "name",
  options: countries,
  placeholder: "Выберите страну"
});

Здесь важно, что список стран редко требует динамической подгрузки. Основная задача — обеспечить стабильный идентификатор, который будет использоваться для запроса городов.


Инициализация зависимого селекта городов

Города изначально пусты, так как зависят от выбранной страны:

const citySelect = new TomSelect("#city", {
  valueField: "id",
  labelField: "name",
  searchField: "name",
  options: [],
  placeholder: "Сначала выберите страну"
});

На этом этапе поле городов фактически «заблокировано логикой данных», а не UI-состоянием.


Связка селектов через событие изменения

Основной механизм синхронизации — обработчик изменения значения страны:

countrySelect.on("change", (countryId) => {
  citySelect.clear();
  citySelect.clearOptions();

  if (!countryId) return;

  const newCities = cities[countryId] || [];
  citySelect.addOptions(newCities);
  citySelect.refreshOptions(false);
});

Здесь реализуется ключевой паттерн:

  • очистка текущего значения города
  • удаление старых опций
  • загрузка нового набора
  • обновление интерфейса

Это предотвращает ситуацию, когда пользователь выбирает город, не относящийся к текущей стране.


Асинхронная загрузка городов через API

В реальных системах города подгружаются с сервера:

const citySelect = new TomSelect("#city", {
  valueField: "id",
  labelField: "name",
  searchField: "name",
  load: function(query, callback) {
    const countryId = countrySelect.getValue();

    if (!countryId) {
      callback();
      return;
    }

    fetch(`/api/cities?country=${countryId}&q=${encodeURIComponent(query)}`)
      .then(res => res.json())
      .then(data => callback(data))
      .catch(() => callback());
  }
});

Важный момент: load вызывается при поиске, а значит фильтрация происходит на сервере. Это снижает нагрузку на клиент и позволяет работать с большими справочниками.


Сброс состояния при смене страны

Корректная синхронизация требует полного сброса состояния зависимого селекта:

function resetCities() {
  citySelect.clear(true);
  citySelect.clearOptions();
  citySelect.setPlaceholder("Выберите страну");
}

И вызов:

countrySelect.on("change", () => {
  resetCities();
});

Это предотвращает визуальные артефакты, когда старое значение города остаётся в поле после смены страны.


Предзагрузка популярных городов

Для улучшения UX можно заранее загрузить популярные города:

citySelect.addOptions([
  { id: "moscow", name: "Москва" },
  { id: "astana", name: "Астана" }
]);
citySelect.refreshOptions(false);

После выбора страны эти значения либо дополняются, либо полностью заменяются.


Группировка городов по регионам

Иногда требуется вложенная структура через optgroups:

const groupedCities = [
  {
    optgroup: "Север",
    options: [
      { id: "spb", name: "Санкт-Петербург" }
    ]
  },
  {
    optgroup: "Центр",
    options: [
      { id: "moscow", name: "Москва" }
    ]
  }
];

Инициализация:

const citySelect = new TomSelect("#city", {
  optgroupField: "optgroup",
  labelField: "name",
  valueField: "id",
  options: groupedCities.flatMap(g => g.options)
});

Такой подход полезен при работе с крупными странами, где важно визуально разделять регионы.


Кэширование запросов городов

При частой смене страны имеет смысл кэшировать результаты:

const cache = {};

function loadCities(countryId) {
  if (cache[countryId]) {
    return Promise.resolve(cache[countryId]);
  }

  return fetch(`/api/cities?country=${countryId}`)
    .then(res => res.json())
    .then(data => {
      cache[countryId] = data;
      return data;
    });
}

Интеграция с селектом:

countrySelect.on("change", (countryId) => {
  citySelect.clear();
  citySelect.clearOptions();

  if (!countryId) return;

  loadCities(countryId).then(data => {
    citySelect.addOptions(data);
    citySelect.refreshOptions(false);
  });
});

Поиск городов внутри выбранной страны

Фильтрация запроса по введённому тексту:

load: function(query, callback) {
  const countryId = countrySelect.getValue();

  fetch(`/api/cities?country=${countryId}&search=${query}`)
    .then(res => res.json())
    .then(callback)
    .catch(() => callback());
}

Важно учитывать, что поиск всегда должен быть ограничен выбранной страной, иначе пользователь получит неконсистентные результаты.


Управление состоянием доступности селекта

До выбора страны селект городов можно визуально и функционально отключать:

citySelect.disable();

И включать после выбора:

countrySelect.on("change", (value) => {
  if (value) {
    citySelect.enable();
  } else {
    citySelect.disable();
  }
});

Это создаёт явный контракт поведения интерфейса.


Обработка очистки формы

При сбросе формы важно синхронно очищать оба поля:

function resetForm() {
  countrySelect.clear();
  citySelect.clear();
  citySelect.clearOptions();
  citySelect.disable();
}

Это особенно важно при повторном использовании формы без перезагрузки страницы.


Нормализация данных API

Частая проблема — несоответствие форматов ответа сервера и ожиданий селекта. Требуется преобразование:

function normalizeCities(data) {
  return data.map(item => ({
    id: item.city_id,
    name: item.city_name
  }));
}

Использование:

fetch("/api/cities?country=kz")
  .then(res => res.json())
  .then(data => citySelect.addOptions(normalizeCities(data)));

Обработка состояния без страны

Если страна не выбрана, логика должна быть жёсткой:

  • города не загружаются
  • поиск отключён
  • поле заблокировано
  • отображается подсказка
citySelect.settings.placeholder = "Сначала выберите страну";
citySelect.refreshState();

Поддержка множественного выбора городов

В некоторых интерфейсах допускается выбор нескольких городов:

const citySelect = new TomSelect("#city", {
  maxItems: 5,
  valueField: "id",
  labelField: "name",
  searchField: "name"
});

При смене страны необходимо очищать все выбранные значения:

countrySelect.on("change", () => {
  citySelect.clear(true);
});

Согласованность состояния при асинхронных ответах

При медленной сети возможна гонка запросов. Решение — контроль актуального запроса:

let currentRequest = null;

function loadCities(countryId) {
  const requestId = Date.now();
  currentRequest = requestId;

  return fetch(`/api/cities?country=${countryId}`)
    .then(res => res.json())
    .then(data => {
      if (currentRequest !== requestId) return [];
      return data;
    });
}

Это предотвращает подмену данных устаревшим ответом.


Итоговая связка поведения

Поведение системы «страны → города» при использовании Tom Select сводится к нескольким устойчивым правилам:

  • страна является единственным источником истины для списка городов
  • смена страны всегда сбрасывает состояние города
  • загрузка городов может быть синхронной или асинхронной
  • поиск всегда ограничен текущей страной
  • состояние UI и данных синхронизируется через события изменения

Такая архитектура обеспечивает предсказуемость интерфейса даже при сложной сетевой логике и больших справочниках.