GrSelect
Берут, когда поле формы со списком значений.
Когда брать
- поле формы со списком значений — 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelValueобязательный | GrSelectModelValue<TValue> | — | — |
options | GrSelectOptionOrGroup<TValue>[] | undefined | undefined | Список опций. Поддерживает плоский массив опций и группы опций (`{ label, options }`). |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно и уходит в форму, но не меняется. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки. |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | Визуальный оттенок рамки: `default | success | warning | danger`. `invalid` сильнее — ошибка перекрывает любую другую подсветку. В `view="link"` рамки нет, и состояние туда не применяется. |
valueKey | string | undefined | undefined | Имя поля-идентификатора, когда значения опций — объекты. Без него объекты сравнивались бы по ссылке, и пришедшая снаружи копия с тем же `id` не совпала бы ни с одной опцией. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). |
ariaLabel | string | undefined | undefined | — |
view | GrSelectView | undefined | "default" | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | Placeholder (показывается, когда значение не выбрано). |
multiple | boolean | undefined | false | Multiple selection. |
tagTone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "neutral" | Вид чипов выбранных значений в режиме `multiple`. Чип — это `GrBadge`: своя плашка на светлой теме почти не отличалась от фона поля. |
tagDark | boolean | undefined | false | — |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "sm" | — |
tagRadius | GrBadgeRadius | undefined | "round" | — |
optionsView | GrSelectOptionsView | undefined | "native" | Как отображать список опций: нативный `<select>` или кастомная панель. |
allowCustomValue | boolean | undefined | false | Разрешает ввод/выбор значения, которого нет в `options`. |
filterable | boolean | undefined | false | Поиск/фильтрация опций по вводу (независимо от `allowCustomValue`). Показывает поле поиска над списком и фильтрует опции. Работает только в `optionsView="panel"` (при `native` панель форсится автоматически). |
filterPlaceholder | string | undefined | undefined | Placeholder поля поиска (`filterable`). i18n: fallback `gr.select.searchPlaceholder`. |
search | string | undefined | undefined | Текст поиска как контролируемое значение (`v-model:search`). Без него `loading` был декоративным: набранное пользователем наружу не выходило, и сходить за опциями на сервер было не с чем. |
loading | boolean | undefined | false | Состояние загрузки: вместо списка опций панель показывает индикатор загрузки. Полезно для удалённой подгрузки опций. Форсит `optionsView="panel"`. |
loadingText | string | undefined | undefined | Текст индикатора загрузки. i18n: fallback `gr.select.loading`. |
noResultsText | string | undefined | undefined | Текст пустого результата фильтрации. i18n: fallback `gr.select.noResults`. |
tags | boolean | undefined | false | Режим тегов для `multiple`: выбранные значения показываются как удаляемые chips в триггере (вместо строки «a, b, c»). Форсит `optionsView="panel"`. |
customValuePlaceholder | string | undefined | undefined | Placeholder для инпута кастомного значения (только в `optionsView="panel"`). i18n-friendly: если не задан — берётся из адаптера перевода (`gr.select.customValuePlaceholder`), иначе — встроенный fallback. |
dropdownMaxHeight | number | undefined | 280 | Максимальная высота панели (только в `optionsView="panel"`). |
virtual | boolean | undefined | false | Виртуализация панели: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `dropdownMaxHeight`. Только для `optionsView="panel"`: у нативного `<select>` панели нет вовсе. Несовместим с `view="link"` — там ширина панели равна ширине отрисованной опции и прыгала бы при прокрутке. |
closeOnSelect | boolean | undefined | true | Закрывать панель после выбора (только в `optionsView="panel"`). |
maxTagCount | number | undefined | undefined | Сколько chips показать до сворачивания в «+N» (только `tags`). |
clearable | boolean | undefined | undefined | Разрешает очистку выбранного значения. |
clearLabel | string | undefined | undefined | i18n-label для кнопки очистки (`aria-label`). |
variant | GrSelectVariant | undefined | undefined | Цвет/вариант ссылки для `view="link"` (аналогично `GrLink`). В `view="default"` не используется. |
underline | GrSelectUnderline | undefined | undefined | Подчёркивание для `view="link"` (аналогично `GrLink`). В `view="default"` не используется. |
open | boolean | undefined | undefined | Контролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп. Только для `optionsView="panel"`: у нативного `<select>` панель браузерная. |
name | string | undefined | undefined | Имя для нативной формы: в native-режиме уходит на сам `<select>`, в panel-режиме значения сериализуются hidden-инпутами (ключ — `keyOf`). |
prefixMinWidth | string | undefined | undefined | Ширины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`). Аддоны живут в панельном триггере: внутрь нативного `<select>` разметку положить нельзя. |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | — |
suffixFixed | boolean | undefined | false | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Собственные `<option>` для нативного режима. |
prefix | any | Аддон слева в панельном триггере (в нативном режиме недоступен). |
suffix | any | Аддон справа в панельном триггере, перед крестиком и шевроном. |
value | { selectedOptions: GrSelectOption<TValue>[]; selectedValues: TValue[]; displayLabel: string; placeholder?: string | undefined; hasSelection: boolean; } | Отображение значения в триггере вместо текста по умолчанию. |
option | { option: GrSelectOption<TValue>; selected: boolean; } | Строка списка вместо подписи опции. |
loading | any | Содержимое панели, пока едут опции. |
empty | any | Содержимое панели, когда подходящих опций нет. |
Events
| Event | Type | Описание |
|---|---|---|
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> разметку положить нельзя.
<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.
<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-картами.
<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('&', '&').replaceAll('"', '"')
}
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-логики.
<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.
<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-панели.
<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, где пользователь может добавить свой вариант и одновременно кастомизировать отображение выбранного значения.
<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».
<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: —
<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