Appearance
ComboBox Component Implementation
The ComboBox component combines the functionality of a searchable dropdown with the ability to create custom entries.
Component Code
vue
<script setup>
import { ref, computed, watch } from "vue";
import OMenu from "./OMenu.vue";
import OChip from "./OChip.vue";
// Props
const props = defineProps({
// Core functionality
items: { type: Array, default: () => [] },
modelValue: { type: [String, Number, Array, Object], default: null },
multiple: { type: Boolean, default: false },
// Item configuration
itemText: { type: String, default: "text" },
itemValue: { type: String, default: "value" },
itemDisabled: { type: String, default: "disabled" },
// Autocomplete specific
filterFunction: { type: Function, default: null },
selectedOnTop: { type: Boolean, default: false },
// Menu configuration
placement: { type: String, default: "bottom" },
offset: { type: Array, default: () => [0, 0.125] },
menuWidth: { type: [String, Number], default: null },
matchTriggerWidth: { type: Boolean, default: true },
// Input props (pass-through)
placeholder: { type: String, default: "" },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
});
// Emits
const emit = defineEmits([
"update:modelValue",
"change",
"select",
"remove",
"search",
"create",
]);
// Refs
const menuRef = ref(null);
const inputRef = ref(null);
const isMenuOpen = ref(false);
const searchText = ref("");
const isFocused = ref(false);
const customItems = ref([]);
// Computed
const allItems = computed(() => {
return [...props.items, ...customItems.value];
});
const selectedItems = computed(() => {
if (!props.multiple) {
if (props.modelValue == null) return [];
const selected = allItems.value.find(
(item) => getItemValue(item) === props.modelValue
);
return selected ? [selected] : [];
}
if (!Array.isArray(props.modelValue)) return [];
return props.modelValue
.map((value) => {
return allItems.value.find((item) => getItemValue(item) === value);
})
.filter(Boolean);
});
const selectedItem = computed(() => {
if (props.multiple) return null;
return selectedItems.value[0] || null;
});
const hasSelection = computed(() => {
return props.multiple
? selectedItems.value.length > 0
: props.modelValue != null;
});
const sortedItems = computed(() => {
// Filter out custom items - only show original items from props.items
let items = props.items.filter((item) => !isDisabled(item));
if (!searchText.value.trim()) {
// No search - just sort by selected if needed
if (!props.selectedOnTop) {
return items;
}
const selected = [];
const unselected = [];
items.forEach((item) => {
if (isSelected(item)) {
selected.push(item);
} else {
unselected.push(item);
}
});
return [...selected, ...unselected];
}
// With search - filter and sort by relevance
const search = searchText.value.toLowerCase().trim();
const scoredItems = items.map((item) => {
const text = getItemText(item).toLowerCase();
let score = 0;
// First check if item passes custom filter (if provided)
if (props.filterFunction && !props.filterFunction(item, searchText.value)) {
return { item, score: 0 }; // Item doesn't pass custom filter
}
// Apply default scoring system
if (text === search) {
score = 100; // Exact match
} else if (text.startsWith(search)) {
score = 50; // Starts with search
} else if (text.includes(search)) {
score = 25; // Contains search
} else {
score = 0; // No match
}
// Boost score for selected items if selectedOnTop is true
if (props.selectedOnTop && isSelected(item)) {
score += 1000;
}
return { item, score };
});
// Filter out items with score 0 (no match) and sort by score (highest first)
return scoredItems
.filter((scored) => scored.score > 0)
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score;
}
// Maintain original order for items with same score
return items.indexOf(a.item) - items.indexOf(b.item);
})
.map((scored) => scored.item);
});
// Helper functions
const getItemText = (item) => {
if (!item) return "";
if (typeof item === "string" || typeof item === "number") {
return item.toString();
}
return item[props.itemText] || "";
};
const getItemValue = (item) => {
if (!item) return null;
if (typeof item === "string" || typeof item === "number") {
return item;
}
return item[props.itemValue] !== undefined ? item[props.itemValue] : item;
};
const isDisabled = (item) => {
if (!item) return false;
if (typeof item === "string" || typeof item === "number") return false;
return Boolean(item[props.itemDisabled]);
};
const isSelected = (item) => {
const itemValue = getItemValue(item);
if (props.multiple) {
return (
Array.isArray(props.modelValue) && props.modelValue.includes(itemValue)
);
}
return props.modelValue === itemValue;
};
const createCustomItem = (value) => {
// Check if custom item already exists
const existing = customItems.value.find(
(item) => getItemValue(item) === value
);
if (existing) return existing;
const customItem = {
[props.itemText]: value,
[props.itemValue]: value,
__isCustom: true,
};
customItems.value.push(customItem);
return customItem;
};
// Event handlers
const handleMenuOpen = () => {
isMenuOpen.value = true;
};
const handleMenuClose = () => {
isMenuOpen.value = false;
// Single mode: whatever is typed becomes the value
if (!props.multiple) {
const trimmedText = searchText.value.trim();
if (trimmedText) {
// Always use the typed text, regardless of previous selection
const customItem = createCustomItem(trimmedText);
const itemValue = getItemValue(customItem);
emit("update:modelValue", itemValue);
emit("change", itemValue);
emit("create", customItem, itemValue);
searchText.value = trimmedText;
} else if (hasSelection.value && selectedItem.value) {
// Only restore previous selection if input is empty
searchText.value = getItemText(selectedItem.value);
}
} else {
// Multiple mode: just clear search text
// (chip creation happens in blur or enter handler)
searchText.value = "";
}
};
const handleInputFocus = () => {
isFocused.value = true;
if (!props.disabled && !isMenuOpen.value) {
menuRef.value?.openMenu();
}
};
const toggleMenu = () => {
if (isMenuOpen.value) {
menuRef.value?.closeMenu();
} else {
menuRef.value?.openMenu();
}
};
const handleSearchInput = (value) => {
searchText.value = value;
emit("search", value);
// Keep menu open while focused and has input
if (isFocused.value && !isMenuOpen.value) {
menuRef.value?.openMenu();
}
};
const handleOptionClick = (item) => {
if (isDisabled(item)) return;
const itemValue = getItemValue(item);
if (props.multiple) {
let newValue = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
if (isSelected(item)) {
// Remove item
newValue = newValue.filter((val) => val !== itemValue);
emit("remove", item, itemValue);
} else {
// Add item
newValue.push(itemValue);
emit("select", item, itemValue);
}
emit("update:modelValue", newValue);
emit("change", newValue);
// Clear search after selection in multiple mode
searchText.value = "";
} else {
emit("update:modelValue", itemValue);
emit("change", itemValue);
emit("select", item, itemValue);
// Set display text and close menu
searchText.value = getItemText(item);
menuRef.value?.closeMenu();
}
};
const handleClear = () => {
const newValue = props.multiple ? [] : null;
emit("update:modelValue", newValue);
emit("change", newValue);
searchText.value = "";
};
const removeItem = (itemValue) => {
if (!props.multiple || props.disabled) return;
const item = allItems.value.find((item) => getItemValue(item) === itemValue);
const newValue = Array.isArray(props.modelValue)
? props.modelValue.filter((val) => val !== itemValue)
: [];
emit("update:modelValue", newValue);
emit("change", newValue);
if (item) {
emit("remove", item, itemValue);
}
};
const handleKeyDown = (event) => {
if (props.disabled) return;
// Handle Enter key in multiple mode
if (event.key === "Enter" && props.multiple) {
event.preventDefault();
const trimmedText = searchText.value.trim();
if (trimmedText) {
// Capture text before clearing
const textToAdd = trimmedText;
const customItem = createCustomItem(textToAdd);
const itemValue = getItemValue(customItem);
// Check if already selected
if (
!Array.isArray(props.modelValue) ||
!props.modelValue.includes(itemValue)
) {
const newValue = Array.isArray(props.modelValue)
? [...props.modelValue, itemValue]
: [itemValue];
emit("update:modelValue", newValue);
emit("change", newValue);
emit("create", customItem, itemValue);
}
// Clear search text after emitting
searchText.value = "";
}
return;
}
// Handle Enter key in single mode
if (event.key === "Enter" && !props.multiple) {
event.preventDefault();
const trimmedText = searchText.value.trim();
if (trimmedText) {
const customItem = createCustomItem(trimmedText);
const itemValue = getItemValue(customItem);
emit("update:modelValue", itemValue);
emit("change", itemValue);
emit("create", customItem, itemValue);
searchText.value = trimmedText;
menuRef.value?.closeMenu();
}
return;
}
if (!props.multiple) return;
// Handle backspace in multiple mode
if (event.key === "Backspace") {
// Remove all if Shift/Ctrl/Cmd is pressed
if (event.shiftKey || event.ctrlKey || event.metaKey) {
if (selectedItems.value.length > 0) {
event.preventDefault();
emit("update:modelValue", []);
emit("change", []);
searchText.value = "";
}
return;
}
// Remove one item at a time if input is empty and there are selected items
if (!searchText.value && selectedItems.value.length > 0) {
event.preventDefault();
const lastItem = selectedItems.value[selectedItems.value.length - 1];
const lastValue = getItemValue(lastItem);
removeItem(lastValue);
}
}
};
// Watch for model value changes to update search text
watch(
() => props.modelValue,
(newValue) => {
if (!props.multiple && !isFocused.value && !isMenuOpen.value) {
const selected = allItems.value.find(
(item) => getItemValue(item) === newValue
);
searchText.value = selected ? getItemText(selected) : "";
} else if (props.multiple && !isFocused.value && !isMenuOpen.value) {
// In multiple mode, clear search when not focused
searchText.value = "";
}
},
{ immediate: true }
);
// Clear search text when menu closes in multiple mode
watch(isMenuOpen, (isOpen) => {
if (!isOpen && props.multiple) {
searchText.value = "";
}
});
// Expose methods
defineExpose({
focus: () => {
if (inputRef.value) {
inputRef.value.focus();
}
},
blur: () => {
if (inputRef.value) {
inputRef.value.blur();
}
},
openMenu: () => menuRef.value?.openMenu(),
closeMenu: () => menuRef.value?.closeMenu(),
search: (value) => {
searchText.value = value;
},
});
</script>
<template>
<div class="combobox">
<OMenu
ref="menuRef"
trigger-type="click"
:placement="placement"
:offset="offset"
:width="menuWidth"
:match-trigger-width="matchTriggerWidth"
:disabled="disabled || readonly"
@open="handleMenuOpen"
@close="handleMenuClose"
>
<template #trigger>
<div
class="trigger-container"
:class="{
'has-selected-items': multiple && selectedItems.length > 0,
disabled: disabled,
}"
@click.stop
>
<!-- Main container with chips and input (flex: 1, wrappable) -->
<div class="main-container">
<!-- Chips display -->
<template v-if="multiple">
<OChip
v-for="item in selectedItems"
:key="getItemValue(item)"
:chip="item"
:text-key="itemText"
:value-key="itemValue"
:closable="!disabled"
@on-delete-chip="removeItem"
/>
</template>
<input
ref="inputRef"
type="text"
:value="searchText"
:placeholder="
multiple && selectedItems.length > 0 ? '' : placeholder
"
:disabled="disabled || loading"
:readonly="readonly"
class="combobox-input"
@input="handleSearchInput($event.target.value)"
@focus="handleInputFocus"
@keydown="handleKeyDown"
@click.stop
/>
</div>
<!-- Append wrapper with loading, clear, and arrow -->
<div class="append-wrapper">
<div v-if="loading" class="loading-indicator">
<svg
class="spinner"
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
>
<circle
class="path"
cx="25"
cy="25"
r="20"
fill="none"
stroke-width="4"
/>
</svg>
</div>
<div
v-else-if="clearable && (hasSelection || searchText)"
class="clear-button"
@click.stop="handleClear"
>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div
class="select-arrow"
:class="{ rotated: isMenuOpen }"
@click.stop="toggleMenu"
>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 9L12 15L18 9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</div>
</template>
<template #content>
<div class="combobox-dropdown">
<!-- Options list -->
<div
v-for="item in sortedItems"
:key="getItemValue(item)"
class="select-option"
:class="{
selected: isSelected(item),
disabled: isDisabled(item),
}"
@click="handleOptionClick(item)"
>
<div class="option-content">
<!-- Checkbox for multiple selection -->
<div v-if="multiple" class="option-checkbox">
<svg
v-if="isSelected(item)"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 6L9 17L4 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<span class="option-text">{{ getItemText(item) }}</span>
</div>
</div>
<!-- Empty state -->
<div v-if="sortedItems.length === 0 && !loading" class="select-empty">
<slot name="no-data">
<span class="empty-text">
{{
searchText
? "No matching options found"
: "No options available"
}}
</span>
</slot>
</div>
<!-- Loading state -->
<div v-if="loading" class="select-loading">
<div class="loading-spinner">
<svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
<circle
class="path"
cx="25"
cy="25"
r="20"
fill="none"
stroke-width="4"
/>
</svg>
</div>
<span>Loading...</span>
</div>
</div>
</template>
</OMenu>
</div>
</template>
<style scoped>
.combobox {
width: 100%;
}
.trigger-container {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 0.0625rem solid grey;
border-radius: 0.25rem;
background: #ffffff;
min-height: 3.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus-within {
border-color: #1976d2;
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}
&.has-selected-items {
padding: 0.375rem 0.75rem;
min-height: auto;
}
&.disabled {
opacity: 0.5;
pointer-events: none;
background: #f5f5f5;
cursor: not-allowed;
}
}
/* Main container with chips and input (flex: 1, wrappable) */
.main-container {
flex-grow: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.combobox-input {
border: none;
outline: none;
background: transparent;
padding: 0.5rem 0;
font-size: 1rem;
/* Let input take remaining space in flex parent, but min 30% wrapping if needed */
flex: 1 1 20%;
min-width: 20%;
max-width: 100%;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::placeholder {
color: #9ca3af;
}
}
/* Append wrapper with loading, clear, and arrow */
.append-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.clear-button,
.loading-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
color: #6b7280;
transition: color 0.2s ease;
}
.clear-button {
&:hover {
color: #374151;
}
svg {
width: 1rem;
height: 1rem;
}
}
.loading-indicator {
cursor: default;
.spinner {
width: 1rem;
height: 1rem;
animation: rotate 2s linear infinite;
.path {
stroke: #1976d2;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
}
}
.select-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
color: #6b7280;
transition: transform 0.2s ease, color 0.2s ease;
&:hover {
color: #374151;
}
svg {
width: 1rem;
height: 1rem;
}
&.rotated {
transform: rotate(180deg);
}
}
.combobox-dropdown {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 0;
margin-top: 0.25rem;
z-index: 1000;
}
/* Custom scrollbar styling */
.combobox-dropdown::-webkit-scrollbar {
width: 8px;
}
.combobox-dropdown::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 4px;
}
.combobox-dropdown::-webkit-scrollbar-thumb {
background: #c0c0c0;
border-radius: 4px;
}
.combobox-dropdown::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
.select-option {
padding: 0.625rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
margin: 0 0.25rem;
border-radius: 6px;
position: relative;
}
.select-option:hover:not(.disabled) {
background: #f5f7fa;
transform: translateX(2px);
}
.select-option.selected {
background: linear-gradient(90deg, #e3f2fd 0%, #e8f4fd 100%);
color: #1976d2;
font-weight: 500;
}
.select-option.selected:hover:not(.disabled) {
background: linear-gradient(90deg, #d0e7fc 0%, #d5e9fc 100%);
}
.select-option.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.select-option.disabled:hover {
background: transparent;
transform: none;
}
.option-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.option-checkbox {
width: 1.125rem;
height: 1.125rem;
min-width: 1.125rem;
border: 2px solid #9e9e9e;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
transition: all 0.2s ease;
flex-shrink: 0;
}
.select-option.selected .option-checkbox {
background: #1976d2;
border-color: #1976d2;
}
.select-option.selected .option-checkbox svg {
color: #ffffff;
stroke-width: 2.5;
}
.option-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9375rem;
line-height: 1.5;
color: inherit;
}
.select-empty,
.select-loading {
padding: 2rem 1rem;
text-align: center;
color: #757575;
font-size: 0.875rem;
}
.select-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.empty-text {
color: #9e9e9e;
font-style: italic;
}
.select-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 120px;
}
.loading-spinner {
animation: rotate 2s linear infinite;
width: 2rem;
height: 2rem;
}
.loading-spinner .path {
stroke: #1976d2;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
.select-loading span {
color: #757575;
font-size: 0.875rem;
font-weight: 500;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
</style>Key Implementation Details
Custom Entry Creation
The ComboBox allows users to create custom entries by:
- Typing text and pressing Enter
- Typing text and closing the menu (single mode)
- Custom entries are stored in
customItemsref - Creates event is emitted when custom items are added
Differences from Autocomplete
- Custom Items Management: Maintains a separate
customItemsarray for user-created entries - Menu Close Behavior: In single mode, typed text becomes the value automatically
- Enter Key Handling: Creates custom entries instead of just selecting
- allItems Computed: Combines
props.itemswithcustomItems - Create Event: Emits
createevent when custom items are added
State Management
The component manages several reactive states:
customItems: Array of user-created custom itemsallItems: Computed combination of props.items and customItemssearchText: Current input textisMenuOpen: Menu visibility stateisFocused: Input focus state
Event Flow
Single Mode:
- User types text
- On Enter or menu close → creates custom item
- Emits
create,update:modelValue, andchangeevents - Text remains in input
Multiple Mode:
- User types text
- On Enter → creates custom chip
- Emits
create,update:modelValue, andchangeevents - Clears input for next entry
Usage
vue
<template>
<OComboBox
v-model="selectedValue"
:items="items"
:multiple="false"
placeholder="Select or type custom value..."
@create="handleCreate"
/>
</template>
<script setup>
import { ref } from "vue";
const selectedValue = ref(null);
const items = ref([
{ text: "Option 1", value: "opt1" },
{ text: "Option 2", value: "opt2" },
]);
const handleCreate = (item, value) => {
console.log("Custom item created:", item, value);
};
</script>Component Dependencies
OMenu.vue- Dropdown menu positioning and behaviorOChip.vue- Chip display for multiple selection mode- Vue 3 Composition API (
ref,computed,watch)
Props Reference
See API Documentation for complete props reference.
Related Components
- Autocomplete - For selection-only (no custom entries)
- Dropdown - For simple dropdowns without search
- Input - For basic text input