Кастомизация заголовков групп

Группировка опций в выпадающих списках реализуется через механизм optgroup, который позволяет логически объединять элементы по категориям. В стандартной конфигурации библиотека отображает заголовок группы как простой текстовый блок, однако в реальных интерфейсах этого недостаточно: требуется визуальная иерархия, дополнительная информация, иконки, счётчики элементов, состояния активности и даже динамическая подгрузка содержимого.

Механизм кастомизации заголовков групп в Tom Select строится вокруг переопределения рендер-функций и модификации структуры optgroup. Основной точкой расширения выступает render.optgroup_header, а также обработка данных в optgroups и options.


Структура данных optgroup и её влияние на заголовки

Группы в Tom Select задаются через объектную структуру:

{
  value: "fruits",
  label: "Фрукты"
}

Или через расширенный вариант:

{
  value: "fruits",
  label: "Фрукты",
  disabled: false
}

Каждая группа связывается с набором опций:

{
  value: "apple",
  text: "Яблоко",
  optgroup: "fruits"
}

Ключевым моментом является то, что заголовок группы формируется не из опции, а из объекта optgroup. Это позволяет полностью отделить представление группы от её содержимого.


Базовая кастомизация заголовка группы

Основной способ изменения отображения групповых заголовков — переопределение render.optgroup_header.

new TomSelect("#select", {
  optgroups: [
    { value: "fruits", label: "Фрукты" },
    { value: "vegetables", label: "Овощи" }
  ],
  options: [
    { value: "apple", text: "Яблоко", optgroup: "fruits" },
    { value: "carrot", text: "Морковь", optgroup: "vegetables" }
  ],
  render: {
    optgroup_header: function(data, escape) {
      return `
        <div class="group-header">
          <span class="group-title">${escape(data.label)}</span>
        </div>
      `;
    }
  }
});

Здесь data содержит объект группы, а escape обеспечивает защиту от XSS при вставке текста.


Использование дополнительных полей в заголовках групп

Структура optgroup может быть расширена произвольными полями, которые затем используются в рендере:

optgroups: [
  {
    value: "fruits",
    label: "Фрукты",
    icon: "?",
    count: 12
  }
]

Рендер:

render: {
  optgroup_header: function(data, escape) {
    return `
      <div class="group-header">
        <span class="group-icon">${data.icon || ""}</span>
        <span class="group-title">${escape(data.label)}</span>
        <span class="group-count">${data.count ?? ""}</span>
      </div>
    `;
  }
}

Такой подход позволяет превращать заголовок группы в полноценный UI-компонент.


Встраивание счётчиков элементов группы

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

const options = [
  { value: "apple", text: "Яблоко", optgroup: "fruits" },
  { value: "banana", text: "Банан", optgroup: "fruits" },
  { value: "carrot", text: "Морковь", optgroup: "vegetables" }
];

const optgroupCounts = options.reduce((acc, item) => {
  acc[item.optgroup] = (acc[item.optgroup] || 0) + 1;
  return acc;
}, {});

Далее данные передаются в optgroups:

optgroups: [
  { value: "fruits", label: "Фрукты", count: optgroupCounts["fruits"] },
  { value: "vegetables", label: "Овощи", count: optgroupCounts["vegetables"] }
]

И рендер остаётся ответственным только за отображение.


Условное форматирование заголовков групп

Заголовок может изменяться в зависимости от состояния группы: пустая, активная, заблокированная.

render: {
  optgroup_header: function(data, escape) {
    const isEmpty = data.count === 0;

    return `
      <div class="group-header ${isEmpty ? "is-empty" : ""}">
        <span class="group-title">${escape(data.label)}</span>
        ${isEmpty ? "<span class='group-state'>нет элементов</span>" : ""}
      </div>
    `;
  }
}

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


Стилизация через CSS-классы

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

.group-header {
  display: flex;
  justify-content: space-between;
  padding: 8px 12px;
  font-weight: 600;
  border-bottom: 1px solid #eee;
}

.group-title {
  color: #333;
}

.group-count {
  font-size: 12px;
  color: #888;
}

.group-header.is-empty {
  opacity: 0.5;
}

Стилизация играет ключевую роль, поскольку заголовки групп часто становятся визуальными разделителями внутри dropdown-списка.


Интеграция иконок и визуальных маркеров

Добавление иконок повышает читаемость больших списков:

optgroups: [
  { value: "fruits", label: "Фрукты", iconClass: "icon-fruit" },
  { value: "vegetables", label: "Овощи", iconClass: "icon-veggie" }
]

Рендер:

render: {
  optgroup_header: function(data, escape) {
    return `
      <div class="group-header">
        <i class="${data.iconClass}"></i>
        <span>${escape(data.label)}</span>
      </div>
    `;
  }
}

Иконки позволяют быстро визуально различать группы без чтения текста.


Динамическая генерация заголовков групп

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

new TomSelect("#select", {
  load: function(query, callback) {
    fetch("/api/items?q=" + encodeURIComponent(query))
      .then(res => res.json())
      .then(data => {
        const groups = {};

        data.options.forEach(item => {
          if (!groups[item.category]) {
            groups[item.category] = {
              value: item.category,
              label: item.category_name,
              count: 0
            };
          }
          groups[item.category].count++;
        });

        this.settings.optgroups = Object.values(groups);
        callback(data.options);
      });
  }
});

Заголовки групп в таком случае обновляются синхронно с результатами поиска.


Кастомизация поведения клика по заголовку группы

Хотя заголовки групп по умолчанию не интерактивны, можно добавить поведение через DOM-обработчики:

render: {
  optgroup_header: function(data, escape) {
    return `
      <div class="group-header js-group-header" data-group="${data.value}">
        ${escape(data.label)}
      </div>
    `;
  }
}

Инициализация обработчика:

const control = new TomSelect("#select");

control.dropdown.addEventListener("click", function(e) {
  const header = e.target.closest(".js-group-header");
  if (!header) return;

  const group = header.dataset.group;
  console.log("Группа выбрана:", group);
});

Это позволяет реализовывать сворачивание/разворачивание групп или быстрый выбор всех элементов.


Доступность и ARIA-структура

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

  • role="group" для контейнера группы
  • aria-label для заголовка

Пример:

render: {
  optgroup_header: function(data, escape) {
    return `
      <div role="group" aria-label="${escape(data.label)}" class="group-header">
        ${escape(data.label)}
      </div>
    `;
  }
}

Это улучшает навигацию с клавиатуры и работу screen reader.


Оптимизация рендеринга заголовков групп

При большом количестве групп важно минимизировать перерасчёт DOM:

  • избегать тяжёлых вычислений в optgroup_header
  • заранее вычислять count, label, state
  • использовать кеширование данных групп
  • не пересоздавать optgroups без необходимости

Особенно критично это при использовании load() с удалёнными API, где частая перерисовка может приводить к мерцанию интерфейса.


Композиция сложных заголовков

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

render: {
  optgroup_header: function(data, escape) {
    return `
      <div class="group-header">
        <div class="group-main">
          <span>${escape(data.label)}</span>
        </div>
        <div class="group-meta">
          <span>${data.count} элементов</span>
          <span>${data.description || ""}</span>
        </div>
      </div>
    `;
  }
}

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


Управление порядком групп через кастомный рендер

Порядок групп можно контролировать не только через массив optgroups, но и через сортировку перед передачей в Tom Select:

optgroups.sort((a, b) => a.priority - b.priority);

Заголовки в этом случае автоматически следуют заданной логике приоритета.


Согласование заголовков групп с поиском

При использовании встроенного поиска важно учитывать, что заголовок группы может отражать состояние фильтра:

  • количество совпадений
  • скрытые элементы
  • активный фильтр

Пример обновления:

optgroups.forEach(g => {
  g.count = options.filter(o => o.optgroup === g.value && o.visible).length;
});

Это делает заголовки динамическими и контекстно-зависимыми.