Skip to content

Instantly share code, notes, and snippets.

@mrhammadasif
Created April 16, 2025 01:43
Show Gist options
  • Save mrhammadasif/f03a20b2854c58a9b63712af62ad4e7e to your computer and use it in GitHub Desktop.
Save mrhammadasif/f03a20b2854c58a9b63712af62ad4e7e to your computer and use it in GitHub Desktop.
This is Select UI component for Vue 3 which uses Remote Options
<script setup>
import { onClickOutside } from '@vueuse/core' // Optional
import {
nextTick,
onMounted,
ref,
watch,
} from 'vue'
import { PagedRequest } from '~/shared/models/PagedRequest' // Assuming this path is correct in your project
const api = useApi() // Assuming useApi() composable is correctly set up
// --- Configuration ---
const PAGE_SIZE = 10
const DEBOUNCE_DELAY = 1000
// --- Props and Emits (Optional: Add if needed for parent communication) ---
// const props = defineProps({ /* ... */ });
// const emit = defineEmits(['update:selected']); // Example emit
// --- State ---
const searchTerm = ref('')
const options = ref([])
const cachedBrowseOptions = ref([])
const selectedOption = ref(null)
const isLoading = ref(false) // Loading state for API calls
const isLoadingMore = ref(false) // Specific loading state for "Load More" action
const isDropdownOpen = ref(false)
const currentPageIndex = ref(1) // Tracks the page *just fetched*
const hasMoreItems = ref(true)
const isSearching = ref(false)
const searchTimeout = ref(null)
const focusedIndex = ref(-1)
// --- State for total counts ---
const totalBrowseCount = ref(0)
const totalSearchCount = ref(0)
// --- Refs ---
const componentRoot = ref(null) // Outermost element for onClickOutside
const inputRef = ref(null)
const dropdownContainerRef = ref(null) // The main dropdown box
const itemsContainerRef = ref(null) // The scrollable area for items
// --- API Fetching Logic ---
async function fetchData(page = 1, filter = '', loadMoreAction = false) {
// Prevent fetching if already loading overall, or if loading more when none exist
// Note: isLoading check prevents multiple simultaneous *new* fetches,
// but doesn't stop an old fetch from completing after state changed.
if (isLoading.value && !loadMoreAction) {
console.log('Fetch prevented: isLoading is true and not a loadMoreAction')
return
}
if (isLoadingMore.value && loadMoreAction) {
console.log('Fetch prevented: isLoadingMore is true and is a loadMoreAction')
return
}
if (page > 1 && !hasMoreItems.value) {
console.log('Fetch prevented: Attempting to load more but no more items.')
return
}
// Set appropriate loading state
if (loadMoreAction) {
isLoadingMore.value = true
}
else {
isLoading.value = true
}
if (page === 1) { focusedIndex.value = -1 }
// Store the filter this request was initiated with
const requestFilter = filter.trim().toLowerCase()
// Construct request payload (adjust filter syntax if needed)
const req = new PagedRequest({
pageIndex: page,
pageSize: PAGE_SIZE,
// Ensure filter is applied only if filter string is not empty
filter: filter ? `organizationName.toLower().contains("${filter.toLowerCase()}")` : '',
orderBy: 'organizationName asc',
})
// Define API call function
const API_ENDPOINT = () => api.get('/me/tenants', { params: req.asQueryParams() })
try {
// Perform API call
const response = await API_ENDPOINT().then(resp => resp.data)
// ****** ADDED CHECK FOR RACE CONDITION ******
// Get the current search term *after* the await completes
const currentSearchTerm = searchTerm.value.trim().toLowerCase()
// If the search term that initiated this request is different from the current one,
// ignore the results of this request as they are stale.
if (requestFilter !== currentSearchTerm) {
console.log(`Ignoring stale fetch results for filter: "${requestFilter}" (current term: "${currentSearchTerm}")`)
// Still need to reset loading state in finally block, so just return here.
return
}
// ****** END CHECK ******
const responseData = response
// Validate response structure (optional but recommended)
if (typeof responseData !== 'object' || !responseData || !Array.isArray(responseData.data) || typeof responseData.count !== 'number') {
console.error('Invalid API response structure:', responseData)
// Don't throw error here, handle gracefully
if (page === 1) { options.value = [] }
hasMoreItems.value = false
if (!filter) {
totalBrowseCount.value = options.value.length
}
else { totalSearchCount.value = options.value.length }
return // Exit after handling invalid structure
}
const items = responseData.data
const totalCount = responseData.count
// --- Data Update Logic ---
// This part now only runs if the check above passes (i.e., results are not stale)
if (filter) { // Search results
if (page === 1) {
options.value = items
}
else {
options.value = [...options.value, ...items]
}
totalSearchCount.value = totalCount
isSearching.value = true // Ensure search flag is set
}
else { // Browse/Load More results
if (page === 1) {
options.value = items
}
else {
options.value = [...options.value, ...items]
}
// Cache cumulatively only when browsing
cachedBrowseOptions.value = [...options.value]
totalBrowseCount.value = totalCount
isSearching.value = false // Ensure search flag is cleared
}
// Update hasMoreItems status
hasMoreItems.value = options.value.length < totalCount
// Update current page index
currentPageIndex.value = page
}
catch (error) {
// Check if the error is due to the request being ignored (it shouldn't be, the return happens before error)
// Log other errors
console.error('Failed to fetch data:', error)
// Reset state appropriately on error
if (page === 1) { options.value = [] } // Clear only if first page fails
hasMoreItems.value = false
// Update counts based on current options length after error
if (!filter) {
totalBrowseCount.value = options.value.length
}
else { totalSearchCount.value = options.value.length }
}
finally {
// Reset loading states regardless of success, failure, or ignored results
isLoading.value = false
isLoadingMore.value = false
}
}
// --- Event Handlers ---
function handleSearchInput() {
// Clear selection if search term changes after selection
if (selectedOption.value && searchTerm.value !== selectedOption.value.organizationName) {
selectedOption.value = null
// Optionally emit null or empty value to parent
// emit('update:selected', null);
}
const currentSearchTerm = searchTerm.value.trim()
if (currentSearchTerm) { // Entering Search Mode
if (!isSearching.value) {
// Cache browse list only if switching from browse to search
if (options.value.length > 0 && cachedBrowseOptions.value.length === 0) {
cachedBrowseOptions.value = [...options.value]
}
else if (options.value.length > 0 && !isSearching.value) {
cachedBrowseOptions.value = [...options.value]
}
// Ensure totalBrowseCount reflects the state before search
if (totalBrowseCount.value === 0 && cachedBrowseOptions.value.length > 0) {
totalBrowseCount.value = cachedBrowseOptions.value.length // Use cache length as fallback
}
else if (totalBrowseCount.value === 0 && options.value.length > 0 && !isSearching.value) {
// If cache wasn't set but options exist from browse
totalBrowseCount.value = options.value.length
}
}
hasMoreItems.value = true // Assume search might have results
fetchData(1, currentSearchTerm) // Fetch page 1 of search
openDropdown()
}
else { // Input Cleared: Restore Browse List
if (isSearching.value || options.value.length !== cachedBrowseOptions.value.length) {
// If we were searching OR the current options don't match the cache (e.g., stale results updated)
options.value = [...cachedBrowseOptions.value]
isSearching.value = false
totalSearchCount.value = 0 // Clear search count
if (cachedBrowseOptions.value.length > 0) {
currentPageIndex.value = Math.ceil(cachedBrowseOptions.value.length / PAGE_SIZE)
// Use the stored totalBrowseCount if available and valid, otherwise estimate
hasMoreItems.value = totalBrowseCount.value > 0
? cachedBrowseOptions.value.length < totalBrowseCount.value
: (cachedBrowseOptions.value.length % PAGE_SIZE === 0) // Estimate if count is unknown
openDropdown()
}
else { // Cache was empty, fetch initial browse data
currentPageIndex.value = 1
totalBrowseCount.value = 0
hasMoreItems.value = true
fetchData(1) // Fetch page 1 of browse list
}
}
else if (options.value.length === 0 && !isLoading.value && !isDropdownOpen.value) {
// If input cleared, options are empty, not loading, and dropdown is closed,
// likely initial state or after selection. Open and fetch if needed.
openDropdown() // This will trigger fetch if options are empty
}
else if (options.value.length === 0 && !isLoading.value && isDropdownOpen.value) {
// Dropdown is open, input cleared, options empty. Fetch browse data.
currentPageIndex.value = 1
totalBrowseCount.value = 0
hasMoreItems.value = true
fetchData(1)
}
}
}
function handleDebouncedSearchInput() {
clearTimeout(searchTimeout.value)
searchTimeout.value = setTimeout(handleSearchInput, DEBOUNCE_DELAY)
}
function loadMore() { // Load more for BROWSE list
if (!isLoading.value && !isLoadingMore.value && hasMoreItems.value && !isSearching.value) {
fetchData(currentPageIndex.value + 1, '', true) // Pass true for loadMoreAction
}
}
function loadMoreSearchResults() { // Load more for SEARCH list
if (!isLoading.value && !isLoadingMore.value && hasMoreItems.value && isSearching.value) {
fetchData(currentPageIndex.value + 1, searchTerm.value.trim(), true) // Pass true for loadMoreAction
}
}
function selectOption(option) {
selectedOption.value = option
searchTerm.value = option.organizationName // Display selected name
isDropdownOpen.value = false
focusedIndex.value = -1
// Optionally emit selection to parent
// emit('update:selected', option);
// Restore browse state internally if selection happened during search
if (isSearching.value) {
// Keep the selected term in the input, but reset internal state to browse
isSearching.value = false
totalSearchCount.value = 0
// Restore options from cache to reflect browse state if dropdown reopens
options.value = [...cachedBrowseOptions.value]
currentPageIndex.value = Math.ceil(cachedBrowseOptions.value.length / PAGE_SIZE)
hasMoreItems.value = totalBrowseCount.value > 0
? cachedBrowseOptions.value.length < totalBrowseCount.value
: (cachedBrowseOptions.value.length % PAGE_SIZE === 0) // Estimate if count is unknown
}
}
function openDropdown() {
if (!isDropdownOpen.value) {
isDropdownOpen.value = true
// Fetch initial data if list is empty and not loading/searching
if (options.value.length === 0 && !isLoading.value && !isSearching.value && !searchTerm.value) {
currentPageIndex.value = 1
hasMoreItems.value = true
totalBrowseCount.value = 0
fetchData(1)
}
}
}
function closeDropdown() {
if (isDropdownOpen.value) {
// Don't clear search term on blur if nothing selected
focusedIndex.value = -1
isDropdownOpen.value = false
}
}
// --- Keyboard Navigation ---
function focusNext() {
if (options.value.length > 0) {
focusedIndex.value = (focusedIndex.value + 1) % options.value.length
scrollToFocused()
}
}
function focusPrevious() {
if (options.value.length > 0) {
focusedIndex.value = (focusedIndex.value - 1 + options.value.length) % options.value.length
scrollToFocused()
}
}
function selectFocused() {
if (focusedIndex.value >= 0 && focusedIndex.value < options.value.length) {
selectOption(options.value[focusedIndex.value])
}
}
// Updated scroll logic to target the inner container
function scrollToFocused() {
nextTick(() => {
// Target the inner scrollable container
if (itemsContainerRef.value && focusedIndex.value !== -1) {
// Find the specific item element within the container
const itemElement = itemsContainerRef.value.querySelector(`#option-${focusedIndex.value}`)
if (itemElement) {
// Use scrollIntoView on the item within its scrollable parent
itemElement.scrollIntoView({ block: 'nearest' })
}
}
})
}
// --- Lifecycle Hooks ---
onMounted(() => {
// Initial fetch moved to openDropdown
})
// Optional: Close dropdown when clicking outside the component root
onClickOutside(componentRoot, closeDropdown, { ignore: [inputRef] })
</script>
<template>
<div
ref="componentRoot"
class="autocomplete-select">
<label
for="autocomplete-input"
class="sr-only">Search Organizations</label> <div class="input-wrapper">
<input
id="autocomplete-input"
ref="inputRef"
v-model="searchTerm"
type="text"
placeholder="Search or select an organization..."
autocomplete="off"
aria-autocomplete="list"
:aria-expanded="isDropdownOpen"
aria-controls="autocomplete-listbox"
@input="handleDebouncedSearchInput"
@focus="openDropdown"
@keydown.down.prevent="focusNext"
@keydown.up.prevent="focusPrevious"
@keydown.enter.prevent="selectFocused">
<span
v-if="isLoading && !isLoadingMore"
class="spinner input-spinner"
aria-label="Loading..." />
</div>
<div
v-if="isDropdownOpen"
id="autocomplete-listbox"
ref="dropdownContainerRef"
class="dropdown-box"
role="listbox"
aria-label="Organization options">
<div
ref="itemsContainerRef"
class="items-scroll-area">
<ul
class="items-list"
role="presentation">
<li
v-if="isLoading && !isLoadingMore && !options.length"
class="status-item"
role="presentation">
<span
class="spinner small"
aria-hidden="true" /> Loading initial items...
</li>
<li
v-if="!isLoading && !options.length && searchTerm"
class="status-item"
role="option">
No results found for "{{ searchTerm }}"
</li>
<li
v-if="!isLoading && !options.length && !searchTerm && !hasMoreItems"
class="status-item"
role="option">
No organizations available.
</li>
<li
v-for="(option, index) in options"
:id="`option-${index}`"
:key="option.id"
:class="{ focused: index === focusedIndex }"
class="option-item"
role="option"
:aria-selected="index === focusedIndex"
@click="selectOption(option)"
@mouseenter="focusedIndex = index">
{{ option.organizationName }}
</li>
</ul>
</div>
<div class="dropdown-footer">
<div
v-if="isLoadingMore"
class="footer-status loading-more"
role="status">
<span
class="spinner small"
aria-hidden="true" /> Loading more...
</div>
<button
v-if="!isLoading && !isLoadingMore && hasMoreItems && !isSearching && options.length > 0"
:disabled="isLoadingMore"
class="load-more-button"
@click.stop="loadMore">
Load More ({{ options.length }} / {{ totalBrowseCount }})
</button>
<button
v-if="!isLoading && !isLoadingMore && hasMoreItems && isSearching && options.length > 0"
:disabled="isLoadingMore"
class="load-more-button"
@click.stop="loadMoreSearchResults">
Load More Results ({{ options.length }} / {{ totalSearchCount }})
</button>
<div
v-if="!isLoading && !isLoadingMore && !hasMoreItems && options.length > 0"
class="footer-status all-loaded"
role="status">
All organizations loaded.
</div>
</div>
</div>
<div
v-if="selectedOption"
class="selected-item-display">
Selected: {{ selectedOption.organizationName }}
</div>
</div>
</template>
<style scoped>
.autocomplete-select {
position: relative;
width: 300px; /* Adjust width as needed */
font-family: sans-serif;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
#autocomplete-input {
width: 100%;
padding: 8px 12px;
padding-right: 30px; /* Space for spinner */
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
font-size: 1rem;
}
#autocomplete-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Styles for the main dropdown container */
.dropdown-box {
position: absolute;
top: 100%;
left: 0;
right: 0;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
background-color: white;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
/* Remove fixed height and overflow from here */
display: flex; /* Use flexbox for layout */
flex-direction: column; /* Stack items-area and footer */
}
/* Styles for the scrollable area */
.items-scroll-area {
max-height: 200px; /* Or your desired scrollable height */
overflow-y: auto;
/* Add some padding if needed, or rely on item padding */
padding: 4px 0; /* Example padding */
}
.items-list {
list-style: none;
padding: 0;
margin: 0;
}
/* Styles for individual list items */
.option-item, .status-item {
padding: 8px 12px;
cursor: default;
}
.option-item {
cursor: pointer;
}
.option-item:hover,
.option-item.focused {
background-color: #f0f0f0;
}
/* Status messages inside scroll area */
.status-item {
color: #666;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
/* Styles for the footer area */
.dropdown-footer {
padding: 5px 0; /* Padding around the footer content */
border-top: 1px solid #eee; /* Separator line */
background-color: #f8f9fa; /* Slight background tint for footer */
text-align: center; /* Center button/text */
flex-shrink: 0; /* Prevent footer from shrinking */
}
.footer-status {
padding: 8px 12px;
font-size: 0.9rem;
color: #666;
display: flex;
align-items: center;
justify-content: center;
}
.load-more-button {
background-color: #007bff;
color: white;
border: none;
padding: 8px 15px; /* Slightly smaller padding */
cursor: pointer;
width: calc(100% - 20px); /* Adjust width if needed */
margin: 5px 10px; /* Add margin */
text-align: center;
font-size: 0.9rem;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.load-more-button:hover:not(:disabled) {
background-color: #0056b3;
}
.load-more-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.selected-item-display {
margin-top: 10px;
font-size: 0.9rem;
color: #333;
padding: 5px;
background-color: #e9ecef;
border-radius: 4px;
}
.sr-only { /* Screen reader only */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Spinner Styles */
.spinner {
border: 3px solid rgba(0, 0, 0, 0.1);
border-left-color: #007bff;
border-radius: 50%;
width: 16px;
height: 16px;
animation: spin 1s linear infinite;
box-sizing: border-box;
}
.input-spinner { /* Spinner positioned inside input */
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.spinner.small { /* Spinner used in list/footer */
display: inline-block; /* Changed to inline-block */
width: 14px;
height: 14px;
border-width: 2px;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment