GrSelect

  • ядро
  • Формы
  • API · 44 props
  • ⌨ клавиши

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

import { GrSelect } from '@feugene/granularity/components/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
state \ variantprimarydefaultmuteddanger
default
success
warning
danger

Размеры

  • xs
  • sm
  • md
  • lg

Состояния

  • обычная
  • disabled
  • loading
  • readonly
  • invalid

Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.

Playground контролы выводятся из API, а не пишутся руками

Сцена

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

0 изменено· состояние — в адресе

Примеры 5 у каждого — живое демо и его код

  • Статус, исполнитель и метки задачи

    Разметку принимает только панель, поэтому options-view="panel" рисует точку статуса в prefix и в каждой строке, а статусы разложены по группам «Open» и «Closed». Исполнитель — объект: задача держит свою копию, и сопоставляет её со списком value-key="id" — === не совпал бы. Метки приходят со своим tone, после третьей сворачиваются в «+1», а allow-custom-value заводит метку, которой у команды ещё нет.

    BILL-1839Seats added on the last day of the cycle are not proratedOpened by Mina Ortiz from ticket #4182
    BugBillingCustomer report +1
  • Фильтры, которые читаются фразой

    view="link" превращает селект в слово фразы «Show all events by anyone in the last 30 days». Фильтр, который сужает журнал, переходит в variant="primary" — фраза сама показывает, что включено; underline отмечает слова, которые можно поменять. Список за каждым словом остаётся нативным.

    Security log
    Show
    by
    in
    • Mina Ortiz signed in from Lisbon, Chrome on macOSToday
    • Daniel Kim made Grace Wu an adminYesterday
    • Mina Ortiz created the API key «Zapier export»2 days ago
    • Grace Wu signed in from Dublin, Safari on iOS4 days ago
    • Daniel Kim revoked the API key «Old CRM sync»9 days ago
    • Grace Wu removed Jonas Weber from the workspace21 days ago
  • Оформление заказа с телефона

    Короткие списки, которые просматривают, остаются нативными — на телефоне системное колесо лучше любой панели. Слот по умолчанию принимает свои опции: отключённую первую строку и страны в <optgroup> по сроку доставки. Регион ждёт выключенным, пока неизвестна страна, state="warning" подкрашивает почти разобранный слот доставки, а name отправляет каждое значение вместе с формой.

    Delivery addressStep 2 of 3 · 3 items, €184.50

    Only 2 places left — book now

  • Четыреста часовых поясов

    Все зоны IANA, которые знает браузер, по регионам. virtual держит в DOM только строки у области просмотра, а группы переживают окно: строка из середины «America» по-прежнему слышна как её часть. filterable сужает список по городу, слот option добавляет смещение от UTC, а #empty объясняет промах.

    Workspace time zoneReports, digests and the billing day are cut at midnight here

    Local time appears here

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
modelValueобязательныйv-modelGrSelectModelValue<TValue>—Выбранное значение (`v-model`); в `multiple` — массив.
optionsGrSelectOptionOrGroup<TValue>[] | undefinedundefinedСписок опций. Поддерживает плоский массив опций и группы опций (`{ label, options }`).
disabledboolean | undefinedfalseВыключить поле целиком. Складывается с `disabled` у `GrFormField`.
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Доступное имя вне `GrFormField`.
shape"box" | "pill" | undefinedundefinedФорма рамки. `box` — скругление шкалы контролов, как было всегда; `pill` — пилюля, как у `GrSegmented`. В `view="link"` не участвует: у ссылки рамки нет.
viewGrSelectView | undefined"default"Вид триггера: `default` — поле с рамкой, `link` — строчная ссылка без рамки, как `GrLink`. По умолчанию `default`.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedВысота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`.
placeholderstring | undefinedundefinedPlaceholder (показывается, когда значение не выбрано).
multipleboolean | undefinedfalseMultiple selection.

Слоты

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

События

EventСигнатураОписание
update:modelValue(e: "update:modelValue", value: GrSelectModelValue<TValue>): voidНовое значение (`v-model`): выбор опции, снятие чипа или очистка.
change(e: "change", value: GrSelectModelValue<TValue>): voidЗначение изменилось — тот же payload, что у `update:modelValue`.
clear(e: "clear"): voidЗначение снято кнопкой очистки.
update:open(e: "update:open", value: boolean): voidПанель открылась/закрылась (`v-model:open`).
update:search(e: "update:search", value: string): voidТекст поиска как контролируемое значение (`v-model:search`).
focus(e: "focus", event: FocusEvent): voidФокус вошёл в контрол. Переход между полем и его панелью событием не считается.
blur(e: "blur", event: FocusEvent): voidФокус ушёл из контрола — из поля и из панели вместе.
search(e: "search", value: string): voidПользователь набрал запрос — сигнал сходить за опциями.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrSelectConfigurableProps, GrSelectView, GrSelectSize } from '@feugene/granularity/components/GrSelect'

Файлdist/types/components/GrSelect/config.d.ts

grSelectConfigconst
const grSelectConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSelect">;

Файлdist/types/components/GrSelect/defaults.d.ts

GrSelectConfigurablePropsinterface

Пропы GrSelect, настраиваемые глобально через <GrConfigProvider :component-defaults="{ GrSelect: { … } }">. Только оформление и поведение по умолчанию — см. GrButton/defaults.ts.

interface GrSelectConfigurableProps {
    size: GrSelectSize;
    variant: GrSelectVariant;
    underline: GrSelectUnderline;
    clearable: boolean;
    /** Форма рамки — см. `components/shared/controlShape.ts`. */
    shape: GrControlShape;
}

Файлdist/types/components/GrSelect/grSelectStyles.d.ts

GrSelectViewtype
type GrSelectView = 'default' | 'link';
GrSelectSizetype
type GrSelectSize = GrComponentSize;
GrSelectVarianttype
type GrSelectVariant = 'primary' | 'default' | 'muted' | 'danger';
GrSelectUnderlinetype
type GrSelectUnderline = 'auto' | 'always' | 'none';
GrSelectOptionsViewtype
type GrSelectOptionsView = 'native' | 'panel';
GrSelectStatetype
type GrSelectState = GrControlState;
GrSelectOptiontype
type GrSelectOption<TValue extends GrSelectValue = string> = {
    value: TValue;
    label: string;
    disabled?: boolean;
    /**
     * Тон чипа в режиме `tags` — перекрывает `tagTone` для этой опции.
     *
     * Цвет у каждого тега свой — обычная модель (метки, категории, статусы), и
     * без неё потребитель уходил в слот `#value` рисовать выбранное сам, теряя
     * ровно то, что в `tags` сделано правильно: чипы снаружи `role="combobox"`,
     * снятие крестиком, сворачивание в «+N» и клавиатуру.
     */
    tone?: GrChipTone;
    /** Плотная заливка чипа этой опции — перекрывает `tagDark`. */
    dark?: boolean;
};
GrSelectOptionGrouptype

Группа опций: заголовок label + вложенные опции options.

type GrSelectOptionGroup<TValue extends GrSelectValue = string> = {
    label: string;
    options: GrSelectOption<TValue>[];
};
GrSelectOptionOrGrouptype

Элемент списка опций: либо одиночная опция, либо группа опций.

type GrSelectOptionOrGroup<TValue extends GrSelectValue = string> = GrSelectOption<TValue> | GrSelectOptionGroup<TValue>;
GrSelectModelValuetype
type GrSelectModelValue<TValue extends GrSelectValue = string> = TValue | TValue[];

Файлdist/types/components/GrSelect/GrSelect.vue.d.ts

GrSelectPropsinterface

Пропсы публичного GR-примитива «Select».

interface GrSelectProps<TValue extends GrSelectValue = string> {
    /** Выбранное значение (`v-model`); в `multiple` — массив. */
    modelValue: GrSelectModelValue<TValue>;
    /** Список опций. Поддерживает плоский массив опций и группы опций (`{ label, options }`). */
    options?: GrSelectOptionOrGroup<TValue>[];
    /** Выключить поле целиком. Складывается с `disabled` у `GrFormField`. */
    disabled?: boolean;
    /** Только для чтения: значение видно и уходит в форму, но не меняется. */
    readonly?: boolean;
    /** Визуальное и ARIA-состояние ошибки. */
    invalid?: boolean;
    /**
     * Визуальный оттенок рамки: `default | success | warning | danger`. `invalid`
     * сильнее — ошибка перекрывает любую другую подсветку. В `view="link"` рамки
     * нет, и состояние туда не применяется.
     */
    state?: GrSelectState;
    /**
     * Имя поля-идентификатора, когда значения опций — объекты. Без него объекты
     * сравнивались бы по ссылке, и пришедшая снаружи копия с тем же `id` не
     * совпала бы ни с одной опцией.
     */
    valueKey?: string;
    /** Обязательное поле (`aria-required`). */
    required?: boolean;
    /** Доступное имя вне `GrFormField`. */
    ariaLabel?: string;
    /**
     * Форма рамки. `box` — скругление шкалы контролов, как было всегда; `pill` —
     * пилюля, как у `GrSegmented`. В `view="link"` не участвует: у ссылки рамки нет.
     */
    shape?: GrControlShape;
    /**
     * Вид триггера: `default` — поле с рамкой, `link` — строчная ссылка без рамки, как
     * `GrLink`. По умолчанию `default`.
     */
    view?: GrSelectView;
    /** Высота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`. */
    size?: GrSelectSize;
    /** Placeholder (показывается, когда значение не выбрано). */
    placeholder?: string;
    /** Multiple selection. */
    multiple?: boolean;
    /**
     * Вид чипов выбранных значений в режиме `multiple`. Чип — это `GrBadge`: своя
     * плашка на светлой теме почти не отличалась от фона поля.
     */
    tagTone?: GrBadgeTone;
    /** Плотная заливка чипов в режиме `tags`. Поле `dark` у опции сильнее. */
    tagDark?: boolean;
    /** Размер чипов по шкале `GrBadge`. По умолчанию `sm`. */
    tagSize?: GrBadgeSize;
    /** Скругление чипов: `square`, `semi` или `round`. По умолчанию `round`. */
    tagRadius?: GrBadgeRadius;
    /** Как отображать список опций: нативный `<select>` или кастомная панель. */
    optionsView?: GrSelectOptionsView;
    /** Разрешает ввод/выбор значения, которого нет в `options`. */
    allowCustomValue?: boolean;
    /**
     * Поиск/фильтрация опций по вводу (независимо от `allowCustomValue`). Показывает
     * поле поиска над списком и фильтрует опции. Работает только в `optionsView="panel"`
     * (при `native` панель форсится автоматически).
     */
    filterable?: boolean;
    /** Placeholder поля поиска (`filterable`). i18n: fallback `gr.select.searchPlaceholder`. */
    filterPlaceholder?: string;
    /**
     * Текст поиска как контролируемое значение (`v-model:search`). Без него
     * `loading` был декоративным: набранное пользователем наружу не выходило, и
     * сходить за опциями на сервер было не с чем.
     */
    search?: string;
    /**
     * Состояние загрузки: вместо списка опций панель показывает индикатор загрузки.
     * Полезно для удалённой подгрузки опций. Форсит `optionsView="panel"`.
     */
    loading?: boolean;
    /** Текст индикатора загрузки. i18n: fallback `gr.select.loading`. */
    loadingText?: string;
    /** Текст пустого результата фильтрации. i18n: fallback `gr.select.noResults`. */
    noResultsText?: string;
    /**
     * Режим тегов для `multiple`: выбранные значения показываются как удаляемые
     * chips в триггере (вместо строки «a, b, c»). Форсит `optionsView="panel"`.
     */
    tags?: boolean;
    /** Placeholder для инпута кастомного значения (только в `optionsView="panel"`). i18n-friendly: если не задан — берётся из адаптера перевода (`gr.select.customValuePlaceholder`), иначе — встроенный fallback. */
    customValuePlaceholder?: string;
    /** Максимальная высота панели (только в `optionsView="panel"`). */
    dropdownMaxHeight?: number;
    /**
     * Виртуализация панели: в DOM живёт только окно вокруг вьюпорта. Высоту окна
     * задаёт `dropdownMaxHeight`.
     *
     * Только для `optionsView="panel"`: у нативного `<select>` панели нет вовсе.
     * Несовместим с `view="link"` — там ширина панели равна ширине отрисованной
     * опции и прыгала бы при прокрутке.
     */
    virtual?: boolean;
    /** Закрывать панель после выбора (только в `optionsView="panel"`). */
    closeOnSelect?: boolean;
    /** Сколько chips показать до сворачивания в «+N» (только `tags`). */
    maxTagCount?: number;
    /** Разрешает очистку выбранного значения. */
    clearable?: boolean;
    /** i18n-label для кнопки очистки (`aria-label`). */
    clearLabel?: string;
    /**
     * Цвет/вариант ссылки для `view="link"` (аналогично `GrLink`).
     * В `view="default"` не используется.
     */
    variant?: GrSelectVariant;
    /**
     * Подчёркивание для `view="link"` (аналогично `GrLink`).
     * В `view="default"` не используется.
     */
    underline?: GrSelectUnderline;
    /**
     * Контролируемое состояние панели (`v-model:open`). Без пропа панель ведёт
     * себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп.
     * Только для `optionsView="panel"`: у нативного `<select>` панель браузерная.
     */
    open?: boolean;
    /**
     * Имя для нативной формы: в native-режиме уходит на сам `<select>`, в
     * panel-режиме значения сериализуются hidden-инпутами (ключ — `keyOf`).
     */
    name?: string;
    /**
     * Ширины аддонов `prefix`/`suffix` — общий контракт контролов пакета
     * (`docs/form-controls.md`). Аддоны живут в панельном триггере: внутрь
     * нативного `<select>` разметку положить нельзя.
     */
    prefixMinWidth?: string;
    /** Максимальная ширина префикса: содержимое шире обрезается. */
    prefixMaxWidth?: string;
    /** Минимальная ширина суффикса. По умолчанию — по ступени размера. */
    suffixMinWidth?: string;
    /** Максимальная ширина суффикса: содержимое шире обрезается. */
    suffixMaxWidth?: string;
    /** Жёсткая ширина префикса: `prefixMaxWidth`, иначе `prefixMinWidth`, иначе по ступени размера. */
    prefixFixed?: boolean;
    /** Жёсткая ширина суффикса: `suffixMaxWidth`, иначе `suffixMinWidth`, иначе по ступени размера. */
    suffixFixed?: boolean;
}
GrSelectEmitsinterface
interface GrSelectEmits<TValue extends GrSelectValue = string> {
    /** Новое значение (`v-model`): выбор опции, снятие чипа или очистка. */
    (e: 'update:modelValue', value: GrSelectModelValue<TValue>): void;
    /** Значение изменилось — тот же payload, что у `update:modelValue`. */
    (e: 'change', value: GrSelectModelValue<TValue>): void;
    /** Значение снято кнопкой очистки. */
    (e: 'clear'): void;
    /** Панель открылась/закрылась (`v-model:open`). */
    (e: 'update:open', value: boolean): void;
    /** Текст поиска как контролируемое значение (`v-model:search`). */
    (e: 'update:search', value: string): void;
    /** Фокус вошёл в контрол. Переход между полем и его панелью событием не считается. */
    (e: 'focus', event: FocusEvent): void;
    /** Фокус ушёл из контрола — из поля и из панели вместе. */
    (e: 'blur', event: FocusEvent): void;
    /** Пользователь набрал запрос — сигнал сходить за опциями. */
    (e: 'search', value: string): void;
}

Файлdist/types/components/GrSelect/safelist.d.ts

grSelectSafelistconst
const grSelectSafelist: string[];

Файлdist/types/components/GrSelect/index.d.ts

GrSelectInstancetype
type GrSelectInstance = ComponentExposed<typeof GrSelectComponent>;

Доступность

Паттерн APG

combobox + listbox

Клавиши

↑ / ↓Home / EndEnterEsc

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

Использование из прозы библиотеки

Удалённая подгрузка
vue
<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 подменяют оба состояния.

Высота панели

dropdownMaxHeight — потолок списка, а не панели. Саму панель ограничивает место, оставшееся до края вьюпорта: слой сообщает замер в --gr-floating-available-height, и на коротком экране сжимается именно список — поле поиска и строки состояний остаются на виду, а полоса прокрутки одна. Механизм общий на списочные панели пакета и описан в ../overlays.md.

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

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

vue
<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". Без поля поиска всё остаётся на триггере.

При открытии панели активной становится выбранная опция, и список сразу прокручен к ней — с virtual тоже: окно встаёт вокруг неё, и aria-activedescendant всегда указывает на отрисованный элемент. Стрелки держат активную опцию в окне.

Теги
vue
<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» и клавиатуру.

ts
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).

Свои <option> в нативном режиме

Дефолтный слот принимает собственные <option> и <optgroup> — например, недоступную подсказку первой строкой. Выбор у них ставит компонент по v-model, как у опций из options: свой :selected писать не нужно, и сервер отдаёт уже выбранную опцию.

vue
<template>
  <GrSelect v-model="country" name="country">
    <option value="" disabled>
      Choose a country
    </option>
    <optgroup label="Next working day">
      <option value="ie">
        Ireland
      </option>
    </optgroup>
  </GrSelect>
</template>

Раньше опции из слота не получали ничего: при country = '' браузер показывал первую доступную опцию, а программная смена модели на экран не доходила.

Пустое значение в нативном режиме

У одиночного нативного выбора пустая модель (undefined, null, '') всегда видна как пустая. С clearable её показывает опция очистки. Без него компонент ставит первой опцию-подсказку <option value="" disabled hidden selected> с текстом placeholder, а без него — переведённым «Select…» (gr.select.placeholder). Без такой опции браузер выбирает первую сам: поле показывало бы «Germany» при пустом значении, а select.value уходил бы в форму мимо модели.

Пока поле показывает подсказку или опцию очистки, у <select> стоит data-gr-select-empty, а его текст — цветом подсказки (--gr-muted-fg), как ::placeholder у GrInput: иначе подсказка читается выбранным значением. Опции раскрытого списка возвращают обычный цвет, недоступные — браузерный. Цвета ставятся стилем в строке, а не утилитами: правило с тем же телом слилось бы с общим и сдвинуло порядок правил у соседей.

Подсказку не выбрать обратно (disabled) и нет в раскрытом списке (hidden): вернуть «ничего» без clearable нельзя — так и задумано у обязательного поля. Как только значение выбрано, подсказки нет. Если в options есть опция с пустым значением, она сама и есть «ничего не выбрано», и подсказка не добавляется; своя пустая <option> в слоте перекрывает её тем же образом.

Значения-объекты
vue
<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>, в панельном — кнопку-триггер.