Разделители значений

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

JavaScript, HTML, CSS

или:

apple; orange; banana

Библиотека Awesomplete изначально ориентирована на работу с одним значением, однако благодаря переопределению механизма вставки можно реализовать поддержку множественного ввода с разделителями практически любого типа.

Подобная схема особенно полезна при создании:

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

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

По умолчанию Awesomplete заменяет всё содержимое поля выбранным элементом:

new Awesomplete(input, {
    list: ["JavaScript", "HTML", "CSS"]
});

Если ввести:

JavaScript, H

и выбрать HTML, результат окажется таким:

HTML

Предыдущая часть строки будет потеряна.

Причина заключается в стандартной реализации метода replace().


Принцип работы разделителей

Основная идея состоит в следующем:

  1. Определяется текущий фрагмент после последнего разделителя.
  2. Автодополнение работает только с этим фрагментом.
  3. После выбора элемента заменяется исключительно текущая часть строки.
  4. Остальные значения сохраняются.

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

Наиболее распространённый вариант — разделение через запятую.

Исходное поле

<input id="tags">

Настройка Awesomplete

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

new Awesomplete(input, {
    list: [
        "JavaScript",
        "TypeScript",
        "HTML",
        "CSS",
        "Node.js",
        "React"
    ],

    filter(text, inputValue) {
        const value = inputValue.match(/[^,]*$/)[0].trim();

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

    item(text, inputValue) {
        const value = inputValue.match(/[^,]*$/)[0].trim();

        return Awesomplete.ITEM(text, value);
    },

    replace(text) {
        const before = this.input.value.match(/^.+,\s*|/)[0];

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

Разбор регулярного выражения

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

inputValue.match(/[^,]*$/)[0]

Конструкция означает:

Часть Назначение
[^,] любой символ кроме запятой
* любое количество
$ конец строки

Пример:

JavaScript, HT

Результат:

HT

Именно этот фрагмент участвует в фильтрации.


Замена только последнего значения

Метод replace() отвечает за вставку выбранного элемента.

Логика

Из строки:

JavaScript, HT

необходимо получить:

JavaScript, HTML,

Код

replace(text) {
    const before = this.input.value.match(/^.+,\s*|/)[0];

    this.input.value = before + text + ", ";
}

Как работает выражение ^.+,\s*|

Оно извлекает всё содержимое до последней запятой.

Пример

Исходная строка:

JavaScript, CSS, Re

Результат:

JavaScript, CSS,

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

before + text + ", "

Итог:

JavaScript, CSS, React,

Удаление лишнего пробела

Иногда необходимо хранить значения без пробелов.

Вариант без пробелов

replace(text) {
    const before = this.input.value.match(/^.+,|/)[0];

    this.input.value = before + text + ",";
}

Результат:

JavaScript,HTML,CSS,

Использование точки с запятой

Разделителем может выступать любой символ.

Пример с ;

new Awesomplete(input, {

    filter(text, inputValue) {
        const value = inputValue.match(/[^;]*$/)[0].trim();

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

    item(text, inputValue) {
        const value = inputValue.match(/[^;]*$/)[0].trim();

        return Awesomplete.ITEM(text, value);
    },

    replace(text) {
        const before = this.input.value.match(/^.+;\s*|/)[0];

        this.input.value = before + text + "; ";
    }
});

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

Иногда требуется поддержка одновременно нескольких символов:

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

Пример

filter(text, inputValue) {
    const value = inputValue.match(/[^,;|]*$/)[0].trim();

    return Awesomplete.FILTER_CONTAINS(text, value);
}

Поддержка перевода строки

Awesomplete можно использовать даже внутри многострочного поля.

Пример textarea

<textarea id="skills"></textarea>

Настройка

const textarea = document.getElementById("skills");

new Awesomplete(textarea, {

    list: [
        "Docker",
        "Kubernetes",
        "Nginx",
        "Redis"
    ],

    filter(text, inputValue) {
        const value = inputValue.match(/[^\n]*$/)[0].trim();

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

    replace(text) {
        const before = this.input.value.match(/^.+\n|/s)[0];

        this.input.value = before + text + "\n";
    }
});

Использование табуляции

Редкий, но возможный вариант — разделение через символ табуляции.

filter(text, inputValue) {
    const value = inputValue.match(/[^\t]*$/)[0];

    return Awesomplete.FILTER_CONTAINS(text, value);
}

Разделение по пробелу

Иногда автодополнение должно работать после каждого слова.

Пример

filter(text, inputValue) {
    const value = inputValue.match(/[^\s]*$/)[0];

    return Awesomplete.FILTER_CONTAINS(text, value);
}

Применение

Подобный подход встречается:

  • в системах поиска;
  • при анализе команд;
  • в редакторах запросов;
  • в терминальных интерфейсах.

Комбинация с объектами данных

Разделители хорошо работают совместно с объектными структурами.

Исходные данные

const technologies = [
    {
        label: "JavaScript",
        value: "js"
    },
    {
        label: "TypeScript",
        value: "ts"
    },
    {
        label: "Python",
        value: "py"
    }
];

Конфигурация

new Awesomplete(input, {

    list: technologies,

    replace(item) {

        const before = this.input.value.match(/^.+,\s*|/)[0];

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

Вставка label вместо value

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

replace(item) {

    const before = this.input.value.match(/^.+,\s*|/)[0];

    this.input.value =
        before +
        item.label +
        ", ";
}

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

При множественном вводе часто возникает проблема повторов.

Проверка перед вставкой

replace(text) {

    const values = this.input.value
        .split(",")
        .map(v => v.trim())
        .filter(Boolean);

    if (values.includes(text)) {
        return;
    }

    values.push(text);

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

Автоматическая очистка значений

Иногда полезно удалять лишние пробелы.

Нормализация

replace(text) {

    const values = this.input.value
        .split(",")
        .map(v => v.trim())
        .filter(Boolean);

    values.push(text);

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

Работа с курсором

Стандартная реализация вставляет значение в конец строки. Это создаёт проблемы, если курсор находится в середине текста.

Пример проблемы

JavaScript, HTML, CSS

Курсор установлен после HTML.

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


Использование позиции курсора

Получение позиции

const cursor = input.selectionStart;

Выделение частей строки

const beforeCursor =
    input.value.slice(0, cursor);

const afterCursor =
    input.value.slice(cursor);

Замена текущего сегмента

replace(text) {

    const cursor = this.input.selectionStart;

    const before =
        this.input.value.slice(0, cursor);

    const after =
        this.input.value.slice(cursor);

    const start =
        before.replace(/[^,]*$/, "");

    const end =
        after.replace(/^[^,]*/, "");

    this.input.value =
        start +
        text +
        end;
}

Разделители и AJAX-загрузка

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

Пример

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

    const query =
        input.value.match(/[^,]*$/)[0].trim();

    if (!query) {
        return;
    }

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

    const data =
        await response.json();

    awesomplete.list = data;
});

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

Вместо одиночных символов можно использовать полноценные конструкции.

Пример

tag:javascript
tag:html

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

const token =
    input.value.match(/tag:[^\s]*$/);

Разделение через JSON-структуры

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

Пример преобразования

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

Результат:

[
    "JavaScript",
    "HTML",
    "CSS"
]

Использование Set для уникальности

replace(text) {

    const values = new Set(
        this.input.value
            .split(",")
            .map(v => v.trim())
            .filter(Boolean)
    );

    values.add(text);

    this.input.value =
        [...values].join(", ") + ", ";
}

Поддержка пользовательских функций-разделителей

Разделитель можно полностью абстрагировать.

Функция получения текущего значения

function getCurrentToken(value) {
    return value.match(/[^,]*$/)[0].trim();
}

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

filter(text, inputValue) {

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

Создание универсального обработчика

function createTokenizer(separator) {

    const escaped =
        separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

    const regexp =
        new RegExp(`[^${escaped}]*$`);

    return function(value) {
        return value.match(regexp)[0].trim();
    };
}

Пример универсального решения

const getToken =
    createTokenizer(",");

new Awesomplete(input, {

    filter(text, inputValue) {

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

    item(text, inputValue) {

        return Awesomplete.ITEM(
            text,
            getToken(inputValue)
        );
    }
});

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

Потеря предыдущих значений

Причина:

this.input.value = text;

Исправление:

this.input.value =
    before + text;

Неверная работа регулярного выражения

Ошибка:

/[^,]$/

Такой шаблон получает только один символ.

Правильно:

/[^,]*$/

Отсутствие trim()

Без очистки пробелов поиск работает некорректно:

" HTML"

Вместо:

"HTML"

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

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

  • ограничивать число результатов;
  • выполнять debounce;
  • использовать серверную фильтрацию;
  • кэшировать запросы;
  • отключать тяжёлые регулярные выражения.

Пример debounce

function debounce(fn, delay) {

    let timer;

    return function(...args) {

        clearTimeout(timer);

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

Архитектура поля тегов

На практике механизм разделителей часто становится основой полноценного компонента тегов.

Типичная схема

[input]
 ↓
текущий токен
 ↓
фильтрация
 ↓
выбор элемента
 ↓
replace()
 ↓
обновление строки

Благодаря переопределению методов filter(), item() и replace() библиотека Awesomplete позволяет реализовывать сложные сценарии множественного ввода без модификации внутреннего кода библиотеки.