How-To: Consume a Paginated Typeahead
The data-mappings admin tool uses a typeahead component to look up canonical targets (doctors, medicines, nationality codes, etc.) without loading the whole list. This recipe shows how to build or consume a typeahead backed by a paginated search endpoint.
Reference: MappingTargetSelect.vue and the legacy-import.mappings.target-options route in cpr-backend.
Backend endpoint shape
http
GET /admin/legacy-import/mappings/{type}/options?q=alic&per_page=20Returns:
json
{
"data": [
{ "value": "1", "label": "Dr. Alice Cruz" },
{ "value": "2", "label": "Dr. Alicia Reyes" }
],
"meta": { "current_page": 1, "last_page": 4, "total": 68 }
}Two important conventions:
- Use
value+labelkeys — generic across bothfktypes (where value is an ID) andvaluetypes (where value is a code likephl). - The endpoint is paginated but the typeahead only shows the first page — it's a search, not infinite scroll.
Typeahead component (Inertia / Vue 3)
vue
<!-- resources/js/components/MappingTargetSelect.vue -->
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { debounce } from 'lodash-es'
import axios from 'axios'
import { route } from 'ziggy-js'
const props = defineProps<{
type: string
modelValue: string | number | null
initialLabel?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [string | number | null] }>()
const query = ref(props.initialLabel ?? '')
const options = ref<Array<{ value: string; label: string }>>([])
const open = ref(false)
const loading = ref(false)
const fetchOptions = debounce(async (q: string) => {
loading.value = true
try {
const { data } = await axios.get(
route('admin.legacy-import.mappings.target-options', { type: props.type }),
{ params: { q, per_page: 20 } },
)
options.value = data.data
} finally {
loading.value = false
}
}, 250)
watch(query, (q) => {
if (q.length >= 1) fetchOptions(q)
else options.value = []
})
function select(option: { value: string; label: string }) {
emit('update:modelValue', option.value)
query.value = option.label
open.value = false
}
</script>
<template>
<div class="relative">
<UiInput
v-model="query"
@focus="open = true"
@blur="setTimeout(() => open = false, 150)"
placeholder="Search…"
/>
<ul v-if="open && options.length" class="absolute z-10 mt-1 w-full bg-white border rounded shadow">
<li
v-for="option in options"
:key="option.value"
class="px-3 py-2 hover:bg-gray-100 cursor-pointer"
@mousedown.prevent="select(option)"
>
{{ option.label }}
</li>
</ul>
<p v-if="loading" class="text-xs text-gray-500 mt-1">Searching…</p>
</div>
</template>Why these specific choices
| Choice | Why |
|---|---|
debounce(..., 250) | One request per pause, not per keystroke |
setTimeout(() => open = false, 150) on blur | Gives the click event time to fire before the dropdown unmounts |
@mousedown.prevent on <li> | Stops the input from losing focus before the click handler runs |
| Search the server, not a local list | Backing tables (doctors, medicines, occupations) are too large for a 1MB JSON payload |
| First-page-only | Users will refine q rather than scrolling 4 pages |
Nuxt version
Same logic, but use $fetch and the runtime config:
ts
const { data } = await $fetch<TypeaheadResponse>(`/admin/legacy-import/mappings/${type}/options`, {
query: { q, per_page: 20 },
headers: { 'X-Branch-Id': branchStore.currentBranchId },
})When to fall back to a full list
| Use a typeahead | Use a plain <select> |
|---|---|
| > 50 options | ≤ 50 options |
| Backing table grows over time | Static enum / fixed lookup |
| Users know what they're looking for | Users need to browse |
Examples in CPR:
- Typeahead: doctors, procedures, medicines, bill items, nationality, occupation
- Plain select: civil status, gender, eye (
od|os|ou), permission roles
Checklist
- [ ] Endpoint returns
{ data: [{ value, label }], meta: { ... } } - [ ] Debounce input by ~250ms
- [ ] Handle blur with a delayed close so clicks register
- [ ] Show a loading hint while in-flight
- [ ] Don't fetch on focus — wait for the user to type
- [ ] If the field is required, clear
modelValuewhen the user clears the input
