Сворачивание и разворачивание групп

Группы в Tom Select используются для логического разделения элементов внутри выпадающего списка. Они особенно полезны при работе с большими наборами данных: категориями товаров, странами и городами, ролями пользователей, тегами, разделами документации и любыми структурированными коллекциями.

Базовая структура групп строится через свойства:

  • optgroupField
  • optgroups
  • optgroupLabelField
  • optgroupValueField

Пример конфигурации:

<select id="frameworks" multiple>
    <option value="vue" data-group="frontend">Vue</option>
    <option value="react" data-group="frontend">React</option>
    <option value="laravel" data-group="backend">Laravel</option>
    <option value="django" data-group="backend">Django</option>
</select>
new TomSelect('#frameworks', {
    optgroupField: 'group',

    optgroups: [
        { value: 'frontend', label: 'Frontend' },
        { value: 'backend', label: 'Backend' }
    ]
});

После инициализации список будет отображать элементы по секциям.


Зачем нужно сворачивание групп

При большом количестве элементов группы могут занимать значительную часть интерфейса. Сворачивание позволяет:

  • уменьшить визуальный шум;
  • ускорить навигацию;
  • скрывать редко используемые категории;
  • улучшить UX длинных списков;
  • реализовать древовидную структуру;
  • создавать интерфейсы наподобие accordion.

Типичные сценарии:

Сценарий Пример
Каталог товаров Электроника, одежда, книги
IDE/редактор Сниппеты по категориям
CRM Группы клиентов
География Континенты → страны
Управление доступом Роли и разрешения

Отсутствие встроенного механизма

Tom Select не содержит штатной системы сворачивания групп. Библиотека предоставляет:

  • рендеринг групп;
  • DOM-структуру;
  • события;
  • API управления dropdown.

Сворачивание реализуется вручную через:

  • кастомный рендеринг;
  • обработчики событий;
  • CSS;
  • изменение DOM.

Это даёт полную гибкость.


DOM-структура групп

После рендеринга Tom Select создаёт примерно такую структуру:

<div class="optgroup">
    <div class="optgroup-header">Frontend</div>

    <div data-selectable class="option">Vue</div>
    <div data-selectable class="option">React</div>
</div>

Ключевые элементы:

Элемент Назначение
.optgroup контейнер группы
.optgroup-header заголовок
.option элемент списка

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


Простое сворачивание через CSS-класс

Наиболее распространённый способ — добавление класса:

.optgroup.collapsed .option {
    display: none;
}

Теперь достаточно переключать класс collapsed.


Добавление обработчика клика

Базовый вариант

const select = new TomSelect('#frameworks', {
    onInitialize() {

        this.dropdown.addEventListener('click', (event) => {

            const header = event.target.closest('.optgroup-header');

            if (!header) return;

            const group = header.parentElement;

            group.classList.toggle('collapsed');
        });
    }
});

Что происходит:

  1. Пользователь нажимает на заголовок.
  2. Находится .optgroup-header.
  3. Получается родитель .optgroup.
  4. Переключается класс collapsed.

Полная настройка через render

Стандартный рендер заголовков часто недостаточен. Обычно добавляют:

  • иконки;
  • стрелки;
  • счётчики;
  • кнопки;
  • data-атрибуты.

Пример:

new TomSelect('#frameworks', {

    render: {

        optgroup_header(data, escape) {

            return `
                <div class="optgroup-header">
                    <span class="toggle-icon">▶</span>
                    <span class="group-title">
                        ${escape(data.label)}
                    </span>
                </div>
            `;
        }
    }
});

Анимация сворачивания

Проблема display:none

Свойство:

display: none;

не анимируется.

Для плавного эффекта используют:

  • max-height;
  • opacity;
  • overflow.

Пример плавного раскрытия

.optgroup-content {
    max-height: 500px;
    overflow: hidden;

    transition:
        max-height 0.3s ease,
        opacity 0.3s ease;

    opacity: 1;
}

.optgroup.collapsed .optgroup-content {
    max-height: 0;
    opacity: 0;
}

Изменение структуры группы

Для анимации потребуется дополнительный контейнер:

<div class="optgroup">

    <div class="optgroup-header">
        Frontend
    </div>

    <div class="optgroup-content">

        <div class="option">Vue</div>
        <div class="option">React</div>

    </div>
</div>

Оборачивание элементов после рендера

Tom Select сам не создаёт .optgroup-content, поэтому структуру изменяют вручную.

Пример

onInitialize() {

    const groups = this.dropdown.querySelectorAll('.optgroup');

    groups.forEach(group => {

        const options = [
            ...group.querySelectorAll('.option')
        ];

        const wrapper = document.createElement('div');

        wrapper.className = 'optgroup-content';

        options.forEach(option => {
            wrapper.appendChild(option);
        });

        group.appendChild(wrapper);
    });
}

Сохранение состояния групп

При повторном открытии dropdown состояние может сбрасываться. Поэтому удобно хранить информацию отдельно.


Использование объекта состояния

const collapsedGroups = {};

Сохранение по имени группы

this.dropdown.addEventListener('click', (event) => {

    const header = event.target.closest('.optgroup-header');

    if (!header) return;

    const group = header.parentElement;

    const groupName = group.dataset.group;

    group.classList.toggle('collapsed');

    collapsedGroups[groupName] =
        group.classList.contains('collapsed');
});

Восстановление состояния

groups.forEach(group => {

    const name = group.dataset.group;

    if (collapsedGroups[name]) {
        group.classList.add('collapsed');
    }
});

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

Для постоянного хранения:

localStorage.setItem(
    'collapsed-groups',
    JSON.stringify(collapsedGroups)
);

Загрузка:

const collapsedGroups =
    JSON.parse(
        localStorage.getItem('collapsed-groups')
    ) || {};

Добавление стрелок состояния

CSS

.toggle-icon {
    display: inline-block;
    transition: transform 0.2s ease;
}

Поворот стрелки

.optgroup.collapsed .toggle-icon {
    transform: rotate(-90deg);
}

Автоматическое сворачивание пустых групп

Иногда после поиска группа не содержит видимых элементов.

Проверка элементов

function updateEmptyGroups(select) {

    const groups =
        select.dropdown.querySelectorAll('.optgroup');

    groups.forEach(group => {

        const visibleOptions =
            group.querySelectorAll('.option:not(.hidden)');

        if (!visibleOptions.length) {
            group.style.display = 'none';
        } else {
            group.style.display = '';
        }
    });
}

Сворачивание при поиске

Некоторые интерфейсы автоматически раскрывают группы при вводе поиска.


Реализация

onType(str) {

    const groups =
        this.dropdown.querySelectorAll('.optgroup');

    groups.forEach(group => {

        if (str.length) {
            group.classList.remove('collapsed');
        }
    });
}

Раскрытие только совпадающих групп

Более продвинутый вариант:

function expandMatchedGroups(select) {

    const groups =
        select.dropdown.querySelectorAll('.optgroup');

    groups.forEach(group => {

        const visible =
            group.querySelector('.option:not(.hidden)');

        if (visible) {
            group.classList.remove('collapsed');
        }
    });
}

Режим accordion

Иногда требуется раскрывать только одну группу одновременно.


Логика accordion

this.dropdown.addEventListener('click', (event) => {

    const header =
        event.target.closest('.optgroup-header');

    if (!header) return;

    const currentGroup =
        header.parentElement;

    const groups =
        this.dropdown.querySelectorAll('.optgroup');

    groups.forEach(group => {

        if (group !== currentGroup) {
            group.classList.add('collapsed');
        }
    });

    currentGroup.classList.toggle('collapsed');
});

Инициализация свёрнутых групп

Можно сразу скрывать определённые секции.

Пример

onInitialize() {

    const groups =
        this.dropdown.querySelectorAll('.optgroup');

    groups.forEach(group => {

        if (group.dataset.group === 'backend') {
            group.classList.add('collapsed');
        }
    });
}

Управление через API

Удобно создать методы управления.


collapseGroup

function collapseGroup(name) {

    const group = document.querySelector(
        `.optgroup[data-group="${name}"]`
    );

    if (!group) return;

    group.classList.add('collapsed');
}

expandGroup

function expandGroup(name) {

    const group = document.querySelector(
        `.optgroup[data-group="${name}"]`
    );

    if (!group) return;

    group.classList.remove('collapsed');
}

toggleGroup

function toggleGroup(name) {

    const group = document.querySelector(
        `.optgroup[data-group="${name}"]`
    );

    if (!group) return;

    group.classList.toggle('collapsed');
}

Кнопки управления всеми группами

Свернуть всё

function collapseAll(select) {

    select.dropdown
        .querySelectorAll('.optgroup')
        .forEach(group => {

            group.classList.add('collapsed');
        });
}

Развернуть всё

function expandAll(select) {

    select.dropdown
        .querySelectorAll('.optgroup')
        .forEach(group => {

            group.classList.remove('collapsed');
        });
}

Работа с динамической загрузкой

При использовании load() группы могут появляться позже.


Повторная инициализация

load(query, callback) {

    fetch('/api/tags')
        .then(r => r.json())
        .then(data => {

            callback(data);

            requestAnimationFrame(() => {

                initializeGroups(this);
            });
        });
}

Вынесение логики в плагин

При сложной системе сворачивания удобно создавать собственный plugin.


Пример плагина

TomSelect.define('collapsible_groups', function() {

    const self = this;

    self.on('initialize', () => {

        self.dropdown.addEventListener(
            'click',
            (event) => {

                const header =
                    event.target.closest('.optgroup-header');

                if (!header) return;

                const group =
                    header.parentElement;

                group.classList.toggle('collapsed');
            }
        );
    });
});

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

new TomSelect('#frameworks', {
    plugins: ['collapsible_groups']
});

Добавление анимации в plugin

group.animate([
    {
        opacity: 0
    },
    {
        opacity: 1
    }
], {
    duration: 200
});

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

При изменении dropdown можно автоматически обрабатывать новые группы.


Пример наблюдателя

const observer = new MutationObserver(() => {

    initializeGroups(select);
});

observer.observe(select.dropdown, {
    childList: true,
    subtree: true
});

Предотвращение закрытия dropdown

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

Решение

header.addEventListener('mousedown', (event) => {
    event.preventDefault();
});

Поддержка клавиатуры

Для accessibility желательно поддерживать:

  • Enter;
  • Space;
  • ArrowLeft;
  • ArrowRight.

Пример

header.addEventListener('keydown', (event) => {

    if (event.key === 'Enter') {
        group.classList.toggle('collapsed');
    }

    if (event.key === 'ArrowLeft') {
        group.classList.add('collapsed');
    }

    if (event.key === 'ArrowRight') {
        group.classList.remove('collapsed');
    }
});

Accessibility

Рекомендуется использовать:

aria-expanded="true"

и:

role="button"
tabindex="0"

Обновление aria-expanded

const expanded =
    !group.classList.contains('collapsed');

header.setAttribute(
    'aria-expanded',
    expanded
);

Производительность

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

  • избегать постоянных querySelector;
  • использовать делегирование событий;
  • не выполнять полную перерисовку;
  • кэшировать DOM-элементы;
  • минимизировать reflow.

Частые ошибки

Потеря обработчиков после refreshOptions

Tom Select может пересоздавать dropdown.

Решение:

select.on('dropdown_open', () => {
    initializeGroups(select);
});

Неправильная структура DOM

Некоторые разработчики изменяют внутренний HTML слишком агрессивно, из-за чего ломается навигация.

Безопаснее:

  • добавлять обёртки;
  • использовать классы;
  • не удалять .option.

Конфликт с виртуализацией

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

  • нельзя жёстко привязываться к DOM;
  • элементы могут пересоздаваться;
  • состояние нужно хранить отдельно.

Пример полноценной реализации

const collapsed = {};

new TomSelect('#frameworks', {

    onInitialize() {

        const select = this;

        initialize();

        function initialize() {

            const groups =
                select.dropdown.querySelectorAll('.optgroup');

            groups.forEach(group => {

                const header =
                    group.querySelector('.optgroup-header');

                if (!header) return;

                const name =
                    group.dataset.group;

                if (collapsed[name]) {
                    group.classList.add('collapsed');
                }

                header.addEventListener('click', () => {

                    group.classList.toggle('collapsed');

                    collapsed[name] =
                        group.classList.contains('collapsed');
                });
            });
        }

        select.on('dropdown_open', initialize);
    }
});

Стили полноценного интерфейса

.optgroup-header {

    cursor: pointer;

    display: flex;

    align-items: center;

    gap: 8px;

    padding: 8px 12px;

    font-weight: bold;

    user-select: none;
}

.optgroup.collapsed .option {
    display: none;
}

.toggle-icon {
    transition: transform 0.2s ease;
}

.optgroup.collapsed .toggle-icon {
    transform: rotate(-90deg);
}