GrSelect

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда поле формы со списком значений.

Когда брать

  • поле формы со списком значений — 5–50 вариантов, которые просматривают, а не ищут: статус, категория, ответственный;
  • мобильная формаoptionsView="native" отдаёт выбор системному колесу, и на телефоне это лучше любой своей панели;
  • множественный выборmultiple с tags, когда выбранное должно быть видно целиком, и maxTagCount, когда хвост пора сворачивать;
  • список на сотни строкvirtual оставляет в DOM окно вокруг вьюпорта;
  • значение-объектvalueKey сравнивает по идентификатору, а не по ссылке: модель приходит снаружи отдельной копией и по === не совпала бы.

Когда взять другое

НужноБерите
Пользователь ищет вводом, а не просматривает списокGrAutocomplete
Варианты — дерево с уровнямиGrTreeSelect
Выбирают команду приложения, а не значение поляGrCommandPalette
Вариантов 2–5 и они должны быть видны сразуGrSegmented / GrRadioGroup
Значений несколько, и все они видны спискомGrCheckboxGroup
Справочника нет, пользователь вводит свои строкиGrInputTag

filterable фильтрует уже загруженный список — это удобство внутри выбора, а не поиск. Как только запрос уходит на сервер и список строится по нему, задача меняется: там роль combobox носит сам <input>, и это GrAutocomplete.

Удалённая подгрузка

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

loading без v-model:search/@search был декоративным: набранный текст жил внутри компонента и наружу не выходил — сходить на сервер было не с чем. Теперь запрос доступен обоими способами: v-model:search для контролируемого значения и @search для побочного эффекта.

Пока loading, списка в DOM нет, поэтому aria-controls с триггера снимается — ссылка не висит в пустоту, а вместо неё стоит aria-busy.

Состав панели

Прямые потомки role="listbox" — только опции и группы: роль объявляет остальных недопустимыми детьми. Заголовок группы лежит внутри своей role="group" и даёт ей имя через aria-labelledby; загрузка и пустой результат вынесены из списка в живые регионы role="status" aria-live="polite" — иначе состояния менялись бы молча.

Опции не забирают фокус (tabindex="-1", mousedown подавлен): у combobox он живёт на триггере или в поле поиска, а активную опцию называет aria-activedescendant. Панель с полем поиска на закрытии возвращает фокус на триггер — если он ещё внутри панели.

Слоты #empty и #loading подменяют оба состояния.

Виртуализация

virtual оставляет в DOM только окно вокруг вьюпорта; высоту окна задаёт dropdownMaxHeight. Режим только для optionsView="panel" — у нативного <select> своей панели нет вовсе.

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

Группы переживают окно. Если панель прокручена внутрь группы, её заголовка в разметке уже нет — обёртка role="group" всё равно создаётся и берёт имя через aria-label вместо aria-labelledby. Иначе опции середины группы стали бы прямыми детьми listbox’а и потеряли бы имя набора.

Набор считается по группе, а не по списку. При virtual опции несут aria-setsize/aria-posinset, и для опции внутри группы это размер её группы — так того требует ARIA. Опции вне групп вместе с кнопкой «Add …» образуют набор уровня listbox’а. В обычном режиме атрибутов нет: там набор виден по DOM.

Не сочетается с view="link". В этом виде опции несут w-max, то есть ширина панели равна ширине самой широкой отрисованной опции — при виртуализации она прыгала бы на каждой прокрутке. Оба несовместимых сочетания пакет сообщает dev-предупреждением. Устройство примитива — virtual-list.md.

Активная опция и фокус

aria-activedescendant работает только на элементе, который держит фокус. При filterable/allowCustomValue фокус уходит в поле поиска внутри панели — связка с активной опцией живёт там же, и поле объявляет себя role="combobox". Без поля поиска всё остаётся на триггере.

Теги

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

Чипы живут рядом с кнопкой-комбобоксом, а не внутри неё: role="combobox" объявляет потомков презентационными, и крестик внутри был недостижим с клавиатуры (axe: nested-interactive). Теперь это настоящие кнопки в таб-порядке.

maxTagCount сворачивает хвост в «+N» — без него длинный выбор превращался в простыню чипов.

Цвет тега задаётся и на опции: tone и dark в самой опции перекрывают общие tagTone/tagDark. Нужно это чаще, чем кажется — метки, категории и статусы обычно приходят со своим цветом, — а без такой возможности потребитель уходил рисовать выбранное в слот #value и терял ровно то, ради чего берут tags: чипы снаружи role="combobox", снятие крестиком, сворачивание в «+N» и клавиатуру.

const options = [
  { value: 'bug', label: 'Баг', tone: 'danger' },
  { value: 'idea', label: 'Идея', tone: 'success', dark: true },
]

События

СобытиеКогда
update:modelValueзначение изменилось
changeто же значение, отдельным каналом (паритет с GrTreeSelect); в обоих режимах отрисовки
clearзначение снято кнопкой очистки
update:openпанель открылась/закрылась (v-model:open)
update:search / searchпользователь набрал запрос

Панель управляема через v-model:open (контракт панельных оверлеев пакета, как у GrPopover): без пропа open селект ведёт себя сам, с ним состоянием владеет родитель. Проп name включает участие в нативной форме: в native-режиме имя уходит на сам <select>, в panel-режиме значения сериализуются hidden-инпутами (ключ — valueKey/keyOf, по одному на значение при multiple).

Значения-объекты

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

Значением опции может быть объект — тогда обязателен valueKey с именем поля-идентификатора. Через него компонент строит ключ, по которому сравнивает значения и кладёт их в DOM: === означал бы сравнение ссылок, а модель обычно приходит снаружи отдельной копией — с тем же id, но другим объектом, и не совпала бы ни с одной опцией. Без valueKey объектные значения в dev-сборке дают предупреждение.

Наружу уходит сам объект, а не строка из DOM. allowCustomValue с объектными значениями не работает по природе: пользователь вводит текст.

Состояния

state (default | success | warning | danger) задаёт оттенок рамки, invalid форсирует красную и объявляет aria-invalid — ошибка перекрывает любую другую подсветку. В view="link" рамки нет, и состояние туда не применяется.

readonly показывает значение, но не открывает панель и не меняет выбор; disabled гасит контрол целиком. Кнопка очистки не прячет шеврон: поле с выбранным значением обязано выглядеть выпадающим списком.

Заголовок группы связан с опциями через aria-describedby — так группа слышна, не превращая плоский список в дерево.

Аддоны `prefix` / `suffix`

Слоты доступны только при optionsView="panel" — внутрь нативного <select> разметку положить нельзя (в dev об этом предупредит консоль). Кладут в оболочку иконку, единицу или метку; ширина ограничивается шестью пропами (prefixMinWidth/prefixMaxWidth/prefixFixed и то же для суффикса). Общий контракт контролов — form-controls.md.

Императивный API

focus() и blur() через ref компонента — в нативном режиме адресуют <select>, в панельном — кнопку-триггер.

Playground 36

Загружается…

Код
<GrSelect />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
modelValueобязательныйGrSelectModelValue<TValue>
optionsGrSelectOptionOrGroup<TValue>[] | undefinedundefinedСписок опций. Поддерживает плоский массив опций и группы опций (`{ label, options }`).
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не меняется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
state"default" | "success" | "warning" | "danger" | undefined"default"Визуальный оттенок рамки: `default | success | warning | danger`. `invalid` сильнее — ошибка перекрывает любую другую подсветку. В `view="link"` рамки нет, и состояние туда не применяется.
valueKeystring | undefinedundefinedИмя поля-идентификатора, когда значения опций — объекты. Без него объекты сравнивались бы по ссылке, и пришедшая снаружи копия с тем же `id` не совпала бы ни с одной опцией.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
ariaLabelstring | undefinedundefined
viewGrSelectView | undefined"default"
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefinedPlaceholder (показывается, когда значение не выбрано).
multipleboolean | undefinedfalseMultiple selection.
tagTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"neutral"Вид чипов выбранных значений в режиме `multiple`. Чип — это `GrBadge`: своя плашка на светлой теме почти не отличалась от фона поля.
tagDarkboolean | undefinedfalse
tagSize"xs" | "sm" | "md" | "lg" | undefined"sm"
tagRadiusGrBadgeRadius | undefined"round"
optionsViewGrSelectOptionsView | undefined"native"Как отображать список опций: нативный `<select>` или кастомная панель.
allowCustomValueboolean | undefinedfalseРазрешает ввод/выбор значения, которого нет в `options`.
filterableboolean | undefinedfalseПоиск/фильтрация опций по вводу (независимо от `allowCustomValue`). Показывает поле поиска над списком и фильтрует опции. Работает только в `optionsView="panel"` (при `native` панель форсится автоматически).
filterPlaceholderstring | undefinedundefinedPlaceholder поля поиска (`filterable`). i18n: fallback `gr.select.searchPlaceholder`.
loadingboolean | undefinedfalseСостояние загрузки: вместо списка опций панель показывает индикатор загрузки. Полезно для удалённой подгрузки опций. Форсит `optionsView="panel"`.
loadingTextstring | undefinedundefinedТекст индикатора загрузки. i18n: fallback `gr.select.loading`.
noResultsTextstring | undefinedundefinedТекст пустого результата фильтрации. i18n: fallback `gr.select.noResults`.
tagsboolean | undefinedfalseРежим тегов для `multiple`: выбранные значения показываются как удаляемые chips в триггере (вместо строки «a, b, c»). Форсит `optionsView="panel"`.
customValuePlaceholderstring | undefinedundefinedPlaceholder для инпута кастомного значения (только в `optionsView="panel"`). i18n-friendly: если не задан — берётся из адаптера перевода (`gr.select.customValuePlaceholder`), иначе — встроенный fallback.
dropdownMaxHeightnumber | undefined280Максимальная высота панели (только в `optionsView="panel"`).
virtualboolean | undefinedfalseВиртуализация панели: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `dropdownMaxHeight`. Только для `optionsView="panel"`: у нативного `<select>` панели нет вовсе. Несовместим с `view="link"` — там ширина панели равна ширине отрисованной опции и прыгала бы при прокрутке.
closeOnSelectboolean | undefinedtrueЗакрывать панель после выбора (только в `optionsView="panel"`).
maxTagCountnumber | undefinedundefinedСколько chips показать до сворачивания в «+N» (только `tags`).
clearableboolean | undefinedundefinedРазрешает очистку выбранного значения.
clearLabelstring | undefinedundefinedi18n-label для кнопки очистки (`aria-label`).
variantGrSelectVariant | undefinedundefinedЦвет/вариант ссылки для `view="link"` (аналогично `GrLink`). В `view="default"` не используется.
underlineGrSelectUnderline | undefinedundefinedПодчёркивание для `view="link"` (аналогично `GrLink`). В `view="default"` не используется.
openboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп. Только для `optionsView="panel"`: у нативного `<select>` панель браузерная.
namestring | undefinedundefinedИмя для нативной формы: в native-режиме уходит на сам `<select>`, в panel-режиме значения сериализуются hidden-инпутами (ключ — `keyOf`).
prefixMinWidthstring | undefinedundefinedШирины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`). Аддоны живут в панельном триггере: внутрь нативного `<select>` разметку положить нельзя.
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalse
suffixFixedboolean | undefinedfalse

Slots

SlotTypeОписание
defaultanyСобственные `<option>` для нативного режима.
prefixanyАддон слева в панельном триггере (в нативном режиме недоступен).
suffixanyАддон справа в панельном триггере, перед крестиком и шевроном.
value{ selectedOptions: GrSelectOption<TValue>[]; selectedValues: TValue[]; displayLabel: string; placeholder?: string | undefined; hasSelection: boolean; }Отображение значения в триггере вместо текста по умолчанию.
option{ option: GrSelectOption<TValue>; selected: boolean; }Строка списка вместо подписи опции.
loadinganyСодержимое панели, пока едут опции.
emptyanyСодержимое панели, когда подходящих опций нет.

Events

EventTypeОписание
update:modelValue[GrSelectModelValue<TValue>]
change[GrSelectModelValue<TValue>]Значение изменилось — тот же payload, что у `update:modelValue`.
clear[]Значение снято кнопкой очистки.
update:open[boolean]Панель открылась/закрылась (`v-model:open`).
update:search[string]Текст поиска как контролируемое значение (`v-model:search`).
focus[FocusEvent]
blur[FocusEvent]
search[string]Пользователь набрал запрос — сигнал сходить за опциями.

Примеры 9

Аддоны в триггере панели

Аддоны доступны в режиме optionsView="panel": внутрь нативного <select> разметку положить нельзя.

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>

Удалённый поиск, теги и события

v-model:search + @search для подгрузки с сервера, maxTagCount для длинного выбора и события change/clear/update:open.

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>

Интерактивный конструктор селекта

Живой playground для всех ключевых пропсов GrSelect: меняйте view, size, optionsView, variant, underline и состояния (multiple/clearable/disabled/allow-custom-value) без переключения между отдельными demo-картами.

Builderзависит от окружения витрины
<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>

Лучший формат для дизайн-ревью и QA: один сценарий сразу покрывает весь контракт пропсов и помогает быстро проверить native/panel-режимы и link-стилизацию.

Нативный режим: одиночный выбор и очистка

Базовый сценарий для GrSelect: обычный single-select и clearable режим в native-rendering без дополнительной composition-логики.

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>

Панель для множественного выбора

Отдельно показываем optionsView="panel" вместе с multiple, чтобы было видно поведение dropdown-панели как mini-picker.

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>

Этот сценарий помогает быстро проверить panel-behavior, множественный выбор и то, как компонент ведёт себя в формах фильтров.

Опции с группами

Опции можно группировать в стандартном формате { label, options: [{ value, label }] }. В optionsView="native" группы рендерятся как нативные <optgroup>, а в optionsView="panel" — как заголовки групп внутри dropdown-панели.

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>

Группы поддерживаются в обоих режимах отображения и смешиваются с плоскими опциями; в panel-режиме фильтрация по custom-value скрывает пустые группы.

Своё значение и слот значения

Сложный режим для cases, где пользователь может добавить свой вариант и одновременно кастомизировать отображение выбранного значения.

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>

Именно этот режим критичен для демо complex-компонента: здесь одновременно видны custom input, panel dropdown и slot-based composition.

Фильтр, загрузка и режим тегов

Три доработки panel-режима: filterable добавляет поле поиска над списком (независимо от allow-custom-value), loading показывает индикатор загрузки вместо опций (для удалённой подгрузки), а tags рендерит выбор multiple как удаляемые chips вместо строки «a, b, c».

Filterable — search box over the option list
Loading — spinner while options are fetched
Ничего не найдено
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>

filterable/loading/tags форсят panel-режим (в нативном <select> они невозможны). Поиск и подгрузка комбинируются: пока loading — список скрыт, дальше работает клиентская фильтрация.

Сгруппированный справочник на 10 000 позиций

С virtual в DOM живёт только окно вокруг вьюпорта. Группы при этом сохраняются: если панель прокручена внутрь группы, её обёртка всё равно создаётся и берёт имя через aria-label — заголовка в разметке в этот момент нет.

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>

aria-setsize/aria-posinset считаются по своему набору: у опции внутри группы это размер группы, а не всего списка. Режим только для optionsView="panel" и не сочетается с view="link" — там ширина панели равна ширине отрисованной опции.

Доступность

Паттерн APG
combobox + listbox
Клавиши
/ — по опциям, Home/End — к краям, Enter — выбрать активный элемент (при allowCustomValue строка «Add …» — первый элемент навигации, Enter на ней добавляет набранное), Esc — закрыть, Tab — закрыть и уйти, печатные символы — typeahead

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты