GrSelect

  • core
  • forms
  • API · 44 props
  • ⌨ keys

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

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

Overview

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 — multiple with tags, when the selection has to be visible as a whole, and maxTagCount, when it is time to fold the tail;
  • a list of hundreds of rows — virtual keeps in the DOM the window around the viewport;
  • an object value — valueKey compares by identifier rather than by reference: the model arrives from the outside as a separate copy and would not match by ===.

When to take something else

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

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

Variants every tone and view, rendered by the real component

Variants · GrSelect
state \ variantprimarydefaultmuteddanger
default
success
warning
danger

Sizes

  • xs
  • sm
  • md
  • lg

States

  • normal
  • disabled
  • loading
  • readonly
  • invalid

The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 5 each with a live demo and its code

  • Status, assignee and labels of an issue

    Only the panel takes markup, so options-view="panel" draws the status dot in the prefix and in every row, with statuses in «Open» and «Closed» groups. The assignee is an object: the issue holds its own copy, and value-key="id" matches it with the list — === would not. Labels bring their own tone, fold after three into «+1», and allow-custom-value creates one the team does not have yet.

    BILL-1839Seats added on the last day of the cycle are not proratedOpened by Mina Ortiz from ticket #4182
    BugBillingCustomer report +1
  • Filters that read as a sentence

    view="link" turns a select into a word of the sentence «Show all events by anyone in the last 30 days». A filter that narrows the log switches to variant="primary", so the sentence itself shows what is on; underline marks the words you can change. The list behind each word stays native.

    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
  • A checkout on a phone

    Short lists that are browsed stay native — on a phone the system wheel beats any panel. The default slot takes options of your own: a disabled first line and countries in <optgroup> by delivery time. The region waits switched off until the country is known, state="warning" tints a slot that is nearly gone, and name posts every value with the form.

    Delivery addressStep 2 of 3 · 3 items, €184.50

    Only 2 places left — book now

  • Four hundred time zones

    Every IANA zone the browser knows, grouped by region. virtual keeps only the rows near the viewport in the DOM, and the groups survive the window: a row in the middle of «America» is still heard as part of it. filterable narrows the list by city, the option slot adds the UTC offset, and #empty words the miss.

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

    Local time appears here

API generated from the package · cannot drift

Props

PropTypedefaultDescription
modelValuerequiredv-modelGrSelectModelValue<TValue>—The selected value (`v-model`); in `multiple` it is an array.
optionsGrSelectOptionOrGroup<TValue>[] | undefinedundefinedThe list of options. It supports a flat array of options and groups of options (`{ label, options }`).
disabledboolean | undefinedfalseSwitch the field off entirely. It adds up with the `disabled` of `GrFormField`.
readonlyboolean | undefinedfalseRead only: the value is visible and goes into the form but does not change.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
state"default" | "success" | "warning" | "danger" | undefined"default"The visual shade of the border: `default | success | warning | danger`. `invalid` is stronger — an error overrides any other highlight. In `view="link"` there is no border, and the state does not apply there.
valueKeystring | undefinedundefinedThe name of the identifier field when the values of the options are objects. Without it the objects would be compared by reference, and a copy arriving from outside with the same `id` would not match any option.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`.
shape"box" | "pill" | undefinedundefinedThe shape of the border. `box` — the rounding of the controls scale, as it has always been; `pill` — a pill, as in `GrSegmented`. Takes no part in `view="link"`: a link has no border.
viewGrSelectView | undefined"default"The look of the trigger: `default` is a field with a border, `link` is an inline link without a border, as in `GrLink`. `default` by default.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe height and the type size by the controls scale. `md` by default; taken from `GrConfigProvider`.
placeholderstring | undefinedundefinedThe placeholder (shown when no value is chosen).
multipleboolean | undefinedfalseMultiple selection.

Slots

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

Events

EventSignatureDescription
update:modelValue(e: "update:modelValue", value: GrSelectModelValue<TValue>): voidThe new value (`v-model`): choosing an option, removing a chip or clearing.
change(e: "change", value: GrSelectModelValue<TValue>): voidThe value has changed — the same payload as in `update:modelValue`.
clear(e: "clear"): voidThe value has been removed by the clear button.
update:open(e: "update:open", value: boolean): voidThe panel has opened or closed (`v-model:open`).
update:search(e: "update:search", value: string): voidThe search text as a controlled value (`v-model:search`).
focus(e: "focus", event: FocusEvent): voidFocus entered the control. Moving between the field and its panel does not count as an event.
blur(e: "blur", event: FocusEvent): voidFocus left the control — the field and the panel together.
search(e: "search", value: string): voidThe user has typed a query — a signal to go for the options.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrSelectConfigurableProps, GrSelectView, GrSelectSize } from '@feugene/granularity/components/GrSelect'

Filedist/types/components/GrSelect/config.d.ts

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

Filedist/types/components/GrSelect/defaults.d.ts

GrSelectConfigurablePropsinterface
interface GrSelectConfigurableProps {
    size: GrSelectSize;
    variant: GrSelectVariant;
    underline: GrSelectUnderline;
    clearable: boolean;
    shape: GrControlShape;
}

Filedist/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;
    tone?: GrChipTone;
    dark?: boolean;
};
GrSelectOptionGrouptype
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[];

Filedist/types/components/GrSelect/GrSelect.vue.d.ts

GrSelectPropsinterface
interface GrSelectProps<TValue extends GrSelectValue = string> {
    modelValue: GrSelectModelValue<TValue>;
    options?: GrSelectOptionOrGroup<TValue>[];
    disabled?: boolean;
    readonly?: boolean;
    invalid?: boolean;
    state?: GrSelectState;
    valueKey?: string;
    required?: boolean;
    ariaLabel?: string;
    shape?: GrControlShape;
    view?: GrSelectView;
    size?: GrSelectSize;
    placeholder?: string;
    multiple?: boolean;
    tagTone?: GrBadgeTone;
    tagDark?: boolean;
    tagSize?: GrBadgeSize;
    tagRadius?: GrBadgeRadius;
    optionsView?: GrSelectOptionsView;
    allowCustomValue?: boolean;
    filterable?: boolean;
    filterPlaceholder?: string;
    search?: string;
    loading?: boolean;
    loadingText?: string;
    noResultsText?: string;
    tags?: boolean;
    customValuePlaceholder?: string;
    dropdownMaxHeight?: number;
    virtual?: boolean;
    closeOnSelect?: boolean;
    maxTagCount?: number;
    clearable?: boolean;
    clearLabel?: string;
    variant?: GrSelectVariant;
    underline?: GrSelectUnderline;
    open?: boolean;
    name?: string;
    prefixMinWidth?: string;
    prefixMaxWidth?: string;
    suffixMinWidth?: string;
    suffixMaxWidth?: string;
    prefixFixed?: boolean;
    suffixFixed?: boolean;
}
GrSelectEmitsinterface
interface GrSelectEmits<TValue extends GrSelectValue = string> {
    (e: 'update:modelValue', value: GrSelectModelValue<TValue>): void;
    (e: 'change', value: GrSelectModelValue<TValue>): void;
    (e: 'clear'): void;
    (e: 'update:open', value: boolean): void;
    (e: 'update:search', value: string): void;
    (e: 'focus', event: FocusEvent): void;
    (e: 'blur', event: FocusEvent): void;
    (e: 'search', value: string): void;
}

Filedist/types/components/GrSelect/safelist.d.ts

grSelectSafelistconst
const grSelectSafelist: string[];

Filedist/types/components/GrSelect/index.d.ts

GrSelectInstancetype
type GrSelectInstance = ComponentExposed<typeof GrSelectComponent>;

Accessibility

APG pattern

combobox + listbox

Keys

↑ / ↓Home / EndEnterEsc

Usage from the library prose

Remote loading
vue
<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.

The height of the panel

dropdownMaxHeight is the ceiling of the list, not of the panel. The panel itself is limited by the space left up to the edge of the viewport: the layer reports the measurement in --gr-floating-available-height, and on a short screen it is the list that shrinks — the search field and the state rows stay in view, and there is one scrollbar. The mechanism is shared by the list panels of the package and is described in ../overlays.md.

Virtualisation

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

vue
<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.

When the panel opens, the selected option becomes the active one, and the list is scrolled to it at once — with virtual too: the window settles around it, and aria-activedescendant always points to a rendered element. The arrows keep the active option inside the window.

The tags
vue
<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 field grows together with the rows of chips. In a narrow column the chips wrap, and the field becomes taller — never lower than the ordinary control row of its size; the border and the focus ring go round all the rows. The chevron and the clear button sit at the vertical centre. Previously the field kept the height of a single row, and the second row hung below the border on top of whatever came next.

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.

ts
const options = [
  { value: 'bug', label: 'Bug', tone: 'danger' },
  { value: 'idea', label: 'Idea', tone: 'success', dark: true },
]
The events
EventWhen
update:modelValuethe value has changed
changethe same value, through a separate channel (parity with GrTreeSelect); in both render modes
clearthe value was removed with the clear button
update:openthe panel opened/closed (v-model:open)
update:search / searchthe user typed a query

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

Your own <option> in the native mode

The default slot accepts <option> and <optgroup> of your own — for example, an unavailable hint as the first row. Their selection is set by the component from v-model, as for the options from options: there is no need to write a :selected of your own, and the server renders the option already 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>

Previously the options from the slot got nothing: with country = '' the browser showed the first available option, and a programmatic change of the model never reached the screen.

An empty value in native mode

A single native select always shows an empty model (undefined, null, '') as empty. With clearable the clear option shows it. Without it the component puts first a hint option <option value="" disabled hidden selected> with the text of placeholder, and without that — the translated “Select…” (gr.select.placeholder). Without such an option the browser picks the first one itself: the field would show “Germany” with an empty value, and select.value would go into the form past the model.

While the field shows the hint or the clear option, the <select> carries data-gr-select-empty, and its text is in the hint colour (--gr-muted-fg), like ::placeholder of GrInput: otherwise the hint reads as a chosen value. The options of the opened list get the normal colour back, disabled ones the browser’s. The colours are set by an inline style rather than utilities: a rule with the same body would merge with a shared one and shift the order of rules for the neighbours.

The hint cannot be picked back (disabled) and is not in the opened list (hidden): without clearable there is no way to return to “nothing” — that is intended for a required field. As soon as a value is chosen, the hint is gone. If options has an option with an empty value, it is itself the “nothing selected”, and the hint is not added; your own empty <option> in the slot overrides it in the same way.

Object values
vue
<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.