Type sta, choose status:, then choose error. Enter without a highlighted suggestion applies the query.
Applied: entry:http
<script setup lang="ts">
import { ref } from 'vue'
import { Search } from '@lucide/vue'
import {
Autocomplete,
Tag,
Kbd,
Stack,
Text,
type AutocompleteOption,
type CompletionContext,
type CompletionEdit,
} from '@hina-ui/vue'
import { queryToken, queryValues } from '../../autocomplete'
const text = ref('entry:http ')
const applied = ref('entry:http ')
const options = ref<AutocompleteOption[]>([])
const descriptions: Record<string, string> = {
entry: 'Entry type',
status: 'Request status',
duration: 'Request duration',
service: 'Service name',
}
function query(context: CompletionContext) {
const token = queryToken(context)
const values = token.key ? (queryValues[token.key] ?? []) : Object.keys(queryValues)
options.value = values
.filter(value => value.startsWith(token.prefix))
.map(value => ({
value,
label: token.key ? value : `${value}:`,
description: token.key ? undefined : descriptions[value],
}))
}
function complete(option: AutocompleteOption, context: CompletionContext): CompletionEdit {
const token = queryToken(context)
return { range: token.range, text: option.label, keepOpen: !token.key }
}
</script>
<template>
<Stack class="w-full max-w-lg">
<Autocomplete
v-model="text"
:options="options"
:get-completion="complete"
select-on-tab
aria-label="Request query"
placeholder="entry:http status:error duration:>500ms"
@query="query"
@submit="applied = $event"
>
<template #leading><Search /></template>
<template #trailing>
<Tag v-if="text !== applied" size="sm" tone="warning" class="mx-2 whitespace-nowrap">
Unapplied
</Tag>
<Kbd v-else class="mx-2">Enter</Kbd>
</template>
</Autocomplete>
<Text tone="muted" size="sm">
Type sta, choose status:, then choose error. Enter without a highlighted suggestion applies
the query.
</Text>
<Text size="sm" class="break-all">Applied: {{ applied || '—' }}</Text>
</Stack>
</template>
Usage
import { Autocomplete } from '@hina-ui/vue'
v-model always holds the complete input string. Accepting a suggestion edits this text; blur never restores a selected label or clears the input. Use Combobox when the value must belong to a set of options.
options is the exact candidate list; the component does not filter it. Without getCompletion, accepting an option replaces the entire text with option.label and closes the list. Arbitrary text remains valid.
Choose a suggestion or keep your own text.
Text: —
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Autocomplete, FormField, Stack, Text } from '@hina-ui/vue'
const text = ref('')
const candidates = ['Vue', 'React', 'Svelte', 'Solid', 'Angular']
const options = computed(() =>
candidates
.filter(label => label.toLowerCase().includes(text.value.toLowerCase()))
.map(label => ({ value: label, label })),
)
</script>
<template>
<Stack class="w-full max-w-sm">
<FormField label="Framework" description="Choose a suggestion or keep your own text.">
<Autocomplete v-model="text" :options="options" placeholder="Type a framework name" />
</FormField>
<Text tone="muted" size="sm">Text: {{ text || '—' }}</Text>
</Stack>
</template>
Examples
Continued completion at the caret
The first example completes query fragments. Type sta, highlight status: with an arrow key, and press Enter. The list stays open and offers ok, error, and timeout. Accept a value to close the list, then press Enter to apply the query. Move the caret back to an earlier fragment to replace it while preserving the suffix.
@query provides the current text and selection after input, caret movement, selection changes, and completion. getCompletion(option, context) synchronously returns a replacement range and inserted text. Ranges use native input UTF-16 offsets, with an inclusive start and exclusive end. The caret moves to the end of the inserted text.
function complete(option, context) {
const range = locateToken(context)
return {
range,
text: option.label,
keepOpen: option.kind === 'key',
}
}
The application supplies locateToken, key/value classification, and suggestions. Use keepOpen: true to continue completing; the following query event contains the updated text and caret. Use the context passed to the current getCompletion call instead of a cached selection. A range outside the current text throws RangeError.
Remote suggestions
loading shows a spinner and list status while preserving text. Replacing options clears the old highlight; new results never automatically highlight the first item. Customize empty and loading messages through #empty and #loading.
The caller owns requests, debouncing, cancellation, and stale-response handling. This example fetches static JSON shipped with the documentation and filters it in the caller. Replace that request with a search endpoint using the same data flow.
Fetch suggestions while typing; arbitrary text is also accepted.
Text: —
<script setup lang="ts">
import { ref, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
import { useRuntimeConfig } from '#app'
import { Search } from '@lucide/vue'
import {
Autocomplete,
FormField,
Stack,
Text,
type AutocompleteOption,
type CompletionContext,
} from '@hina-ui/vue'
const text = ref('')
const search = ref('')
const keyword = refDebounced(search, 250)
const options = ref<AutocompleteOption[]>([])
const loading = ref(false)
const error = ref(false)
const baseURL = useRuntimeConfig().app.baseURL
function query(context: CompletionContext) {
if (search.value === context.text) return
search.value = context.text
options.value = []
loading.value = !!context.text.trim()
error.value = false
}
watch(keyword, async (value, _, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
if (!value.trim()) {
options.value = []
loading.value = false
return
}
loading.value = true
error.value = false
try {
const response = await fetch(`${baseURL}demo/tags.json`, { signal: controller.signal })
if (!response.ok) throw new Error(String(response.status))
const result = (await response.json()) as {
data: { items: { id: number; name: string; nameEn: string }[] }
}
if (controller.signal.aborted || search.value !== value) return
options.value = result.data.items
.filter(tag => (tag.nameEn || tag.name).toLowerCase().includes(value.trim().toLowerCase()))
.slice(0, 10)
.map(tag => ({ value: tag.id, label: tag.nameEn || tag.name }))
} catch {
if (!controller.signal.aborted && search.value === value) error.value = true
} finally {
if (!controller.signal.aborted && search.value === value) loading.value = false
}
})
</script>
<template>
<Stack class="w-full max-w-sm">
<FormField
label="Tag search"
description="Fetch suggestions while typing; arbitrary text is also accepted."
>
<Autocomplete
v-model="text"
:options="options"
:loading="loading"
placeholder="Try novel or music"
@query="query"
>
<template #leading><Search /></template>
<template #empty>
{{
error
? 'Could not load suggestions. Edit the text to retry.'
: text.trim()
? 'No suggestions. You can still use this text.'
: 'Type to search for tags.'
}}
</template>
</Autocomplete>
</FormField>
<Text tone="muted" size="sm">Text: {{ text || '—' }}</Text>
</Stack>
</template>
Sizes and states
The input uses the same sizes and variants as Input. FormField supplies label, description, error, and disabled associations. Undeclared attributes such as name, maxlength, and aria-label reach the native input.
Enter a status value.
<script setup lang="ts">
import { Autocomplete, FormField, Stack } from '@hina-ui/vue'
const options = [
{ value: 'http', label: 'entry:http' },
{ value: 'rpc', label: 'entry:rpc' },
]
</script>
<template>
<Stack class="w-full max-w-sm" gap="lg">
<FormField label="Small">
<Autocomplete :options="options" size="sm" placeholder="Type a query" />
</FormField>
<FormField label="Secondary">
<Autocomplete :options="options" variant="secondary" placeholder="Type a query" />
</FormField>
<FormField label="Large">
<Autocomplete :options="options" size="lg" placeholder="Type a query" />
</FormField>
<FormField label="Invalid" error="Enter a status value.">
<Autocomplete :options="[]" model-value="status:" />
</FormField>
<FormField label="Read only">
<Autocomplete :options="options" model-value="entry:http" readonly />
</FormField>
<FormField label="Disabled" disabled>
<Autocomplete :options="options" model-value="entry:http" />
</FormField>
</Stack>
</template>
In a form
Use FormField’s name to connect validation rules. Errors and the disabled state during submission reach the input automatically. Suggestions help complete text; custom values remain valid for submission.
Connect @submit to the Form instance’s submit(): Enter accepts a highlighted suggestion, or validates and submits the form when nothing is highlighted. The Save button uses the same validation flow.
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import * as v from 'valibot'
import { Autocomplete, Button, Form, FormField, Text } from '@hina-ui/vue'
const form = ref<InstanceType<typeof Form>>()
const values = reactive({ framework: '' })
const saved = ref('')
const candidates = ['Vue', 'React', 'Svelte', 'Solid', 'Angular']
const options = computed(() =>
candidates
.filter(label => label.toLowerCase().includes(values.framework.toLowerCase()))
.map(label => ({ value: label, label })),
)
const schema = v.object({
framework: v.pipe(v.string(), v.trim(), v.nonEmpty('Enter a framework name')),
})
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
saved.value = (data as { framework: string }).framework
}
</script>
<template>
<Form
ref="form"
v-slot="{ submitting }"
:values="values"
:rules="schema"
class="w-full max-w-sm"
@submit="save"
>
<FormField
name="framework"
label="Primary framework"
description="Choose a suggestion or enter another framework."
required
>
<Autocomplete
v-model="values.framework"
:options="options"
name="framework"
placeholder="Choose or enter a framework"
@submit="form?.submit()"
/>
</FormField>
<Button type="submit" :loading="submitting" class="self-start">Save</Button>
<Text v-if="saved" role="status" tone="muted" size="sm">Saved: {{ saved }}</Text>
</Form>
</template>
Keyboard and focus
- Focus, click, or input opens the list while focus stays in the input. No initial suggestion is highlighted.
- Arrow keys navigate enabled suggestions. Keyboard scrolling stays inside the popup.
- Enter accepts a highlighted suggestion. Without one, it closes the list and emits
submit(text); it does not also submit a native form. selectOnTabis off by default. When enabled, Tab accepts a highlighted suggestion and retains input focus. Without a highlight, or with Shift+Tab, normal focus navigation remains intact.- Escape closes the list first. When already closed, it clears nonempty text and emits
clear. Neither action submits the query. - IME composition does not select, submit, or clear. Suggestions update after composition ends.
- Blur preserves text.
readonlypermits focus, text selection, and copying without opening suggestions;disabledprevents interaction.
The input, list, and candidates use combobox, listbox, and option roles with aria-activedescendant linking the active option. Provide FormField, an associated label, or aria-label. Option slots are for presentation; avoid nested buttons, links, or other independently interactive controls.
API
Props
T extends AutocompleteOption is inferred from options. Extra business fields retain their types in callbacks and slots.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | '' | Complete text; supports v-model |
open | boolean | false | Popup state; supports v-model:open |
options | readonly T[] | — | Current candidates; each value must be stable and unique |
getCompletion | (option: T, context: CompletionContext) => CompletionEdit | — | Synchronous edit; defaults to replacing all text with label |
loading | boolean | false | Loading state; existing candidates stay selectable, so clear stale options in the caller when needed |
selectOnTab | boolean | false | Accept the highlighted suggestion with Tab |
placeholder | string | — | Placeholder text |
variant | 'primary' | 'secondary' | 'primary' | Input appearance |
size | 'sm' | 'md' | 'lg' | 'md' | Size |
disabled | boolean | false | Disable interaction |
readonly | boolean | false | Read-only input |
invalid | boolean | false | Validation failure |
class | string | — | Input frame classes |
Slots
| Slot | Props | Description |
|---|---|---|
leading | — | Leading icon or adornment |
trailing | — | Shortcut hint or state marker; follows logical direction in RTL |
option | { option: T, active: boolean } | Candidate content; row interaction and accessibility remain managed |
empty | — | Empty results message |
loading | — | Loading message inside the list |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | Text changed |
update:open | boolean | Popup state changed |
query | CompletionContext | Focus or reopening, text or selection changes, and completed edits |
select | AutocompleteSelection<T> | Accepted suggestion, including the previous context and applied edit |
submit | string | Enter applies text when no suggestion is highlighted |
clear | — | Escape cleared nonempty text while the popup was closed |
Exposed instance
input exposes the native HTMLInputElement, including setSelectionRange(). focus() and blur() control input focus. The instance is available after mounting.
Types
interface AutocompleteOption {
value: string | number
label: string
description?: string
disabled?: boolean
}
interface CompletionContext {
text: string
selectionStart: number
selectionEnd: number
}
interface CompletionEdit {
range: [number, number]
text: string
keepOpen?: boolean
}
interface AutocompleteSelection<T extends AutocompleteOption = AutocompleteOption> {
option: T
context: CompletionContext
edit: CompletionEdit
}