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><span>No product matches.</span></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><span>No product matches.</span></div>
</div>
</div>
</div>
Selecting a whole group
Live. A .form-combo-group heading holds one
.form-combo-group-action: the group's name on the left, a small button on the right,
set in the panel's own case rather than the heading's capitals. With
data-combo-toggle-group the script clicks every enabled row of the group that is not
ticked yet — or, once all of them are, every ticked one — so an option's own click handler
is the whole integration, and a search that hides a row does not keep it out of the group.
The script marks a heading data-all-selected while every enabled row under it is
ticked, which shows .form-combo-group-unpick in place of
.form-combo-group-pick. Focus stays in the input, as it does for an option: the arrow
keys stop on the action between the rows and Enter presses it, so give it
an id and tabindex="-1". Name it with aria-labelledby pointing
at itself and the group's name, so a reader hears which group it selects. Without
data-combo-toggle-group the button is the app's own, for any other one action.
<div class="form-field" style="max-width:460px">
<label class="form-label" for="cb-tasks">Task templates</label>
<div class="form-combo" data-combo="multi" data-combo-managed>
<div class="form-combo-box">
<span class="chip" data-value="welcome-letter">
<span class="chip-label">Welcome letter</span>
<button class="chip-dismiss" type="button" aria-label="Remove Welcome letter"><i class="ri-close-line"></i></button>
<input type="hidden" name="templates" value="welcome-letter" />
</span>
<input class="form-combo-input" id="cb-tasks" type="text" role="combobox" placeholder="Add templates…"
aria-expanded="false" aria-controls="cb-tasks-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 template"><i class="ri-close-line"></i></button>
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show templates"><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="templates" />
</span>
</template>
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-tasks-list" role="listbox" aria-multiselectable="true" aria-label="Task templates">
<div class="form-combo-group" role="presentation">
<span id="cb-tasks-onboarding">Onboarding</span>
<button class="form-combo-group-action" type="button" tabindex="-1" id="cb-tasks-onboarding-all"
aria-labelledby="cb-tasks-onboarding-all cb-tasks-onboarding" data-combo-toggle-group>
<span class="form-combo-group-pick">Select all</span>
<span class="form-combo-group-unpick">Clear</span>
</button>
</div>
<div class="form-combo-option" role="option" id="cb-tasks-welcome-letter" data-value="welcome-letter" aria-selected="true">Welcome letter</div>
<div class="form-combo-option" role="option" id="cb-tasks-account-setup" data-value="account-setup" aria-selected="false">Account setup checklist</div>
<div class="form-combo-option" role="option" id="cb-tasks-first-call" data-value="first-call" aria-selected="false">First check-in call</div>
<div class="form-combo-group" role="presentation">
<span id="cb-tasks-billing">Billing</span>
<button class="form-combo-group-action" type="button" tabindex="-1" id="cb-tasks-billing-all"
aria-labelledby="cb-tasks-billing-all cb-tasks-billing" data-combo-toggle-group>
<span class="form-combo-group-pick">Select all</span>
<span class="form-combo-group-unpick">Clear</span>
</button>
</div>
<div class="form-combo-option" role="option" id="cb-tasks-contract-renewal" data-value="contract-renewal" aria-selected="false">Contract renewal reminder</div>
<div class="form-combo-option" role="option" id="cb-tasks-overdue-invoice" data-value="overdue-invoice" aria-selected="false">Overdue invoice notice</div>
<div class="form-combo-option" role="option" id="cb-tasks-payment-receipt" data-value="payment-receipt" aria-selected="false">Payment receipt</div>
<div class="form-combo-option" role="option" id="cb-tasks-price-change" data-value="price-change" aria-selected="false">Price change notice</div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i><span>No template matches.</span></div>
</div>
</div>
</div>
A panel wider than its field
Live. In a narrow form column a long option is cut, and two names that differ only
at the end cannot be told apart. --form-combo-panel-width on the field opens the list
wider than the box; .form-combo--fit sizes it to its longest row, up to 36rem. Either
way the panel is never narrower than the box, never wider than the viewport, and opens back
towards the start when the end side has no room for it.
.form-combo--wrap lets an option's .form-combo-option-text run onto a
second line instead of ending in an ellipsis — for a phone, where even the widest panel is
too narrow.
<div class="sedna-grid sedna-grid--sm sedna-grid--fit">
<div class="form-field">
<label class="form-label" for="cb-reminder">Reminder</label>
<div class="form-combo" data-combo="single" data-combo-managed style="--form-combo-panel-width: 26rem">
<div class="form-combo-box">
<span class="form-combo-value">Contract renewal reminder for annual plans</span>
<input class="form-combo-input" id="cb-reminder" type="text" role="combobox" placeholder="Pick a template…"
aria-expanded="false" aria-controls="cb-reminder-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show templates"><i class="ri-arrow-down-s-line"></i></button>
</span>
</div>
<input type="hidden" name="reminder" value="renewal-annual" data-combo-field />
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-reminder-list" role="listbox" aria-label="Templates">
<div class="form-combo-option" role="option" id="cb-reminder-annual" data-value="renewal-annual" aria-selected="true"><span class="form-combo-option-text">Contract renewal reminder for annual plans</span></div>
<div class="form-combo-option" role="option" id="cb-reminder-monthly" data-value="renewal-monthly" aria-selected="false"><span class="form-combo-option-text">Contract renewal reminder for monthly plans</span></div>
<div class="form-combo-option" role="option" id="cb-reminder-trial" data-value="trial-ending" aria-selected="false"><span class="form-combo-option-text">Trial ending notice</span></div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i><span>No template matches.</span></div>
</div>
</div>
</div>
<div class="form-field">
<label class="form-label" for="cb-notice">Notice</label>
<div class="form-combo form-combo--fit" data-combo="single" data-combo-managed>
<div class="form-combo-box">
<span class="form-combo-value">Overdue invoice notice, second reminder</span>
<input class="form-combo-input" id="cb-notice" type="text" role="combobox" placeholder="Pick a template…"
aria-expanded="false" aria-controls="cb-notice-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show templates"><i class="ri-arrow-down-s-line"></i></button>
</span>
</div>
<input type="hidden" name="notice" value="overdue-second" data-combo-field />
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-notice-list" role="listbox" aria-label="Templates">
<div class="form-combo-option" role="option" id="cb-notice-first" data-value="overdue-first" aria-selected="false"><span class="form-combo-option-text">Overdue invoice notice, first reminder</span></div>
<div class="form-combo-option" role="option" id="cb-notice-second" data-value="overdue-second" aria-selected="true"><span class="form-combo-option-text">Overdue invoice notice, second reminder</span></div>
<div class="form-combo-option" role="option" id="cb-notice-final" data-value="overdue-final" aria-selected="false"><span class="form-combo-option-text">Overdue invoice notice, final reminder before collection</span></div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i><span>No template matches.</span></div>
</div>
</div>
</div>
<div class="form-field">
<label class="form-label" for="cb-wrap">On a phone</label>
<div class="form-combo form-combo--wrap" data-combo="single" data-combo-managed>
<div class="form-combo-box">
<span class="form-combo-value"></span>
<input class="form-combo-input" id="cb-wrap" type="text" role="combobox" placeholder="Pick a template…"
aria-expanded="false" aria-controls="cb-wrap-list" aria-autocomplete="list" autocomplete="off" />
<span class="form-combo-actions">
<button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show templates"><i class="ri-arrow-down-s-line"></i></button>
</span>
</div>
<input type="hidden" name="phone" value="" data-combo-field />
<div class="form-combo-panel">
<div class="form-combo-list" id="cb-wrap-list" role="listbox" aria-label="Templates">
<div class="form-combo-option" role="option" id="cb-wrap-standard" data-value="standard" aria-selected="false"><span class="form-combo-option-text">Contract renewal reminder for annual plans with a standard billing cycle</span></div>
<div class="form-combo-option" role="option" id="cb-wrap-custom" data-value="custom" aria-selected="false"><span class="form-combo-option-text">Contract renewal reminder for annual plans with a custom billing cycle</span></div>
</div>
<div class="form-combo-empty"><i class="ri-search-line"></i><span>No template matches.</span></div>
</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><span>No product matches.</span></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><span>No product matches “quinoa”.</span></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><span>The catalogue did not answer.</span>
<button class="btn btn--sm" type="button"><i class="ri-refresh-line"></i> <span>Retry</span></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);
}
}
Selecting a whole group in Blazor
Nothing is bound to the group's button. The script clicks the rows that have to change, and each
click reaches Toggle as a pointer's would — one event per row, in the order they are
listed. A page that would rather take the whole group in one call leaves
data-combo-toggle-group off and puts its own @onclick on the button.
<div class="form-combo-list" id="templates-list" role="listbox" aria-multiselectable="true" aria-label="Task templates">
@foreach (var group in _all.GroupBy(t => t.Group))
{
<div class="form-combo-group" role="presentation">
<span id="templates-@group.Key.Id">@group.Key.Name</span>
@* data-combo-toggle-group: the script clicks each row of the group that has to
change, so Toggle below is the whole integration. It also marks the heading
data-all-selected, which picks the label. *@
<button class="form-combo-group-action" type="button" tabindex="-1" id="templates-@group.Key.Id-all"
aria-labelledby="templates-@group.Key.Id-all templates-@group.Key.Id" data-combo-toggle-group>
<span class="form-combo-group-pick">Select all</span>
<span class="form-combo-group-unpick">Clear</span>
</button>
</div>
@foreach (var template in group)
{
<div class="form-combo-option" role="option" id="templates-@template.Id" data-value="@template.Id"
aria-selected="@(_picked.Contains(template) ? "true" : "false")"
@onclick="() => Toggle(template)">@template.Name</div>
}
}
</div>
@code {
private void Toggle(TaskTemplate template)
{
if (!_picked.Remove(template)) _picked.Add(template);
}
}
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, stopping on a group's action | move through suggestions, if any |
| Enter | pick and close | tick or untick, stay open; on a group's action, press it | 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 |