Query — server search
The server-search Query demo passes an application-owned async source to the
shared search controller. The demo’s MSW endpoint simulates debounce,
AbortSignal cancellation, stale-response protection, loading/error/empty
phases, and cursor pagination.
Source for this preview
---import {FAKE_SERVER_DESTINATION_TOTAL} from '../../fake-server-destinations';
---
<article class="demo-card combobox-card"> <section id="async-combobox-demo" class="combobox-panel combobox-server-panel"> <h3>Server-side search</h3> <p> MSW simulates a cursor API over {FAKE_SERVER_DESTINATION_TOTAL} distinct countries with debounce, cancellation, and page loading. </p>
<div class="combobox-shell"> <label class="combobox-label" for="remote-destination-search"> Remote destination </label> <floating-root id="async-combobox-root" placement="bottom-start"> <floating-list navigation loop allow-escape> <floating-query id="async-combobox-query"> <floating-reference> <input id="remote-destination-search" class="combobox-input" type="text" autocomplete="off" spellcheck="false" aria-describedby="remote-combobox-status" placeholder="Try korea, japan, china…" /> <span class="combobox-loading-indicator" aria-hidden="true"></span> </floating-reference> <template slot="content"> <floating-results class="combobox-popup async-combobox-popup" aria-label="Remote destination suggestions" > <floating-results-status type="loading"> <div class="combobox-empty" role="option" aria-disabled="true" > Querying remote endpoint… </div> </floating-results-status> <floating-results-status type="error"> <div class="combobox-empty" role="option" aria-disabled="true" > Remote search failed. </div> </floating-results-status> <floating-results-status type="empty"> <div class="combobox-empty" role="option" aria-disabled="true" > The server found no match for “<span data-search-text="$query" ></span>” </div> </floating-results-status> <floating-results-item> <floating-list-item> <div class="combobox-option"> <span> <strong data-search-text="label"></strong> <small data-search-text="region"></small> </span> <span class="language-badge">API</span> </div> </floating-list-item> </floating-results-item> <floating-results-more> <div class="combobox-pagination" role="status"> <span class="combobox-pagination-progress"> <strong data-search-text="$count"></strong> of <span data-search-text="$total"></span> loaded </span> <button class="combobox-load-more" type="button" data-search-load-more > <span class="combobox-load-more-label">Show next 8</span> <span class="combobox-load-more-pending">Loading…</span> </button> </div> </floating-results-more> </floating-results> </template> <p id="remote-combobox-status" class="sr-only" aria-live="polite" > Remote destination suggestions closed </p> </floating-query> </floating-list> </floating-root> <span class="combobox-icon" aria-hidden="true">⌕</span> </div>
<div class="combobox-server-meta" aria-label="Server search behavior"> <code>MSW Service Worker</code> <span>{FAKE_SERVER_DESTINATION_TOTAL} countries</span> <span>8 / page</span> <span>AbortSignal</span> <span>cursor API</span> </div>
<code>application source + <floating-query></code></section></article>
<script> import { FloatingRootElement, dismiss, flip, offset, size, shift, type FloatingQueryElement, } from '@floating-ui-plus/web-components'; import type {MultilingualDestination} from '../../multilingual-destinations'; import {FAKE_SERVER_DESTINATION_PAGE_SIZE} from '../../fake-server-destinations'; import {searchDestinationsOnServer} from '../../server-destination-search'; import {initializeExample} from './initialize-example';
const SERVER_QUERY_MAX_HEIGHT = 24 * 16;
initializeExample('async-combobox', (scope) => { const floating = FloatingRootElement.query( scope, '#async-combobox-root', ); const query = scope.querySelector<FloatingQueryElement>( '#async-combobox-query', ); if (!query) return;
query.configure<MultilingualDestination>({ search: { source: searchDestinationsOnServer, getItemKey: (item) => item.id, debounceMs: 180, cacheTtlMs: 5_000, limit: FAKE_SERVER_DESTINATION_PAGE_SIZE, }, getItemLabel: (item) => item.label, status: { closed: 'Remote destination suggestions closed', idle: 'Remote destination search is idle', loading: 'Querying remote destinations', error: 'Remote destination search failed', empty: ({search}) => `No remote matches for ${search.query}`, results: ({search}) => `${search.items.length} remote destinations available`, }, }); const activate = (event: Event) => { const item = ( event as CustomEvent<{item: MultilingualDestination}> ).detail.item; const input = scope.querySelector<HTMLInputElement>( '#remote-destination-search', ); if (input) input.value = item.label; }; query.addEventListener('queryactivate', activate); floating.configure({ middleware: [ offset(8), flip({padding: 18}), shift({padding: 18}), size({ padding: 18, rootBoundary: 'viewport', apply({availableHeight, elements}) { const maxHeight = `${Math.max( 0, Math.min(availableHeight, SERVER_QUERY_MAX_HEIGHT), )}px`; elements.floating.style.setProperty( '--async-combobox-popup-max-height', maxHeight, ); elements.floating.style.maxHeight = maxHeight; }, }), ], plugins: [dismiss()], });
return () => query.removeEventListener('queryactivate', activate); });</script><script setup lang="ts">import { FloatingContent, FloatingList, FloatingListItem, FloatingReference, FloatingRoot, FloatingResults, autoUpdate, dismiss, flip, offset, size, shift, useQuery, useSearch,} from '@floating-ui-plus/vue';import {shallowRef, watch} from 'vue';import type {MultilingualDestination} from '../../multilingual-destinations';import { FAKE_SERVER_DESTINATION_PAGE_SIZE, FAKE_SERVER_DESTINATION_TOTAL,} from '../../fake-server-destinations';import {searchDestinationsOnServer} from '../../server-destination-search';
const SERVER_QUERY_MAX_HEIGHT = 24 * 16;let floatingElement: HTMLElement | null = null;
const search = useSearch<MultilingualDestination>({ source: searchDestinationsOnServer, getItemKey: (item) => item.id, debounceMs: 180, cacheTtlMs: 5_000, limit: FAKE_SERVER_DESTINATION_PAGE_SIZE,});const selectedDestination = shallowRef<MultilingualDestination | null>(null);const { open, activeIndex, statusText, loading, inputProps, rolePlugin, getOptionProps, getNavigationOptions,} = useQuery({ search, getItemLabel: (item) => item.label, onActivate(item) { selectedDestination.value = item; // QueryController leaves result presentation to the application. Preserve // the selected label in this destination field without reopening it. search.controller.setQuery(item.label); }, status: { closed: () => selectedDestination.value ? `${selectedDestination.value.label} selected from the server` : 'Remote destination suggestions closed', idle: 'Remote destination search is idle', loading: 'Querying remote destinations', error: 'Remote destination search failed', empty: ({search: state}) => `No remote matches for ${state.query}`, results: ({search: state}) => `${state.items.length} remote destinations available`, },});// Vue keeps FloatingContent mounted while closed. Clear a stale viewport cap// so the next open can let flip measure the surface before size constrains it.watch( open, (isOpen) => { if (isOpen || !floatingElement) return; floatingElement.style.removeProperty('max-height'); floatingElement.style.removeProperty( '--vue-async-combobox-popup-max-height', ); }, {flush: 'sync'},);const options = { placement: 'bottom-start', middleware: [ offset(8), flip({padding: 18}), shift({padding: 18}), size({ padding: 18, rootBoundary: 'viewport', apply({availableHeight, elements}) { floatingElement = elements.floating; // autoUpdate can still run while the retained surface is closed. if (!open.value) { elements.floating.style.removeProperty('max-height'); elements.floating.style.removeProperty( '--vue-async-combobox-popup-max-height', ); return; } const maxHeight = `${Math.max( 0, Math.min(availableHeight, SERVER_QUERY_MAX_HEIGHT), )}px`; elements.floating.style.setProperty( '--vue-async-combobox-popup-max-height', maxHeight, ); elements.floating.style.maxHeight = maxHeight; }, }), ], whileElementsMounted: autoUpdate,} as const;const plugins = [dismiss(), rolePlugin];const navigationOptions = getNavigationOptions({allowEscape: true});const loadMore = () => void search.controller.loadMore();</script>
<template> <article class="vue-demo-card vue-combobox-card"> <div class="vue-card-top"> <span class="vue-number">F</span> <span>search sources</span> </div> <section class="vue-combobox-panel vue-server-combobox-panel"> <h3>Server-side search</h3> <p> MSW simulates a cursor API over {{ FAKE_SERVER_DESTINATION_TOTAL }} distinct countries with debounce, cancellation, and page loading. </p>
<FloatingRoot v-model:open="open" :options="options" :plugins="plugins"> <FloatingList v-model:active-index="activeIndex" navigation loop :navigation-options="navigationOptions" > <div class="vue-combobox-shell"> <label class="vue-combobox-label" for="vue-remote-search"> Remote destination </label> <span class="vue-combobox-icon" aria-hidden="true">⌕</span> <FloatingReference id="vue-remote-search" as="input" type="text" autocomplete="off" spellcheck="false" placeholder="Try korea, japan, china…" aria-describedby="vue-remote-query-status" class="vue-combobox-input" v-bind="inputProps" /> <span v-if="loading" class="vue-combobox-loading-indicator" aria-hidden="true" ></span> </div>
<FloatingContent class="vue-combobox-popup vue-async-combobox-popup"> <div class="vue-async-combobox-scroll"> <FloatingResults :search="search"> <template #loading> <div class="vue-combobox-empty" role="option" aria-disabled="true"> Querying remote endpoint… </div> </template> <template #error> <div class="vue-combobox-empty" role="option" aria-disabled="true"> Remote search failed. </div> </template> <template #results> <FloatingListItem v-for="(item, index) in search.items.value" :key="item.id" tag="div" :label="item.label" :value="item" v-bind="getOptionProps(item, index)" class="vue-combobox-option" > <span> <strong>{{ item.label }}</strong> <small>{{ item.region }}</small> </span> <span class="vue-language-badge">API</span> </FloatingListItem> <div v-if="search.state.value.hasMore" class="vue-combobox-pagination" role="status" > <span class="vue-combobox-pagination-progress"> <strong>{{ search.items.value.length }}</strong> of {{ search.state.value.total }} loaded </span> <button class="vue-combobox-load-more" type="button" :disabled="loading" @mousedown.prevent @click="loadMore" > <span v-if="!loading">Show next 8</span> <span v-else>Loading…</span> </button> </div> </template> <template #empty> <div class="vue-combobox-empty" role="option" aria-disabled="true"> The server found no match for “{{ search.query.value }}” </div> </template> </FloatingResults> </div> </FloatingContent> </FloatingList> </FloatingRoot>
<div class="vue-combobox-server-meta" aria-label="Server search behavior"> <code>MSW Service Worker</code> <span>{{ FAKE_SERVER_DESTINATION_TOTAL }} countries</span> <span>8 / page</span> <span>AbortSignal</span> <span>cursor API</span> </div>
<p id="vue-remote-query-status" class="sr-only" aria-live="polite"> {{ statusText }} </p>
<code>application source + useQuery()</code> </section> </article></template>What this demo shows
Section titled “What this demo shows”- An application-owned request source with query and cursor inputs.
- Debounced requests with cancellation when the query changes.
- Stale-response protection and explicit
idle,loading,error,empty, andresultsphases. - Cursor-based loading of the next page.
- A
size()middleware constraint that keeps the popup inside the available viewport and the result list scrollable.
The package does not choose a transport or cache library. The same composition
works with fetch, TanStack Query, or another request layer.
Search configuration
Section titled “Search configuration”query.configure({ search: { source: ({query, signal, cursor}) => fetchDestinationPage({query, signal, cursor}), getItemKey: (item) => item.id, debounceMs: 200, }, getItemLabel: (item) => item.label,});The Web Components and Vue previews show only the selected server component, so the source tab below each preview maps directly to the running card. See the fuzzy-search demo for local matching.