Combo fields
tier 2 — classes.form-combo is a field you type into to pick something: a searchable select, a
multiselect whose picks are chips, a list the app searches on the server, and free entry that turns
what is typed into chips. It is markup and classes like everything else —
data-combo adds the keyboard.
data-combo-managed, which lets the script keep
the selection itself for a page with no app behind it. Leave it off wherever C# renders
the chips, or both add one.
Multiselect with chips
Live. Each pick is a .chip inside .form-combo-box, and the
list stays open while you pick. A chip goes by its ×, by unticking its row, or by
Backspace twice in the empty input — the first press only marks it, so
holding the key to fix a typo never eats the chips in front of it.
aria-multiselectable="true" on the list is what draws the checkboxes. Open is
aria-expanded="true" on the input, and nothing else: the attribute a screen reader
announces is the one that shows the panel.
<div class="form-field" style="max-width:460px">
<label class="form-label" for="cb-values">Select values</label>
<div class="form-combo" data-combo="multi" data-combo-managed>
<div class="form-combo-box">
<span class="chip" data-value="chai">
<span class="chip-label">Chai</span>
<button class="chip-dismiss" type="button" aria-label="Remove Chai"><i class="ri-close-line"></i></button>
<input type="hidden" name="products" value="chai" />
</span>
<span class="chip" data-value="chang">
<span class="chip-label">Chang</span>
<button class="chip-dismiss" type="button" aria-label="Remove Chang"><i class="ri-close-line"></i></button>
<input type="hidden" name="products" value="chang" />
</span>
<input class="form-combo-input" id="cb-values" type="text" role="combobox" placeholder="Add products…"
aria-expanded="false" aria-controls="cb-values-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-clear" type="button" aria-label="Remove every product"><i class="ri-close-line"></i></button>
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show products"><i class="ri-arrow-down-s-line"></i></button>
</span>
</div>
<template data-combo-chip>
<span class="chip">
<span class="chip-label"></span>
<button class="chip-dismiss" type="button" aria-label="Remove {label}"><i class="ri-close-line"></i></button>
<input type="hidden" name="products" />
</span>
</template>
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-values-list" role="listbox" aria-multiselectable="true" aria-label="Products">
<div class="form-combo-option" role="option" id="cb-values-aniseed-syrup" data-value="aniseed-syrup" aria-selected="false">Aniseed Syrup</div>
<div class="form-combo-option" role="option" id="cb-values-camembert" data-value="camembert" aria-selected="false">Camembert</div>
<div class="form-combo-option" role="option" id="cb-values-chai" data-value="chai" aria-selected="true">Chai</div>
<div class="form-combo-option" role="option" id="cb-values-chang" data-value="chang" aria-selected="true">Chang</div>
<div class="form-combo-option" role="option" id="cb-values-chocolate-biscuits" data-value="chocolate-biscuits" aria-selected="false">Chocolate Biscuits</div>
<div class="form-combo-option" role="option" id="cb-values-clam-chowder" data-value="clam-chowder" aria-selected="false">Clam Chowder</div>
<div class="form-combo-option" role="option" id="cb-values-crab-meat" data-value="crab-meat" aria-selected="false">Crab Meat</div>
<div class="form-combo-option" role="option" id="cb-values-cranberry-sauce" data-value="cranberry-sauce" aria-selected="false">Cranberry Sauce</div>
<div class="form-combo-option" role="option" id="cb-values-dried-pears" data-value="dried-pears" aria-selected="false">Dried Pears</div>
<div class="form-combo-option" role="option" id="cb-values-filo-mix" data-value="filo-mix" aria-selected="false">Filo Mix</div>
<div class="form-combo-option" role="option" id="cb-values-gnocchi" data-value="gnocchi" aria-selected="false">Gnocchi</div>
<div class="form-combo-option" role="option" id="cb-values-gorgonzola" data-value="gorgonzola" aria-selected="false">Gorgonzola</div>
<div class="form-combo-option" role="option" id="cb-values-hot-pepper-sauce" data-value="hot-pepper-sauce" aria-selected="false">Hot Pepper Sauce</div>
<div class="form-combo-option" role="option" id="cb-values-ikura" data-value="ikura" aria-selected="false">Ikura</div>
<div class="form-combo-option" role="option" id="cb-values-konbu" data-value="konbu" aria-selected="false">Konbu</div>
<div class="form-combo-option" role="option" id="cb-values-maple-syrup" data-value="maple-syrup" aria-selected="false">Maple Syrup</div>
<div class="form-combo-option" role="option" id="cb-values-marmalade" data-value="marmalade" aria-selected="false">Marmalade</div>
<div class="form-combo-option" role="option" id="cb-values-mascarpone" data-value="mascarpone" aria-selected="false">Mascarpone</div>
<div class="form-combo-option" role="option" id="cb-values-pavlova" data-value="pavlova" aria-selected="false">Pavlova</div>
<div class="form-combo-option" role="option" id="cb-values-queso-cabrales" data-value="queso-cabrales" aria-selected="false">Queso Cabrales</div>
<div class="form-combo-option" role="option" id="cb-values-raclette" data-value="raclette" aria-selected="false">Raclette</div>
<div class="form-combo-option" role="option" id="cb-values-ravioli" data-value="ravioli" aria-selected="false">Ravioli</div>
<div class="form-combo-option" role="option" id="cb-values-scones" data-value="scones" aria-selected="false">Scones</div>
<div class="form-combo-option" role="option" id="cb-values-tofu" data-value="tofu" aria-selected="false">Tofu</div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i>No product matches.</div>
</div>
</div>
</div>
Searchable select
Live. .form-combo-value holds the chosen label, under the input in the
same cell, so it reads like a .form-select until you type. The input needs a
placeholder — :placeholder-shown is how the stylesheet knows nothing
is typed. A managed field writes the value into its input[data-combo-field]; a Blazor
page renders .form-combo-value from its own state instead.
<div class="form-field" style="max-width:360px">
<label class="form-label" for="cb-product">Product</label>
<div class="form-combo" data-combo="single" data-combo-managed>
<div class="form-combo-box">
<span class="form-combo-value">Chang</span>
<input class="form-combo-input" id="cb-product" type="text" role="combobox" placeholder="Pick a product…"
aria-expanded="false" aria-controls="cb-product-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-clear" type="button" aria-label="Clear the product"><i class="ri-close-line"></i></button>
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show products"><i class="ri-arrow-down-s-line"></i></button>
</span>
</div>
<input type="hidden" name="product" value="chang" data-combo-field />
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-product-list" role="listbox" aria-label="Products">
<div class="form-combo-group" role="presentation">Beverages</div>
<div class="form-combo-option" role="option" id="cb-product-chai" data-value="chai" aria-selected="false">Chai</div>
<div class="form-combo-option" role="option" id="cb-product-chang" data-value="chang" aria-selected="true">Chang</div>
<div class="form-combo-group" role="presentation">Condiments</div>
<div class="form-combo-option" role="option" id="cb-product-aniseed-syrup" data-value="aniseed-syrup" aria-selected="false">Aniseed Syrup</div>
<div class="form-combo-option" role="option" id="cb-product-cranberry-sauce" data-value="cranberry-sauce" aria-selected="false">Cranberry Sauce</div>
<div class="form-combo-option" role="option" id="cb-product-hot-pepper-sauce" data-value="hot-pepper-sauce" aria-selected="false">Hot Pepper Sauce</div>
<div class="form-combo-option" role="option" id="cb-product-maple-syrup" data-value="maple-syrup" aria-selected="false">Maple Syrup</div>
<div class="form-combo-option" role="option" id="cb-product-marmalade" data-value="marmalade" aria-selected="false">Marmalade</div>
<div class="form-combo-group" role="presentation">Dairy</div>
<div class="form-combo-option" role="option" id="cb-product-camembert" data-value="camembert" aria-selected="false">Camembert</div>
<div class="form-combo-option" role="option" id="cb-product-gorgonzola" data-value="gorgonzola" aria-selected="false">Gorgonzola</div>
<div class="form-combo-option" role="option" id="cb-product-mascarpone" data-value="mascarpone" aria-selected="false">Mascarpone</div>
<div class="form-combo-option" role="option" id="cb-product-queso-cabrales" data-value="queso-cabrales" aria-selected="false">Queso Cabrales</div>
<div class="form-combo-option" role="option" id="cb-product-raclette" data-value="raclette" aria-selected="false">Raclette</div>
<div class="form-combo-group" role="presentation">Seafood</div>
<div class="form-combo-option" role="option" id="cb-product-clam-chowder" data-value="clam-chowder" aria-selected="false">Clam Chowder</div>
<div class="form-combo-option" role="option" id="cb-product-crab-meat" data-value="crab-meat" aria-selected="false">Crab Meat</div>
<div class="form-combo-option" role="option" id="cb-product-ikura" data-value="ikura" aria-selected="false">Ikura</div>
<div class="form-combo-option" role="option" id="cb-product-konbu" data-value="konbu" aria-selected="false">Konbu</div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i>No product matches.</div>
</div>
</div>
</div>
Free entry
Live — try pasting ops@example.org, billing@example.net; ops@example.
data-combo="free" has no list. Enter, a comma, a semicolon,
leaving the field and pasting all commit; a pasted list is split and entries already there are
skipped. An address field also splits on spaces, which an address never holds.
.chip--invalid keeps a rejected entry as a chip rather than throwing the typo away;
clicking its label in a managed field puts it back in the input. A managed field checks addresses
with data-combo-validate="email", which, unlike type="email", rejects a
domain with no dot.
Enter, comma or paste to add. Backspace twice removes the last one.
<div class="form-field" style="max-width:520px">
<label class="form-label" for="cb-recipients">Send the report to</label>
<div class="form-combo" data-combo="free" data-combo-managed data-combo-validate="email">
<div class="form-combo-box">
<i class="ri-mail-line form-combo-icon" aria-hidden="true"></i>
<span class="chip" data-value="alex.fischer@example.com">
<span class="chip-label">alex.fischer@example.com</span>
<button class="chip-dismiss" type="button" aria-label="Remove alex.fischer@example.com"><i class="ri-close-line"></i></button>
<input type="hidden" name="recipients" value="alex.fischer@example.com" />
</span>
<input class="form-combo-input" id="cb-recipients" type="text" inputmode="email" placeholder="Add an address…"
autocomplete="off" aria-describedby="cb-recipients-hint" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-clear" type="button" aria-label="Remove every address"><i class="ri-close-line"></i></button>
</span>
</div>
<template data-combo-chip>
<span class="chip">
<span class="chip-label"></span>
<button class="chip-dismiss" type="button" aria-label="Remove {label}"><i class="ri-close-line"></i></button>
<input type="hidden" name="recipients" />
</span>
</template>
</div>
<p class="form-hint" id="cb-recipients-hint">Enter, comma or paste to add. Backspace twice removes the last one.</p>
</div>
Collapsed until focused
.form-combo--collapse keeps a long set of picks to one row while the field is not in
use: the first three chips, then a count of the rest from a CSS counter. Focus it and every chip
wraps back and the list opens, because the reader came to change the picks. Without the modifier the box grows a row at a time, which is right in a form where the
picks are the thing being reviewed.
Shows three until you click in, then all of them.
<div class="form-field" style="max-width:460px">
<label class="form-label" for="cb-collapse">Stocked in this warehouse</label>
<div class="form-combo form-combo--collapse" data-combo="multi" data-combo-managed>
<div class="form-combo-box">
<span class="chip" data-value="chai">
<span class="chip-label">Chai</span>
<button class="chip-dismiss" type="button" aria-label="Remove Chai"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="chai" />
</span>
<span class="chip" data-value="chang">
<span class="chip-label">Chang</span>
<button class="chip-dismiss" type="button" aria-label="Remove Chang"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="chang" />
</span>
<span class="chip" data-value="aniseed-syrup">
<span class="chip-label">Aniseed Syrup</span>
<button class="chip-dismiss" type="button" aria-label="Remove Aniseed Syrup"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="aniseed-syrup" />
</span>
<span class="chip" data-value="konbu">
<span class="chip-label">Konbu</span>
<button class="chip-dismiss" type="button" aria-label="Remove Konbu"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="konbu" />
</span>
<span class="chip" data-value="tofu">
<span class="chip-label">Tofu</span>
<button class="chip-dismiss" type="button" aria-label="Remove Tofu"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="tofu" />
</span>
<span class="chip" data-value="pavlova">
<span class="chip-label">Pavlova</span>
<button class="chip-dismiss" type="button" aria-label="Remove Pavlova"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="pavlova" />
</span>
<span class="chip" data-value="raclette">
<span class="chip-label">Raclette</span>
<button class="chip-dismiss" type="button" aria-label="Remove Raclette"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="raclette" />
</span>
<span class="chip" data-value="scones">
<span class="chip-label">Scones</span>
<button class="chip-dismiss" type="button" aria-label="Remove Scones"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" value="scones" />
</span>
<input class="form-combo-input" id="cb-collapse" type="text" role="combobox" placeholder="Add products…"
aria-expanded="false" aria-controls="cb-collapse-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-clear" type="button" aria-label="Remove every product"><i class="ri-close-line"></i></button>
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show products"><i class="ri-arrow-down-s-line"></i></button>
</span>
</div>
<template data-combo-chip>
<span class="chip">
<span class="chip-label"></span>
<button class="chip-dismiss" type="button" aria-label="Remove {label}"><i class="ri-close-line"></i></button>
<input type="hidden" name="stocked" />
</span>
</template>
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-collapse-list" role="listbox" aria-multiselectable="true" aria-label="Products">
<div class="form-combo-option" role="option" id="cb-collapse-aniseed-syrup" data-value="aniseed-syrup" aria-selected="true">Aniseed Syrup</div>
<div class="form-combo-option" role="option" id="cb-collapse-camembert" data-value="camembert" aria-selected="false">Camembert</div>
<div class="form-combo-option" role="option" id="cb-collapse-chai" data-value="chai" aria-selected="true">Chai</div>
<div class="form-combo-option" role="option" id="cb-collapse-chang" data-value="chang" aria-selected="true">Chang</div>
<div class="form-combo-option" role="option" id="cb-collapse-chocolate-biscuits" data-value="chocolate-biscuits" aria-selected="false">Chocolate Biscuits</div>
<div class="form-combo-option" role="option" id="cb-collapse-clam-chowder" data-value="clam-chowder" aria-selected="false">Clam Chowder</div>
<div class="form-combo-option" role="option" id="cb-collapse-crab-meat" data-value="crab-meat" aria-selected="false">Crab Meat</div>
<div class="form-combo-option" role="option" id="cb-collapse-cranberry-sauce" data-value="cranberry-sauce" aria-selected="false">Cranberry Sauce</div>
<div class="form-combo-option" role="option" id="cb-collapse-dried-pears" data-value="dried-pears" aria-selected="false">Dried Pears</div>
<div class="form-combo-option" role="option" id="cb-collapse-filo-mix" data-value="filo-mix" aria-selected="false">Filo Mix</div>
<div class="form-combo-option" role="option" id="cb-collapse-gnocchi" data-value="gnocchi" aria-selected="false">Gnocchi</div>
<div class="form-combo-option" role="option" id="cb-collapse-gorgonzola" data-value="gorgonzola" aria-selected="false">Gorgonzola</div>
<div class="form-combo-option" role="option" id="cb-collapse-hot-pepper-sauce" data-value="hot-pepper-sauce" aria-selected="false">Hot Pepper Sauce</div>
<div class="form-combo-option" role="option" id="cb-collapse-ikura" data-value="ikura" aria-selected="false">Ikura</div>
<div class="form-combo-option" role="option" id="cb-collapse-konbu" data-value="konbu" aria-selected="true">Konbu</div>
<div class="form-combo-option" role="option" id="cb-collapse-maple-syrup" data-value="maple-syrup" aria-selected="false">Maple Syrup</div>
<div class="form-combo-option" role="option" id="cb-collapse-marmalade" data-value="marmalade" aria-selected="false">Marmalade</div>
<div class="form-combo-option" role="option" id="cb-collapse-mascarpone" data-value="mascarpone" aria-selected="false">Mascarpone</div>
<div class="form-combo-option" role="option" id="cb-collapse-pavlova" data-value="pavlova" aria-selected="true">Pavlova</div>
<div class="form-combo-option" role="option" id="cb-collapse-queso-cabrales" data-value="queso-cabrales" aria-selected="false">Queso Cabrales</div>
<div class="form-combo-option" role="option" id="cb-collapse-raclette" data-value="raclette" aria-selected="true">Raclette</div>
<div class="form-combo-option" role="option" id="cb-collapse-ravioli" data-value="ravioli" aria-selected="false">Ravioli</div>
<div class="form-combo-option" role="option" id="cb-collapse-scones" data-value="scones" aria-selected="true">Scones</div>
<div class="form-combo-option" role="option" id="cb-collapse-tofu" data-value="tofu" aria-selected="true">Tofu</div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i>No product matches.</div>
</div>
</div>
<p class="form-hint">Shows three until you click in, then all of them.</p>
</div>
Server-backed states
data-combo-source="server" tells the script the app filters, so it hides no rows. The
states are the app's markup: .form-combo-skeleton rows for the first page,
aria-busy="true" on the list while a query runs — which dims the rows that are
there and shows .form-combo-busy —, .form-combo-empty when a result
came back empty, and .form-combo-status--error with a retry.
.form-combo-more, straight after the list, asks for the next page — after it
rather than in it, because a listbox may hold only options. It is a real button, and the script
clicks it once the list is scrolled to its end. These are drawn with
.form-combo--inline, which keeps the list open in the flow — the shape for a
picker that is the whole content of a drawer or a popover.
<div class="sedna-grid sedna-grid--sm sedna-grid--fit">
<div class="form-field">
<label class="form-label" for="cs-loading">Opening</label>
<div class="form-combo form-combo--inline">
<div class="form-combo-box">
<input class="form-combo-input" id="cs-loading" type="text" role="combobox" placeholder="Search the catalogue…"
aria-expanded="true" aria-controls="cs-loading-list" autocomplete="off" />
<span class="form-combo-actions">
<span class="spinner form-combo-busy" role="status" aria-label="Loading"></span>
</span>
</div>
<div class="form-combo-panel">
<div class="form-combo-head">Loading products…</div>
<div class="form-combo-list" id="cs-loading-list" role="listbox" aria-busy="true" aria-label="Products">
<div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:46%"></div></div>
<div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:62%"></div></div>
<div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:38%"></div></div>
</div>
</div>
</div>
</div>
<div class="form-field">
<label class="form-label" for="cs-page">First page</label>
<div class="form-combo form-combo--inline">
<div class="form-combo-box">
<input class="form-combo-input" id="cs-page" type="text" role="combobox" placeholder="Search the catalogue…"
aria-expanded="true" aria-controls="cs-page-list" autocomplete="off" />
<span class="form-combo-actions">
<span class="spinner form-combo-busy" role="status" aria-label="Loading"></span>
</span>
</div>
<div class="form-combo-panel">
<div class="form-combo-head">50 of 12,480 · type to search</div>
<div class="form-combo-list" id="cs-page-list" role="listbox" aria-multiselectable="true" aria-label="Products">
<div class="form-combo-option" role="option" id="cs-page-1" aria-selected="false"><span class="form-combo-option-text">Pavlova</span><span class="form-combo-option-meta">NW-10046</span></div>
<div class="form-combo-option" role="option" id="cs-page-2" aria-selected="true"><span class="form-combo-option-text">Queso Cabrales</span><span class="form-combo-option-meta">NW-10047</span></div>
<div class="form-combo-option" role="option" id="cs-page-3" aria-selected="false"><span class="form-combo-option-text">Konbu</span><span class="form-combo-option-meta">NW-10048</span></div>
</div>
<button class="form-combo-more" type="button">Load 50 more</button>
</div>
</div>
</div>
<div class="form-field">
<label class="form-label" for="cs-search">Searching</label>
<div class="form-combo form-combo--inline">
<div class="form-combo-box">
<input class="form-combo-input" id="cs-search" type="text" role="combobox" placeholder="Search the catalogue…"
value="cran" aria-expanded="true" aria-controls="cs-search-list" autocomplete="off" />
<span class="form-combo-actions">
<span class="spinner form-combo-busy" role="status" aria-label="Searching"></span>
</span>
</div>
<div class="form-combo-panel">
<div class="form-combo-head">Searching…</div>
<div class="form-combo-list" id="cs-search-list" role="listbox" aria-busy="true" aria-multiselectable="true" aria-label="Products">
<div class="form-combo-option" role="option" id="cs-search-1" aria-selected="false"><span class="form-combo-option-text">Pavlova</span><span class="form-combo-option-meta">NW-10046</span></div>
<div class="form-combo-option" role="option" id="cs-search-2" aria-selected="true"><span class="form-combo-option-text">Queso Cabrales</span><span class="form-combo-option-meta">NW-10047</span></div>
<div class="form-combo-option" role="option" id="cs-search-3" aria-selected="false"><span class="form-combo-option-text">Konbu</span><span class="form-combo-option-meta">NW-10048</span></div>
</div>
</div>
</div>
</div>
<div class="form-field">
<label class="form-label" for="cs-none">Nothing found</label>
<div class="form-combo form-combo--inline">
<div class="form-combo-box">
<input class="form-combo-input" id="cs-none" type="text" role="combobox" placeholder="Search the catalogue…"
value="quinoa" aria-expanded="true" aria-controls="cs-none-list" autocomplete="off" />
</div>
<div class="form-combo-panel">
<div class="form-combo-list" id="cs-none-list" role="listbox" aria-label="Products"></div>
<div class="form-combo-empty"><i class="ri-search-line"></i>No product matches “quinoa”.</div>
</div>
</div>
</div>
<div class="form-field">
<label class="form-label" for="cs-error">Failed</label>
<div class="form-combo form-combo--inline">
<div class="form-combo-box">
<input class="form-combo-input" id="cs-error" type="text" role="combobox" placeholder="Search the catalogue…"
value="cran" aria-expanded="true" aria-controls="cs-error-status" aria-describedby="cs-error-status" autocomplete="off" />
</div>
<div class="form-combo-panel">
<div class="form-combo-status form-combo-status--error" id="cs-error-status" role="alert">
<i class="ri-error-warning-line"></i>The catalogue did not answer.
<button class="btn btn-sm" type="button"><i class="ri-refresh-line"></i> Retry</button>
</div>
</div>
</div>
</div>
</div>
A multiselect in Blazor
The whole list is on the page, so the script filters it and the component binds nothing to the
input. @onclick on an option and on a chip's dismiss button is the integration;
aria-selected is rendered as the string "true" or "false",
because a Razor boolean attribute is written with no value at all.
<div class="form-field">
<label class="form-label" for="tags">Tags</label>
<div class="form-combo" data-combo="multi">
<div class="form-combo-box">
@foreach (var tag in _picked)
{
<span class="chip" data-value="@tag.Id">
<span class="chip-label">@tag.Name</span>
<button class="chip-dismiss" type="button" aria-label="Remove @tag.Name"
@onclick="() => _picked.Remove(tag)"><i class="ri-close-line"></i></button>
</span>
}
<input class="form-combo-input" id="tags" type="text" role="combobox" placeholder="Add tags…"
aria-expanded="false" aria-controls="tags-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-clear" type="button" aria-label="Remove every tag"
@onclick="_picked.Clear"><i class="ri-close-line"></i></button>
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show tags">
<i class="ri-arrow-down-s-line"></i>
</button>
</span>
</div>
<div class="form-combo-panel">
<div class="form-combo-list" id="tags-list" role="listbox" aria-multiselectable="true" aria-label="Tags">
@foreach (var tag in _all)
{
<div class="form-combo-option" role="option" id="tags-@tag.Id" data-value="@tag.Id"
aria-selected="@(_picked.Contains(tag) ? "true" : "false")"
@onclick="() => Toggle(tag)">@tag.Name</div>
}
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i>No tag matches.</div>
</div>
</div>
</div>
@code {
// The whole list is on the page, so the script filters it as the reader types —
// no binding on the input, no round trip per keystroke.
private List<Tag> _all = [];
private readonly List<Tag> _picked = [];
private void Toggle(Tag tag)
{
if (!_picked.Remove(tag)) _picked.Add(tag);
}
}
A server-backed list in Blazor
The picks render from the record being edited, not from the search results, so a tag already on
the note is a chip before any search has run. The query is bound on oninput and
debounced in C#, and a superseded search is cancelled rather than allowed to overwrite a newer one.
<div class="form-combo" data-combo="multi" data-combo-source="server">
<div class="form-combo-box">
@* The note's own tags come with the note, so they are chips before any search
has run — whether or not they are in the page of results below. *@
@foreach (var tag in _note.Tags)
{
<span class="chip" data-value="@tag.Id">
<span class="chip-label">@tag.Name</span>
<button class="chip-dismiss" type="button" aria-label="Remove @tag.Name"
@onclick="() => _note.Tags.Remove(tag)"><i class="ri-close-line"></i></button>
</span>
}
<input class="form-combo-input" id="note-tags" type="text" role="combobox" placeholder="Search tags…"
aria-expanded="false" aria-controls="@(_failed ? "note-tags-status" : "note-tags-list")" aria-autocomplete="list" autocomplete="off"
@bind="_query" @bind:event="oninput" @bind:after="SearchAsync" @onfocus="OpenedAsync" />
<span class="form-combo-actions">
<span class="spinner form-combo-busy" role="status" aria-label="Searching"></span>
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show tags">
<i class="ri-arrow-down-s-line"></i>
</button>
</span>
</div>
<div class="form-combo-panel">
@if (_failed)
{
<div class="form-combo-status form-combo-status--error" id="note-tags-status" role="alert">
<i class="ri-error-warning-line"></i>The tags could not be loaded.
<button class="btn btn-sm" type="button" @onclick="SearchAsync">Retry</button>
</div>
}
else
{
<div class="form-combo-head">@_total tags</div>
<div class="form-combo-list" id="note-tags-list" role="listbox" aria-multiselectable="true"
aria-label="Tags" aria-busy="@(_busy ? "true" : "false")">
@if (_rows is null)
{
<div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:50%"></div></div>
<div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:35%"></div></div>
}
else
{
@foreach (var tag in _rows)
{
<div class="form-combo-option" role="option" id="note-tags-@tag.Id" data-value="@tag.Id"
aria-selected="@(_note.Tags.Contains(tag) ? "true" : "false")"
@onclick="() => Toggle(tag)">@tag.Name</div>
}
}
</div>
@if (_rows is not null && _rows.Count < _total)
{
@* After the list, not in it. The script clicks it once the list is scrolled to its end. *@
<button class="form-combo-more" type="button" @onclick="NextPageAsync">Load more</button>
}
<div class="form-combo-empty"><i class="ri-search-line"></i>No tag matches “@_query”.</div>
}
</div>
</div>
@code {
private const int PageSize = 50;
private string _query = "";
private List<Tag>? _rows; // null until the first page arrives
private int _total;
private bool _busy, _failed;
private CancellationTokenSource? _pending;
// The most-used tags, loaded the first time the field is focused.
private Task OpenedAsync() => _rows is null ? LoadAsync(debounce: false) : Task.CompletedTask;
private Task SearchAsync() => LoadAsync(debounce: true);
private async Task LoadAsync(bool debounce)
{
_pending?.Cancel();
var mine = _pending = new CancellationTokenSource();
try
{
if (debounce) await Task.Delay(250, mine.Token);
_busy = true;
_failed = false;
var page = await Tags.SearchAsync(_query, offset: 0, take: PageSize, mine.Token);
_rows = page.Rows;
_total = page.Total;
}
catch (OperationCanceledException) { return; } // a newer keystroke won
catch (HttpRequestException) { _failed = true; }
finally { if (_pending == mine) _busy = false; }
}
private async Task NextPageAsync()
{
_busy = true;
var page = await Tags.SearchAsync(_query, offset: _rows!.Count, take: PageSize, default);
_rows.AddRange(page.Rows);
_busy = false;
}
private void Toggle(Tag tag)
{
if (!_note.Tags.Remove(tag)) _note.Tags.Add(tag);
}
}
Free entry in Blazor
One change arrives per entry, already split and de-duplicated, and the script clears
the input afterwards. The platform's own change on leaving the field is replaced by
the same commit, so a typed entry is never added twice.
<div class="form-field">
<label class="form-label" for="recipients">Send the report to</label>
<div class="form-combo" data-combo="free">
<div class="form-combo-box">
<i class="ri-mail-line form-combo-icon" aria-hidden="true"></i>
@foreach (var address in _recipients)
{
<span class="chip @(IsAddress(address) ? "" : "chip--invalid")" data-value="@address">
<span class="chip-label">@address</span>
<button class="chip-dismiss" type="button" aria-label="Remove @address"
@onclick="() => _recipients.Remove(address)"><i class="ri-close-line"></i></button>
</span>
}
@* No binding: the script clears the input after each entry. One `change`
arrives per entry, already split out of a pasted list, duplicates dropped. *@
<input class="form-combo-input" id="recipients" type="text" inputmode="email"
placeholder="Add an address…" autocomplete="off"
aria-invalid="@(_recipients.All(IsAddress) ? "false" : "true")"
aria-describedby="recipients-error"
@onchange="Add" />
</div>
</div>
@if (_recipients.FirstOrDefault(a => !IsAddress(a)) is { } bad)
{
<p class="form-error" id="recipients-error">“@bad” is not an e-mail address.</p>
}
</div>
@code {
private readonly List<string> _recipients = [];
private void Add(ChangeEventArgs e)
{
if (e.Value is string entry && !_recipients.Contains(entry, StringComparer.OrdinalIgnoreCase))
_recipients.Add(entry);
}
private static bool IsAddress(string value) =>
MailAddress.TryCreate(value, out var parsed) && parsed.Host.Contains('.');
}
Keyboard
| Key | Single | Multi | Free entry |
|---|---|---|---|
| ↓ ↑ | open, then move | open, then move | move through suggestions, if any |
| Enter | pick and close | tick or untick, stay open | add what was typed |
| , ; paste | — | — | add; a pasted list is split |
| Backspace in an empty input | — | mark the last chip, then remove it | mark the last chip, then remove it |
| Esc | close and clear the query | close and clear the query | clear what was typed |
| Tab | close | close | add what was typed, then move on |