GrSelect

Package: @feugene/granularitycoreGroup: forms

Selection of one or several values from a list of options.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a form field with a list of values — 5–50 options that are browsed rather than searched: a status, a category, an assignee;
  • a mobile formoptionsView="native" gives the choice to the system wheel, and on a phone that is better than any panel of your own;
  • multiple selectionmultiple with tags, when the selection has to be visible as a whole, and maxTagCount, when it is time to fold the tail;
  • a list of hundreds of rowsvirtual keeps in the DOM the window around the viewport;
  • an object valuevalueKey compares by identifier rather than by reference: the model arrives from the outside as a separate copy and would not match by ===.

When to take something else

NeedTake
The user searches by typing rather than browsing the listGrAutocomplete
The options are a tree with levelsGrTreeSelect
An application command is chosen rather than a field valueGrCommandPalette
There are 2–5 options and they have to be visible at onceGrSegmented / GrRadioGroup
There are several values and all of them are visible as a listGrCheckboxGroup
There is no reference list, the user types their own stringsGrInputTag

filterable filters an already loaded list — that is a convenience inside the choice rather than a search. As soon as the request goes to the server and the list is built from it, the task changes: there the combobox role belongs to the <input> itself, and that is GrAutocomplete.

Remote loading

<GrSelect
  v-model="value"
  v-model:search="query"
  :options="options"
  :loading="pending"
  filterable
  @search="fetchOptions"
/>

loading without v-model:search/@search used to be decorative: the typed text lived inside the component and did not come out — there was nothing to go to the server with. Now the query is available both ways: v-model:search for a controlled value and @search for a side effect.

While loading is on, there is no list in the DOM, so aria-controls is removed from the trigger — the reference does not hang into the void, and aria-busy stands in its place.

What the panel holds

The direct children of role="listbox" are only options and groups: the role declares everything else an invalid child. The heading of a group lies inside its role="group" and gives it a name through aria-labelledby; loading and an empty result are taken out of the list into role="status" aria-live="polite" live regions — otherwise the states would change silently.

The options do not take the focus (tabindex="-1", mousedown suppressed): in a combobox it lives on the trigger or in the search field, and the active option is named by aria-activedescendant. A panel with a search field returns the focus to the trigger on closing — if it is still inside the panel.

The #empty and #loading slots replace both states.

Virtualisation

virtual keeps in the DOM only the window around the viewport; the height of the window is set by dropdownMaxHeight. The mode is for optionsView="panel" only — a native <select> has no panel of its own at all.

<GrSelect v-model="city" :options="cities" options-view="panel" virtual />

The groups survive the window. If the panel is scrolled into the middle of a group, its heading is no longer in the markup — the role="group" wrapper is created all the same and takes its name through aria-label instead of aria-labelledby. Otherwise the options in the middle of a group would become direct children of the listbox and would lose the name of their set.

The set is counted by group rather than by list. With virtual the options carry aria-setsize/aria-posinset, and for an option inside a group that is the size of its group — that is what ARIA requires. Options outside groups together with the “Add …” button form the set at the level of the listbox. In the ordinary mode the attributes are absent: there the set is visible in the DOM.

It does not combine with view="link". In that look the options carry w-max, that is, the width of the panel equals the width of the widest rendered option — with virtualisation it would jump on every scroll. The package reports both incompatible combinations with a dev warning. How the primitive works — virtual-list.md.

The active option and the focus

aria-activedescendant works only on the element that holds the focus. With filterable/allowCustomValue the focus goes into the search field inside the panel — the link with the active option lives there as well, and the field declares itself role="combobox". Without a search field everything stays on the trigger.

The tags

<GrSelect v-model="values" multiple tags :max-tag-count="3" :options="options" />

The chips live beside the combobox button rather than inside it: role="combobox" declares its descendants presentational, and a cross inside was unreachable from the keyboard (axe: nested-interactive). Now those are real buttons in the tab order.

maxTagCount folds the tail into a “+N” — without it a long selection turned into a sheet of chips.

The colour of a tag is also set on an option: the tone and dark of the option itself override the general tagTone/tagDark. That is needed more often than it seems — labels, categories and statuses usually arrive with a colour of their own — and without such a possibility the consumer went off to draw the selection in the #value slot and lost exactly what tags is taken for: chips outside role="combobox", removal with a cross, folding into a “+N” and the keyboard.

const options = [
  { value: 'bug', label: 'Bug', tone: 'danger' },
  { value: 'idea', label: 'Idea', tone: 'success', dark: true },
]

The events

EventWhen
update:modelValuethe value has changed
changethe same value, through a separate channel (parity with GrTreeSelect); in both render modes
clearthe value was removed with the clear button
update:openthe panel opened/closed (v-model:open)
update:search / searchthe user typed a query

The panel is controllable through v-model:open (the contract of the panel overlays of the package, as in GrPopover): without the open prop the select runs itself, with it the parent owns the state. The name prop enables participation in a native form: in the native mode the name goes onto the <select> itself, and in the panel mode the values are serialised with hidden inputs (the key is valueKey/keyOf, one per value with multiple).

Object values

<GrSelect v-model="owner" :options="ownerOptions" value-key="id" />

The value of an option may be an object — then valueKey with the name of the identifier field is mandatory. Through it the component builds the key by which it compares the values and puts them into the DOM: === would mean comparing references, and the model usually arrives from the outside as a separate copy — with the same id but a different object, and it would match none of the options. Without valueKey object values give a warning in a dev build.

The object itself goes out, not a string from the DOM. allowCustomValue does not work with object values by nature: the user types text.

The states

state (default | success | warning | danger) sets the hue of the border, invalid forces the red one and announces aria-invalid — an error overrides any other highlighting. In view="link" there is no border, and the state is not applied there.

readonly shows the value but does not open the panel and does not change the selection; disabled dims the whole control. The clear button does not hide the chevron: a field with a selected value has to look like a dropdown list.

The heading of a group is linked to the options through aria-describedby — that way the group is heard without turning a flat list into a tree.

The `prefix` / `suffix` addons

The slots are available only with optionsView="panel" — markup cannot be put inside a native <select> (in dev the console will warn about that). They put an icon, a unit or a label into the shell; the width is bounded by six props (prefixMinWidth/prefixMaxWidth/prefixFixed and the same for the suffix). The shared contract of the controls — form-controls.md.

The imperative API

focus() and blur() through a ref on the component — in the native mode they address the <select>, in the panel mode the trigger button.

Playground 36

Loading…

Code
<GrSelect />

Install

npm i @feugene/granularity

Import

import { GrSelect } from '@feugene/granularity/components/GrSelect'

API

Props

PropTypedefaultDescription
modelValuerequiredGrSelectModelValue<TValue>
optionsGrSelectOptionOrGroup<TValue>[] | undefinedundefinedThe list of options. It supports a flat array of options and groups of options (`{ label, options }`).
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead only: the value is visible and goes into the form but does not change.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
state"default" | "success" | "warning" | "danger" | undefined"default"The visual shade of the border: `default | success | warning | danger`. `invalid` is stronger — an error overrides any other highlight. In `view="link"` there is no border, and the state does not apply there.
valueKeystring | undefinedundefinedThe name of the identifier field when the values of the options are objects. Without it the objects would be compared by reference, and a copy arriving from outside with the same `id` would not match any option.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
ariaLabelstring | undefinedundefined
viewGrSelectView | undefined"default"
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefinedThe placeholder (shown when no value is chosen).
multipleboolean | undefinedfalseMultiple selection.
tagTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"neutral"The look of the chips of the chosen values in the `multiple` mode. A chip is a `GrBadge`: a plate of our own would be nearly indistinguishable from the background of the field on the light theme.
tagDarkboolean | undefinedfalse
tagSize"xs" | "sm" | "md" | "lg" | undefined"sm"
tagRadiusGrBadgeRadius | undefined"round"
optionsViewGrSelectOptionsView | undefined"native"How to display the list of options: a native `<select>` or a custom panel.
allowCustomValueboolean | undefinedfalsePermits entering or choosing a value that is not in `options`.
filterableboolean | undefinedfalseSearch and filtering of the options by input (independently of `allowCustomValue`). It shows a search field above the list and filters the options. It works only in `optionsView="panel"` (with `native` the panel is forced automatically).
filterPlaceholderstring | undefinedundefinedThe placeholder of the search field (`filterable`). i18n: the fallback is `gr.select.searchPlaceholder`.
loadingboolean | undefinedfalseThe loading state: instead of the list of options the panel shows a loading indicator. It is useful for fetching options remotely. It forces `optionsView="panel"`.
loadingTextstring | undefinedundefinedThe text of the loading indicator. i18n: the fallback is `gr.select.loading`.
noResultsTextstring | undefinedundefinedThe text for an empty result of the filtering. i18n: the fallback is `gr.select.noResults`.
tagsboolean | undefinedfalseThe tags mode for `multiple`: the chosen values are shown as removable chips in the trigger (instead of an "a, b, c" line). It forces `optionsView="panel"`.
customValuePlaceholderstring | undefinedundefinedThe placeholder for the input of a custom value (only in `optionsView="panel"`). i18n-friendly: if it is not set, it is taken from the translation adapter (`gr.select.customValuePlaceholder`), otherwise from the built-in fallback.
dropdownMaxHeightnumber | undefined280The maximum height of the panel (only in `optionsView="panel"`).
virtualboolean | undefinedfalseVirtualisation of the panel: only a window around the viewport lives in the DOM. The height of the window is set by `dropdownMaxHeight`. Only for `optionsView="panel"`: a native `<select>` has no panel at all. It is incompatible with `view="link"` — there the width of the panel equals the width of the rendered option and would jump while scrolling.
closeOnSelectboolean | undefinedtrueClose the panel after a choice (only in `optionsView="panel"`).
maxTagCountnumber | undefinedundefinedHow many chips to show before folding into "+N" (only with `tags`).
clearableboolean | undefinedundefinedPermits clearing the chosen value.
clearLabelstring | undefinedundefinedThe i18n label for the clear button (`aria-label`).
variantGrSelectVariant | undefinedundefinedThe colour or variant of the link for `view="link"` (as in `GrLink`). It is not used in `view="default"`.
underlineGrSelectUnderline | undefinedundefinedThe underline for `view="link"` (as in `GrLink`). It is not used in `view="default"`.
openboolean | undefinedundefinedThe controlled state of the panel (`v-model:open`). Without the prop the panel behaves on its own (uncontrolled), with it — listen to `update:open` and change the prop. Only for `optionsView="panel"`: in a native `<select>` the panel belongs to the browser.
namestring | undefinedundefinedThe name for a native form: in the native mode it goes onto the `<select>` itself, and in the panel mode the values are serialised by hidden inputs (the key is `keyOf`).
prefixMinWidthstring | undefinedundefinedThe widths of the `prefix`/`suffix` addons — the common contract of the controls of the package (`docs/form-controls.md`). The addons live in the panel trigger: markup cannot be put inside a native `<select>`.
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalse
suffixFixedboolean | undefinedfalse

Slots

SlotTypeDescription
defaultanyYour own `<option>`s for the native mode.
prefixanyAn addon on the left in the panel trigger (unavailable in the native mode).
suffixanyAn addon on the right in the panel trigger, before the cross and the chevron.
value{ selectedOptions: GrSelectOption<TValue>[]; selectedValues: TValue[]; displayLabel: string; placeholder?: string | undefined; hasSelection: boolean; }The display of the value in the trigger instead of the default text.
option{ option: GrSelectOption<TValue>; selected: boolean; }A row of the list instead of the label of the option.
loadinganyThe content of the panel while the options are on their way.
emptyanyThe content of the panel when there are no suitable options.

Events

EventTypeDescription
update:modelValue[GrSelectModelValue<TValue>]
change[GrSelectModelValue<TValue>]The value has changed — the same payload as in `update:modelValue`.
clear[]The value has been removed by the clear button.
update:open[boolean]The panel has opened or closed (`v-model:open`).
update:search[string]The search text as a controlled value (`v-model:search`).
focus[FocusEvent]
blur[FocusEvent]
search[string]The user has typed a query — a signal to go for the options.

Examples 9

Addons in the panel trigger

Addons
<script setup lang="ts">
import { ref } from 'vue'

import { GrSelect } from '@feugene/granularity'

const currencies = [
  { value: 'eur', label: 'Euro' },
  { value: 'usd', label: 'US Dollar' },
  { value: 'gbp', label: 'Pound Sterling' },
]

const currency = ref('eur')
</script>

<template>
  <GrSelect
    v-model="currency"
    :options="currencies"
    options-view="panel"
    clearable
    aria-label="Settlement currency"
  >
    <template #prefix>
      <span class="i-lucide-banknote block h-4 w-4" />
    </template>
    <template #suffix>
      per month
    </template>
  </GrSelect>
</template>

Remote Search

AmsterdamBerlin +1
Запрос: · выбрано: 3 · последнее событие: . Хвост чипов свёрнут в «+N», крестики достижимы `Tab`.

Remote Search
<script setup lang="ts">
import { ref } from 'vue'

import { GrSelect } from '@feugene/granularity'

const CATALOG = [
  { value: 'ams', label: 'Amsterdam' },
  { value: 'ber', label: 'Berlin' },
  { value: 'bcn', label: 'Barcelona' },
  { value: 'lis', label: 'Lisbon' },
  { value: 'prg', label: 'Prague' },
  { value: 'waw', label: 'Warsaw' },
]

const value = ref<string[]>(['ams', 'ber', 'bcn'])
const query = ref('')
const options = ref(CATALOG)
const loading = ref(false)
const lastEvent = ref('')

let requestId = 0

// Запрос уходит наружу — без этого `loading` было не с чем связать.
async function fetchOptions(search: string): Promise<void> {
  const id = ++requestId
  loading.value = true

  await new Promise(resolve => setTimeout(resolve, 400))
  if (id !== requestId)
    return

  options.value = CATALOG.filter(option => option.label.toLowerCase().includes(search.trim().toLowerCase()))
  loading.value = false
}
</script>

<template>
  <div class="grid gap-3">
    <GrSelect
      v-model="value"
      v-model:search="query"
      :options="options"
      :loading="loading"
      :max-tag-count="2"
      multiple
      tags
      filterable
      options-view="panel"
      clearable
      aria-label="Cities"
      placeholder="Pick cities"
      @search="fetchOptions"
      @change="lastEvent = 'change'"
      @clear="lastEvent = 'clear'"
      @update:open="lastEvent = $event ? 'opened' : 'closed'"
    />

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Запрос: <span class="font-semibold text-[var(--gr-fg)]">{{ query || '—' }}</span> ·
      выбрано: <span class="font-semibold text-[var(--gr-fg)]">{{ value.length }}</span> ·
      последнее событие: <span class="font-semibold text-[var(--gr-fg)]">{{ lastEvent }}</span>.
      Хвост чипов свёрнут в «+N», крестики достижимы `Tab`.
    </div>
  </div>
</template>

Interactive select constructor

Builderdepends on the showcase environment
<script setup lang="ts">
import { computed, ref, watch } from 'vue'

import {
  GrCard,
  GrFormField,
  GrInput,
  GrRadioGroup,
  GrSelect,
  GrSwitch,
  type GrSelectOptionsView,
  type GrSelectSize,
  type GrSelectUnderline,
  type GrSelectVariant,
  type GrSelectView,
} from '@feugene/granularity'

import CodeBlock from '../../../components/doc/CodeBlock.vue'

const view = ref<GrSelectView>('default')
const size = ref<GrSelectSize>('md')
const variant = ref<GrSelectVariant>('primary')
const underline = ref<GrSelectUnderline>('auto')
const optionsView = ref<GrSelectOptionsView>('native')
const placeholder = ref('Pick workspace')
const ariaLabel = ref('Pick workspace')
const customValuePlaceholder = ref('Add value…')

const multiple = ref(false)
const clearable = ref(false)
const disabled = ref(false)
const allowCustomValue = ref(false)
// «Не закрывать панель при выборе» — инвертированная семантика `close-on-select`.
// Наиболее востребовано при мультивыборе в panel-режиме (набор нескольких опций
// без переоткрытия панели). Действует только для `options-view="panel"`.
const keepPanelOpen = ref(false)

// Управление панелью имеет смысл только в panel-режиме.
const panelStayOpenAvailable = computed(() => optionsView.value === 'panel')

const singleValue = ref<string>('')
const multipleValue = ref<string[]>([])

const demoOptions = [
  { value: 'alpha', label: 'Alpha workspace' },
  { value: 'beta', label: 'Beta workspace' },
  { value: 'gamma', label: 'Gamma workspace' },
  { value: 'delta', label: 'Delta workspace: very long label that should wrap' },
]

const viewOptions = [
  { value: 'default', label: 'Default' },
  { value: 'link', label: 'Link' },
] satisfies Array<{ value: GrSelectView, label: string }>

const sizeOptions = [
  { value: 'xs', label: 'XS' },
  { value: 'sm', label: 'SM' },
  { value: 'md', label: 'MD' },
  { value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrSelectSize, label: string }>

const variantOptions = [
  { value: 'primary', label: 'Primary' },
  { value: 'default', label: 'Default' },
  { value: 'muted', label: 'Muted' },
  { value: 'danger', label: 'Danger' },
] satisfies Array<{ value: GrSelectVariant, label: string }>

const underlineOptions = [
  { value: 'auto', label: 'Auto' },
  { value: 'always', label: 'Always' },
  { value: 'none', label: 'None' },
] satisfies Array<{ value: GrSelectUnderline, label: string }>

const optionsViewOptions = [
  { value: 'native', label: 'Native' },
  { value: 'panel', label: 'Panel' },
] satisfies Array<{ value: GrSelectOptionsView, label: string }>

watch(multiple, (next) => {
  if (next) {
    if (!Array.isArray(multipleValue.value))
      multipleValue.value = []
  }
  else {
    singleValue.value = ''
  }
})

const effectiveAriaLabel = computed(() => ariaLabel.value.trim() || placeholder.value.trim() || 'Select value')

const previewSummary = computed(() => {
  if (disabled.value)
    return 'Disabled preserves the visual contract of the selected view/size while turning off interactivity and pointer events'

  if (allowCustomValue.value)
    return 'Allow custom value enables free-form input alongside the existing options — useful for tag-like pickers'

  if (multiple.value && optionsView.value === 'panel')
    return 'Panel mode with multiple selection acts as a mini-picker — combine with `close-on-select=false` for filter-like UX'

  if (view.value === 'link')
    return 'Link view aligns the trigger with `GrLink` styling — good for inline switchers and toolbar actions'

  return 'Combine `view`, `size`, `optionsView`, and state switches to quickly verify the select contract before shipping to a product scenario'
})

function escapeAttribute(value: string) {
  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
}

const previewCode = computed(() => {
  const attributes: string[] = []

  attributes.push(`v-model="${multiple.value ? 'selectedValues' : 'selectedValue'}"`)
  attributes.push(':options="options"')
  attributes.push(`view="${view.value}"`)
  attributes.push(`size="${size.value}"`)
  attributes.push(`options-view="${optionsView.value}"`)

  if (view.value === 'link') {
    attributes.push(`variant="${variant.value}"`)
    attributes.push(`underline="${underline.value}"`)
  }

  if (placeholder.value.trim())
    attributes.push(`placeholder="${escapeAttribute(placeholder.value.trim())}"`)

  attributes.push(`aria-label="${escapeAttribute(effectiveAriaLabel.value)}"`)

  if (multiple.value)
    attributes.push('multiple')

  if (clearable.value)
    attributes.push('clearable')

  if (disabled.value)
    attributes.push('disabled')

  if (allowCustomValue.value) {
    attributes.push('allow-custom-value')

    if (customValuePlaceholder.value.trim())
      attributes.push(`custom-value-placeholder="${escapeAttribute(customValuePlaceholder.value.trim())}"`)
  }

  if (panelStayOpenAvailable.value && keepPanelOpen.value)
    attributes.push(':close-on-select="false"')

  return ['<GrSelect', ...attributes.map(attribute => `  ${attribute}`), '/>'].join('\n')
})

const linkVariantDisabled = computed(() => view.value !== 'link')
</script>

<template>
  <div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
    <div class="grid gap-4">
      <div
          class="relative grid min-h-[280px] overflow-hidden rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
>
        <div class="flex h-full min-w-0 flex-col items-center justify-center gap-4 text-center">
          <div class="showcase-demo-caption text-xs">
            Preview
          </div>

          <div class="flex w-full max-w-[320px] min-w-0 justify-center">
            <GrSelect
                v-if="multiple"
                v-model="multipleValue"
                :options="demoOptions"
                :view="view"
                :size="size"
                :variant="variant"
                :underline="underline"
                :options-view="optionsView"
                :placeholder="placeholder"
                :aria-label="effectiveAriaLabel"
                :multiple="true"
                :clearable="clearable"
                :disabled="disabled"
                :allow-custom-value="allowCustomValue"
                :custom-value-placeholder="customValuePlaceholder"
                :close-on-select="!keepPanelOpen"
            />
            <GrSelect
                v-else
                v-model="singleValue"
                :options="demoOptions"
                :view="view"
                :size="size"
                :variant="variant"
                :underline="underline"
                :options-view="optionsView"
                :placeholder="placeholder"
                :aria-label="effectiveAriaLabel"
                :clearable="clearable"
                :disabled="disabled"
                :allow-custom-value="allowCustomValue"
                :custom-value-placeholder="customValuePlaceholder"
                :close-on-select="!keepPanelOpen"
            />
          </div>

          <div
              class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2"
>
            <div class="showcase-demo-text max-w-[40ch] text-center text-sm">
              {{ previewSummary }}
            </div>
          </div>
        </div>
      </div>

      <CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
    </div>

    <div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
      <div class="showcase-demo-title text-sm font-semibold">
        Properties
      </div>

      <div class="grid gap-4">
        <GrFormField label="View">
          <GrRadioGroup v-model="view" :options="viewOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Size">
          <GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Options view">
          <GrRadioGroup v-model="optionsView" :options="optionsViewOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Variant (link only)">
          <GrSelect
              v-model="variant"
              :options="variantOptions"
              :disabled="linkVariantDisabled"
              aria-label="Select variant"
          />
        </GrFormField>

        <GrFormField label="Underline (link only)">
          <GrRadioGroup
              v-model="underline"
              :options="underlineOptions"
              :disabled="linkVariantDisabled"
              variant="button"
              size="sm"
          />
        </GrFormField>

        <GrFormField label="Placeholder">
          <GrInput v-model="placeholder" placeholder="Pick workspace" aria-label="Placeholder" />
        </GrFormField>

        <GrFormField label="Accessibility label">
          <GrInput v-model="ariaLabel" placeholder="Optional override for screen readers" aria-label="Accessibility label" />
        </GrFormField>

        <GrFormField label="Custom value placeholder">
          <GrInput
              v-model="customValuePlaceholder"
              :disabled="!allowCustomValue || optionsView !== 'panel'"
              placeholder="Add value…"
              aria-label="Custom value placeholder"
          />
        </GrFormField>
      </div>

      <GrCard class="grid gap-3 p-4">
        <GrSwitch v-model="multiple" size="sm">
          Multiple
        </GrSwitch>
        <div class="grid gap-1">
          <GrSwitch v-model="keepPanelOpen" size="sm" :disabled="!panelStayOpenAvailable">
            Keep panel open on select
          </GrSwitch>
          <p class="showcase-demo-text pl-[2.75rem] text-xs leading-snug opacity-80">
            {{ panelStayOpenAvailable
              ? 'Sets `close-on-select=false` — the panel stays open after each pick (great for multiple selection)'
              : 'Switch `Options view` to `Panel` to keep the dropdown open while picking multiple values' }}
          </p>
        </div>
        <GrSwitch v-model="clearable" size="sm">
          Clearable
        </GrSwitch>
        <GrSwitch v-model="disabled" size="sm">
          Disabled
        </GrSwitch>
        <GrSwitch v-model="allowCustomValue" size="sm">
          Allow custom value
        </GrSwitch>
      </GrCard>
    </div>
  </div>
</template>

Native single and clearable

Native single
value: —
Native clearable
value: beta
Object values
value: #2 — Grace Hopper
Validation state
Region is required

Modes
<script setup lang="ts">
import { ref } from 'vue'

import { GrSelect } from '@feugene/granularity'

const options = [
  { value: 'alpha', label: 'Alpha workspace' },
  { value: 'beta', label: 'Beta workspace' },
  { value: 'gamma', label: 'Gamma workspace' },
]

const nativeValue = ref('')
const clearableValue = ref('beta')

// Значения-объекты: `valueKey` даёт стабильный ключ, поэтому модель может
// приходить отдельной копией — сравнение идёт по `id`, а не по ссылке.
type Owner = { id: number, name: string }

const owners: Owner[] = [
  { id: 1, name: 'Ada Lovelace' },
  { id: 2, name: 'Grace Hopper' },
]

const ownerOptions = owners.map(owner => ({ value: owner, label: owner.name }))
const owner = ref<Owner>({ id: 2, name: 'Grace Hopper' })

const region = ref('')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Native single
      </div>
      <GrSelect
        v-model="nativeValue"
        :options="options"
        placeholder="Pick workspace"
        aria-label="Pick workspace"
      />
      <div class="text-sm text-[var(--gr-muted-fg)]">
        value: {{ nativeValue || '—' }}
      </div>
    </div>

    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Native clearable
      </div>
      <GrSelect
        v-model="clearableValue"
        clearable
        :options="options"
        placeholder="Pick owner"
        aria-label="Pick owner"
      />
      <div class="text-sm text-[var(--gr-muted-fg)]">
        value: {{ clearableValue || '—' }}
      </div>
    </div>

    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Object values
      </div>
      <GrSelect
        v-model="owner"
        :options="ownerOptions"
        value-key="id"
        placeholder="Pick owner"
        aria-label="Pick owner (object value)"
      />
      <div class="text-sm text-[var(--gr-muted-fg)]">
        value: #{{ owner.id }} — {{ owner.name }}
      </div>
    </div>

    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Validation state
      </div>
      <GrSelect
        v-model="region"
        :options="[{ value: 'eu', label: 'EU' }, { value: 'us', label: 'US' }]"
        :invalid="region === ''"
        :state="region === '' ? 'default' : 'success'"
        placeholder="Pick region"
        aria-label="Pick region"
      />
      <div class="text-sm text-[var(--gr-muted-fg)]">
        {{ region === '' ? 'Region is required' : 'Looks good' }}
      </div>
    </div>
  </div>
</template>

Panel mode for multiple selection

designplatform

Panel
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrSelect } from '@feugene/granularity'

const options = [
  { value: 'design', label: 'Design' },
  { value: 'platform', label: 'Platform' },
  { value: 'billing', label: 'Billing' },
  { value: 'support', label: 'Support' },
]

const selectedTeams = ref<string[]>(['design', 'platform'])
</script>

<template>
  <div class="grid gap-3">
    <GrSelect
      v-model="selectedTeams"
      multiple
      options-view="panel"
      :close-on-select="false"
      :options="options"
      placeholder="Pick teams"
      aria-label="Pick teams"
    />

    <div class="flex flex-wrap gap-2">
      <GrBadge v-for="team in selectedTeams" :key="team">
        {{ team }}
      </GrBadge>
      <span v-if="selectedTeams.length === 0" class="text-sm text-[var(--gr-muted-fg)]">
        Nothing selected yet
      </span>
    </div>
  </div>
</template>

Grouped options

Native (optgroup)
Panel (group headers)

Groups
<script setup lang="ts">
import { ref } from 'vue'

import { GrSelect } from '@feugene/granularity'

const groupedOptions = [
  {
    label: 'Popular cities',
    options: [
      { value: 'Shanghai', label: 'Shanghai' },
      { value: 'Beijing', label: 'Beijing' },
    ],
  },
  {
    label: 'City name',
    options: [
      { value: 'Chengdu', label: 'Chengdu' },
      { value: 'Shenzhen', label: 'Shenzhen' },
      { value: 'Guangzhou', label: 'Guangzhou' },
      { value: 'Dalian', label: 'Dalian' },
    ],
  },
]

const nativeCity = ref('Beijing')
const panelCity = ref('Chengdu')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid gap-2">
      <span class="text-sm text-[var(--gr-muted-fg)]">Native (optgroup)</span>
      <GrSelect
        v-model="nativeCity"
        :options="groupedOptions"
        placeholder="Pick a city"
        aria-label="Pick a city (native)"
      />
    </div>

    <div class="grid gap-2">
      <span class="text-sm text-[var(--gr-muted-fg)]">Panel (group headers)</span>
      <GrSelect
        v-model="panelCity"
        options-view="panel"
        :options="groupedOptions"
        placeholder="Pick a city"
        aria-label="Pick a city (panel)"
      />
    </div>
  </div>
</template>

Custom value and value slot

current value: —

Custom Value
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrSelect } from '@feugene/granularity'

const options = [
  { value: 'ru', label: 'Russia' },
  { value: 'kz', label: 'Kazakhstan' },
  { value: 'uz', label: 'Uzbekistan' },
]

const region = ref('')
</script>

<template>
  <div class="grid gap-3">
    <GrSelect
      v-model="region"
      options-view="panel"
      allow-custom-value
      :options="options"
      placeholder="Pick or add region"
      aria-label="Pick or add region"
    >
      <template #value="{ displayLabel, hasSelection, placeholder }">
        <span v-if="hasSelection" class="inline-flex items-center gap-2 min-w-0">
          <GrBadge>custom</GrBadge>
          <span class="truncate">{{ displayLabel }}</span>
        </span>
        <span v-else class="text-[var(--gr-muted-fg)]">{{ placeholder }}</span>
      </template>
    </GrSelect>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      current value: {{ region || '—' }}
    </div>
  </div>
</template>

Filter, loading and tag mode

Filterable — search box over the option list
Loading — spinner while options are fetched
No results
Tags — multiple selection as removable chips
United StatesGermanyFrance
Selected: us, de, fr

Filter Loading Tags
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrButton, GrSelect } from '@feugene/granularity'

const countries = [
  { value: 'us', label: 'United States' },
  { value: 'gb', label: 'United Kingdom' },
  { value: 'de', label: 'Germany' },
  { value: 'fr', label: 'France' },
  { value: 'es', label: 'Spain' },
  { value: 'it', label: 'Italy' },
  { value: 'nl', label: 'Netherlands' },
  { value: 'se', label: 'Sweden' },
  { value: 'pl', label: 'Poland' },
  { value: 'pt', label: 'Portugal' },
]

// Filterable single select
const country = ref('')

// Loading state (simulated async load of options)
const asyncOptions = ref<Array<{ value: string, label: string }>>([])
const loading = ref(false)

function loadOptions() {
  loading.value = true
  asyncOptions.value = []
  window.setTimeout(() => {
    asyncOptions.value = countries
    loading.value = false
  }, 1200)
}

const asyncValue = ref('')

// Tags mode (multiple with removable chips)
const teams = ref<string[]>(['us', 'de', 'fr'])
</script>

<template>
  <div class="grid gap-6">
    <div class="grid gap-2">
      <div class="showcase-demo-caption text-xs">
        Filterable — search box over the option list
      </div>
      <GrSelect
        v-model="country"
        options-view="panel"
        filterable
        clearable
        :options="countries"
        placeholder="Pick a country"
        aria-label="Pick a country"
      />
      <GrBadge>{{ country || '—' }}</GrBadge>
    </div>

    <div class="grid gap-2">
      <div class="showcase-demo-caption text-xs">
        Loading — spinner while options are fetched
      </div>
      <div class="flex items-center gap-2">
        <div class="min-w-[220px]">
          <GrSelect
            v-model="asyncValue"
            options-view="panel"
            filterable
            :loading="loading"
            :options="asyncOptions"
            placeholder="Open to load…"
            aria-label="Async country"
          />
        </div>
        <GrButton size="sm" variant="outline" @click="loadOptions">
          Reload options
        </GrButton>
      </div>
    </div>

    <div class="grid gap-2">
      <div class="showcase-demo-caption text-xs">
        Tags — multiple selection as removable chips
      </div>
      <GrSelect
        v-model="teams"
        multiple
        tags
        filterable
        options-view="panel"
        :close-on-select="false"
        :options="countries"
        placeholder="Pick countries"
        aria-label="Pick countries"
      />
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Selected: {{ teams.length ? teams.join(', ') : 'none' }}
      </div>
    </div>
  </div>
</template>

Virtual

Selected:

Virtual
<script setup lang="ts">
import { ref } from 'vue'

import { GrSelect } from '@feugene/granularity'

// Сто групп по сто позиций. Группы переживают окно: если панель прокручена
// внутрь группы, её обёртка всё равно есть и берёт имя через `aria-label`.
const groupedOptions = Array.from({ length: 100 }, (_, groupIndex) => ({
  label: `Region ${groupIndex + 1}`,
  options: Array.from({ length: 100 }, (_, index) => ({
    value: `r${groupIndex + 1}-city-${index + 1}`,
    label: `Region ${groupIndex + 1} · City ${index + 1}`,
  })),
}))

const city = ref('')
</script>

<template>
  <div class="grid gap-3">
    <GrSelect
      v-model="city"
      :options="groupedOptions"
      options-view="panel"
      virtual
      filterable
      clearable
      placeholder="Search among 10 000 cities…"
      aria-label="Search a city"
    />

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Selected: <code>{{ city || '—' }}</code>
    </p>
  </div>
</template>

Accessibility

APG pattern
combobox + listbox

Full keyboard contract of the package

Component documentationAll components