MultiCombobox

Types to search and picks several items.

Key
<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiCombobox } from '@hina-ui/vue'

  const studios = ref<Array<string | number>>([1])
  const options = [
    { value: 1, label: 'Key' },
    { value: 2, label: 'Type-Moon' },
    { value: 3, label: 'Nitroplus' },
    { value: 4, label: 'Leaf' },
    { value: 5, label: 'Frontwing' },
    { value: 6, label: 'Yuzusoft' },
  ]
</script>

<template>
  <MultiCombobox v-model="studios" :options="options" aria-label="Studios" class="w-80" />
</template>
vue

Usage

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

The multi combobox is the multi-value form of Combobox: type in the field to narrow the list, pick several items, and the chosen ones sit as chips before the text input, wrapping when they no longer fit. v-model binds an array of chosen values and options uses the types defined by Select. Attributes it does not declare land on the inner text input, so name it with aria-label or aria-labelledby.

Nothing chosen yet

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiCombobox, Stack, Text } from '@hina-ui/vue'

  const genres = ref<Array<string | number>>([])
  const options = [
    { value: 'school', label: 'School' },
    { value: 'sf', label: 'Science fiction' },
    { value: 'romance', label: 'Romance' },
    { value: 'mystery', label: 'Mystery' },
    { value: 'fantasy', label: 'Fantasy' },
    { value: 'daily', label: 'Slice of life' },
  ]
</script>

<template>
  <Stack gap="sm" class="w-full max-w-sm">
    <MultiCombobox
      v-model="genres"
      :options="options"
      placeholder="Type a genre"
      aria-label="Genres"
    />
    <Text tone="muted" size="sm">
      {{ genres.length ? genres.join(', ') : 'Nothing chosen yet' }}
    </Text>
  </Stack>
</template>
vue

It differs from MultiSelect in whether you type: a multi select picks from a fixed list and locks page scrolling while open; a multi combobox narrows the list by typing and never locks scrolling, so the field stays editable. It differs from TagsInput in where values come from: a tags input accepts any text, here every value must come from the options.

Examples

Remote search

ignoreFilter disables local filtering, and v-model:search provides the input text. Pass remote search results directly to options. Supply selected item data separately through selectedOptions; each Chip resolves its name from the values in v-model. This data neither adds dropdown candidates nor selects additional values.

Names are remembered when search results are replaced or cleared. Asynchronously supplied or updated names are reflected in the chips. When both sources contain the same value, selectedOptions takes precedence for the chip name. A selected value returned by the search still appears as a checked candidate.

The example prefills three tags before requesting search results. Debouncing and cancelling requests belong to the caller; loading replaces the toggle arrow with a loading indicator.

The example requests a static JSON file shipped with the docs and simulates filtering in the caller, without a server proxy. Replace the request URL and result mapping when connecting a search API.

Light novelsTappei NagatsukiTime travel

Selected: 3 · Results: 0

<script setup lang="ts">
  import { computed, onMounted, ref } from 'vue'
  import { refDebounced, useFetch } from '@vueuse/core'
  import { useRuntimeConfig } from '#app'
  import { Inline, MultiCombobox, Stack, Text } from '@hina-ui/vue'

  interface TagPage {
    data: { items: Array<{ id: number; name: string; nameEn: string }> }
  }

  const selectedTags = [
    { value: 31, label: 'Light novels' },
    { value: 32, label: 'Tappei Nagatsuki' },
    { value: 33, label: 'Time travel' },
  ]
  const selected = ref<Array<string | number>>(selectedTags.map(tag => tag.value))
  const baseURL = useRuntimeConfig().app.baseURL
  const search = ref('')
  const keyword = refDebounced(search, 300)
  const url = computed(
    () => `${baseURL}demo/tags.json?${new URLSearchParams({ search: keyword.value })}`,
  )
  const { data, isFetching, execute } = useFetch(url, {
    refetch: true,
    immediate: false,
  }).json<TagPage>()
  const options = computed(
    () =>
      data.value?.data.items
        .filter(tag =>
          tag.nameEn.toLocaleLowerCase().includes(keyword.value.trim().toLocaleLowerCase()),
        )
        .slice(0, 10)
        .map(tag => ({ value: tag.id, label: tag.nameEn })) ?? [],
  )

  onMounted(() => execute())
</script>

<template>
  <Stack class="w-80">
    <MultiCombobox
      v-model="selected"
      v-model:search="search"
      :options="options"
      :selected-options="selectedTags"
      :loading="isFetching"
      ignore-filter
      placeholder="Search tags"
      aria-label="Tags"
    >
      <template #option="{ option }">
        <Inline gap="sm" align="center" :wrap="false" class="min-w-0">
          <Text as="span" class="truncate">{{ option.label }}</Text>
          <Text as="span" tone="muted" size="xs" class="ms-auto shrink-0 font-mono">
            #{{ option.value }}
          </Text>
        </Inline>
      </template>
    </MultiCombobox>
    <Text tone="muted">Selected: {{ selected.length }} · Results: {{ options.length }}</Text>
  </Stack>
</template>
vue

Custom content

The option slot customises each row in the list, for example with a cover and an id.

The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to an array of option values. See Select for the type definitions.

School
<script setup lang="ts">
  import { ref } from 'vue'
  import { Inline, MultiCombobox, Text } from '@hina-ui/vue'

  const tags = ref<Array<string | number>>([12])
  const options = [
    { value: 12, label: 'School', description: '1,204 works' },
    { value: 34, label: 'Science fiction', description: '388 works' },
    { value: 56, label: 'Romance', description: '2,019 works' },
    { value: 78, label: 'Mystery', description: '271 works' },
  ]
</script>

<template>
  <MultiCombobox v-model="tags" :options="options" aria-label="Tags" class="w-80">
    <template #option="{ option }">
      <Inline gap="sm" align="center" :wrap="false" class="min-w-0">
        <Text as="span" class="truncate">{{ option.label }}</Text>
        <Text as="span" tone="muted" size="xs" class="ms-auto shrink-0 font-mono">
          #{{ option.value }}
        </Text>
      </Inline>
    </template>
  </MultiCombobox>
</template>
vue

Clearable

clearable adds a button at the end that empties everything and returns focus to the input. Each chip has its own remove button, so it is off by default.

KeyType-Moon
<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiCombobox } from '@hina-ui/vue'

  const studios = ref<Array<string | number>>([1, 2])
  const options = [
    { value: 1, label: 'Key' },
    { value: 2, label: 'Type-Moon' },
    { value: 3, label: 'Nitroplus' },
  ]
</script>

<template>
  <MultiCombobox v-model="studios" :options="options" clearable aria-label="Studios" class="w-80" />
</template>
vue

Sizes

size has three steps, sm, md and lg. With nothing chosen the height equals an input of the same step.

Key
Key
Key
<script setup lang="ts">
  import { MultiCombobox, Stack } from '@hina-ui/vue'

  const options = [
    { value: 1, label: 'Key' },
    { value: 2, label: 'Type-Moon' },
  ]
</script>

<template>
  <Stack gap="sm" class="w-full max-w-sm">
    <MultiCombobox size="sm" :options="options" :model-value="[1]" aria-label="Small" />
    <MultiCombobox size="md" :options="options" :model-value="[1]" aria-label="Medium" />
    <MultiCombobox size="lg" :options="options" :model-value="[1]" aria-label="Large" />
  </Stack>
</template>
vue

States

invalid gives the field a warning color and disabled disables the group. variant="secondary" is the flat form used inside a surface.

Key
Key
Key
<script setup lang="ts">
  import { MultiCombobox, Stack } from '@hina-ui/vue'

  const options = [
    { value: 1, label: 'Key' },
    { value: 2, label: 'Type-Moon' },
  ]
</script>

<template>
  <Stack gap="sm" class="w-full max-w-sm">
    <MultiCombobox :options="options" :model-value="[1]" invalid aria-label="Invalid" />
    <MultiCombobox :options="options" :model-value="[1]" disabled aria-label="Disabled" />
    <MultiCombobox
      :options="options"
      :model-value="[1]"
      variant="secondary"
      aria-label="Secondary"
    />
  </Stack>
</template>
vue

In a form

Inside a FormField the label points at the input, and the description and error message are rendered by the field; validation rules and submission belong to the Form. The value is an array, so count limits go on the array.

One to three of them

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, MultiCombobox, Text } from '@hina-ui/vue'

  const tags = [
    { label: 'Romance', value: 'romance' },
    { label: 'Romantic comedy', value: 'rom-com' },
    { label: 'School', value: 'school' },
    { label: 'Isekai', value: 'isekai' },
    { label: 'Healing', value: 'healing' },
    { label: 'Mystery', value: 'mystery' },
  ]

  const schema = v.object({
    tags: v.pipe(
      v.array(v.string('Pick tags')),
      v.minLength(1, 'Pick at least one tag'),
      v.maxLength(3, 'Pick at most three tags'),
    ),
  })

  const values = reactive({ tags: [] as Array<string | number> })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="tags" label="Tags" description="One to three of them" required>
      <MultiCombobox v-model="values.tags" :options="tags" />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
  </Form>
</template>
vue

Virtual scrolling

virtualize renders rows near the viewport, sharing measurement and scrolling with VirtualList. It is off by default. Pass { estimateSize, overscan } to configure estimated row height and the buffer on each side; actual heights are measured. Keyboard navigation covers the full collection and skips disabled items. Search still processes the full dataset. Rows unmount outside the rendered range; keep persistent slot state outside the row, keyed by its unique value.

Item 07891

Selected: 1

<script setup lang="ts">
  import { ref } from 'vue'
  import { MultiCombobox, Stack, Text } from '@hina-ui/vue'

  const selected = ref<Array<string | number>>([7890])
  const options = Array.from({ length: 10000 }, (_, index) => ({
    value: index,
    label: `Item ${String(index + 1).padStart(5, '0')}`,
    disabled: index % 97 === 0,
  }))
</script>

<template>
  <Stack gap="sm" class="w-80 max-w-full">
    <MultiCombobox
      v-model="selected"
      :options="options"
      :virtualize="{ estimateSize: 36, overscan: 6 }"
      aria-label="Ten thousand items"
    />
    <Text size="sm" tone="muted">Selected: {{ selected.length }}</Text>
  </Stack>
</template>
vue

Behavior

  • Typing opens the list and filters it; the arrow keys move the highlight, Enter ticks or unticks the highlighted item, the list stays open and the typed text and its filtering stay put, so several items from one query can be ticked in a row; clearing the input brings the whole list back.
  • Clicking the blank area of the field or the text of a chip focuses the input and opens the list, the same as clicking the input.
  • With an empty input, Backspace removes the last chip.
  • Esc or a click outside closes the list. Page scrolling is never locked.
  • Chosen items stay ticked in the list; picking one again unticks it.

Accessibility

  • The text input is role="combobox", the list is role="listbox" and each option is role="option" with aria-selected.
  • Name the text input with aria-label or aria-labelledby; the remove, clear and toggle buttons carry names from the locale.
  • invalid also sets aria-invalid on the text input.

API

Props

T extends SelectOption is inferred from options and selectedOptions, and defaults to SelectOption.

Prop
Type
Default
Description
modelValue
Array<string | number>
[]
The chosen values
options
SelectItems<T>
—
The items, see Select
virtualize
VirtualizeOptions
false
Virtual scrolling; content-based estimate, overscan 6
selectedOptions
T[]
—
Selected item data for chip labels only; not added to the dropdown
placeholder
string
locale pack
Placeholder of the input while nothing is chosen
search
string
''
Text in the input; v-model:search
ignoreFilter
boolean
false
Whether to skip local filtering for remote search
loading
boolean
false
Whether the toggle arrow shows a spinner
clearable
boolean
false
Whether the clear-all button is shown
name
string
—
Form field name
open
boolean
false
Whether the list is open; v-model:open
variant
'primary' | 'secondary'
'primary'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
disabled
boolean
false
Whether the group is disabled
invalid
boolean
false
Whether the group failed validation
class
string
—
Classes appended to the root element

Slots

Slot
Payload
Description
option
{ option: T }
Content of each row in the list

Events

Event
Payload
Description
update:modelValue
value: Array<string | number>
The chosen values changed
update:search
value: string
The typed text changed
update:open
open: boolean
The list opened or closed
clear
—
Everything was cleared
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts