Современные пользовательские интерфейсы требуют высокой интерактивности и удобства взаимодействия. В этой статье поговорим о том, как реализовать мощный, адаптивный компонент мульти-выбора на основе Vue 3 Composition API. ChipsMultiSelect — это компонент, который объединяет возможности выпадающего списка, визуализации выбора в виде "чипсов" и встроенной фильтрации.
Выбранные элементы отображаются в виде “чипсов”

Фильтрация элементов списка. Компонент совмещает функции выпадающего списка, набора “чипсов” и поле ввода для фильтрации.

При большом количестве “чипсов” происходит автоматический перенос на новую строку и увеличение высоты поля ввода

Основные возможности ChipsMultiSelect

  • Интерактивное управление состоянием:

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

    Удаление осуществляется с помощью иконки "крестик" на чипсе.

  • Поддержка поиска и фильтрации:

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

    Динамическая фильтрация обновляет список на основе пользовательского ввода.

  • Адаптивный дизайн:

    Высота поля ввода автоматически регулируется при большом количестве чипсов.

    При достижении максимальной ширины, происходит перенос добавленных "чипсов" на новую строку

  • Легкая интеграция:

    Поддержка работы с различными форматами данных, включая строки, объекты и массивы.

    Особенности реализации

    Проблемы, с которыми столкнулся при реализации данного компонента – стилизация input таким образом чтоб в нем отобразить список чипсов. Также необходимо спозиционировать каретку ввода сразу после чипсов. К тому же при расположении чипсов более чем в один ряд поле ввода должно смещаться к последней чипсе.

    В общем случае с input решение выглядит довольно громоздким и сложным. Но тут нам на помощь приходят редактируемые div-ы (div cо свойством contenteditable = true ). Этот подход снимает все проблемы стилизации и заметно упрощает реализацию. Достаточно всего лишь застилизовать div под input.

    Доступ к данным, введенным пользователем для фильтрации получим с использованием innerText

    Еще одна особенность – чтоб при нажатии пользователем клавиши Enter при фильтрации не происходило перехода на новую строку event.preventDefault()

    Компонент ChipsMultiSelect состоит из нескольких ключевых частей:

  • Компонент чипса (ChipsItem): Единичная чипса. Может быть объектом а не только строкой а также предоставляет возможность удалить его через крестик. Возможно переиспользовать в проекте, поэтому решено выделить в отдельный компонент.

  • Список чипсов (ChipsList): Отвечает за отображение коллекции выбранных чипсов и обрабатывает пользовательские действия.

  • Основной компонент (ChipsMultiSelect): содержит ChipsList, выпадающий список, функционал фильтрации.

Исходный код компонентов:

Компонент чипса (ChipsItem):

<script setup>
import Icon from '@/ui-library-b2b/icons/B2BBaseIcon.vue'

const props = defineProps({
  item: {
    type: Object,
  },
  bindName: {
    type: String,
    default: 'name',
  },
})

const emit = defineEmits(['delete'])

function deleteItem() {
  emit('delete', props.item)
}
</script>

<template>
  <div class="selected-item">
    {{ item[bindName] }}
    <div class="selected-item__close" @click.stop="deleteItem()">
      <Icon icon="Close" />
    </div>
  </div>
</template>

<style scoped lang="scss">
.selected-item {
  display: flex;
  gap: 4px;
  align-items: center;
  color: var(--text-colors);
  font-weight: 300;
  font-style: normal;
  line-height: 20px;
  white-space: nowrap;
  font-size: 14px;
  letter-spacing: 0.005em;
  text-align: left;
  flex-direction: row;
  padding: 4px 6px 4px 8px;
  background: rgba(16, 24, 40, 0.1);
  border-radius: 2px;

  &__close {
    color: black;
    cursor: pointer;
  }
}
</style>

Список чипсов (ChipsList):

import { ref } from 'vue'
import SelectedItem from '@/ui-library-b2b/search/ChipsItem.vue'

const props = defineProps({
  bindName: {
    type: String,
    default: 'name',
  },
  inn: {
    type: Boolean,
    default: false,
  },
})
const emit = defineEmits(['on-keyup', 'blur'])
const chips = defineModel()
const multiselectRef = ref(null)

function deleteItem(item) {
  chips.value = chips.value.filter((el) => el !== item)
}

function onKeyUp(e) {
  emit('on-keyup', multiselectRef.value.textContent)
  if (e.key === 'Enter') {
    multiselectRef.value.textContent = ''
  }
}

function onBlur() {
  emit('blur', multiselectRef.value.textContent)
  multiselectRef.value.textContent = ''
}

function handleInput() {
  const maxLength = 12

  if (props.inn) {
    if (multiselectRef.value.textContent.length > maxLength) {
      multiselectRef.value.textContent = multiselectRef.value.textContent.slice(0, maxLength)
    }
    multiselectRef.value.textContent = multiselectRef.value.textContent.replace(/\D/g, '')
  }
}
</script>

<template>
  <div class="chips">
    <div v-for="(item, index) in chips" :key="index">
      <SelectedItem :item="item" :bind-name @delete="deleteItem" />
    </div>
    <div
      ref="multiselectRef"
      contenteditable="true"
      spellcheck="false"
      class="custom-div"
      @keydown.enter.prevent=""
      @keyup="onKeyUp"
      @blur="onBlur"
      @input="handleInput"
    />
  </div>
</template>

<style lang='scss' scoped>
.chips {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: 3px;
  margin-top: 4px;
  width: 100%;
}

.custom-div {
  flex-grow: 1;
  white-space: nowrap;
  display: flex;
  align-items: center;
  overflow: hidden;
}

.custom-div:focus {
  outline: none;
}
</style>

Основной компонент (ChipsMultiSelect)

<script setup lang="ts">
// import
import { ref } from 'vue'
import Chips from '@/ui-library-b2b/search/ChipsList.vue'
import Icon from '@/ui-library-b2b/icons/B2BBaseIcon.vue'

// props
const props = defineProps({
  caption: {
    type: String,
    default: 'Список холдингов',
  },
  placeholder: {
    type: String,
    default: '',
  },
})

// const
const searchText = defineModel()
const chips = ref([])
const title = ref('My title')
const titleElement = ref(null)

// methods
function validate(event: Event) {
  event.preventDefault()
  // (event.target as HTMLInputElement).blur()
  chips.value.push(titleElement.value.innerText.trim())
  titleElement.value.innerText = ''
}

function keyUp() {
  searchText.value = titleElement.value.innerText
  console.log(titleElement.value.innerText)
}

defineExpose({ titleElement })
</script>

<template>
  <div class="multi-search">
    <div class="multi-search__input">
      <Icon class="multi-search__icon-search" icon="Search" />
      <Chips v-model="chips" style="padding-left: 40px;" />
      <div
        ref="titleElement" contenteditable="true" spellcheck="false" :placeholder="chips.length > 0 ? '' : placeholder" @keyup="keyUp"
        @keydown.enter="validate"
      />
    </div>
  </div>
</template>

<style scoped lang="scss">
.multi-search{
  [contenteditable=true]:empty:before{
    content: attr(placeholder);
    padding-top: 3px;
    pointer-events: none;
    display: block;
    font-style: normal;
    font-weight: 400;
    font-size: 14px;
    line-height: 20px;
    letter-spacing: 0.005em;
    color: rgba(16, 24, 40, 0.5);
  }

  div[contenteditable=true] {
    padding: 5px;
    width: 100%;
    outline:none;
  }

  position: relative;

  &__icon-search{
    position: fixed;
    margin: 5px 10px;
    width: 24px;
    height: 24px;
  }

  &__input{
    display: flex;
    flex-direction: row;
    flex-wrap: nowrap;
    width: 800px;
    height: 36px;
    box-sizing: border-box;
    border: 1px solid rgba(16, 24, 40, 0.1);
    box-shadow: inset 0px 1px 0px rgba(16, 24, 40, 0.05), inset 0px 2px 0px rgba(16, 24, 40, 0.05);
    border-radius: 4px;

  }

  .btn {
    width: 16px;
    height: 16px;

    position: absolute;
    top: 8px;
    bottom: 10px;
    right: 10px;
    display: none;
    border: 0;
    padding-top: 0 -5px;
    border-radius: 50%;
    background-color: #fff;
    transition: background 200ms;
    outline: none;

    &:hover {
      width: 16px;
      height: 16px;
      display: block;
      background: url("../../assets/img/navigation/close.svg") no-repeat;
    }

  }

  input:valid ~ div {
    display: block;
  }

  .ok {
    background: url("../../assets/img/navigation/ok.svg") no-repeat;
  }

  .err {
    background: url("../../assets/img/navigation/close_gray.svg") no-repeat;
  }
}
</style>

Возможности кастомизации и расширения


Этот компонент можно легко кастомизировать для различных целей:

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

  • Поддержка разных форматов ввода: Ввод можно адаптировать под различные требования, например, поддержка телефонных номеров, ИНН, дат и других форматов.

  • Динамическое обновление: Компонент можно адаптировать для работы с динамическими данными, например, при получении данных с сервера.

Применение и реальные кейсы использования:

  • CRM-системы: фильтрация и выбор значений из больших справочников.

  • E-commerce: подбор товаров по характеристикам.

  • Управление тегами: работа с категориями в CMS.

Заключение

В данной статье мы реализовали компонент, который:

• Снижает сложность интеграции фильтров и поиска в веб-приложениях.

• Упрощает управление пользовательским вводом и валидацией данных.

• Обеспечивает высокую степень кастомизации через свойства и события.

ChipsMultiSelect демонстрирует, как Vue 3 может быть использован для создания интерактивных UI-компонентов. Он сочетает в себе гибкость, мощный функционал и удобство использования, что делает его незаменимым инструментом для веб-разработчиков. Компонент легко интегрируется в различные проекты и улучшает пользовательский опыт.

Исходный код компонентов

Комментарии (7)


  1. gruzoveek
    03.12.2024 05:38

    Довольно часто бывает нужен такой компонент. Вот недавно искал такой для нативного js без jQuery, select2 по этой причине не подошел. А для vue2 как-то находил прикольный компонент, который кроме прочего мог очень большие списки без тормозов выводить.


    1. alex8079
      03.12.2024 05:38

      "Tom Select" кажется на ванильном JS сделан.

      А для Vue я вообще замучился искать select c фильтрацией, который мог бы подтягивать опции из бекенда, при этом иметь нормальные настройки. Например, нормальное управление плейсхолдером в дропдауне при подтягивани опций с сервера.
      В одном селекте я вел эпичную битву с отсутствием ивента на "удаление ранее выбранной опции", потому что персонажу, создавшему эту поделку не пришло в голову, что для такого действия нужно генерировать ивент (в Vue). Для меня такое - базовый функционал.

      У всех этих библиотек куча кастомизируемого мусора, а то, что (мне) нужно делается костыльно, либо вообще невозможно сделать. Причем, приходится потратить много времени, чтобы понять, что "чего-то важного для меня не хватает".

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

      IMHO, все, что касается SPA создают альернативно мыслящие любители кривых UI, которые постепенно делают интернет неюзабельным.

      Нет, ну можно и свой велдосипед написать на модном SPA фреймворке...


  1. VitaminND
    03.12.2024 05:38

    Супер! Очень познавательно!


  1. sfirestar
    03.12.2024 05:38

    Классная статья! Приятно видеть пример с contenteditable и vue. Кстати, планируется ли какая-то защита от дурака в нем? В contenteditable можно же по-идее вставить даже картинку из буфера обмена.


  1. Dadadam999
    03.12.2024 05:38

    Спасибо, полезно


  1. webnetkz
    03.12.2024 05:38

    Спасибо тебе друг, да вернется тебе благо!


  1. koltykov
    03.12.2024 05:38

    Спасибо! В закладки!

    На ванильном JS находил как-то для проекта, но без переноса строк и т.д. Кривоватый в общем. Сейчас как раз vue3 начал использовать в проектах. А тут все как надо продумано.