Stingray.Components.MultiSelectComponent
2.2.0
dotnet add package Stingray.Components.MultiSelectComponent --version 2.2.0
NuGet\Install-Package Stingray.Components.MultiSelectComponent -Version 2.2.0
<PackageReference Include="Stingray.Components.MultiSelectComponent" Version="2.2.0" />
<PackageVersion Include="Stingray.Components.MultiSelectComponent" Version="2.2.0" />
<PackageReference Include="Stingray.Components.MultiSelectComponent" />
paket add Stingray.Components.MultiSelectComponent --version 2.2.0
#r "nuget: Stingray.Components.MultiSelectComponent, 2.2.0"
#:package Stingray.Components.MultiSelectComponent@2.2.0
#addin nuget:?package=Stingray.Components.MultiSelectComponent&version=2.2.0
#tool nuget:?package=Stingray.Components.MultiSelectComponent&version=2.2.0
Stingray.Components.MultiSelectComponent
Searchable single- and multi-select dropdowns for Blazor, in server-paged and offline variants.
Virtualized option lists, debounced search, keyboard navigation, EditForm validation, RTL, and
localization through a cascaded IStringLocalizer. Works on Blazor Server and WebAssembly; the
design deliberately keeps opening, closing, focusing and keyboard navigation off the SignalR wire.
Contents
- Setup
- Requirements
- Choosing a component
- Quick start
- Parameters
- Cascading values
- Localization
- Theming
- Validation
- Handling load failures
- Behaviour worth knowing
- Upgrading from 1.x
- For AI coding agents
Setup
Add the stylesheet to your host page (App.razor, or index.html for WebAssembly):
<link rel="stylesheet" href="_content/Stingray.Components.MultiSelectComponent/stingray-dropdown.css" />
Link it yourself rather than have the component inject it, so your app controls load order —
anything after that line can override it at equal specificity. An injected link would be appended to
<head> after your own stylesheets and would then beat every override you wrote, at equal
specificity, from inside a package you cannot edit.
That is the only wiring. There is no service registration and no <script> tag — the component
imports its own module, on a URL it versions itself.
If the dropdowns render as plain unstyled boxes, or open and show nothing, this link is missing or the path is wrong.
Version the stylesheet link
_content/ paths are unhashed, and a host serving them with UseStaticFiles sends no
Cache-Control at all — only an ETag. That leaves a browser free to reuse the copy it already has
without revalidating, so after an upgrade you can be running the new assembly against the old
stylesheet. It matters more here than it looks: the menu is painted entirely by this stylesheet's
:popover-open rules, so a stale copy is not a cosmetic regression — it is a dropdown that opens and
shows nothing.
The module handles this itself — it stamps its own import URL with the package version, which is why
you will see ?v= on it in devtools — but the stylesheet is your markup, so give it a version too.
On .NET 9+:
<link rel="stylesheet" href="@Assets["_content/Stingray.Components.MultiSelectComponent/stingray-dropdown.css"]" />
@Assets[…] requires app.MapStaticAssets() in place of app.UseStaticFiles(). It fingerprints the
URL, and — the part that matters here — serves unfingerprinted _content/ paths as no-cache, so
they revalidate on every request.
On .NET 8, or on any host still using UseStaticFiles, stamp the version yourself — but take it
from the package rather than typing it, or it is one more number to remember at upgrade time:
@using Stingray.Components.MultiSelectComponent
<link rel="stylesheet"
href="@($"_content/Stingray.Components.MultiSelectComponent/stingray-dropdown.css?v={MultiSelectVersion.Current}")" />
MultiSelectVersion.Current is the package's own informational version, prerelease label included,
and it is the identical value the module stamps on its import URL. Hard-coding ?v=2.1.0 works
until the upgrade nobody remembers to edit it for, which is the upgrade that needs it.
Requirements
- .NET 8.0 or later.
- The Popover API — Chrome/Edge 114+, Safari 17+, Firefox 125+.
The menu is a popover="auto" element opened by a popovertarget invoker, so the browser owns
opening it, dismissing it on an outside click, and dismissing it on Escape. There is no fallback
path: without the API the trigger does nothing.
Choosing a component
| Fetches pages from you | Filters a list you supply | |
|---|---|---|
| One value | SearchableSingleDropdown |
SearchableSingleDropdownOffline |
| Many values | SearchableMultiDropdown |
SearchableMultiDropdownOffline |
Use a server variant when the option set is too large to send to the browser, or lives behind a
query. You supply SearchDataConsumer (a page of results for a search term) and
LoadSelectedDataConsumer (the display objects behind values that are already selected — needed
because a selected value may not be on the current page).
Use an offline variant when you already have the whole list in memory. You supply Datasource;
sorting and filtering happen locally.
The two type parameters
Every variant is generic over two types, and getting them the right way round matters:
TInput— the option object you work with (Person).TOutput— the value you bind (int, forPerson.Id).
OutputSelector maps one to the other. They are often the same type in offline dropdowns, where
binding the whole object is convenient.
Quick start
Server-paged, multi-select
<SearchableMultiDropdown TInput="Person" TOutput="int"
TextSelector="p => p.Name"
OutputSelector="p => p.Id"
SearchDataConsumer="SearchPeople"
LoadSelectedDataConsumer="LoadPeopleByIds"
@bind-SelectedOptions="selectedIds" />
@code {
List<int> selectedIds = new();
// (search term, page size, start index) -> one page, plus the total for that term.
async Task<SearchableDropDownDto<Person>> SearchPeople(string search, int pageSize, int startIndex)
{
var query = _db.People.Where(p => search == "" || p.Name.Contains(search));
return new SearchableDropDownDto<Person>
{
TotalCount = await query.CountAsync(),
Items = await query.Skip(startIndex).Take(pageSize).ToListAsync(),
};
}
// The display objects behind values that are already selected.
Task<List<Person>> LoadPeopleByIds(List<int> ids) =>
_db.People.Where(p => ids.Contains(p.Id)).ToListAsync();
}
TotalCount is the count for the whole search, not the page — it is what sizes the scrollbar.
Offline, single-select
<SearchableSingleDropdownOffline TInput="string" TOutput="string"
TextSelector="x => x"
OutputSelector="x => x"
Datasource="_countries"
@bind-SelectedOption="_country" />
Custom option rendering
<SearchableSingleDropdownOffline TInput="Person" TOutput="int"
TextSelector="p => p.Name"
OutputSelector="p => p.Id"
Datasource="_people"
@bind-SelectedOption="_personId">
<ItemTemplate>
<span class="badge bg-info me-2">@context.Id</span>
<strong>@context.Name</strong>
</ItemTemplate>
</SearchableSingleDropdownOffline>
ItemTemplate covers both the menu rows and the selected display. Use MenuItemTemplate or
SelectedItemTemplate to differ between them; both fall back to ItemTemplate, then to
TextSelector.
Parameters
Required, on every variant
| Parameter | Type | Purpose |
|---|---|---|
TextSelector |
Func<TInput, string?> |
The option's display text. Also what search filters on, offline. |
OutputSelector |
Func<TInput, TOutput> |
Maps an option to the value you bind. |
Common, on every variant
| Parameter | Type | Default | Purpose |
|---|---|---|---|
MinCharacterCount |
int? |
1 |
Characters before a search runs. Below it, the list is unfiltered. Values under 1 are raised to 1. |
PageSize |
int? |
20 |
Rows per fetch. Values under 1 are raised to 1. |
ClearSearchValueAfterExpanding |
bool? |
true |
Reopening starts from an empty search box. |
ShowPaginationInformation |
bool? |
false |
Shows the "Showing x To y From z" counters. They describe the rendered window, so an empty result reads Showing 0 To 0 From 0; the line is never withheld. |
ShowTooltip |
bool? |
false |
Puts the option's text in a title attribute. |
IsDisabled |
bool |
false |
Disables the control. |
ItemSize |
float |
30 |
Row height in px, for virtualization. Keep in step with --stingray-dropdown-item-height. |
CssClass |
string |
"" |
Added to the component's root element. |
PlaceHolder |
string |
"Select" |
Shown when nothing is selected. |
SearchInputPlaceHolder |
string |
"Search..." |
Placeholder in the search box. |
NoMatchPlaceHolder |
string |
"No available records" |
Shown when the list is empty. |
SearchValue |
string |
"" |
Initial search term. Read once, at first render — the search box is uncontrolled, so assigning this later does not change what is in it. |
InputDefaultValue |
TInput? |
default |
Treated as "no selection" on the input side. |
OutputDefaultValue |
TOutput? |
default |
Treated as "no selection" on the output side. |
InputEquality |
Func<TInput, TInput, bool> |
EqualityComparer<TInput>.Default |
How two options are compared. |
OutputEquality |
Func<TOutput, TOutput, bool> |
EqualityComparer<TOutput>.Default |
How two values are compared. Supply this for reference types compared by id. |
ItemTemplate |
RenderFragment<TInput>? |
— | Custom rendering for menu rows and the selected display. |
MenuItemTemplate |
RenderFragment<TInput>? |
— | Menu rows only. Falls back to ItemTemplate. |
SelectedItemTemplate |
RenderFragment<TInput>? |
— | Selected display only. Falls back to ItemTemplate. |
OnLoadError |
EventCallback<Exception> |
— | Raised when a load fails, with the exception. See Handling load failures. |
The three defaults above that are quiet by design — ClearSearchValueAfterExpanding,
ShowPaginationInformation, ShowTooltip — changed in 2.0. See Upgrading.
Server variants only
| Parameter | Type | Purpose |
|---|---|---|
SearchDataConsumer |
Func<string, int, int, Task<SearchableDropDownDto<TInput>>> |
Required. (search, pageSize, startIndex) → one page plus the total for that search. |
LoadSelectedDataConsumer |
Func<TOutput, Task<TInput>> (single) / Func<List<TOutput>, Task<List<TInput>>> (multi) |
Required. Resolves already-selected values to display objects. |
Offline variants only
| Parameter | Type | Default | Purpose |
|---|---|---|---|
Datasource |
IEnumerable<TInput> |
[] |
Required. The full option list. A null is treated as empty. |
IsDescinding |
bool? |
null |
true/false sort by TextSelector; null keeps the given order. |
Single-select only
| Parameter | Type | Default | Purpose |
|---|---|---|---|
SelectedOption |
TOutput |
— | Bind with @bind-SelectedOption. |
SelectedOptionChanged |
EventCallback<TOutput> |
— | Supplied by @bind-. |
SelectedOptionExpression |
Expression<Func<TOutput>>? |
— | Supplied by @bind-; drives validation. |
AllowDeselect |
bool |
true |
Whether the clear button is offered. |
Multi-select only
| Parameter | Type | Default | Purpose |
|---|---|---|---|
SelectedOptions |
List<TOutput> |
— | Bind with @bind-SelectedOptions. |
SelectedOptionsChanged |
EventCallback<List<TOutput>> |
— | Supplied by @bind-. |
SelectedOptionsExpression |
Expression<Func<List<TOutput>>>? |
— | Supplied by @bind-; drives validation. |
MaxSelectionCount |
int |
0 |
Cap on selections; 0 means no cap. At the cap, unselected rows are disabled and selected ones stay clickable. |
AllowSelectAll |
bool |
true |
(offline multi only) Offers a "select all" row above the options. |
SelectAllPlaceHolder |
string |
"Select All" |
(offline multi only) Label for that row. |
Cascading values
| Cascade | Required | Purpose |
|---|---|---|
IStringLocalizer |
no | Localizes built-in strings; falls back to the English key. |
EditContext |
no | Supplied by EditForm; drives validation styling. |
<CascadingValue Value="Localizer">
<SearchableSingleDropdown … />
</CascadingValue>
Localization
Cascade an IStringLocalizer. Keys are the natural English strings, so an unlocalized app shows
sensible text with no cascade at all:
Select · Search... · No available records · Loading · Showing · To · From ·
Select All · Remove · Clear · Clear all · Could not load data
RTL is supported throughout — the stylesheet uses logical properties, so an ar culture mirrors
without extra work.
Theming
Set any of these on the dropdown or any ancestor:
| Property | Default | Applies to |
|---|---|---|
--stingray-dropdown-hover-bg |
#ededed |
Row hover and the selected row |
--stingray-dropdown-item-height |
30px |
Option row height |
--stingray-dropdown-menu-max-height |
200px |
Scroll height of the option list |
--stingray-dropdown-error-color |
#b02a37 |
The load-failure row |
.my-compact-dropdown {
--stingray-dropdown-item-height: 24px;
--stingray-dropdown-menu-max-height: 320px;
}
Keep --stingray-dropdown-item-height in step with the ItemSize parameter — that is the number
virtualization uses to size the scroll region, and a mismatch shows as drifting scroll positions.
These properties are only ever read by the package, never declared on the component itself, so setting one on any ancestor wins.
Validation
Inside an EditForm, @bind- supplies the expression the component needs and validation styling
follows automatically:
<EditForm Model="_model" OnValidSubmit="Save">
<DataAnnotationsValidator />
<SearchableSingleDropdown TInput="Person" TOutput="int"
TextSelector="p => p.Name"
OutputSelector="p => p.Id"
SearchDataConsumer="SearchPeople"
LoadSelectedDataConsumer="LoadPersonById"
@bind-SelectedOption="_model.OwnerId" />
<ValidationMessage For="() => _model.OwnerId" />
</EditForm>
If you set the value and the change handler separately instead of using @bind-, also set
SelectedOptionExpression / SelectedOptionsExpression — without it the component cannot tell the
EditContext which field changed.
Handling load failures
When SearchDataConsumer or LoadSelectedDataConsumer throws, the component:
- shows a localized "Could not load data" row inside the menu, where the results would be, and
- raises
OnLoadErrorwith the exception.
The exception's own message is never rendered. An e.Message out of EF or an HTTP wrapper names
tables, columns and internal hosts. Subscribe to OnLoadError if you want the detail:
<SearchableMultiDropdown … OnLoadError="ex => _logger.LogError(ex, "Dropdown load failed")" />
The failure is cleared as soon as a later fetch succeeds, or when the search term changes.
Behaviour worth knowing
Search is debounced — 500ms on server variants, 250ms offline. A fetch issued for a term you have already typed past is discarded rather than cached, so results cannot arrive out of order.
Pages are cached per search term, and the cache is dropped when the term changes. Reopening the menu does not refetch.
The menu closes itself when its trigger scrolls out of view or is clipped by a scrollable
ancestor. The menu is position: fixed, so it would otherwise hang in the viewport next to
nothing. A focused menu on a narrow viewport is exempt, so an on-screen keyboard cannot dismiss the
menu it was opened to type into.
Keyboard: the search box is the single tab stop. Arrow keys, Home and End move between options; Enter or Space selects; Escape closes and returns focus to the trigger.
The menu is a popover in the top layer, so it is not clipped by overflow: hidden, not affected
by transformed ancestors, and not subject to your z-index stack — while remaining a child of the
component in the DOM, so your theming tokens still inherit into it.
Multi-select shows up to five chips in the control, with a count badge and a clear-all button. Server-backed multi-selects also pin the current selection above the search results in the menu.
Upgrading from 1.x
No .razor markup changes are required. Five things to check.
Link the stylesheet (see Setup). 1.x shipped scoped CSS that arrived automatically; 2.0 serves one stylesheet from
_content/, so without the link the dropdowns render unstyled.The Popover API is now required. See Requirements. 1.x re-parented the menu to
<body>; 2.0 uses the top layer instead.Four defaults changed. Set them explicitly if you relied on the old values:
Parameter 1.x 2.0 MinCharacterCount31ClearSearchValueAfterExpandingfalsetrueShowPaginationInformationtruefalseShowTooltiptruefalseShowTooltipis the most visible: long option text no longer gets a native hover tooltip.appsettingskeys are gone.StingrayComponents_Dropdown_*,StingrayComponents_PaginatedDropdown_*andStingrayComponents_MinCharacterCountare no longer read. Every one of them is already a parameter — set it there.The
ShowMessagecascade is gone, and with it the need for awindow.DotNetHelperscript in your host page. Load failures now surface in the menu and throughOnLoadError. If your host page declaresDotNetHelperfor this package you can delete it; leaving it does no harm.
Also note: SearchableMultiDropdownOffline.Datasource widened from List<TInput> to
IEnumerable<TInput>. Passing a list still compiles.
If you have CSS overriding the component's internals, re-check it. The control is now a flex row
containing a .sd-toggle button rather than one big <button>, so the chips and clear buttons are
no longer nested interactive content.
For AI coding agents
This package ships an AGENTS.md describing the contracts that are easy to break from the outside
— what looks like a bug but is deliberate, what must never be done, and the accessibility
guarantees. It extracts to:
~/.nuget/packages/stingray.components.multiselectcomponent/<version>/AGENTS.md
Nothing reads it automatically. To point your agent at it, add this to your own CLAUDE.md,
AGENTS.md or equivalent:
## Stingray MultiSelect
When working with `SearchableSingleDropdown`, `SearchableMultiDropdown`,
`SearchableSingleDropdownOffline` or `SearchableMultiDropdownOffline`, first read
`~/.nuget/packages/stingray.components.multiselectcomponent/<version>/AGENTS.md`
(substitute the version from your `.csproj`). It documents behaviour that looks like a bug
and is not, and constraints that are not obvious from the API surface.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.AspNetCore.Components.Web (>= 8.0.11)
- Microsoft.Extensions.Localization.Abstractions (>= 8.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|