Everything you wish the HTML select element could do, wrapped up into a lightweight, extensible Vue component.
—
Built-in support for asynchronous data loading with loading state management, search event handling, and server-side filtering integration.
Properties and methods for controlling loading indicators and asynchronous operations.
/**
* Show loading state indicator
* Toggles adding 'loading' class to main wrapper
* Useful for controlling UI state during AJAX operations
*/
loading: Boolean // default: false
// Data properties (from ajax mixin)
data: {
/**
* Internal mutable loading state
* Synced with loading prop but can be controlled independently
*/
mutableLoading: Boolean // default: false
}
// Methods (from ajax mixin)
methods: {
/**
* Toggle loading state programmatically
* Can be called without parameters to toggle current state
* @param toggle - Optional boolean to set specific state
* @returns Current loading state after toggle
*/
toggleLoading(toggle?: Boolean): Boolean
}Event system specifically designed for AJAX search implementation.
/**
* Emitted whenever search text changes
* Provides search text and loading toggle function for AJAX integration
* @param searchText - Current search input value
* @param toggleLoading - Function to control loading state
*/
'search': (searchText: String, toggleLoading: Function) => voidProperties that commonly work together with AJAX functionality.
/**
* When false, updating options will not reset selected value
* Useful when options are updated via AJAX without losing selection
*/
resetOnOptionsChange: Boolean | Function // default: false
/**
* Disable filtering when using server-side search
* Options should be pre-filtered by server response
*/
filterable: Boolean // default: true (set to false for server-side filtering)<template>
<v-select
v-model="selectedUser"
:options="searchResults"
:loading="isLoading"
@search="onSearch"
label="name"
placeholder="Search users..."
/>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
selectedUser: null,
searchResults: [],
isLoading: false
};
},
methods: {
async onSearch(search, toggleLoading) {
if (search.length < 2) {
this.searchResults = [];
return;
}
toggleLoading(true);
try {
const response = await axios.get('/api/users/search', {
params: { q: search }
});
this.searchResults = response.data;
} catch (error) {
console.error('Search failed:', error);
this.searchResults = [];
} finally {
toggleLoading(false);
}
}
}
};
</script><template>
<v-select
v-model="selectedProduct"
:options="products"
:loading="isSearching"
:filterable="false"
@search="debouncedSearch"
label="name"
placeholder="Search products..."
/>
</template>
<script>
import axios from 'axios';
import { debounce } from 'lodash';
export default {
data() {
return {
selectedProduct: null,
products: [],
isSearching: false
};
},
created() {
// Create debounced search function
this.debouncedSearch = debounce(this.performSearch, 300);
},
methods: {
async performSearch(searchText, toggleLoading) {
if (searchText.length < 2) {
this.products = [];
return;
}
this.isSearching = true;
try {
const response = await axios.get('/api/products', {
params: {
search: searchText,
limit: 20
}
});
this.products = response.data.products;
} catch (error) {
console.error('Product search failed:', error);
this.products = [];
} finally {
this.isSearching = false;
}
}
}
};
</script><template>
<v-select
v-model="selectedRepository"
:options="repositories"
:loading="isLoading"
@search="onSearch"
label="full_name"
placeholder="Search GitHub repositories..."
/>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
selectedRepository: null,
repositories: [],
isLoading: false,
searchCache: new Map(),
lastSearch: ''
};
},
methods: {
async onSearch(searchText, toggleLoading) {
if (searchText.length < 2) {
this.repositories = [];
return;
}
// Check cache first
if (this.searchCache.has(searchText)) {
this.repositories = this.searchCache.get(searchText);
return;
}
this.isLoading = true;
this.lastSearch = searchText;
try {
const response = await axios.get('https://api.github.com/search/repositories', {
params: {
q: searchText,
sort: 'stars',
order: 'desc',
per_page: 10
}
});
const results = response.data.items;
// Only update if this is still the latest search
if (searchText === this.lastSearch) {
this.repositories = results;
// Cache results
this.searchCache.set(searchText, results);
}
} catch (error) {
console.error('Repository search failed:', error);
if (searchText === this.lastSearch) {
this.repositories = [];
}
} finally {
if (searchText === this.lastSearch) {
this.isLoading = false;
}
}
}
}
};
</script><template>
<v-select
v-model="selectedItem"
:options="items"
:loading="isLoading"
:filterable="false"
@search="onSearch"
@open="loadInitialData"
label="title"
placeholder="Search with pagination..."
>
<template #list-footer v-if="hasMoreResults">
<div class="load-more">
<button @click="loadMore" :disabled="isLoadingMore">
{{ isLoadingMore ? 'Loading...' : 'Load More' }}
</button>
</div>
</template>
</v-select>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
selectedItem: null,
items: [],
isLoading: false,
isLoadingMore: false,
currentSearch: '',
currentPage: 1,
hasMoreResults: false
};
},
methods: {
async loadInitialData() {
if (this.items.length === 0) {
await this.performSearch('', 1);
}
},
async onSearch(searchText) {
this.currentSearch = searchText;
this.currentPage = 1;
await this.performSearch(searchText, 1, true);
},
async loadMore() {
this.currentPage++;
await this.performSearch(this.currentSearch, this.currentPage, false);
},
async performSearch(searchText, page, replace = true) {
const isFirstPage = page === 1;
if (replace) {
this.isLoading = true;
} else {
this.isLoadingMore = true;
}
try {
const response = await axios.get('/api/items', {
params: {
q: searchText,
page: page,
per_page: 20
}
});
const newItems = response.data.items;
if (replace) {
this.items = newItems;
} else {
this.items = [...this.items, ...newItems];
}
this.hasMoreResults = response.data.has_more;
} catch (error) {
console.error('Search failed:', error);
if (replace) {
this.items = [];
}
} finally {
this.isLoading = false;
this.isLoadingMore = false;
}
}
}
};
</script>
<style scoped>
.load-more {
padding: 10px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.load-more button {
padding: 5px 15px;
border: 1px solid #007bff;
background: #007bff;
color: white;
border-radius: 4px;
cursor: pointer;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style><template>
<div>
<v-select
v-model="selectedCity"
:options="cities"
:loading="isLoading"
@search="onSearch"
label="name"
placeholder="Search cities..."
>
<template #no-options>
<div class="no-results">
<div v-if="searchError" class="error-message">
<p>❌ Search failed: {{ searchError }}</p>
<button @click="retryLastSearch">Retry</button>
</div>
<div v-else-if="hasSearched && cities.length === 0">
No cities found for your search.
</div>
<div v-else>
Start typing to search cities...
</div>
</div>
</template>
</v-select>
<div v-if="searchError" class="error-banner">
Connection issues detected. Please check your internet connection.
</div>
</div>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
selectedCity: null,
cities: [],
isLoading: false,
searchError: null,
hasSearched: false,
lastSearchText: ''
};
},
methods: {
async onSearch(searchText, toggleLoading) {
if (searchText.length < 2) {
this.cities = [];
this.searchError = null;
this.hasSearched = false;
return;
}
this.lastSearchText = searchText;
this.searchError = null;
this.isLoading = true;
try {
const response = await axios.get('/api/cities', {
params: { q: searchText },
timeout: 5000 // 5 second timeout
});
this.cities = response.data;
this.hasSearched = true;
} catch (error) {
console.error('City search failed:', error);
if (error.code === 'ECONNABORTED') {
this.searchError = 'Request timed out';
} else if (error.response) {
this.searchError = `Server error: ${error.response.status}`;
} else if (error.request) {
this.searchError = 'Network error';
} else {
this.searchError = 'Unknown error occurred';
}
this.cities = [];
this.hasSearched = true;
} finally {
this.isLoading = false;
}
},
retryLastSearch() {
if (this.lastSearchText) {
this.onSearch(this.lastSearchText);
}
}
}
};
</script>
<style scoped>
.no-results {
padding: 20px;
text-align: center;
}
.error-message {
color: #dc3545;
}
.error-message button {
margin-top: 10px;
padding: 5px 10px;
border: 1px solid #dc3545;
background: white;
color: #dc3545;
border-radius: 4px;
cursor: pointer;
}
.error-banner {
margin-top: 10px;
padding: 10px;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
</style><template>
<v-select
v-model="selectedResult"
:options="allResults"
:loading="isLoading"
@search="onSearch"
label="title"
placeholder="Search across multiple sources..."
>
<template #option="result">
<div class="search-result">
<div class="result-title">{{ result.title }}</div>
<div class="result-source">{{ result.source }}</div>
<div class="result-description">{{ result.description }}</div>
</div>
</template>
</v-select>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
selectedResult: null,
allResults: [],
isLoading: false
};
},
methods: {
async onSearch(searchText, toggleLoading) {
if (searchText.length < 2) {
this.allResults = [];
return;
}
this.isLoading = true;
try {
// Search multiple sources in parallel
const [usersResponse, postsResponse, productsResponse] = await Promise.allSettled([
axios.get('/api/users/search', { params: { q: searchText } }),
axios.get('/api/posts/search', { params: { q: searchText } }),
axios.get('/api/products/search', { params: { q: searchText } })
]);
const results = [];
// Process users
if (usersResponse.status === 'fulfilled') {
results.push(...usersResponse.value.data.map(user => ({
id: `user-${user.id}`,
title: user.name,
description: user.email,
source: 'Users',
data: user
})));
}
// Process posts
if (postsResponse.status === 'fulfilled') {
results.push(...postsResponse.value.data.map(post => ({
id: `post-${post.id}`,
title: post.title,
description: post.excerpt,
source: 'Posts',
data: post
})));
}
// Process products
if (productsResponse.status === 'fulfilled') {
results.push(...productsResponse.value.data.map(product => ({
id: `product-${product.id}`,
title: product.name,
description: `$${product.price}`,
source: 'Products',
data: product
})));
}
this.allResults = results;
} catch (error) {
console.error('Multi-source search failed:', error);
this.allResults = [];
} finally {
this.isLoading = false;
}
}
}
};
</script>
<style scoped>
.search-result {
padding: 5px 0;
}
.result-title {
font-weight: bold;
color: #333;
}
.result-source {
font-size: 0.8em;
color: #007bff;
text-transform: uppercase;
}
.result-description {
font-size: 0.9em;
color: #666;
}
</style><template>
<v-select
v-model="selectedOption"
:options="displayOptions"
:loading="isLoading"
@search="onSearch"
@open="onOpen"
label="name"
placeholder="Progressive loading..."
/>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
selectedOption: null,
staticOptions: [
{ id: 1, name: 'Popular Option 1', type: 'static' },
{ id: 2, name: 'Popular Option 2', type: 'static' },
{ id: 3, name: 'Popular Option 3', type: 'static' }
],
dynamicOptions: [],
isLoading: false,
hasLoadedDynamic: false
};
},
computed: {
displayOptions() {
return [...this.staticOptions, ...this.dynamicOptions];
}
},
methods: {
async onOpen() {
// Load additional options when dropdown opens
if (!this.hasLoadedDynamic) {
await this.loadMoreOptions();
}
},
async onSearch(searchText) {
if (searchText.length < 2) {
// Reset to static + any previously loaded dynamic options
return;
}
await this.searchDynamicOptions(searchText);
},
async loadMoreOptions() {
this.isLoading = true;
try {
const response = await axios.get('/api/options/popular');
this.dynamicOptions = response.data.map(option => ({
...option,
type: 'dynamic'
}));
this.hasLoadedDynamic = true;
} catch (error) {
console.error('Failed to load additional options:', error);
} finally {
this.isLoading = false;
}
},
async searchDynamicOptions(searchText) {
this.isLoading = true;
try {
const response = await axios.get('/api/options/search', {
params: { q: searchText }
});
// Replace dynamic options with search results
this.dynamicOptions = response.data.map(option => ({
...option,
type: 'search'
}));
} catch (error) {
console.error('Search failed:', error);
// Keep existing dynamic options on search failure
} finally {
this.isLoading = false;
}
}
}
};
</script>Install with Tessl CLI
npx tessl i tessl/npm-vue-select