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.17.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>No product matches.</div>
        </div>
    </div>
</div>

Searchable select

Live. .form-combo-value holds the chosen label, under the input in the same cell, so it reads like a .form-select until you type. The input needs a placeholder:placeholder-shown is how the stylesheet knows nothing is typed. A managed field writes the value into its input[data-combo-field]; a Blazor page renders .form-combo-value from its own state instead.

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>No product matches.</div>
        </div>
    </div>
</div>

Free entry

Live — try pasting ops@example.org, billing@example.net; ops@example. data-combo="free" has no list. Enter, a comma, a semicolon, leaving the field and pasting all commit; a pasted list is split and entries already there are skipped. An address field also splits on spaces, which an address never holds.

.chip--invalid keeps a rejected entry as a chip rather than throwing the typo away; clicking its label in a managed field puts it back in the input. A managed field checks addresses with data-combo-validate="email", which, unlike type="email", rejects a domain with no dot.

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>No product matches.</div>
        </div>
    </div>
    <p class="form-hint">Shows three until you click in, then all of them.</p>
</div>

Server-backed states

data-combo-source="server" tells the script the app filters, so it hides no rows. The states are the app's markup: .form-combo-skeleton rows for the first page, aria-busy="true" on the list while a query runs — which dims the rows that are there and shows .form-combo-busy —, .form-combo-empty when a result came back empty, and .form-combo-status--error with a retry.

.form-combo-more, straight after the list, asks for the next page — after it rather than in it, because a listbox may hold only options. It is a real button, and the script clicks it once the list is scrolled to its end. These are drawn with .form-combo--inline, which keeps the list open in the flow — the shape for a picker that is the whole content of a drawer or a popover.

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>No product matches “quinoa”.</div>
            </div>
        </div>
    </div>

    <div class="form-field">
        <label class="form-label" for="cs-error">Failed</label>
        <div class="form-combo form-combo--inline">
            <div class="form-combo-box">
                <input class="form-combo-input" id="cs-error" type="text" role="combobox" placeholder="Search the catalogue…"
                       value="cran" aria-expanded="true" aria-controls="cs-error-status" aria-describedby="cs-error-status" autocomplete="off" />
            </div>
            <div class="form-combo-panel">
                <div class="form-combo-status form-combo-status--error" id="cs-error-status" role="alert">
                    <i class="ri-error-warning-line"></i>The catalogue did not answer.
                    <button class="btn btn-sm" type="button"><i class="ri-refresh-line"></i> Retry</button>
                </div>
            </div>
        </div>
    </div>
</div>

A multiselect in Blazor

The whole list is on the page, so the script filters it and the component binds nothing to the input. @onclick on an option and on a chip's dismiss button is the integration; aria-selected is rendered as the string "true" or "false", because a Razor boolean attribute is written with no value at all.

<div class="form-field">
    <label class="form-label" for="tags">Tags</label>
    <div class="form-combo" data-combo="multi">
        <div class="form-combo-box">
            @foreach (var tag in _picked)
            {
                <span class="chip" data-value="@tag.Id">
                    <span class="chip-label">@tag.Name</span>
                    <button class="chip-dismiss" type="button" aria-label="Remove @tag.Name"
                            @onclick="() => _picked.Remove(tag)"><i class="ri-close-line"></i></button>
                </span>
            }
            <input class="form-combo-input" id="tags" type="text" role="combobox" placeholder="Add tags…"
                   aria-expanded="false" aria-controls="tags-list" aria-autocomplete="list" autocomplete="off" />
            <span class="form-combo-actions">
                <button class="form-combo-btn form-combo-clear" type="button" aria-label="Remove every tag"
                        @onclick="_picked.Clear"><i class="ri-close-line"></i></button>
                <button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show tags">
                    <i class="ri-arrow-down-s-line"></i>
                </button>
            </span>
        </div>
        <div class="form-combo-panel">
            <div class="form-combo-list" id="tags-list" role="listbox" aria-multiselectable="true" aria-label="Tags">
                @foreach (var tag in _all)
                {
                    <div class="form-combo-option" role="option" id="tags-@tag.Id" data-value="@tag.Id"
                         aria-selected="@(_picked.Contains(tag) ? "true" : "false")"
                         @onclick="() => Toggle(tag)">@tag.Name</div>
                }
            </div>
            <div class="form-combo-empty"><i class="ri-search-line"></i>No tag matches.</div>
        </div>
    </div>
</div>

@code {
    // The whole list is on the page, so the script filters it as the reader types —
    // no binding on the input, no round trip per keystroke.
    private List<Tag> _all = [];
    private readonly List<Tag> _picked = [];

    private void Toggle(Tag tag)
    {
        if (!_picked.Remove(tag)) _picked.Add(tag);
    }
}

A server-backed list in Blazor

The picks render from the record being edited, not from the search results, so a tag already on the note is a chip before any search has run. The query is bound on oninput and debounced in C#, and a superseded search is cancelled rather than allowed to overwrite a newer one.

<div class="form-combo" data-combo="multi" data-combo-source="server">
    <div class="form-combo-box">
        @* The note's own tags come with the note, so they are chips before any search
           has run — whether or not they are in the page of results below. *@
        @foreach (var tag in _note.Tags)
        {
            <span class="chip" data-value="@tag.Id">
                <span class="chip-label">@tag.Name</span>
                <button class="chip-dismiss" type="button" aria-label="Remove @tag.Name"
                        @onclick="() => _note.Tags.Remove(tag)"><i class="ri-close-line"></i></button>
            </span>
        }
        <input class="form-combo-input" id="note-tags" type="text" role="combobox" placeholder="Search tags…"
               aria-expanded="false" aria-controls="@(_failed ? "note-tags-status" : "note-tags-list")" aria-autocomplete="list" autocomplete="off"
               @bind="_query" @bind:event="oninput" @bind:after="SearchAsync" @onfocus="OpenedAsync" />
        <span class="form-combo-actions">
            <span class="spinner form-combo-busy" role="status" aria-label="Searching"></span>
            <button class="form-combo-btn form-combo-toggle" type="button" tabindex="-1" aria-label="Show tags">
                <i class="ri-arrow-down-s-line"></i>
            </button>
        </span>
    </div>
    <div class="form-combo-panel">
        @if (_failed)
        {
            <div class="form-combo-status form-combo-status--error" id="note-tags-status" role="alert">
                <i class="ri-error-warning-line"></i>The tags could not be loaded.
                <button class="btn btn-sm" type="button" @onclick="SearchAsync">Retry</button>
            </div>
        }
        else
        {
            <div class="form-combo-head">@_total tags</div>
            <div class="form-combo-list" id="note-tags-list" role="listbox" aria-multiselectable="true"
                 aria-label="Tags" aria-busy="@(_busy ? "true" : "false")">
                @if (_rows is null)
                {
                    <div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:50%"></div></div>
                    <div class="form-combo-skeleton"><div class="skeleton skeleton-text" style="width:35%"></div></div>
                }
                else
                {
                    @foreach (var tag in _rows)
                    {
                        <div class="form-combo-option" role="option" id="note-tags-@tag.Id" data-value="@tag.Id"
                             aria-selected="@(_note.Tags.Contains(tag) ? "true" : "false")"
                             @onclick="() => Toggle(tag)">@tag.Name</div>
                    }
                }
            </div>
            @if (_rows is not null && _rows.Count < _total)
            {
                @* After the list, not in it. The script clicks it once the list is scrolled to its end. *@
                <button class="form-combo-more" type="button" @onclick="NextPageAsync">Load more</button>
            }
            <div class="form-combo-empty"><i class="ri-search-line"></i>No tag matches “@_query”.</div>
        }
    </div>
</div>

@code {
    private const int PageSize = 50;

    private string _query = "";
    private List<Tag>? _rows;          // null until the first page arrives
    private int _total;
    private bool _busy, _failed;
    private CancellationTokenSource? _pending;

    // The most-used tags, loaded the first time the field is focused.
    private Task OpenedAsync() => _rows is null ? LoadAsync(debounce: false) : Task.CompletedTask;

    private Task SearchAsync() => LoadAsync(debounce: true);

    private async Task LoadAsync(bool debounce)
    {
        _pending?.Cancel();
        var mine = _pending = new CancellationTokenSource();
        try
        {
            if (debounce) await Task.Delay(250, mine.Token);
            _busy = true;
            _failed = false;
            var page = await Tags.SearchAsync(_query, offset: 0, take: PageSize, mine.Token);
            _rows = page.Rows;
            _total = page.Total;
        }
        catch (OperationCanceledException) { return; }    // a newer keystroke won
        catch (HttpRequestException) { _failed = true; }
        finally { if (_pending == mine) _busy = false; }
    }

    private async Task NextPageAsync()
    {
        _busy = true;
        var page = await Tags.SearchAsync(_query, offset: _rows!.Count, take: PageSize, default);
        _rows.AddRange(page.Rows);
        _busy = false;
    }

    private void Toggle(Tag tag)
    {
        if (!_note.Tags.Remove(tag)) _note.Tags.Add(tag);
    }
}

Free entry in Blazor

One change arrives per entry, already split and de-duplicated, and the script clears the input afterwards. The platform's own change on leaving the field is replaced by the same commit, so a typed entry is never added twice.

<div class="form-field">
    <label class="form-label" for="recipients">Send the report to</label>
    <div class="form-combo" data-combo="free">
        <div class="form-combo-box">
            <i class="ri-mail-line form-combo-icon" aria-hidden="true"></i>
            @foreach (var address in _recipients)
            {
                <span class="chip @(IsAddress(address) ? "" : "chip--invalid")" data-value="@address">
                    <span class="chip-label">@address</span>
                    <button class="chip-dismiss" type="button" aria-label="Remove @address"
                            @onclick="() => _recipients.Remove(address)"><i class="ri-close-line"></i></button>
                </span>
            }
            @* No binding: the script clears the input after each entry. One `change`
               arrives per entry, already split out of a pasted list, duplicates dropped. *@
            <input class="form-combo-input" id="recipients" type="text" inputmode="email"
                   placeholder="Add an address…" autocomplete="off"
                   aria-invalid="@(_recipients.All(IsAddress) ? "false" : "true")"
                   aria-describedby="recipients-error"
                   @onchange="Add" />
        </div>
    </div>
    @if (_recipients.FirstOrDefault(a => !IsAddress(a)) is { } bad)
    {
        <p class="form-error" id="recipients-error">“@bad” is not an e-mail address.</p>
    }
</div>

@code {
    private readonly List<string> _recipients = [];

    private void Add(ChangeEventArgs e)
    {
        if (e.Value is string entry && !_recipients.Contains(entry, StringComparer.OrdinalIgnoreCase))
            _recipients.Add(entry);
    }

    private static bool IsAddress(string value) =>
        MailAddress.TryCreate(value, out var parsed) && parsed.Host.Contains('.');
}

Keyboard

KeySingleMultiFree entry
open, then moveopen, then movemove through suggestions, if any
Enterpick and closetick or untick, stay openadd what was typed
, ; pasteadd; a pasted list is split
Backspace in an empty inputmark 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