Инвертирование выбора

Инвертирование выбора в Choices.js — операция, при которой все ранее выбранные элементы становятся невыбранными, а все невыбранные — выбранными. Подобная логика особенно востребована в интерфейсах массового выбора:

  • списки категорий;
  • фильтры товаров;
  • системы ролей и разрешений;
  • таблицы с множественным выделением;
  • панели настройки параметров.

Библиотека Choices.js не содержит встроенного метода invertSelection(), поэтому инвертирование реализуется программно через API библиотеки.


Особенности выбора в Choices.js

Перед реализацией инверсии необходимо понимать, как библиотека хранит выбранные элементы.

Для <sel ect multiple> библиотека работает с:

  • массивом choices;
  • текущими selected item;
  • внутренним store;
  • DOM-элементом <select>.

Пример исходного списка:

<select id="frameworks" multiple>
  <option value="react">React</option>
  <option value="vue">Vue</option>
  <option value="angular">Angular</option>
  <option value="svelte">Svelte</option>
</select>

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

const choices = new Choices('#frameworks', {
  removeItemButton: true
});

Получение текущего состояния выбора

Для инверсии необходимо определить:

  1. какие элементы выбраны;
  2. какие доступны;
  3. какие нужно переключить.

Получение выбранных элементов

const selectedItems = choices.getValue(true);

console.log(selectedItems);

Результат:

["react", "vue"]

Метод:

getValue(true)

возвращает только значения.

Без аргумента true возвращаются полноценные объекты.


Получение всех доступных option

Наиболее простой способ — обратиться к оригинальному <select>:

const select = document.getElementById('frameworks');

const allValues = Array.fr om(sel ect.options).map(
  option => option.value
);

console.log(allValues);

Результат:

["react", "vue", "angular", "svelte"]

Базовый алгоритм инверсии

Инвертирование работает по простой схеме:

  1. получить все значения;
  2. получить выбранные значения;
  3. вычислить разницу;
  4. очистить выбор;
  5. установить новый набор.

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

function invertSelection(choices, selectElement) {
  const allValues = Array.fr om(selectElement.options).map(
    option => option.value
  );

  const selectedValues = choices.getValue(true);

  const inverted = allValues.filter(
    value => !selectedValues.includes(value)
  );

  choices.removeActiveItems();

  choices.setChoiceByValue(inverted);
}

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

const sel ect = document.getElementById('frameworks');

const choices = new Choices(select, {
  removeItemButton: true
});

invertSelection(choices, select);

Пошаговый разбор инверсии

Шаг 1. Получение полного списка

const allValues = Array.fr om(sel ect.options).map(
  option => option.value
);

Результат:

["react", "vue", "angular", "svelte"]

Шаг 2. Получение выбранных значений

const selectedValues = choices.getValue(true);

Например:

["react", "vue"]

Шаг 3. Вычисление инверсии

const inverted = allValues.filter(
  value => !selectedValues.includes(value)
);

Результат:

["angular", "svelte"]

Шаг 4. Снятие старого выбора

choices.removeActiveItems();

Метод удаляет текущие selected item.


Шаг 5. Установка нового выбора

choices.setChoiceByValue(inverted);

После выполнения выбранными станут:

["angular", "svelte"]

Инвертирование через кнопки интерфейса

Наиболее распространённый сценарий.


HTML

<select id="users" multiple>
  <option value="1">Иван</option>
  <option value="2">Мария</option>
  <option value="3">Олег</option>
  <option value="4">Анна</option>
</select>

<button id="invert">
  Инвертировать
</button>

Javascript

const select = document.getElementById('users');

const choices = new Choices(select);

document
  .getElementById('invert')
  .addEventListener('click', () => {

    const allValues = Array.fr om(sel ect.options).map(
      option => option.value
    );

    const selectedValues = choices.getValue(true);

    const inverted = allValues.filter(
      value => !selectedValues.includes(value)
    );

    choices.removeActiveItems();

    choices.setChoiceByValue(inverted);
  });

Инвертирование с сохранением disabled option

Очень важный сценарий — исключение недоступных элементов.


Проблема

Если option имеет:

<option value="admin" disabled>
  Admin
</option>

то инверсия не должна выбирать такой элемент.


Правильная фильтрация

const allValues = Array.fr om(sel ect.options)
  .filter(option => !option.disabled)
  .map(option => option.value);

Полный пример

function invertSelection(choices, select) {

  const allValues = Array.fr om(sel ect.options)
    .filter(option => !option.disabled)
    .map(option => option.value);

  const selectedValues = choices.getValue(true);

  const inverted = allValues.filter(
    value => !selectedValues.includes(value)
  );

  choices.removeActiveItems();

  choices.setChoiceByValue(inverted);
}

Инвертирование только видимых элементов

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


Пример задачи

Пользователь ввёл:

rea

В списке отображается только:

React

Инверсия должна работать лишь с отфильтрованным набором.


Работа с store

Choices.js хранит choices во внутреннем хранилище:

const storeChoices = choices._store.choices;

Получение только активных элементов:

const visibleChoices = storeChoices.filter(
  item => item.active
);

Инверсия только видимых option

function invertVisible(choices) {

  const visibleChoices = choices._store.choices
    .filter(choice => choice.active);

  const visibleValues = visibleChoices.map(
    choice => choice.value
  );

  const selectedValues = choices.getValue(true);

  const inverted = visibleValues.filter(
    value => !selectedValues.includes(value)
  );

  choices.removeActiveItems();

  choices.setChoiceByValue(inverted);
}

Инвертирование без полной очистки

Иногда удаление всех selected item вызывает:

  • мерцание интерфейса;
  • лишние события;
  • повторный рендер;
  • проблемы производительности.

В таком случае можно переключать элементы по одному.


Переключение состояния вручную

function toggleValue(choices, value, selected) {

  if (selected) {
    choices.removeActiveItemsByValue(value);
  } else {
    choices.setChoiceByValue(value);
  }
}

Полная инверсия через toggle

function invertSelection(choices, select) {

  const selectedValues = choices.getValue(true);

  Array.fr om(sel ect.options).forEach(option => {

    const isSelected = selectedValues.includes(
      option.value
    );

    if (isSelected) {
      choices.removeActiveItemsByValue(
        option.value
      );
    } else {
      choices.setChoiceByValue(
        option.value
      );
    }
  });
}

Инверсия в single select

Для обычного <select> без multiple полноценная инверсия невозможна, потому что выбран может быть только один элемент.


Пример

<select id="theme">
  <option value="light">Light</option>
  <option value="dark">Dark</option>
</select>

Инверсия превращается в переключение.


Реализация

const current = choices.getValue(true);

const next =
  current === 'light'
    ? 'dark'
    : 'light';

choices.setChoiceByValue(next);

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

Инвертирование генерирует множество событий:

  • addItem
  • removeItem
  • change

Проблема каскадных обработчиков

Если на каждое событие подписана логика:

choices.passedElement.element.addEventListener(
  'change',
  event => {
    console.log(event);
  }
);

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


Временное отключение обработчиков

Распространённый подход:

let suppressEvents = false;

select.addEventListener('change', event => {

  if (suppressEvents) {
    return;
  }

  console.log('change');
});

Инверсия с блокировкой событий

suppressEvents = true;

choices.removeActiveItems();

choices.setChoiceByValue(inverted);

suppressEvents = false;

Инверсия больших списков

При количестве option:

  • 1000+
  • 5000+
  • 10000+

неэффективные операции начинают тормозить интерфейс.


Неоптимальный вариант

selectedValues.includes(value)

Проблема — линейный поиск.


Оптимизация через Set

const selectedSet = new Set(
  choices.getValue(true)
);

const inverted = allValues.filter(
  value => !selectedSet.has(value)
);

Полная оптимизированная версия

function invertSelection(choices, select) {

  const allValues = Array.fr om(sel ect.options)
    .map(option => option.value);

  const selectedSet = new Set(
    choices.getValue(true)
  );

  const inverted = allValues.filter(
    value => !selectedSet.has(value)
  );

  choices.removeActiveItems();

  choices.setChoiceByValue(inverted);
}

Частичная инверсия

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


Пример

Инвертировать только:

frontend-технологии

но не трогать backend.


Структура данных

<select id="stack" multiple>
  <option value="react">React</option>
  <option value="vue">Vue</option>
  <option value="node">Node.js</option>
  <option value="php">PHP</option>
</select>

Реализация

const frontend = ['react', 'vue'];

function invertFrontend(choices) {

  const selected = new Set(
    choices.getValue(true)
  );

  frontend.forEach(value => {

    if (selected.has(value)) {

      choices.removeActiveItemsByValue(
        value
      );

    } else {

      choices.setChoiceByValue(value);
    }
  });
}

Инвертирование с пользовательскими данными

Choices.js поддерживает custom properties.


Пример

new Choices('#users', {
  choices: [
    {
      value: '1',
      label: 'Иван',
      customProperties: {
        role: 'admin'
      }
    },
    {
      value: '2',
      label: 'Мария',
      customProperties: {
        role: 'user'
      }
    }
  ]
});

Инверсия по customProperties

const adminValues = choices._store.choices
  .filter(choice =>
    choice.customProperties.role === 'admin'
  )
  .map(choice => choice.value);

Переключение admin-элементов

function invertAdmins(choices) {

  const selected = new Set(
    choices.getValue(true)
  );

  const admins = choices._store.choices
    .filter(choice =>
      choice.customProperties.role === 'admin'
    );

  admins.forEach(admin => {

    if (selected.has(admin.value)) {

      choices.removeActiveItemsByValue(
        admin.value
      );

    } else {

      choices.setChoiceByValue(
        admin.value
      );
    }
  });
}

Типичные ошибки

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

Ошибка:

new Choices('#users');

new Choices('#users');

Инверсия начинает работать непредсказуемо.


Использование устаревших selectedValues

Ошибка:

const selected = choices.getValue(true);

choices.removeActiveItems();

console.log(selected);

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


Инверсия disabled option

Неверно:

choices.setChoiceByValue('admin');

если option disabled.


Инверсия placeholder

Placeholder не должен участвовать в переключении.


Ошибка с пустыми значениями

Проблемный вариант:

<option value=""></option>

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


Безопасная фильтрация

const values = Array.fr om(sel ect.options)
  .filter(option =>
    option.value !== '' &&
    !option.disabled
  )
  .map(option => option.value);

Универсальная функция инверсии

function invertSelection(choices, select, options = {}) {

  const {
    ignoreDisabled = true,
    ignoreEmpty = true
  } = options;

  const values = Array.fr om(select.options)
    .filter(option => {

      if (ignoreDisabled && option.disabled) {
        return false;
      }

      if (ignoreEmpty && option.value === '') {
        return false;
      }

      return true;
    })
    .map(option => option.value);

  const selected = new Set(
    choices.getValue(true)
  );

  const inverted = values.filter(
    value => !selected.has(value)
  );

  choices.removeActiveItems();

  choices.setChoiceByValue(inverted);
}

Практический пример панели фильтров

<select id="filters" multiple>
  <option value="new">Новые</option>
  <option value="popular">Популярные</option>
  <option value="discount">Со скидкой</option>
  <option value="hit">Хиты продаж</option>
</select>

<button id="invertFilters">
  Инвертировать фильтры
</button>
const select = document.getElementById('filters');

const choices = new Choices(select, {
  removeItemButton: true
});

document
  .getElementById('invertFilters')
  .addEventListener('click', () => {

    invertSelection(choices, select);
  });

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