Skip to content

Last updated:

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=20

Returns:

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 + label keys — generic across both fk types (where value is an ID) and value types (where value is a code like phl).
  • 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

ChoiceWhy
debounce(..., 250)One request per pause, not per keystroke
setTimeout(() => open = false, 150) on blurGives 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 listBacking tables (doctors, medicines, occupations) are too large for a 1MB JSON payload
First-page-onlyUsers 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 typeaheadUse a plain <select>
> 50 options≤ 50 options
Backing table grows over timeStatic enum / fixed lookup
Users know what they're looking forUsers 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 modelValue when the user clears the input

CPR - Clinical Patient Records