Реализация множественного выбора

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

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

  • разделение введённых значений;
  • сохранение уже выбранных элементов;
  • повторный запуск автодополнения после каждой вставки;
  • предотвращение дубликатов;
  • поддержка удаления элементов;
  • синхронизация строки ввода и внутреннего массива данных.

Базовая схема работы

Наиболее распространённая модель использует:

  1. Одно текстовое поле.
  2. Разделитель элементов.
  3. Кастомную функцию replace.
  4. Массив выбранных значений.

Простейший пример:

<input id="tags">
const input = document.getElementById("tags");

new Awesomplete(input, {
    list: [
        "JavaScript",
        "TypeScript",
        "React",
        "Vue",
        "Angular",
        "Node.js"
    ]
});

В таком виде поле поддерживает только одиночный выбор.


Использование разделителей

Для множественного выбора строка обычно хранится в формате:

JavaScript, React, Vue

После каждой вставки новое значение добавляется в конец строки.

Извлечение текущего фрагмента

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

Пример:

function extractLast(text) {
    return text.split(",").pop().trim();
}

Если поле содержит:

JavaScript, Re

функция вернёт:

Re

Именно этот фрагмент должен участвовать в поиске совпадений.


Переопределение функции filter

Стандартный фильтр сравнивает введённый текст со всем содержимым поля. Для множественного выбора требуется фильтрация только последнего элемента.

const input = document.getElementById("tags");

new Awesomplete(input, {
    list: [
        "JavaScript",
        "TypeScript",
        "React",
        "Vue",
        "Angular"
    ],

    filter: function(text, inputValue) {
        return Awesomplete.FILTER_CONTAINS(
            text,
            extractLast(inputValue)
        );
    }
});

Теперь:

JavaScript, Re

будет корректно находить:

React

Замена стандартной вставки значения

Главный механизм множественного выбора — переопределение метода replace.

Стандартная реализация:

input.value = selectedItem;

не подходит, потому что уничтожает уже выбранные значения.


Добавление нового элемента в строку

Пример кастомной функции:

replace: function(selected) {

    const parts = this.input.value.split(",");

    parts.pop();

    parts.push(selected);

    parts.push("");

    this.input.value = parts.join(", ");
}

Пошаговая логика

Если поле содержит:

JavaScript, Re

то:

parts = ["JavaScript", " Re"]

После pop():

["JavaScript"]

После вставки выбранного элемента:

["JavaScript", "React"]

После добавления пустого элемента:

["JavaScript", "React", ""]

Итог:

JavaScript, React,

Курсор остаётся готовым к следующему вводу.


Полный пример множественного выбора

<input id="skills">

<script>
function extractLast(text) {
    return text.split(",").pop().trim();
}

new Awesomplete("#skills", {

    list: [
        "JavaScript",
        "TypeScript",
        "React",
        "Vue",
        "Angular",
        "Node.js",
        "Express",
        "MongoDB"
    ],

    filter: function(text, input) {

        return Awesomplete.FILTER_CONTAINS(
            text,
            extractLast(input)
        );
    },

    replace: function(selected) {

        const parts = this.input.value.split(",");

        parts.pop();

        parts.push(selected);

        parts.push("");

        this.input.value = parts.join(", ");
    }
});
</script>

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

Вместо запятой могут использоваться:

  • точка с запятой;
  • вертикальная черта;
  • пробел;
  • перенос строки.

Пример с ;:

function extractLast(text) {
    return text.split(";").pop().trim();
}
replace: function(selected) {

    const parts = this.input.value.split(";");

    parts.pop();

    parts.push(selected);

    parts.push("");

    this.input.value = parts.join("; ");
}

Хранение значений в массиве

Строковое хранение подходит не всегда. Более надёжный вариант — отдельный массив выбранных элементов.

const selectedItems = [];

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

replace: function(selected) {

    selectedItems.push(selected);

    this.input.value = selectedItems.join(", ") + ", ";
}

Преимущества массива

  • удобное удаление элементов;
  • защита от дубликатов;
  • сериализация в JSON;
  • синхронизация с сервером;
  • работа с идентификаторами объектов.

Предотвращение дубликатов

Без дополнительной проверки пользователь может выбрать одно значение несколько раз.

Проверка перед добавлением

replace: function(selected) {

    if (!selectedItems.includes(selected)) {
        selectedItems.push(selected);
    }

    this.input.value = selectedItems.join(", ") + ", ";
}

Исключение выбранных элементов из списка

Можно скрывать уже выбранные значения из выпадающего списка.

filter: function(text, input) {

    const current = extractLast(input);

    if (selectedItems.includes(text)) {
        return false;
    }

    return Awesomplete.FILTER_CONTAINS(
        text,
        current
    );
}

Работа с объектами

В реальных приложениях список часто содержит объекты:

[
    { id: 1, name: "JavaScript" },
    { id: 2, name: "React" }
]

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

  • item
  • replace
  • filter

Отображение имени объекта

item: function(text, input) {

    return Awesomplete.ITEM(
        text.name,
        input
    );
}

Вставка объекта

replace: function(selected) {

    selectedItems.push(selected);

    this.input.value =
        selectedItems
            .map(item => item.name)
            .join(", ") + ", ";
}

Хранение идентификаторов

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

const ids = selectedItems.map(item => item.id);

Результат:

[1, 2, 5, 8]

Реализация тегов

Современные интерфейсы редко используют простую строку. Вместо этого создаются визуальные теги.

Пример структуры:

<div class="tags-container">
    <div id="tags"></div>
    <input id="tag-input">
</div>

Создание визуального тега

function createTag(text) {

    const tag = document.createElement("span");

    tag.className = "tag";

    tag.textContent = text;

    document.getElementById("tags")
        .appendChild(tag);
}

Добавление кнопки удаления

function createTag(text) {

    const tag = document.createElement("span");

    const remove = document.createElement("button");

    remove.textContent = "×";

    remove.addEventListener("click", () => {
        tag.remove();
    });

    tag.textContent = text;

    tag.appendChild(remove);

    document
        .getElementById("tags")
        .appendChild(tag);
}

Интеграция с Awesomplete

replace: function(selected) {

    if (selectedItems.includes(selected)) {
        return;
    }

    selectedItems.push(selected);

    createTag(selected);

    this.input.value = "";
}

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


Стилизация тегов

.tag {
    display: inline-flex;
    align-items: center;

    padding: 4px 10px;
    margin: 4px;

    background: #3f51b5;
    color: white;

    border-radius: 14px;
}

.tag button {
    margin-left: 8px;

    border: none;
    background: transparent;

    color: white;
    cursor: pointer;
}

Удаление последнего элемента клавишей Backspace

Популярное поведение — удаление последнего тега при пустом поле ввода.

input.addEventListener("keydown", event => {

    if (
        event.key === "Backspace" &&
        input.value === ""
    ) {

        selectedItems.pop();

        renderTags();
    }
});

Повторная отрисовка тегов

function renderTags() {

    const container =
        document.getElementById("tags");

    container.innerHTML = "";

    selectedItems.forEach(createTag);
}

Скрытое поле для формы

Для отправки данных через HTML-форму часто используется скрытый input.

<input type="hidden" id="skills-data" name="skills">

Синхронизация скрытого поля

function syncHiddenField() {

    document.getElementById("skills-data")
        .value = JSON.stringify(selectedItems);
}

Вызывать функцию следует после:

  • добавления элемента;
  • удаления элемента;
  • очистки списка.

Ограничение количества элементов

Иногда необходимо разрешить только определённое число значений.

const MAX_ITEMS = 5;
replace: function(selected) {

    if (selectedItems.length >= MAX_ITEMS) {
        return;
    }

    selectedItems.push(selected);

    renderTags();

    this.input.value = "";
}

Асинхронный множественный выбор

Awesomplete поддерживает динамическую подгрузку данных.

Пример:

input.addEventListener("input", async () => {

    const query = input.value;

    const response =
        await fetch("/api/tags?q=" + query);

    const data = await response.json();

    awesomplete.list = data;
});

Исключение уже выбранных значений при загрузке

const filtered = data.filter(item => {
    return !selectedItems.includes(item);
});

awesomplete.list = filtered;

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

Множественный выбор особенно полезен для:

  • пользователей;
  • ролей;
  • категорий;
  • языков;
  • технологий;
  • email-адресов;
  • продуктов;
  • меток.

Пример объекта:

{
    id: 25,
    name: "React",
    color: "#61dafb"
}

Цветные теги

function createTag(item) {

    const tag = document.createElement("span");

    tag.textContent = item.name;

    tag.style.background = item.color;

    document
        .getElementById("tags")
        .appendChild(tag);
}

Поддержка клавиши Enter

Выбор элемента по Enter может конфликтовать с отправкой формы.

input.addEventListener("keydown", event => {

    if (
        event.key === "Enter" &&
        awesomplete.opened
    ) {
        event.preventDefault();
    }
});

Очистка списка при потере фокуса

input.addEventListener("blur", () => {

    awesomplete.close();
});

Проверка пустых значений

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

const parts = input.value
    .split(",")
    .map(item => item.trim())
    .filter(Boolean);

Нормализация регистра

Для защиты от повторов:

function exists(value) {

    return selectedItems.some(item => {

        return item.toLowerCase() ===
               value.toLowerCase();
    });
}

Производительность при больших списках

При работе с тысячами элементов рекомендуется:

  • асинхронная загрузка;
  • серверная фильтрация;
  • debounce;
  • кеширование результатов;
  • ограничение числа подсказок.

Пример debounce:

function debounce(fn, delay) {

    let timer;

    return function(...args) {

        clearTimeout(timer);

        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

Серверный поиск

const loadSuggestions = debounce(async query => {

    const response =
        await fetch("/api/search?q=" + query);

    const data = await response.json();

    awesomplete.list = data;

}, 300);

Архитектурный подход с компонентом

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

class MultiSelect {

    constructor(input, options) {

        this.input = input;

        this.selected = [];

        this.awesomplete =
            new Awesomplete(input, options);
    }
}

Добавление метода add

add(item) {

    if (this.selected.includes(item)) {
        return;
    }

    this.selected.push(item);

    this.render();
}

Добавление метода remove

remove(item) {

    this.selected =
        this.selected.filter(i => i !== item);

    this.render();
}

Централизация состояния

Такой подход позволяет:

  • переиспользовать компонент;
  • подключать различные источники данных;
  • отделять UI от бизнес-логики;
  • подключать серверную синхронизацию;
  • интегрировать компонент с React, Vue и Angular.

Типичные проблемы множественного выбора

Потеря курсора

Возникает при полном обновлении значения поля.

Решение:

input.selectionStart =
input.selectionEnd =
input.value.length;

Повторное открытие списка

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

Решение:

this.evaluate();

Конфликт с мобильной клавиатурой

Некоторые мобильные браузеры некорректно обрабатывают:

  • запятые;
  • Enter;
  • Backspace;
  • blur.

Часто используется модель с визуальными тегами вместо строки-разделителя.


Дублирование пробелов

parts.map(item => item.trim());

Ошибки сериализации

При хранении объектов нельзя использовать:

includes()

для сравнения разных экземпляров.

Следует сравнивать:

item.id

Практическая схема полноценного множественного выбора

Полноценная реализация обычно включает:

  • массив выбранных объектов;
  • визуальные теги;
  • скрытое поле формы;
  • кастомный replace;
  • серверный поиск;
  • исключение дублей;
  • удаление элементов;
  • ограничение количества;
  • debounce;
  • повторную инициализацию списка;
  • пользовательский рендер элементов;
  • синхронизацию состояния интерфейса и данных.