Select
Pick from a list: a native select, or one with a styled list. The real select still submits the value. To keep any typed text instead of an option, use Autocomplete.
Sizes: sm, default and lg
A native select only needs the select class. No
script. It is as tall as a .btn of the same size.
<div class="stack">
<div class="stack" data-direction="row" data-gap="sm">
<select class="select" data-size="sm" aria-label="Small">
<option>Small</option>
<option>Medium</option>
</select>
<button type="button" class="btn" data-size="sm">sm button</button>
</div>
<div class="stack" data-direction="row" data-gap="sm">
<select class="select" aria-label="Default">
<option>Default</option>
<option>Medium</option>
</select>
<button type="button" class="btn">Default button</button>
</div>
<div class="stack" data-direction="row" data-gap="sm">
<select class="select" data-size="lg" aria-label="Large">
<option>Large</option>
<option>Medium</option>
</select>
<button type="button" class="btn" data-size="lg">lg button</button>
</div>
</div>
Select with a styled list
Wrap a real <select> in
<div class="select">. The script draws the
button and the list, and selects the matching option in the
original select, so the form submits as usual. Without the script
you get the native select. A hidden value="" option
is the text shown before anything is chosen.
<div class="select" style="width: 180px">
<select name="theme" aria-label="Theme">
<option value="" hidden>Theme</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
Sizes of a select with a styled list
data-size on the wrapper takes the same values as the
native select.
<div class="stack" data-direction="row" data-gap="sm">
<div class="select" data-size="sm" style="width: 180px">
<select name="size-sm" aria-label="Small">
<option value="" hidden>Small</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="select" style="width: 180px">
<select name="size-default" aria-label="Default">
<option value="" hidden>Default</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="select" data-size="lg" style="width: 180px">
<select name="size-lg" aria-label="Large">
<option value="" hidden>Large</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
Styled list with group headings
Use <optgroup label> as usual. Each group
becomes a role="group" with a heading.
<div class="select" style="width: 220px">
<select name="product-type" aria-label="Product type">
<option value="" hidden>Select a type</option>
<optgroup label="Clothing">
<option value="tshirt">T-shirts</option>
<option value="shirt">Shirts</option>
<option value="pants">Pants</option>
</optgroup>
<optgroup label="Accessories">
<option value="bag">Bags</option>
<option value="watch">Watches</option>
<option value="hat">Hats</option>
</optgroup>
</select>
</div>
Options with a title and a line of text
data-text on an option adds a line of text.
data-badge adds a badge after the title. Search also
matches the text. The button shows only the title.
<div class="field docs-narrow">
<label for="sel-3ds">3DS mode</label>
<div class="select">
<select id="sel-3ds" name="three_ds_mode">
<option value="" hidden>Select a mode</option>
<option value="off" data-text="Transactions skip 3DS.">Off</option>
<option value="smart" data-badge="Recommended" data-text="Skips 3DS where it is rare and adds it to high-risk orders." selected>Smart</option>
<option value="always" data-text="Every transaction tries 3DS.">Always on</option>
</select>
</div>
</div>
Options with an avatar, a title and text; a disabled option
data-avatar takes initials or an image URL. A
disabled option is dimmed and cannot be chosen (aria-disabled="true"
in the list).
<div class="field docs-narrow">
<label for="sel-tax">Tax service</label>
<div class="select">
<select id="sel-tax" name="tax_service">
<option value="" hidden>Select a tax service</option>
<option value="next-tax" data-avatar="NT" data-text="Active" selected>Next Tax</option>
<option value="avalara" data-avatar="AV" data-text="Unavailable" disabled>Avalara</option>
<option value="taxjar" data-avatar="TJ" data-text="Unavailable" disabled>TaxJar</option>
</select>
</div>
</div>
Select with a search field in its list
data-search adds a search field above the list. Its
value is the placeholder. Set the no-results text with
data-empty.
<div class="select" data-search="Search carriers" data-empty="No carriers found" style="width: 240px">
<select name="carrier" aria-label="Carrier">
<option value="" hidden>Select a carrier</option>
<option value="usps">USPS</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="ontrac">OnTrac</option>
<option value="dhl">DHL eCommerce</option>
<option value="amazon">Amazon Shipping</option>
</select>
</div>
Select with more than one choice: type to filter the list
Add multiple to the select. The control shows a chip
for each choice and a text field that filters the list.
Backspace in the empty field removes the last chip.
<div class="select" data-placeholder="Select categories" style="width: 280px">
<select name="category" multiple aria-label="Categories">
<option value="bedding">Bedding</option>
<option value="furniture">Furniture</option>
<option value="lighting">Lighting</option>
<option value="stationery">Stationery</option>
<option value="watches">Watches</option>
</select>
</div>
One choice typed in the control, with an action above the list
data-input turns the control into a text field for
one choice. A <header> in the wrapper moves to
the top of the list (without the script it sits above the select).
<div class="select" data-input data-placeholder="Search or create a customer" style="width: 300px">
<header>
<button type="button" class="btn" data-size="sm" onclick="tsunami.select.close(this)">
<i class="icon icon-plus" aria-hidden="true"></i>Create new customer
</button>
</header>
<select name="customer" aria-label="Customer">
<option value="" hidden></option>
<option value="maya" data-text="maya.okafor@example.com">Maya Okafor</option>
<option value="emma" data-text="emma.wilson@example.com">Emma Wilson</option>
<option value="daniel" data-text="daniel.brandt@example.com">Daniel Brandt</option>
</select>
</div>
Remote options: data-url loads the options from the server
data-url on the wrapper is the search URL. Results
are added as <option>s in the real select, so
the form submits as usual. If there is no text field, the script
adds a search field to the list.
Each request sends q (the typed text, left out when
empty) and page (starting at 1). A
{page} in the URL is replaced with the page number
too, so static JSON files work, e.g.
data/countries-{page}.json.
GET data/countries-{page}.json?q=fra&page=1
{
"results": [{ "id": "fr", "text": "France" }],
"pagination": { "more": false }
}
The page filters the results again by text (the typed words must
be in text), so a server that ignores q,
like a static file, still gives the right list. A real server
should return only items whose text contains the
query.
Remote options with several choices, starting with France
The starting choice is written in the page as
<option selected>. No request is sent until the
list opens.
<div class="select" data-url="data/countries.json" data-placeholder="Select countries" style="width: 100%; max-width: 320px">
<select name="countries" multiple aria-label="Countries">
<option value="fr" selected>France</option>
</select>
</div>
Remote options with one choice: scroll to the end to load the next page
A single select needs an <option value=""> as
the empty value. When the reply has "more": true and
the list is scrolled near the end (50px), the next page is
requested.
<div class="select" data-url="data/countries-{page}.json" data-search="Search countries" style="width: 100%; max-width: 320px">
<select name="country" aria-label="Country">
<option value="" hidden>Select a country</option>
</select>
</div>
What a select with remote options does, case by case
| When | What happens |
|---|---|
| The page loads | No request. Options in the markup are the starting choice. |
| The list opens |
One request for page 1, without q. The list
shows Searching… until the reply arrives.
|
| A key is typed |
Aborts the previous request (AbortController)
and sends a new one with q and
page=1. While waiting, the current list is
filtered in the page right away.
|
| An older reply arrives late | Ignored, because that request was aborted. |
The reply has "more": true |
The listbox gets data-more and a Loading
more… line below the list.
|
| Scrolled within 50px of the end |
Requests the next page with the same q and
appends it. No repeat request while waiting
(aria-busy). If the list is too short to
scroll, the next page is requested automatically.
|
| No results |
Shows No results, or the text in data-empty.
|
| The request fails or is not JSON |
Clears the list. The listbox gets
data-error and shows Could not load results,
and the error is logged to the console. The next keystroke
retries.
|
| An option is chosen |
Same as any select: the option in the real select is
selected, and input/change and
tsunami:select:change fire. A multiple select
clears the text field and requests page 1 again.
|
| A chosen option is not in the reply |
It stays in the real select as hidden, so it
is still selected and submitted with the form, but not
shown in the list.
|
| A chosen option is in the reply | Shown once, in the server's order, with a check mark. |
| The list closes | Aborts any pending request and clears the search field. Opening again sends a new request. No cache. |
Tags: data-tags lets the user create an option by typing it
data-tags offers the typed text as a new option (its
value is that text). Type a word that is not in the list and press
Enter. The new option becomes an
<option data-tag> in the real select.
<div class="select" data-tags data-placeholder="Add tags" style="width: 100%; max-width: 320px">
<select name="tags" multiple aria-label="Tags">
<option value="summer" selected>summer</option>
<option value="sale">sale</option>
<option value="new-arrival">new-arrival</option>
</select>
</div>
Tags with remote options: choose a country from the server or create a new name
<div class="select" data-url="data/countries.json" data-tags data-placeholder="Country or new name" style="width: 100%; max-width: 320px">
<select name="origin" multiple aria-label="Origin"></select>
</div>
What a select with tags does, case by case
| When | What happens |
|---|---|
| The text matches no option | The text is offered as the first option (plus icon) and is active, so Enter creates it right away. |
| The text matches an option, in any case | No tag is offered. "SALE" finds "sale". |
| Only spaces | No tag is offered. Leading and trailing spaces are trimmed. |
| The offered tag is chosen |
Creates an
<option data-tag selected> in the real
select. Its value and label are the typed text.
|
| The text changes first | The old offered tag goes away and the new text is offered instead. |
| A created tag is removed | It stays in the list until the next keystroke, then is removed from the select. |
With data-url too |
The tag shows above the server results, unless a result has the same name (once the reply arrives). |
API
Every function takes any element inside the wrapper (or the
wrapper itself) as its first argument. A native select with only
the select class has no API.
| Function | Arguments | Description |
|---|---|---|
tsunami.select.open(el) |
el: Element |
Opens the list (does nothing if the select is disabled). |
tsunami.select.close(el, focus) |
el: Element, focus: boolean
(default false)
|
Closes the list. If focus is true, focus
moves back to the control.
|
tsunami.select.value(el) |
el: Element |
Returns a string for a single select, or an array of
strings for multiple.
|
tsunami.select.value(el, v) |
v: string or string[] |
Selects the existing options with these values. Fires events when the value changes. |
tsunami.select.refresh(el) |
el: Element |
Redraws the control after code adds, removes or edits
options (dispatching change on the select
also redraws).
|
| Event | On | Detail |
|---|---|---|
tsunami:select:change |
wrapper, bubbles |
{ value, selected: [{ value, label }] }
Fired when the value changes by click, keyboard or
value(). Not fired when the same value is
chosen again.
|
input, change |
real select, bubbles | Fired together, so forms and the Save bar react as if the user chose in the select itself. |
tsunami:select:open |
wrapper, bubbles | No detail. Fired when the list opens. |
tsunami:select:close |
wrapper, bubbles | No detail. Fired when the list closes. |
| Key | Description |
|---|---|
| ↓ ↑ | Closed: opens the list. In the list: moves the active option. |
| Home End | Go to the first/last option (on the button). In a text field they move the cursor as usual. |
| Enter, Space | On a closed button: opens. In the list: chooses the active option (Space works on the button only). |
| Letters | On the button: opens the list and jumps to the option starting with the typed letters (type-ahead). With a search field, the letters go into the search field. |
| Backspace | Multiple, empty text field: removes the last chip |
| Escape | Closes the list and returns focus to the control |
| Tab | Leaves the field and closes the list |
Attributes
| Attribute / element | Values | Description |
|---|---|---|
select.select |
Styled native select. No script. | |
div.select |
Wrapper of a direct child <select>. The
script draws the control and the list. Without the script,
the original select shows.
|
|
data-size |
sm, lg (none = default 2rem)
|
Height. Works on both select.select and
div.select.
|
data-placeholder |
text (default: the text of option[value=""])
|
Text shown before anything is chosen. Also the text field's placeholder. |
data-search |
present / absent; the value is the placeholder (default "Search") | Adds a search field above the list (button control) |
data-input |
present / absent (default absent) |
Makes the control a text field for one choice.
multiple always uses a text field.
|
data-tags |
present / absent (default absent) | Offers the typed text as a new option |
data-url |
URL, supports {page} |
URL to load JSON options with ?q=&page=.
Needs a text field (added automatically).
|
data-empty |
text (default "No results") | Text shown when the search finds nothing |
<header> in the wrapper |
Moved to the top of the list, for action buttons such as Create new customer | |
<select multiple> |
Several choices. The control shows chips and a text field.
The listbox has aria-multiselectable.
|
|
<optgroup label> |
Group with a heading (role="group" +
<strong>)
|
|
option[value=""] |
Add hidden to keep it out of the list |
Empty value = nothing chosen. Required for a single select
with data-url.
|
option[selected] |
Starting choice | |
option[disabled] |
Cannot be chosen. Shown dimmed with
aria-disabled="true".
|
|
option[hidden] |
Not shown in the list, but can still be selected and submitted | |
option[data-label] |
text (default: the option's text) | Label shown on the button/chip and used for search |
option[data-text] |
text | Line of text under the title. Searchable. |
option[data-avatar] |
initials or image URL | Small avatar before the option |
option[data-badge] |
text | Badge after the title |
option[data-tag] |
set by the script |
Option created from typed text (data-tags)
|
data-ready |
set by the script | On the wrapper once the control is drawn. The real select is visually hidden (it still submits and validates). |
data-has-value |
set by the script | On the wrapper while a value is chosen |
role="combobox" |
set by the script |
The button or text field, with aria-expanded,
aria-controls and
aria-activedescendant. Its label comes from
the select's <label for> or
aria-label.
|
[popover] > role="listbox"
|
set by the script |
A popover="manual" list placed below the
control (flips above if there is no room). Each
role="option" has
aria-selected and data-active.
|
aria-busy, data-more,
data-error
|
set by the script |
On the listbox with data-url: loading, more
pages, load failed
|