Modern user interfaces demand high interactivity and usability. This article explores how to create a powerful, adaptive multi-select component using the Vue 3 Composition API. The ChipsMultiSelect component combines the features of a dropdown list, visual selection in the form of "chips," and built-in filtering functionality. Selected items are displayed as "chips". Real-time Filtering: The component integrates a dropdown list, a set of chips, and an input field for filtering. Dynamic Resizing: When there are too many chips, they wrap to a new row, adjusting the height of the input field accordingly. Key Features of ChipsMultiSelect Interactive State Management: Selected items are displayed as chips. Chips can be removed using a "close" icon. Search and Filtering Support: The input field allows users to search for items in real-time. The list updates dynamically based on the user input. Responsive Design: The input field's height adjusts dynamically for multiple rows of chips. Chips wrap to new lines when the maximum width is exceeded. Easy Integration: Supports various data formats, including strings, objects, and arrays. Implementation Details Challenges: Styling the input field to display chips while ensuring proper caret positioning after the chips was a significant challenge. Additionally, the input field needs to shift dynamically to align with the last row of chips when they span multiple lines. Solution: Using editable divs (contenteditable=true) instead of traditional input fields simplifies styling and implementation. This approach resolves positioning and styling issues efficiently. Key Techniques: Use innerText to retrieve user input for filtering. Prevent new lines on Enter keypress using event.preventDefault(). Component Structure Chip Component (ChipsItem): Represents an individual chip. Supports both strings and objects. Includes a "close" button for removal. Designed for reuse across projects. Chip List (ChipsList): Displays a collection of selected chips. Handles user interactions. Main Component (ChipsMultiSelect): Encapsulates ChipsList, dropdown list, and filtering functionality. 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: <script setup> 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> Main component (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> Real-World Applications CRM Systems: Efficient filtering and selection from large directories. E-Commerce: Product filtering based on attributes. Tag Management: Category handling in CMS systems. Conclusion This article demonstrates how to create an interactive UI component that: Simplifies the integration of filters and search functionality in web applications. Enhances data input management and validation. Offers extensive customization options through props and events. ChipsMultiSelect showcases the power of Vue 3 in building interactive UI components. Its flexibility and robust functionality make it a valuable tool for web developers, seamlessly integrating into projects to enhance user experience. Source Code: https://github.com/lyashov/ChipsMultiSelect.git Modern user interfaces demand high interactivity and usability. This article explores how to create a powerful, adaptive multi-select component using the Vue 3 Composition API. The ChipsMultiSelect component combines the features of a dropdown list, visual selection in the form of "chips," and built-in filtering functionality. Selected items are displayed as "chips". Selected items are displayed as "chips". Real-time Filtering: The component integrates a dropdown list, a set of chips, and an input field for filtering. Dynamic Resizing: When there are too many chips, they wrap to a new row, adjusting the height of the input field accordingly. Key Features of ChipsMultiSelect Interactive State Management: Selected items are displayed as chips. Chips can be removed using a "close" icon. Search and Filtering Support: The input field allows users to search for items in real-time. The list updates dynamically based on the user input. Responsive Design: The input field's height adjusts dynamically for multiple rows of chips. Chips wrap to new lines when the maximum width is exceeded. Easy Integration: Supports various data formats, including strings, objects, and arrays. Interactive State Management: Selected items are displayed as chips. Chips can be removed using a "close" icon. Selected items are displayed as chips. Chips can be removed using a "close" icon. Selected items are displayed as chips. Chips can be removed using a "close" icon. Search and Filtering Support: The input field allows users to search for items in real-time. The list updates dynamically based on the user input. The input field allows users to search for items in real-time. The list updates dynamically based on the user input. The input field allows users to search for items in real-time. The list updates dynamically based on the user input. Responsive Design: The input field's height adjusts dynamically for multiple rows of chips. Chips wrap to new lines when the maximum width is exceeded. The input field's height adjusts dynamically for multiple rows of chips. Chips wrap to new lines when the maximum width is exceeded. The input field's height adjusts dynamically for multiple rows of chips. Chips wrap to new lines when the maximum width is exceeded. Easy Integration: Supports various data formats, including strings, objects, and arrays. Supports various data formats, including strings, objects, and arrays. Supports various data formats, including strings, objects, and arrays. Implementation Details Challenges: Styling the input field to display chips while ensuring proper caret positioning after the chips was a significant challenge. Additionally, the input field needs to shift dynamically to align with the last row of chips when they span multiple lines. Solution: Using editable divs (contenteditable=true) instead of traditional input fields simplifies styling and implementation. This approach resolves positioning and styling issues efficiently. Key Techniques: Use innerText to retrieve user input for filtering. Prevent new lines on Enter keypress using event.preventDefault(). Use innerText to retrieve user input for filtering. Prevent new lines on Enter keypress using event.preventDefault(). Component Structure Chip Component (ChipsItem): Represents an individual chip. Supports both strings and objects. Includes a "close" button for removal. Designed for reuse across projects. Chip List (ChipsList): Displays a collection of selected chips. Handles user interactions. Main Component (ChipsMultiSelect): Encapsulates ChipsList, dropdown list, and filtering functionality. Chip Component (ChipsItem): Represents an individual chip. Supports both strings and objects. Includes a "close" button for removal. Designed for reuse across projects. Represents an individual chip. Supports both strings and objects. Includes a "close" button for removal. Designed for reuse across projects. Represents an individual chip. Supports both strings and objects. Includes a "close" button for removal. Designed for reuse across projects. Chip List (ChipsList): Displays a collection of selected chips. Handles user interactions. Displays a collection of selected chips. Handles user interactions. Displays a collection of selected chips. Handles user interactions. Main Component (ChipsMultiSelect): Encapsulates ChipsList, dropdown list, and filtering functionality. Encapsulates ChipsList, dropdown list, and filtering functionality. Encapsulates ChipsList, dropdown list, and filtering functionality. Encapsulates ChipsList, dropdown list, and filtering functionality. ChipsItem : 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> <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 : ChipsList <script setup> 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> <script setup> 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> Main component (ChipsMultiSelect) Main component (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> <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> Real-World Applications CRM Systems: Efficient filtering and selection from large directories. E-Commerce: Product filtering based on attributes. Tag Management: Category handling in CMS systems. CRM Systems: Efficient filtering and selection from large directories. E-Commerce: Product filtering based on attributes. Tag Management: Category handling in CMS systems. Conclusion This article demonstrates how to create an interactive UI component that: Simplifies the integration of filters and search functionality in web applications. Enhances data input management and validation. Offers extensive customization options through props and events. Simplifies the integration of filters and search functionality in web applications. Enhances data input management and validation. Offers extensive customization options through props and events. ChipsMultiSelect showcases the power of Vue 3 in building interactive UI components. Its flexibility and robust functionality make it a valuable tool for web developers, seamlessly integrating into projects to enhance user experience. Source Code: https://github.com/lyashov/ChipsMultiSelect.git Source Code: https://github.com/lyashov/ChipsMultiSelect.git