Created
April 16, 2025 01:43
-
-
Save mrhammadasif/f03a20b2854c58a9b63712af62ad4e7e to your computer and use it in GitHub Desktop.
This is Select UI component for Vue 3 which uses Remote Options
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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