GrSelect
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 form —
optionsView="native"gives the choice to the system wheel, and on a phone that is better than any panel of your own; - multiple selection —
multiplewithtags, when the selection has to be visible as a whole, andmaxTagCount, when it is time to fold the tail; - a list of hundreds of rows —
virtualkeeps in the DOM the window around the viewport; - an object value —
valueKeycompares 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
| Need | Take |
|---|---|
| The user searches by typing rather than browsing the list | GrAutocomplete |
| The options are a tree with levels | GrTreeSelect |
| An application command is chosen rather than a field value | GrCommandPalette |
| There are 2–5 options and they have to be visible at once | GrSegmented / GrRadioGroup |
| There are several values and all of them are visible as a list | GrCheckboxGroup |
| There is no reference list, the user types their own strings | GrInputTag |
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
| Event | When |
|---|---|
update:modelValue | the value has changed |
change | the same value, through a separate channel (parity with GrTreeSelect); in both render modes |
clear | the value was removed with the clear button |
update:open | the panel opened/closed (v-model:open) |
update:search / search | the 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…
<GrSelect />Install
npm i @feugene/granularityImport
import { GrSelect } from '@feugene/granularity/components/GrSelect'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequired | GrSelectModelValue<TValue> | — | — |
options | GrSelectOptionOrGroup<TValue>[] | undefined | undefined | The list of options. It supports a flat array of options and groups of options (`{ label, options }`). |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read only: the value is visible and goes into the form but does not change. |
invalid | boolean | undefined | false | The 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. |
valueKey | string | undefined | undefined | The 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. |
required | boolean | undefined | false | A mandatory field (`aria-required`). |
ariaLabel | string | undefined | undefined | — |
view | GrSelectView | undefined | "default" | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | The placeholder (shown when no value is chosen). |
multiple | boolean | undefined | false | Multiple 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. |
tagDark | boolean | undefined | false | — |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "sm" | — |
tagRadius | GrBadgeRadius | undefined | "round" | — |
optionsView | GrSelectOptionsView | undefined | "native" | How to display the list of options: a native `<select>` or a custom panel. |
allowCustomValue | boolean | undefined | false | Permits entering or choosing a value that is not in `options`. |
filterable | boolean | undefined | false | Search 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). |
filterPlaceholder | string | undefined | undefined | The placeholder of the search field (`filterable`). i18n: the fallback is `gr.select.searchPlaceholder`. |
search | string | undefined | undefined | The search text as a controlled value (`v-model:search`). Without it `loading` was decorative: what the user typed did not come out, and there was nothing to fetch the options from the server with. |
loading | boolean | undefined | false | The 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"`. |
loadingText | string | undefined | undefined | The text of the loading indicator. i18n: the fallback is `gr.select.loading`. |
noResultsText | string | undefined | undefined | The text for an empty result of the filtering. i18n: the fallback is `gr.select.noResults`. |
tags | boolean | undefined | false | The 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"`. |
customValuePlaceholder | string | undefined | undefined | The 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. |
dropdownMaxHeight | number | undefined | 280 | The maximum height of the panel (only in `optionsView="panel"`). |
virtual | boolean | undefined | false | Virtualisation 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. |
closeOnSelect | boolean | undefined | true | Close the panel after a choice (only in `optionsView="panel"`). |
maxTagCount | number | undefined | undefined | How many chips to show before folding into "+N" (only with `tags`). |
clearable | boolean | undefined | undefined | Permits clearing the chosen value. |
clearLabel | string | undefined | undefined | The i18n label for the clear button (`aria-label`). |
variant | GrSelectVariant | undefined | undefined | The colour or variant of the link for `view="link"` (as in `GrLink`). It is not used in `view="default"`. |
underline | GrSelectUnderline | undefined | undefined | The underline for `view="link"` (as in `GrLink`). It is not used in `view="default"`. |
open | boolean | undefined | undefined | The 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. |
name | string | undefined | undefined | The 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`). |
prefixMinWidth | string | undefined | undefined | The 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>`. |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | — |
suffixFixed | boolean | undefined | false | — |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | Your own `<option>`s for the native mode. |
prefix | any | An addon on the left in the panel trigger (unavailable in the native mode). |
suffix | any | An 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. |
loading | any | The content of the panel while the options are on their way. |
empty | any | The content of the panel when there are no suitable options. |
Events
| Event | Type | Description |
|---|---|---|
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
<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
<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
<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>Native single and clearable
<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
<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
<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
<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
<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: —
<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