Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
An unhandled error has occurred.
Sedna.UI
v0.31.0 · main

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.

The page owns the selection In a Blazor app the chips and the options render from the app's state, and the script turns key presses into the clicks and changes that markup already handles: Enter clicks the option, a second Backspace clicks the last chip's dismiss button. The live examples below carry 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.

Chai Chang
Aniseed Syrup
Camembert
Chai
Chang
Chocolate Biscuits
Clam Chowder
Crab Meat
Cranberry Sauce
Dried Pears
Filo Mix
Gnocchi
Gorgonzola
Hot Pepper Sauce
Ikura
Konbu
Maple Syrup
Marmalade
Mascarpone
Pavlova
Queso Cabrales
Raclette
Ravioli
Scones
Tofu
No product matches.
<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.

Chang
Chai
Chang
Aniseed Syrup
Cranberry Sauce
Hot Pepper Sauce
Maple Syrup
Marmalade
Camembert
Gorgonzola
Mascarpone
Queso Cabrales
Raclette
Clam Chowder
Crab Meat
Ikura
Konbu
No product matches.
<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.

Welcome letter
Welcome letter
Account setup checklist
First check-in call
Contract renewal reminder
Overdue invoice notice
Payment receipt
Price change notice
No template matches.
<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.

Contract renewal reminder for annual plans
Contract renewal reminder for annual plans
Contract renewal reminder for monthly plans
Trial ending notice
No template matches.
Overdue invoice notice, second reminder
Overdue invoice notice, first reminder
Overdue invoice notice, second reminder
Overdue invoice notice, final reminder before collection
No template matches.
Contract renewal reminder for annual plans with a standard billing cycle
Contract renewal reminder for annual plans with a custom billing cycle
No template matches.
<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.

alex.fischer@example.com

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.

Chai Chang Aniseed Syrup Konbu Tofu Pavlova Raclette Scones
Aniseed Syrup
Camembert
Chai
Chang
Chocolate Biscuits
Clam Chowder
Crab Meat
Cranberry Sauce
Dried Pears
Filo Mix
Gnocchi
Gorgonzola
Hot Pepper Sauce
Ikura
Konbu
Maple Syrup
Marmalade
Mascarpone
Pavlova
Queso Cabrales
Raclette
Ravioli
Scones
Tofu
No product matches.

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.

Loading products…
50 of 12,480 · type to search
PavlovaNW-10046
Queso CabralesNW-10047
KonbuNW-10048
Searching…
PavlovaNW-10046
Queso CabralesNW-10047
KonbuNW-10048
No product matches “quinoa”.
<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

KeySingleMultiFree entry
↓ ↑open, then moveopen, then move, stopping on a group's actionmove through suggestions, if any
Enterpick and closetick or untick, stay open; on a group's action, press itadd what was typed
, ; paste——add; a pasted list is split
Backspace in an empty input—mark the last chip, then remove itmark the last chip, then remove it
Escclose and clear the queryclose and clear the queryclear what was typed
Tabclosecloseadd what was typed, then move on