Каскадные выпадающие списки

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

В Choices.js каскадная логика не встроена как отдельная функция, но реализуется через управление состоянием экземпляров, динамическое обновление данных и использование событий выбора.


Базовая архитектура каскада

Каскад обычно состоит из нескольких уровней:

  • первый селектор (родительский)
  • второй селектор (зависимый от первого)
  • третий селектор (зависимый от второго)

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


Инициализация нескольких связанных Choices

Создание базовой структуры начинается с инициализации независимых экземпляров:

const countrySelect = new Choices('#country', {
  searchEnabled: true,
  shouldSort: false
});

const citySelect = new Choices('#city', {
  searchEnabled: true,
  shouldSort: false
});

На этом этапе селекты не связаны между собой, но уже готовы к динамическому обновлению.


Обработка события выбора

Основной механизм каскада строится на событии изменения значения родительского селектора.

Choices.js предоставляет событие change через DOM-элемент:

const countryElement = document.querySelector('#country');

countryElement.addEventListener('change', (event) => {
  const selectedCountry = event.target.value;
  updateCities(selectedCountry);
});

Функция updateCities отвечает за перезаполнение зависимого списка.


Полное обновление зависимого списка

Для корректного обновления необходимо:

  1. очистить текущие значения
  2. удалить старые опции
  3. добавить новые опции
  4. обновить состояние UI

Choices.js предоставляет методы clearChoices() и setChoices().

function updateCities(country) {
  const cities = getCitiesByCountry(country);

  citySelect.clearChoices();

  citySelect.setChoices(
    cities.map(city => ({
      value: city.id,
      label: city.name,
      selected: false,
      disabled: false
    })),
    'value',
    'label',
    true
  );

  citySelect.removeActiveItems();
}

Источник данных для каскада

Чаще всего данные хранятся в виде вложенной структуры:

const data = {
  kazakhstan: [
    { id: 'karaganda', name: 'Караганда' },
    { id: 'almaty', name: 'Алматы' }
  ],
  russia: [
    { id: 'moscow', name: 'Москва' },
    { id: 'spb', name: 'Санкт-Петербург' }
  ]
};

function getCitiesByCountry(country) {
  return data[country] || [];
}

Сброс зависимых уровней

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

function resetCitySelect() {
  citySelect.clearChoices();
  citySelect.removeActiveItems();
}

Если каскад имеет больше уровней, сброс выполняется рекурсивно вниз по цепочке.


Многоуровневый каскад

Для цепочек из трёх и более селекторов логика расширяется:

const countrySelect = new Choices('#country');
const regionSelect = new Choices('#region');
const citySelect = new Choices('#city');

document.querySelector('#country').addEventListener('change', (e) => {
  const country = e.target.value;

  updateRegions(country);
  regionSelect.removeActiveItems();

  citySelect.clearChoices();
  citySelect.removeActiveItems();
});

Зависимость второго уровня

function updateRegions(country) {
  const regions = getRegions(country);

  regionSelect.clearChoices();

  regionSelect.setChoices(
    regions.map(r => ({
      value: r.id,
      label: r.name
    })),
    'value',
    'label',
    true
  );
}

Асинхронная загрузка данных

В реальных приложениях каскады часто зависят от API.

async function updateCities(country) {
  citySelect.clearChoices();

  const response = await fetch(`/api/cities?country=${country}`);
  const cities = await response.json();

  citySelect.setChoices(
    cities,
    'id',
    'name',
    true
  );
}

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


Блокировка зависимых селекторов

Во время загрузки данных зависимые селекторы часто временно отключаются:

function setLoading(selectInstance, state) {
  const element = selectInstance.passedElement.element;
  element.disabled = state;
}

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

setLoading(citySelect, true);

fetch('/api/cities')
  .then(r => r.json())
  .then(data => {
    citySelect.setChoices(data, 'id', 'name', true);
  })
  .finally(() => {
    setLoading(citySelect, false);
  });

Очистка состояния при повторном выборе

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

  • активные значения
  • список опций
  • поисковый ввод
function fullReset(selectInstance) {
  selectInstance.clearChoices();
  selectInstance.removeActiveItems();
  selectInstance.setChoiceByValue('');
}

Использование событий Choices.js

Помимо DOM-событий, Choices.js предоставляет внутренние события через API:

countrySelect.passedElement.element.addEventListener(
  'addItem',
  function(event) {
    console.log('Выбран элемент:', event.detail.value);
  }
);

Это позволяет централизованно реагировать на изменения состояния.


Синхронизация нескольких каскадов

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

  • география (страна → город)
  • склад (склад → зона → полка)

Для предотвращения конфликтов используется единый контроллер состояния:

const state = {
  country: null,
  city: null
};

countrySelect.passedElement.element.addEventListener('change', (e) => {
  state.country = e.target.value;
  state.city = null;

  updateCities(state.country);
});

Предварительное заполнение значений

Choices.js позволяет задавать начальные значения, что важно для редактирования форм:

citySelect.setChoices([
  { value: 'karaganda', label: 'Караганда', selected: true }
], 'value', 'label', true);

При каскадной логике важно предварительно загрузить зависимые данные до установки selected состояния.


Обработка пустых состояний

Если у родительского селектора нет выбранного значения, зависимые списки должны быть очищены:

if (!country) {
  citySelect.clearChoices();
  citySelect.removeActiveItems();
  return;
}

Это предотвращает отображение некорректных данных.


Оптимизация частых обновлений

При интенсивной смене значений (например, быстрый выбор в родительском списке) полезно применять debounce:

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

countrySelect.passedElement.element.addEventListener(
  'change',
  debounce((e) => updateCities(e.target.value), 300)
);

Кэширование зависимых данных

Чтобы избежать повторных запросов:

const cache = new Map();

async function getCities(country) {
  if (cache.has(country)) {
    return cache.get(country);
  }

  const res = await fetch(`/api/cities?country=${country}`);
  const data = await res.json();

  cache.set(country, data);
  return data;
}

Типичные ошибки реализации каскада

Основные проблемы возникают при неправильном управлении состоянием:

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

Контроль порядка асинхронных обновлений

При быстром переключении значений возможна гонка запросов:

let requestId = 0;

async function updateCities(country) {
  const currentId = ++requestId;

  const res = await fetch(`/api/cities?country=${country}`);
  const data = await res.json();

  if (currentId !== requestId) return;

  citySelect.setChoices(data, 'id', 'name', true);
}

Это гарантирует актуальность данных.


Динамическое расширение каскада

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

  • добавление новых уровней
  • отключение промежуточных селекторов
  • переключение логики фильтрации

Choices.js позволяет управлять этим через полную пересборку setChoices и пересоздание связей между экземплярами.