GrSelect
Selection of one or several values from a list of options.
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 —
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.
Variants every tone and view, rendered by the real component
state \ variant | primary | default | muted | danger |
|---|---|---|---|---|
default | ||||
success | ||||
warning | ||||
danger |
Sizes
xssmmdlg
States
normaldisabledloadingreadonlyinvalid
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
Loading…
0 changed· state is in the URL
<GrSelect
:options="options"
v-model="value"
ariaLabel="Status"
/>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 theprefixand in every row, with statuses in «Open» and «Closed» groups. The assignee is an object: the issue holds its own copy, andvalue-key="id"matches it with the list —===would not. Labels bring their owntone, fold after three into «+1», andallow-custom-valuecreates 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 +1Filters 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 tovariant="primary", so the sentence itself shows what is on;underlinemarks the words you can change. The list behind each word stays native.Security log Showbyin- 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, andnameposts every value with the form.Four hundred time zones
Every IANA zone the browser knows, grouped by region.
virtualkeeps 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.filterablenarrows the list by city, theoptionslot adds the UTC offset, and#emptywords 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
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequiredv-model | GrSelectModelValue<TValue> | — | The selected value (`v-model`); in `multiple` it is an array. |
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 | Switch the field off entirely. It adds up with the `disabled` of `GrFormField`. |
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 | The accessible name outside `GrFormField`. |
shape | "box" | "pill" | undefined | undefined | The 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. |
view | GrSelectView | 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" | undefined | undefined | The height and the type size by the controls scale. `md` by default; taken from `GrConfigProvider`. |
placeholder | string | undefined | undefined | The placeholder (shown when no value is chosen). |
multiple | boolean | undefined | false | Multiple selection. |
tagTone |
| "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 | A dense fill of the chips in the `tags` mode. The `dark` field of an option is stronger. |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "sm" | The size of the chips by the `GrBadge` scale. `sm` by default. |
tagRadius | GrBadgeRadius | undefined | "round" | The rounding of the chips: `square`, `semi` or `round`. `round` by default. |
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`. |
searchv-model | 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"`. |
openv-model | 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 | The maximum width of the prefix: wider content is clipped. |
suffixMinWidth | string | undefined | undefined | The minimum width of the suffix. By default — by the size step. |
suffixMaxWidth | string | undefined | undefined | The maximum width of the suffix: wider content is clipped. |
prefixFixed | boolean | undefined | false | A fixed width of the prefix: `prefixMaxWidth`, otherwise `prefixMinWidth`, otherwise by the size step. |
suffixFixed | boolean | undefined | false | A fixed width of the suffix: `suffixMaxWidth`, otherwise `suffixMinWidth`, otherwise by the size step. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | Your 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. |
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 |
| 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 | Signature | Description |
|---|---|---|
update:modelValue | (e: "update:modelValue", value: GrSelectModelValue<TValue>): void | The new value (`v-model`): choosing an option, removing a chip or clearing. |
change | (e: "change", value: GrSelectModelValue<TValue>): void | The value has changed — the same payload as in `update:modelValue`. |
clear | (e: "clear"): void | The value has been removed by the clear button. |
update:open | (e: "update:open", value: boolean): void | The panel has opened or closed (`v-model:open`). |
update:search | (e: "update:search", value: string): void | The search text as a controlled value (`v-model:search`). |
focus | (e: "focus", event: FocusEvent): void | Focus entered the control. Moving between the field and its panel does not count as an event. |
blur | (e: "blur", event: FocusEvent): void | Focus left the control — the field and the panel together. |
search | (e: "search", value: string): void | The 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 type { GrSelectConfigurableProps, GrSelectView, GrSelectSize } from '@feugene/granularity/components/GrSelect'Filedist/types/components/GrSelect/config.d.ts
const grSelectConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSelect">;Filedist/types/components/GrSelect/defaults.d.ts
interface GrSelectConfigurableProps {
size: GrSelectSize;
variant: GrSelectVariant;
underline: GrSelectUnderline;
clearable: boolean;
shape: GrControlShape;
}Filedist/types/components/GrSelect/grSelectStyles.d.ts
type GrSelectView = 'default' | 'link';type GrSelectSize = GrComponentSize;type GrSelectVariant = 'primary' | 'default' | 'muted' | 'danger';type GrSelectUnderline = 'auto' | 'always' | 'none';type GrSelectOptionsView = 'native' | 'panel';type GrSelectState = GrControlState;type GrSelectOption<TValue extends GrSelectValue = string> = {
value: TValue;
label: string;
disabled?: boolean;
tone?: GrChipTone;
dark?: boolean;
};type GrSelectOptionGroup<TValue extends GrSelectValue = string> = {
label: string;
options: GrSelectOption<TValue>[];
};type GrSelectOptionOrGroup<TValue extends GrSelectValue = string> = GrSelectOption<TValue> | GrSelectOptionGroup<TValue>;type GrSelectModelValue<TValue extends GrSelectValue = string> = TValue | TValue[];Filedist/types/components/GrSelect/GrSelect.vue.d.ts
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;
}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
const grSelectSafelist: string[];Filedist/types/components/GrSelect/index.d.ts
type GrSelectInstance = ComponentExposed<typeof GrSelectComponent>;Accessibility
APG pattern
combobox + listbox
Keys
↑ / ↓Home / EndEnterEsc
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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.
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.
<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
<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.
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).
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.
<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
<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.
Props in this example