AbpTypeahead
Use AbpTypeahead for an author, user or other lookup whose choices are searched on demand.
Value and display text
<template>
<AbpFormField v-slot="{ id }" label="Author" hint="Type at least two characters, such as Au">
<AbpTypeahead
:id="id"
v-model="authorId"
v-model:display-value="authorName"
:search="search"
:min-length="2"
:debounce="300"
clearable
>
<template #item="{ item }"
><strong>{{ item.label }}</strong></template
>
<template #empty>No matching author</template>
</AbpTypeahead>
</AbpFormField>
<output>Value sent to the API: {{ authorId ?? 'none' }}; display: {{ authorName }}</output>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { AbpFormField, AbpTypeahead, type AbpTypeaheadItem } from '@lsw-abpvue/theme-shared';
const authorId = ref<string | null>('austen');
const authorName = ref('Jane Austen');
const authors: AbpTypeaheadItem[] = [
{ value: 'austen', label: 'Jane Austen' },
{ value: 'orwell', label: 'George Orwell' },
];
async function search(term: string, signal: AbortSignal): Promise<readonly AbpTypeaheadItem[]> {
signal.throwIfAborted();
// For a backend lookup, forward signal to RestService's request configuration.
return authors.filter(item => item.label.toLowerCase().includes(term.toLowerCase()));
}
</script>The model stores the author id while displayValue stores the name. An edit form can provide both immediately; it does not need a lookup request just to show a name already returned by the record API.
Connect the Identity users API
Create src/pages/UserAssignmentPage.vue from this example. Keep Core, OAuth, the router and Basic Theme providers, and install Identity so its /proxy entry is available. The backend must expose the Identity users API and grant the signed-in caller AbpIdentity.Users. No custom author endpoint is assumed.
<template>
<AbpPage title="AbpIdentity::Users">
<AbpFormField v-slot="{ id, describedBy }" label="Assigned user" hint="Search by username">
<AbpTypeahead
:id="id"
v-model="userId"
v-model:display-value="userName"
:search="search"
:disabled="loadingRecord"
:aria-describedby="describedBy"
:min-length="2"
clearable
>
<template #item="{ item }"
><strong>{{ item.label }}</strong></template
>
<template #empty>{{
searchFailed ? 'Search failed. Edit the query to retry.' : 'No matching user'
}}</template>
</AbpTypeahead>
</AbpFormField>
<p v-if="recordFailed" role="alert">
The selected user could not be loaded.
<AbpButton variant="secondary" size="sm" @click="loadSelectedUser">Retry</AbpButton>
</p>
<output>Assigned user id: {{ userId ?? 'none' }}</output>
</AbpPage>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { inject, useCurrentUser } from '@lsw-abpvue/core';
import { AbpPage } from '@lsw-abpvue/components';
import { IdentityUserService } from '@lsw-abpvue/identity/proxy';
import {
AbpButton,
AbpFormField,
AbpTypeahead,
type AbpTypeaheadItem,
} from '@lsw-abpvue/theme-shared';
const users = inject(IdentityUserService);
const initialUserId = useCurrentUser().user.value.id;
const userId = ref<string | null>(initialUserId ?? null);
const userName = ref('');
const loadingRecord = ref(false);
const recordFailed = ref(false);
const searchFailed = ref(false);
const recordRequest = new AbortController();
onBeforeUnmount(() => recordRequest.abort());
onMounted(loadSelectedUser);
async function loadSelectedUser(): Promise<void> {
if (!initialUserId || loadingRecord.value) return;
loadingRecord.value = true;
recordFailed.value = false;
try {
const user = await users.get(initialUserId, { signal: recordRequest.signal });
userId.value = user.id ?? null;
userName.value = user.userName ?? '';
} catch {
if (!recordRequest.signal.aborted) recordFailed.value = true;
} finally {
loadingRecord.value = false;
}
}
async function search(term: string, signal: AbortSignal): Promise<readonly AbpTypeaheadItem[]> {
searchFailed.value = false;
try {
const result = await users.getList(
{ filter: term, sorting: 'userName', skipCount: 0, maxResultCount: 10 },
{ signal },
);
return result.items.flatMap(user =>
user.id ? [{ value: user.id, label: user.userName ?? user.id }] : [],
);
} catch {
// The control expects a resolved list; RestService has already reported a real failure.
if (!signal.aborted) searchFailed.value = true;
return [];
}
}
</script>Register /user-assignment in the existing route array, using the component import and requiresAuthentication: true metadata. The example preselects the current user to demonstrate editing: in a business editor, replace initialUserId with the id returned by its detail DTO. If that DTO already contains a label, set both models directly and skip the lookup request.
The search callback sends filter, skipCount and maxResultCount through IdentityUserService. It forwards the control's signal and maps the backend's IdentityUserDto to options. Only the id belongs in a save DTO; displayValue is presentation text.
A failed lookup shows a retry state. A failed search is distinguished in the empty slot from a successful empty result. The callback returns a resolved list after RestService reports failure because the control expects that contract; aborted work is ignored. It does not turn a failure into a successful assignment.
Request lifecycle
minLength defaults to 1 and debounce to 300 ms. Shorter input does not search; another term or disposal cancels obsolete work. A request must respect the signal to release network work promptly. A search failure is not the same as an empty successful result; let the request/error layer report it.
Custom results
The item slot receives item and active. The empty slot explains a successful search with no matches. Keep options keyboard-selectable and avoid nested links or buttons. The separate select event carries the item, or null when cleared, if the page needs to fill related state.
Clearing a required lookup should be caught by form validation. Do not store the display label as the backend id. See request lifecycle and forms.
Props, events and slots
Types come from the public contract and defaults from the current implementation. A dash means no explicit default; optional boolean props are normally false when omitted.
Props
| Name | Type | Required | Default |
|---|---|---|---|
modelValue | AbpOptionValue | undefined | No | — |
displayValue | string | undefined | No | '' |
search | (term: string, signal: AbortSignal) => Promise<readonly AbpTypeaheadItem[]> | Yes | — |
debounce | number | undefined | No | 300 |
minLength | number | undefined | No | 1 |
placeholder | string | undefined | No | — |
disabled | boolean | undefined | No | — |
readonly | boolean | undefined | No | — |
invalid | boolean | undefined | No | — |
clearable | boolean | undefined | No | — |
id | string | undefined | No | — |
name | string | undefined | No | — |
ariaDescribedby | string | undefined | No | — |
ariaLabel | string | undefined | No | — |
Events
| Name | Payload |
|---|---|
update:modelValue | [value: AbpOptionValue] |
update:displayValue | [value: string] |
select | [item: AbpTypeaheadItem | null] |
Slots
| Name | Context |
|---|---|
item | (context: { item: AbpTypeaheadItem; active: boolean }) => unknown |
empty | () => unknown |