По умолчанию библиотека Awesomplete рассчитана на выбор одного значения из списка подсказок. После выбора элемент полностью заменяет содержимое поля ввода. Для интерфейсов с тегами, списками адресатов, категориями, метками и наборами фильтров этого поведения недостаточно. В подобных случаях требуется механизм множественного выбора.
Реализация множественного выбора строится вокруг нескольких ключевых задач:
Наиболее распространённая модель использует:
replace.Простейший пример:
<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
Именно этот фрагмент должен участвовать в поиске совпадений.
Стандартный фильтр сравнивает введённый текст со всем содержимым поля. Для множественного выбора требуется фильтрация только последнего элемента.
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(", ") + ", ";
}
Без дополнительной проверки пользователь может выбрать одно значение несколько раз.
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" }
]
Для корректной работы необходимо переопределить:
itemreplacefilteritem: 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);
}
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;
}
Популярное поведение — удаление последнего тега при пустом поле ввода.
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;
Множественный выбор особенно полезен для:
Пример объекта:
{
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 может конфликтовать с отправкой формы.
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:
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(item) {
if (this.selected.includes(item)) {
return;
}
this.selected.push(item);
this.render();
}
remove(item) {
this.selected =
this.selected.filter(i => i !== item);
this.render();
}
Такой подход позволяет:
Возникает при полном обновлении значения поля.
Решение:
input.selectionStart =
input.selectionEnd =
input.value.length;
После вставки список может закрываться навсегда.
Решение:
this.evaluate();
Некоторые мобильные браузеры некорректно обрабатывают:
Часто используется модель с визуальными тегами вместо строки-разделителя.
parts.map(item => item.trim());
При хранении объектов нельзя использовать:
includes()
для сравнения разных экземпляров.
Следует сравнивать:
item.id
Полноценная реализация обычно включает:
replace;