ComponentCDK OverlayUpdated in v15

Select

Pick one or several values from a list, with search and free-text tags.

Installation

Import the class, and @use the entry point in your global styles: without it the component renders unstyled.

import { Component } from '@angular/core';
import { WrOption, WrOptionGroup, WrOptionLeading, WrSelect } from 'ngwr/select';

@Component({ imports: [WrOption, WrOptionGroup, WrOptionLeading, WrSelect] })
export class MyComponent {}

Basic usage

[(value)] holds the picked option's value.

Value: none
<wr-select placeholder="Pick a size" [(value)]="size">
  <wr-option value="sm">Small</wr-option>
  <wr-option value="md">Medium</wr-option>
  <wr-option value="lg">Large</wr-option>
  <wr-option value="xl" disabled>Extra large — out of stock</wr-option>
</wr-select>

Groups

Label related options with <wr-option-group>.

Value: angular
<wr-select [(value)]="framework">
  <wr-option-group label="Frontend">
    <wr-option value="angular">Angular</wr-option>
    <wr-option value="react">React</wr-option>
    <wr-option value="vue">Vue</wr-option>
  </wr-option-group>
  <wr-option-group label="Backend">
    <wr-option value="nest">NestJS</wr-option>
    <wr-option value="fastify">Fastify</wr-option>
  </wr-option-group>
</wr-select>

Disabled

<wr-select placeholder="Disabled" disabled />

Width

A select is 10rem wide by default. Change that with --wr-select-min-width; a min-width on .wr-select__trigger has no effect.

Multi mode

The multi mode makes the value an array, shown as chips. clearable adds a clear-all button.

Value: [ "typescript", "angular" ]
<wr-select mode="multi" clearable placeholder="Pick tags" [(value)]="tags">
  <wr-option value="typescript">TypeScript</wr-option>
  <wr-option value="angular">Angular</wr-option>
  <wr-option value="rxjs">RxJS</wr-option>
  <wr-option value="signals">Signals</wr-option>
</wr-select>

Chip overflow + max items

[maxTagCount] folds extra chips into +N more; [maxItems] caps how many can be picked.

Value: [ "typescript", "angular", "rxjs", "signals" ]
<wr-select mode="multi" [maxTagCount]="2" [maxItems]="4" [(value)]="manyTags">
  <wr-option value="typescript">TypeScript</wr-option>
  <wr-option value="angular">Angular</wr-option>
  <wr-option value="rxjs">RxJS</wr-option>
  <wr-option value="signals">Signals</wr-option>
  <wr-option value="cdk">CDK</wr-option>
  <wr-option value="ssr">SSR</wr-option>
</wr-select>

Search mode

The search mode filters the options as you type. clearable adds a clear button and lets Backspace clear the value.

Picked: —
<wr-select mode="search" clearable placeholder="Search a country" [(value)]="country">
  @for (c of countries; track c) {
    <wr-option [value]="c">{{ c }}</wr-option>
  }
</wr-select>

Searchable multi-select

Add searchable to multi for a filter field beside the chips.

Cranes
Value: [ "Cranes" ]
<wr-select mode="multi" searchable placeholder="Filter categories" [(value)]="categories">
  @for (c of allCategories; track c) {
    <wr-option [value]="c">{{ c }}</wr-option>
  }
</wr-select>

Leading visuals and an explicit label

Put an avatar or icon in an <ng-template wrOptionLeading>, not straight into the option. Set label when the row shows more than the name.

<wr-select placeholder="Assign to" [(value)]="assignee">
  @for (p of people; track p.id) {
    <wr-option [value]="p.id" [label]="p.name">
      <ng-template wrOptionLeading let-placement="placement">
        <wr-avatar shape="circle" [size]="avatarPx[placement]">{{ p.initials }}</wr-avatar>
      </ng-template>
      {{ p.name }} <small>{{ p.team }}</small>
    </wr-option>
  }
</wr-select>

<!-- Search -->
<wr-select mode="search" clearable placeholder="Search people" [(value)]="reviewer">
  @for (p of people; track p.id) { … the same options … }
</wr-select>

<!-- Multi -->
<wr-select mode="multi" searchable clearable placeholder="Add assignees" [(value)]="assignees">
  @for (p of people; track p.id) { … the same options … }
</wr-select>

<!-- [options], [loader] and virtualScroll rows: a template directly in <wr-select>. -->
<wr-select mode="search" virtualScroll [options]="people" [displayWith]="name">
  <ng-template wrOptionLeading let-person let-placement="placement">
    <wr-avatar shape="circle" [size]="avatarPx[placement]">{{ person.initials }}</wr-avatar>
  </ng-template>
</wr-select>
import { WrAvatar } from 'ngwr/avatar';
import { WrOption, WrOptionLeading, type WrOptionLeadingPlacement, WrSelect } from 'ngwr/select';

@Component({ imports: [WrSelect, WrOption, WrOptionLeading, WrAvatar] })
export class AssigneePicker {
  // One template, three places: size it per placement.
  protected readonly avatarPx: Record<WrOptionLeadingPlacement, number> = { option: 24, value: 20, chip: 16 };
}
Assignee: 2 Reviewer: 4 — click the field and the avatar steps aside for the query.
Ada LovelaceAlan TuringMargaret Hamilton
Assignees: [ 1, 3, 5 ]

Server-side search

Fetch on (searchChange), pass the results to [options] and [loading], and add serverSearch to turn off the local filter.

<wr-select
  mode="search"
  serverSearch
  placeholder="Search a country"
  [options]="results()"
  [loading]="pending()"
  [(value)]="picked"
  (searchChange)="onSearch($event)"
/>
// Debounced by [debounceMs]; emits '' when the field is cleared.
onSearch(query: string): void {
  this.store.dispatch(searchCountries({ query }));
}
Picked: —

Keyboard

Arrow keys move the highlight, Home and End jump to the ends, Enter picks, Escape closes. Focus stays on the trigger.

Search mode — virtual scroll

Add virtualScroll for a long [options] array. Projected <wr-option>s are not virtualized.

<wr-select
  mode="search"
  virtualScroll
  placeholder="Search 5,000 items"
  [options]="bigOptions"
  [(value)]="picked"
/>
Picked: —

Tag mode

The tag mode turns typed text into chips. [separators] commit (Enter and comma by default), [validate] filters, [maxItems] caps.

ngwrzoneless
Value: [ "ngwr", "zoneless" ] Emails: [ "[email protected]" ] (validated; max 5)
<wr-select mode="tag" placeholder="Add a tag" [(value)]="freeTags" />

<!-- Custom separators, a validator and a cap -->
<wr-select
  mode="tag"
  placeholder="Add email and press Enter or ,"
  [(value)]="recipients"
  [separators]="['Enter', ',', ' ']"
  [validate]="isEmail"
  [maxItems]="5"
/>

Use it in a form

[formField], formControlName and [(ngModel)] all bind value. See Forms.

<!-- Signal Forms -->
<wr-select [formField]="form.framework">
  <wr-option value="angular">Angular</wr-option>
  <wr-option value="react">React</wr-option>
</wr-select>

<!-- Reactive forms -->
<form [formGroup]="form">
  <wr-form-field label="Framework" required>
    <wr-select formControlName="framework" placeholder="Pick one">
      <wr-option value="angular">Angular</wr-option>
      <wr-option value="react">React</wr-option>
    </wr-select>
    <wr-form-error key="required">Pick a framework.</wr-form-error>
  </wr-form-field>
</form>

<!-- Template-driven -->
<wr-select [(ngModel)]="framework" name="framework">…</wr-select>
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';

import { WrFormError, WrFormField } from 'ngwr/form';
import { WrOption, WrSelect } from 'ngwr/select';

@Component({
  imports: [ReactiveFormsModule, WrFormField, WrFormError, WrSelect, WrOption],
  templateUrl: './my.html',
})
export class MyComponent {
  protected readonly form = new FormGroup({
    framework: new FormControl<string | null>(null, Validators.required),
  });
}
Control value: null · touched: false

How a value matches an option

Values match with ===, so a re-fetched object shows the placeholder: bind a primitive key.

<!-- Bind the key, not the object. -->
<wr-select formControlName="categoryId" placeholder="Pick a category">
  @for (cat of categories(); track cat.id) {
    <wr-option [value]="cat.id">{{ cat.name }}</wr-option>
  }
</wr-select>
// Never matches: the trigger shows the placeholder, the form stays valid.
const category = new FormControl<Category | null>(null);
category.setValue({ id: 2, name: 'Laptops' });

// This matches.
const categoryId = new FormControl<number | null>(null);
categoryId.setValue(2);

Select API

NameDescriptionTypeDefault
placeholderPlaceholder shown when no option is selected. Falls back to select.placeholder.string | nullnull
clearLabelClear-selection (×) button aria-label. Falls back to select.clearSelection.string | nullnull
ariaLabelAccessible name of the trigger. Falls back to the placeholder, then to select.label — role="combobox" on an empty trigger otherwise has no name at all, which is the common case before anything is selected.string | nullnull
disabledDisable the select. Bound automatically from the field's disabled state when used with [formField].booleanfalse
readonlyRefuse changes while the trigger stays focusable and the value still submits. Bound automatically from the field's readonly state when used with [formField]. The panel is where every edit happens, so a read-only select does not open — and with it go the clear button, the chip ×, the tag draft and the search query. The text-input shapes take the native readonly attribute, which is what keeps them focusable and selectable where disabled would not; every shape mirrors aria-readonly, which role combobox supports.booleanfalse
roundedPill-shaped corners on the trigger.booleanfalse
sizeControl size — shares the --wr-control-* contract. Read by the trigger's host classes AND by the overlay panel, which builds its size modifier separately.WrSelectSize'md'
modeBehavior mode. <wr-select> is the unified combobox primitive — pick the shape via [mode]: - 'single' (default) — one value, no input. Classic dropdown. - 'multi' — array value, chips on the trigger. - 'search' — type-ahead with sync filter or async loader. - 'tag' — free-text + chips.WrSelectMode | nullnull
searchableAdd a type-ahead filter without changing the value shape — the missing multi-with-search combination, since [mode] picks exactly one shape: ``html <wr-select mode="multi" searchable [(value)]="categories">…</wr-select> ` On multi the trigger keeps its chips and grows an inline text field (the shape tag mode already uses); on single it is equivalent to mode="search". Every search input ([options], [loader], [debounceMs], [minChars], [virtualScroll], the (searchChange) output, …) applies either way. Ignored in tag` mode, which owns its own input.booleanfalse
searchQueryThe live search query. Empty string = no filter. Exposed via context so each <wr-option> can self-hide non-matching rows. Two-way bindable, so the query can be owned or reset from outside: [(searchQuery)]="query". For server-side search prefer the debounced {@link searchChange} output — searchQueryChange fires on every keystroke.string''
optionsSearch mode: dynamic option array. Each item is rendered as a <wr-option> whose label comes from [displayWith]. Works alongside projected <wr-option> children — both lists are filtered by the search query.readonly unknown[][]
displayWithSearch mode: map a dynamic option item to its display label.(item: unknown) => stringString
loaderSearch mode: async loader. When set, the loader is called on every (debounced) keystroke and its result replaces [options]. Supports Observables, Promises, and plain arrays.WrSelectSearchLoader<unknown> | nullnull
serverSearchThe option list is already scoped to the query upstream — skip the built-in client-side label filter. Set it when [options] is fed from a server via the {@link searchChange} output; the async [loader] path implies it. Without this, a server that ranks or fuzzy-matches (returning rows whose labels don't literally contain the query) would have those rows hidden again on the client.booleanfalse
debounceMsSearch mode: debounce (ms) applied to the loader.number250
minCharsSearch mode: minimum query length before the panel opens / loader fires.number0
freeTextSearch mode: allow values not in the options list. Enter on an unmatched query commits the raw text as the form value.booleanfalse
virtualScrollSearch mode: window the option panel so thousands of [options] keep only ~one viewport of rows in the DOM. Opt-in and OFF by default. Engages ONLY on the pure data-array search tier — it silently falls back to the full render when static <wr-option> children are projected or the filtered list is empty. While on, rows are plain role="option" elements (no <wr-option>), navigated via aria-activedescendant, selected by value.booleanfalse
rowHeightSearch mode: uniform option-row height in px for the virtual window. 0 (default) measures the first rendered row once and reuses it, so it adapts to the control size. Read only when virtualScroll engages.number0
viewportHeightSearch mode: height of the virtual scroll viewport — a number (px) or any CSS length.number | string256
overscanSearch mode: extra rows kept above/below the virtual viewport.number6
noResultsTextSearch mode: text shown when the filter / loader returns nothing. Falls back to select.noResults.string | nullnull
loadingTextSearch mode: text shown while the async loader is in flight. Falls back to select.loading.string | nullnull
(searchChange)Debounced search query, gated to searchable selects. Emits on every settled change — including the empty string when the field is cleared — so a store-backed option list can stay in sync and reset itself. [debounceMs] controls the delay; [minChars] does NOT gate it, for the same reason the [loader] path clears its results below that threshold. This is the hook for server-side search: dispatch on (searchChange), feed the results back through [options] (plus [serverSearch]), and drive the panel's progress row with [loading]. Prefer it over the model's raw (searchQueryChange), which fires on every keystroke.string—
loadingShow the panel's progress row. Independent of [loader], which raises and lowers its own flag — use this when the options come from a store fed by the {@link searchChange} output.booleanfalse
separatorsTag mode: keys / characters that commit the current draft into a chip. 'Enter' is the key name; everything else is a literal character watched in keypresses and pastes.readonly string[]['Enter', ',']
allowDuplicatesTag mode: allow the same value to appear more than once.booleanfalse
validateTag mode: custom validator — return true to accept the value, false to silently reject. Receives the trimmed draft + the existing chips.WrSelectTagValidator | nullnull
clearableShow a clear (×) button once at least one option is selected. NOT multi-only, which this said for a long time and which the template refutes: only the button trigger gates on isMulti(), so the chips trigger AND a single mode="search" / [searchable] select carry it too. The wording mattered little while the default was true; it decides what a reader expects to lose now that it is not. The default changed from true to false: the affordance is opt-in, so a trigger that grew an × on its own no longer does — pass clearable to keep it. In search mode this also gates Backspace-to-clear on an empty field, since that key is the keyboard twin of this button.booleanfalse
panelClassExtra CSS classes for the dropdown panel's overlay pane. The pane is appended to the overlay container, not to this component, so nothing in the consumer's own template encloses it and no descendant rule written around the trigger can reach it. This input is the only per-instance handle on it. A space-separated string works as well as an array. The pane is a positioning wrapper carrying the gap above the list; the box that paints is .wr-select-panel inside it, so a rule meant for the surface reads .my-class .wr-select-panel.WrClassInputnull
maxItemsCap on selected items (multi mode). 0 = unlimited. Once reached, the unselected options are marked unavailable — aria-disabled and the disabled look — and refuse a pick until one is removed.number0
maxTagCountMaximum number of chips rendered before collapsing the rest into a +N more indicator. 0 = render every chip.number0
valueUnified value. Single mode holds a scalar (or null); multi / tag mode holds readonly unknown[]. Bound by [formField], or two-way via [(value)].unknownnull
(touch)Emitted on blur / commit so a bound field can mark itself touched.void—

Select outputs

NameDescriptionTypeDefault
(searchChange)Debounced query, for server-side search. Emits '' when cleared; [minChars] does not gate it.string—
(searchQueryChange)Raw, undebounced query — the [(searchQuery)] half. Prefer (searchChange) for server calls.string—
(valueChange)The [(value)] half. Bound automatically by [formField] / [(ngModel)].unknown—
(touch)Emitted on blur / commit so a bound field marks itself touched.void—

Option API

NameDescriptionTypeDefault
valuerequiredForm value contributed when chosen.unknown—
disabledDisable this option.booleanfalse
labelWhat the trigger, chips and search use for this option. Defaults to the option’s text.string | nullnull

Option leading template

NameDescriptionTypeDefault
[wrOptionLeading]On an <ng-template> inside a <wr-option>, or directly in <wr-select> for every row, which [options], [loader] and virtual rows need.TemplateRef<WrOptionLeadingContext>—
$implicitThe option’s value — let-value.T—
placementWhere it is drawn: 'option' (a panel row), 'chip' (a multi chip) or 'value' (the trigger).'option' | 'chip' | 'value'—

Option Group API

NameDescriptionTypeDefault
labelrequiredSection heading.string—

CSS variables

Set these on the component's selector or a wrapper, not on :root: the defaults are declared on ngwr/select's own element.

VariableDefaultDeclared on
--wr-option-font-sizevar(--wr-control-font-size-md).wr-select-panel +2 variant overrides
--wr-option-font-weightvar(--wr-font-weight-medium).wr-select-panel
--wr-option-gap0.1875rem.wr-select-panel +2 variant overrides
--wr-option-group-bordervar(--wr-border-base).wr-option-group
--wr-option-group-label-font-sizevar(--wr-text-xs).wr-option-group
--wr-option-group-label-font-weightvar(--wr-font-weight-semibold).wr-option-group
--wr-option-group-label-paddingvar(--wr-space-sm) var(--wr-space-md) var(--wr-space-xs).wr-option-group
--wr-option-group-margin-topvar(--wr-space-xs).wr-option-group
--wr-option-group-padding-topvar(--wr-space-xs).wr-option-group
--wr-option-inline-gapvar(--wr-space-sm).wr-select-panel +2 variant overrides
--wr-option-line-heightvar(--wr-control-line-height-md).wr-select-panel +2 variant overrides
--wr-option-padding-xvar(--wr-space-md).wr-select-panel +2 variant overrides
--wr-option-padding-yvar(--wr-space-sm).wr-select-panel +2 variant overrides
--wr-option-radiusvar(--wr-border-radius-sm).wr-select-panel
--wr-select-bgvar(--wr-color-surface).wr-select +1 variant override
--wr-select-bordervar(--wr-border-base).wr-select
--wr-select-chip-font-sizevar(--wr-text-sm).wr-select
--wr-select-chip-gapvar(--wr-space-xs).wr-select +1 variant override
--wr-select-chip-line-heightvar(--wr-leading-snug).wr-select
--wr-select-chip-padding-blockvar(--wr-space-xs).wr-select +1 variant override
--wr-select-chip-padding-inline-endvar(--wr-space-xs).wr-select +1 variant override
--wr-select-chip-padding-inline-startvar(--wr-space-sm).wr-select +1 variant override
--wr-select-chips-gapvar(--wr-space-xs).wr-select +1 variant override
--wr-select-clear-radiusvar(--wr-border-radius-pill).wr-select
--wr-select-colorvar(--wr-color-on-surface).wr-select +1 variant override
--wr-select-font-familyvar(--wr-font-family-base).wr-select
--wr-select-font-sizevar(--wr-control-font-size-md).wr-select +2 variant overrides
--wr-select-line-heightvar(--wr-control-line-height-md).wr-select +2 variant overrides
--wr-select-min-width10rem.wr-select
--wr-select-overlay-padding-topvar(--wr-space-xs).wr-select-overlay
--wr-select-padding-xvar(--wr-control-padding-x-md).wr-select +3 variant overrides
--wr-select-padding-yvar(--wr-control-padding-y-md).wr-select +2 variant overrides
--wr-select-panel-bgvar(--wr-color-surface).wr-select-panel
--wr-select-panel-bordervar(--wr-border-base).wr-select-panel
--wr-select-panel-font-familyvar(--wr-font-family-base).wr-select-panel
--wr-select-panel-max-height16rem.wr-select-panel
--wr-select-panel-message-paddingvar(--wr-space-md).wr-select-panel
--wr-select-panel-paddingvar(--wr-space-xs).wr-select-panel
--wr-select-panel-radiusvar(--wr-border-radius-base).wr-select-panel
--wr-select-panel-shadowvar(--wr-shadow-overlay).wr-select-panel
--wr-select-radiusvar(--wr-control-radius-md).wr-select +3 variant overrides
--wr-select-tag-input-paddingvar(--wr-space-xs).wr-select
--wr-select-trigger-gapvar(--wr-space-sm).wr-select

Dependencies

Angular CDKoverlay, portal
Built onForm, Overlay
Used byPagination, Schema Form

See also