Полезные чипсы с Vue 3 Composition API: Создание адаптивного компонента с фильтрацией и множественным выбором

от автора

Современные пользовательские интерфейсы требуют высокой интерактивности и удобства взаимодействия. В этой статье поговорим о том, как реализовать мощный, адаптивный компонент мульти-выбора на основе 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-компонентов. Он сочетает в себе гибкость, мощный функционал и удобство использования, что делает его незаменимым инструментом для веб-разработчиков. Компонент легко интегрируется в различные проекты и улучшает пользовательский опыт.

Исходный код компонентов https://github.com/lyashov/ChipsMultiSelect.git


ссылка на оригинал статьи https://habr.com/ru/articles/863210/