Современные пользовательские интерфейсы требуют высокой интерактивности и удобства взаимодействия. В этой статье поговорим о том, как реализовать мощный, адаптивный компонент мульти-выбора на основе 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)
sfirestar
03.12.2024 05:38Классная статья! Приятно видеть пример с contenteditable и vue. Кстати, планируется ли какая-то защита от дурака в нем? В contenteditable можно же по-идее вставить даже картинку из буфера обмена.
koltykov
03.12.2024 05:38Спасибо! В закладки!
На ванильном JS находил как-то для проекта, но без переноса строк и т.д. Кривоватый в общем. Сейчас как раз vue3 начал использовать в проектах. А тут все как надо продумано.
gruzoveek
Довольно часто бывает нужен такой компонент. Вот недавно искал такой для нативного js без jQuery, select2 по этой причине не подошел. А для vue2 как-то находил прикольный компонент, который кроме прочего мог очень большие списки без тормозов выводить.
alex8079
"Tom Select" кажется на ванильном JS сделан.
А для Vue я вообще замучился искать select c фильтрацией, который мог бы подтягивать опции из бекенда, при этом иметь нормальные настройки. Например, нормальное управление плейсхолдером в дропдауне при подтягивани опций с сервера.
В одном селекте я вел эпичную битву с отсутствием ивента на "удаление ранее выбранной опции", потому что персонажу, создавшему эту поделку не пришло в голову, что для такого действия нужно генерировать ивент (в Vue). Для меня такое - базовый функционал.
У всех этих библиотек куча кастомизируемого мусора, а то, что (мне) нужно делается костыльно, либо вообще невозможно сделать. Причем, приходится потратить много времени, чтобы понять, что "чего-то важного для меня не хватает".
jQuery и по сей день остается самым быстрым и простым в плане поиска и адаптации каких-либо компонентов, и никакие помои, выливаемые в интернете на тех, кто использует jQuery, не изменят этот факт.
IMHO, все, что касается SPA создают альернативно мыслящие любители кривых UI, которые постепенно делают интернет неюзабельным.
Нет, ну можно и свой велдосипед написать на модном SPA фреймворке...