Теги и токены

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

Подобный подход широко используется в интерфейсах:

  • ввода email-адресов;
  • назначения тегов;
  • выбора категорий;
  • ввода ключевых слов;
  • систем фильтрации;
  • выбора нескольких пользователей;
  • поиска по множественным параметрам.

Awesomplete не содержит встроенной полноценной системы тегов, однако предоставляет гибкие механизмы через свойства replace, filter, item, data, а также пользовательскую обработку токенов.


Принцип работы токенизированного ввода

Стандартный режим работы Awesomplete предполагает:

<input id="tags">
new Awesomplete(document.querySelector("#tags"), {
    list: ["JavaScript", "Python", "Rust"]
});

После выбора элемента содержимое поля полностью заменяется выбранным значением.

Для работы с токенами необходимо изменить поведение вставки значения. Вместо полной замены поля библиотека должна:

  1. определить текущий токен;
  2. заменить только его;
  3. сохранить остальные значения.

Структура токенов

Наиболее распространённый формат:

JavaScript, Python, Rust

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

Разделителем может выступать:

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

Например:

frontend | backend | devops

или:

css;html;javascript

Разделение строки на токены

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

Простейший вариант:

function splitTags(text) {
    return text.split(",");
}

Однако такой подход создаёт проблемы:

  • остаются пробелы;
  • появляются пустые элементы;
  • отсутствует нормализация.

Более корректная реализация:

function splitTags(text) {
    return text
        .split(",")
        .map(tag => tag.trim())
        .filter(tag => tag.length > 0);
}

Теперь строка:

JavaScript,   Python, , Rust

преобразуется в:

["JavaScript", "Python", "Rust"]

Получение текущего токена

Awesomplete должен понимать, какой именно фрагмент вводится в данный момент.

Пример:

JavaScript, Py

Текущий токен:

Py

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

function extractCurrentToken(text) {
    let tokens = text.split(",");
    return tokens[tokens.length - 1].trim();
}

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

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

По умолчанию Awesomplete сравнивает всё содержимое поля ввода, что для тегов не подходит.

Необходимо фильтровать только активный токен.

new Awesomplete(input, {
    list: languages,

    filter: function(text, inputValue) {
        let current = extractCurrentToken(inputValue);

        return Awesomplete.FILTER_CONTAINS(text, current);
    }
});

Теперь при вводе:

JavaScript, Py

поиск выполняется только по строке:

Py

Замена только активного токена

Ключевым элементом системы становится переопределение метода replace.

Стандартное поведение:

input.value = selectedValue;

Для тегов необходимо заменить только последний токен.


Базовая реализация replace

new Awesomplete(input, {
    list: languages,

    replace: function(selected) {
        let before = this.input.value.split(",");

        before.pop();

        before.push(selected);

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

Как работает алгоритм

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

JavaScript, Py

то:

split(",")

даёт:

["JavaScript", " Py"]

Далее:

before.pop();

удаляет последний незавершённый токен.

После:

before.push(selected);

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

Результат:

JavaScript, Python,

Автоматическое добавление разделителя

Фрагмент:

+ ", "

добавляет:

  • запятую;
  • пробел;
  • возможность сразу вводить следующий тег.

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


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

Точка с запятой

replace: function(selected) {
    let parts = this.input.value.split(";");

    parts.pop();

    parts.push(selected);

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

Вертикальная черта

replace: function(selected) {
    let parts = this.input.value.split("|");

    parts.pop();

    parts.push(selected);

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

Поддержка пробельных разделителей

Иногда токены разделяются пробелами:

tag1 tag2 tag3

Получение активного токена:

function currentToken(value) {
    return value.split(/\s+/).pop();
}

Использование регулярных выражений

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

Пример:

function splitTokens(value) {
    return value.split(/[;,|]/);
}

Поддерживаются:

  • ;
  • ,
  • |

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

function tokenize(value) {
    return value
        .split(/[;,]/)
        .map(item => item.trim())
        .filter(Boolean);
}

Работа с дубликатами

При выборе тегов часто требуется запрет повторений.


Проверка существующих токенов

function hasToken(value, token) {
    return tokenize(value).includes(token);
}

Исключение дубликатов в replace

replace: function(selected) {

    let tokens = tokenize(this.input.value);

    tokens.pop();

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

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

Регистронезависимое сравнение

Проблема:

javascript
JavaScript
JAVASCRIPT

Для системы это могут быть одинаковые значения.

Решение:

function containsToken(tokens, value) {
    return tokens.some(
        token => token.toLowerCase() === value.toLowerCase()
    );
}

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

Awesomplete позволяет скрывать элементы, которые уже выбраны.

filter: function(text, inputValue) {

    let tokens = tokenize(inputValue);

    let current = tokens.pop() || "";

    if (containsToken(tokens, text)) {
        return false;
    }

    return Awesomplete.FILTER_CONTAINS(text, current);
}

Поддержка объектов данных

Теги могут быть представлены объектами.

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

Настройка свойства data

new Awesomplete(input, {

    list: tags,

    data: function(item) {
        return {
            label: item.name,
            value: item.name
        };
    }
});

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

Иногда необходимо отображать текст, но сохранять идентификаторы.


Визуальное значение

JavaScript, Python

Сохраняемые данные

1,2

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

<input id="tags-visible">
<input type="hidden" id="tags-hidden">

Синхронизация значений

const selectedIds = [];

new Awesomplete(input, {

    list: data,

    replace: function(item) {

        let label = item.label;
        let value = item.value;

        selectedIds.push(value);

        let tokens = tokenize(this.input.value);

        tokens.pop();

        tokens.push(label);

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

        hidden.value = selectedIds.join(",");
    }
});

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

Awesomplete работает с обычным <input>, однако поверх него можно построить полноценный интерфейс тегов.


Пример HTML

<div class="tags-container">
    <div class="tag">JavaScript</div>

    <input id="tag-input">
</div>

Добавление тегов в DOM

function addTag(name) {

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

    tag.className = "tag";

    tag.textContent = name;

    container.insertBefore(tag, input);
}

Удаление тегов

function createRemoveButton(tagElement) {

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

    button.textContent = "×";

    button.oncl ick = () => {
        tagElement.remove();
    };

    return button;
}

Хранение токенов отдельно от поля ввода

Более надёжный подход:

const tags = [];

Поле ввода используется только для текущего поиска.


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

Компоненты

Компонент Назначение
Awesomplete Автодополнение
input Ввод текста
tags[] Хранилище тегов
DOM Визуализация
hidden input Отправка формы

Обновление списка подсказок

Awesomplete позволяет динамически менять список.

awesomplete.list = getAvailableTags();

Асинхронная загрузка тегов

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

    let query = extractCurrentToken(input.value);

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

    let data = await response.json();

    awesomplete.list = data;
});

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

const MAX_TAGS = 5;

Проверка лимита

if (tags.length >= MAX_TAGS) {
    return;
}

Минимальная длина токена

if (current.length < 2) {
    return false;
}

Автоматическое создание новых тегов

Если пользователь ввёл отсутствующее значение:

GraphQL

можно создать новый тег.


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

if (!availableTags.includes(current)) {
    tags.push(current);
}

Использование событий Awesomplete


awesomplete-selectcomplete

Событие вызывается после завершения выбора.

input.addEventListener(
    "awesomplete-selectcomplete",
    function(event) {

        console.log(event.text.value);
    }
);

awesomplete-highlight

Срабатывает при перемещении по списку.

input.addEventListener(
    "awesomplete-highlight",
    function(event) {

        console.log(event.text.label);
    }
);

Работа с Enter

При создании тегов Enter часто используется для:

  • подтверждения выбора;
  • создания нового тега;
  • завершения ввода.

Перехват Enter

input.addEventListener("keydown", function(event) {

    if (event.key === "Enter") {

        event.preventDefault();

        let value = extractCurrentToken(input.value);

        addTag(value);
    }
});

Работа с Backspace

Во многих интерфейсах удаление пустого токена через Backspace удаляет предыдущий тег.


Реализация

input.addEventListener("keydown", function(event) {

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

        removeLastTag();
    }
});

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

.tags-container {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}

.tag {
    padding: 4px 10px;
    background: #ececec;
    border-radius: 4px;
}

Стилизация активного ввода

.tags-container input {
    border: none;
    outline: none;
    flex: 1;
}

Поддержка paste

При вставке строки:

JavaScript, Python, Rust

необходимо автоматически создать несколько тегов.


Разбор вставленного текста

input.addEventListener("paste", function(event) {

    event.preventDefault();

    const text = (
        event.clipboardData ||
        window.clipboardData
    ).getData("text");

    const tokens = tokenize(text);

    tokens.forEach(addTag);
});

Нормализация токенов

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

function normalize(token) {

    return token
        .trim()
        .toLowerCase();
}

Поддержка Unicode

Awesomplete корректно работает с Unicode-символами:

Программирование
Разработка
デザイン

Токены с пробелами

Иногда тег может содержать пробел:

machine learning

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


Использование кавычек

"machine learning", javascript

Разбор сложных токенов

function parseQuotedTokens(text) {

    const regex = /"([^"]+)"|([^,]+)/g;

    const result = [];

    let match;

    while ((match = regex.exec(text))) {

        result.push(
            (match[1] || match[2]).trim()
        );
    }

    return result;
}

Производительность при большом количестве тегов

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

  • избегать постоянного split;
  • кэшировать токены;
  • минимизировать DOM-операции;
  • использовать debounce;
  • ограничивать количество отображаемых результатов.

Debounce для поиска

function debounce(callback, delay) {

    let timeout;

    return function(...args) {

        clearTimeout(timeout);

        timeout = setTimeout(() => {
            callback.apply(this, args);
        }, delay);
    };
}

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

input.addEventListener(
    "input",
    debounce(loadSuggestions, 300)
);

Полноценный пример системы тегов

<input id="skills">

<script>

const skills = [
    "JavaScript",
    "Python",
    "Rust",
    "Go",
    "TypeScript",
    "C++"
];

const input = document.querySelector("#skills");

function tokenize(text) {

    return text
        .split(",")
        .map(item => item.trim())
        .filter(Boolean);
}

function currentToken(text) {

    let tokens = text.split(",");

    return tokens.pop().trim();
}

new Awesomplete(input, {

    list: skills,

    filter: function(text, inputValue) {

        return Awesomplete.FILTER_CONTAINS(
            text,
            currentToken(inputValue)
        );
    },

    replace: function(selected) {

        let tokens = tokenize(this.input.value);

        tokens.pop();

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

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

</script>