Autocomplete

Free-text input with caret-aware suggestions.

Enter

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>
vue

Usage

import { Autocomplete } from '@hina-ui/vue'
ts

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>
vue

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',
  }
}
ts

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>
vue

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>
vue

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.

Choose a suggestion or enter another framework.

<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>
vue

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.
  • selectOnTab is 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. readonly permits focus, text selection, and copying without opening suggestions; disabled prevents 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
}
ts