Select
Select lets someone pick one or more values from a list of options, shown in a dropdown instead of laid out on screen all at once. It can filter that list live as the user types, let several options be picked in one visit, or hand the whole dropdown over to the browser so it looks and behaves like the device's own native picker.
Dependencies
When to use
Reach for select when someone is picking from a closed list of options you
already know, such as a country, a category, or a status, especially once
that list is too long to show as plain
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/select.js";
import "@eunomia/elements/label.js";
<eun-select label="Country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
Importing the file registers <eun-select> as a custom element, with no
further setup needed. It works with any framework, or none, since it's a
standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/select.js";
</script>
<eun-select label="Country">
<option value="fr">France</option>
</eun-select>
npm install @eunomia/elements
import "@eunomia/elements/select.js";
function CountryField() {
return (
<eun-select label="Country" name="country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/select.js";
export function CountryField() {
return (
<eun-select label="Country" name="country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/select.js";
</script>
<template>
<eun-select label="Country" name="country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/select.js";
@Component({
selector: "app-country-field",
template: `
<eun-select label="Country" name="country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class CountryFieldComponent {}
Alternatives
eun-radioeun-checkboxGuidance
- Always set
label(or slot one): see the Accessibility tab - Use
searchableonce a list is long enough that typing to filter beats scanning, since for short, fixed lists the plain trigger is faster - Group related options with
optgrouponce a list mixes categories - Reserve
readonly-availablefor genuinely inline-editable data, not every field in a long form - Reach for
nativeon touch devices or long, plain lists where the OS's own picker (native scrolling, platform-specific gestures) beats a custom listbox - Set
confirm-labelto something clearer than the default "Confirm" when the context calls for it (e.g. "Done", "Apply"), since the button itself always shows whilemultipleand open: it's the only on-screen "done picking" control, and everything else (Escape, outside click, focus leaving the field) is undiscoverable without it
- Using a select for fewer than ~5 options, since visible
radio(orcheckbox, oncemultiple) buttons remove a click and let users compare choices at a glance - Relying on option order alone to convey grouping: use
optgroupinstead - Overriding colors with inline styles instead of the
--field-*CSS variables - Combining
nativewithsearchableormultiple, since a native dropdown can't filter or show this component's own checkbox-per-option UI, sonativealways wins and the other is silently ignored
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| default-value | string | string[] | — | The default values applied when the field connects, and restored on form reset |
| search-placeholder | string | 'Search…' | The fallback for placeholder when searchable is set |
| select-placeholder | string | 'Select…' | The fallback for placeholder when searchable isn't set |
| readonly-available | boolean | false | Whether the field can switch between a static display and an editable one |
| confirm-label | string | 'Confirm' | The label of the confirm button, shown while editing in readonlyAvailable mode or while multiple and the dropdown is open. Set to an empty string to hide the button entirely |
| no-animation | boolean | false | Disables the dropdown panel's open and close height transition, on top of the automatic reduced motion handling |
| value | string | string[] | — | The selected option's value, or every picked option's value while multiple |
| searchable | boolean | false | Whether the field filters its options as the user types. When false, the field is read only and driven by arrow keys, Enter, and type-ahead. Ignored while native is set |
| native | boolean | false | Renders the field as a real select, using the browser's own dropdown instead of the listbox panel. Takes priority over searchable and multiple, neither of which a native dropdown can support |
| multiple | boolean | false | Whether more than one option can be picked at once. Each option then renders a checkbox, and picking one toggles it without closing the dropdown, so value becomes an array of every picked option's value. Ignored while native is set |
| icon | EunomiaIconName | — | An optional leading icon |
| placeholder | string | — | The placeholder shown when the field is empty. Defaults to searchPlaceholder when searchable, and selectPlaceholder otherwise |
| empty-value-label | string | 'No value set' | Text displayed in place of the value when readonly and empty |
| no-results-label | string | 'No results' | Text displayed in the dropdown when no option matches the search |
| loading | boolean | false | Whether options are currently being fetched. Shows a spinning loading indicator in place of the dropdown arrow, and a single disabled loading row instead of the option list. Has no effect while native, beyond the arrow swap |
| loading-label | string | 'Loading…' | The accessible label announced by the loading indicator while loading is set |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| name | string | — | The field name |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether a selection is required |
| hideError | boolean | false | Whether the errors are hidden |
| validators | Array<Validators<string | string[]>> | — | The list of validation rules applied to the field value |
| readonlyAvailable | boolean | false | Whether the field can switch between a static display and an editable one |
Slots
| Name | Description |
|---|---|
| (default) | The option and optgroup elements defining the choices |
| icon | Replaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty |
| readonly-content | Optional custom content displayed instead of the raw label in the static readonly display |
| confirm | Optional custom content for the confirm trigger, such as an icon, displayed as a link while editing in readonlyAvailable mode, or while multiple and the dropdown is open. Overrides confirmLabel when used. The button itself always shows in either of those states unless confirmLabel is set to an empty string |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired when an option is selected |
| euncommit | CommitEvent | Fired when picking an option confirms an edit in readonlyAvailable mode |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-background | Sets the background color of the field |
| --field-border-color | Sets the border color of the field |
| --field-border-color-hover | Sets the border color on hover |
| --field-border-color-focus | Sets the border color on focus |
| --field-border-radius | Sets the corner radius of the field |
| --field-padding | Sets the padding of the field |
| --field-text-color | Sets the text color of the field |
| --field-placeholder-color | Sets the color of the placeholder |
| --field-icon-color | Sets the color of the leading icon and the dropdown arrow |
| --field-error-color | Sets the color of the error message |
| --field-hint-color | Sets the color of the hint message |
| --field-focus-outline-color | Sets the color of the focus outline |
| --field-match-color | Sets the color of the matched search substring, highlighted while searchable |
| --field-match-background | Sets the background of the matched search substring, none by default |
| --field-match-font-weight | Sets the font weight of the matched search substring |
| --field-match-text-decoration | Sets the text decoration, such as underline, of the matched search substring, none by default |
| --field-confirm-link-color | Sets the color of the confirm slot's link, while editing in readonlyAvailable mode |
| --field-confirm-link-color-hover | Sets the hover color of the confirm slot's link |
| --field-listbox-max-height | Sets the maximum height of the dropdown panel, past which it scrolls |
| --field-listbox-transition-duration | Sets the duration of the dropdown panel's open and close height transition, disabled entirely by the no-animation attribute or reduced motion settings |
| --eun-viewport-inset-top | Reserves space, such as a page's sticky header height, that the dropdown panel keeps clear of at the top of the viewport. Set on the root or an ancestor rather than on the field itself |
| --eun-viewport-inset-bottom | The same as eun-viewport-inset-top, reserved at the bottom of the viewport, such as for a sticky footer |
Basic
<eun-select label="Country" placeholder="Select a country">
<option value="fr">France</option>
<option value="be">Belgium</option>
<option value="ch">Switzerland</option>
</eun-select>
<eun-select label="Country" placeholder="Select a country">
<option value="fr">France</option>
<option value="be">Belgium</option>
<option value="ch">Switzerland</option>
</eun-select>
Searchable
Filters the list live as the user types, instead of only navigating it with Arrow keys and type-ahead.
<eun-select searchable label="Country" placeholder="Search a country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
<eun-select searchable label="Country" placeholder="Search a country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
Multiple
Each option renders a checkbox, and picking one toggles it without closing
the dropdown, so several can be picked in one visit. value becomes a
string[] of every picked option's value, and the field itself shows every
picked label, joined with a comma.
<eun-select multiple label="Countries" placeholder="Select countries">
<option value="fr">France</option>
<option value="be">Belgium</option>
<option value="ch">Switzerland</option>
</eun-select>
Read the selection back as a plain array:
const select = document.querySelector("eun-select");
select.addEventListener("eunchange", () => console.log(select.value)); // e.g. ["fr", "ch"]
Pre-checked by default
default-value accepts a comma-separated list of values while multiple,
since each matching option starts checked, exactly like value itself
would, but only takes effect on connection and is restored on a native form
reset() (value isn't). Both accept an array directly too when set from
JS (select.defaultValue = ["fr", "ch"]), since the comma-separated form is
only for the HTML attribute, which can't hold an array natively.
<eun-select multiple label="Countries" default-value="fr,ch">
<option value="fr">France</option>
<option value="be">Belgium</option>
<option value="ch">Switzerland</option>
</eun-select>
Closing the dropdown
Since picking an option no longer closes it, a confirm button, labeled
"Confirm" by default, shows automatically once the dropdown is open, giving
a discoverable, on-screen "done picking" control. Escape, an outside
click, and focus leaving the field entirely (e.g. Tab) all close it too,
though none of those are visible affordances on their own. Set
confirm-label for wording that fits the context better:
<eun-select multiple label="Countries" confirm-label="Done">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
Slot content into confirm instead for anything richer than plain text (an
icon, for instance), since it overrides confirm-label when present. Set
confirm-label="" (and don't slot anything) to hide the button entirely,
though that leaves multiple with no on-screen way to close the dropdown at
all: see the Accessibility tab.
Combined with searchable
multiple and searchable compose: typing filters the list exactly as it
already does, and the field keeps whatever's been typed instead of jumping
back to the picked labels while the dropdown stays open, so several options
can be filtered-and-picked in a row before it closes.
Variants
variant selects the visual style: outline (default, bordered box),
fill (filled background, no border), or underline (bottom border only).
<eun-select label="Fill" variant="fill">
<option value="fr">France</option>
</eun-select>
Part of the shared vocabulary covered in
With a leading icon
<eun-select icon="public" label="Country">
<option value="fr">France</option>
</eun-select>
Need something other than a eun-icon, or a different icon library
entirely? Slot your own content into icon instead. It replaces the
prop-driven icon entirely.
<eun-select label="Country">
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z"
></path>
</svg>
<option value="fr">France</option>
</eun-select>
Grouped options
<eun-select label="Country">
<optgroup label="Europe">
<option value="fr">France</option>
<option value="be">Belgium</option>
</optgroup>
<optgroup label="Other">
<option value="ca">Canada</option>
</optgroup>
</eun-select>
Native
Renders a real <select> instead of the listbox, handing the dropdown over
to the browser/OS. Takes precedence over searchable and multiple, since
a native dropdown has no way to filter its options as the user types, and
its own multi-select UI (platform-specific, e.g. ctrl/cmd-click) is entirely
separate from this component's checkbox-per-option one.
<eun-select native label="Country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
States
<eun-select label="Disabled" disabled>...</eun-select>
<eun-select label="Readonly" readonly value="fr">...</eun-select>
<eun-select label="Required" required hint="This field is mandatory"
>...</eun-select
>
readonly and disabled are not interchangeable: a readonly field still submits its value and stays focusable, a disabled one does neither. See the Accessibility tab.
Loading
Set loading while options are being fetched, for example a searchable
field querying a server for matches as the user types. A spinning eun-loader
replaces the dropdown arrow, and the listbox shows a single "Loading…" row
instead of stale/no-results content. The field itself stays editable, so the
user can keep refining the query while a previous request is still in
flight.
<eun-select label="Country" searchable loading placeholder="Search a country">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
import "@eunomia/elements/loader.js";
eun-loader is used internally, but its module must still be imported alongside eun-select for the spinner to render, exactly like eun-icon for the leading icon above.
Debouncing a real request
Same idea as eun-search's own "Debouncing a real request" example (its
Loading section): wrap whatever triggers the fetch in debounce, exported
from @eunomia/elements, so a fast typist doesn't fire one request per
keystroke, and toggle loading around it.
import { debounce } from "@eunomia/elements";
const select = document.querySelector("eun-select");
const runSearch = debounce(async (query: string) => {
select.loading = true;
try {
const countries = await fetchCountries(query); // your own API call
select.replaceChildren(
...countries.map((country) => {
const option = document.createElement("option");
option.value = country.code;
option.textContent = country.name;
return option;
}),
);
} finally {
select.loading = false;
}
}, 300);
eun-select doesn't (yet) expose a public event fired as the user types while searchable is set. The only event it fires is eunchange, once an option is picked. Wire runSearch to whatever your own app already uses to read the typed value (a wrapping form's own input handling, a signal/store update, etc.).
Click to edit
Set readonly-available to let a field switch between a static,
clickable display of its current value and a real editable select,
instead of always being one or the other.
Save/Cancel step, since choosing a result from an already-open list is
itself the confirming gesture. Cancelling means dismissing the dropdown
(Escape or an outside click) without picking anything.
That leaves no way to confirm having kept the current value without picking a different one first, so a confirm button, labeled "Confirm" by default, shows automatically while editing for exactly that case:
<eun-select readonly-available label="Country" value="fr">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
document
.querySelector("eun-select[readonly-available]")
.addEventListener("euncommit", (event) => save(event.target.value));
import { useEffect, useRef } from "react";
function CountryField() {
const fieldRef = useRef(null);
useEffect(() => {
const field = fieldRef.current;
const onCommit = (event) => save(event.target.value);
field.addEventListener("euncommit", onCommit);
return () => field.removeEventListener("euncommit", onCommit);
}, []);
return (
<eun-select ref={fieldRef} readonly-available label="Country" value="fr">
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
);
}
<script setup>
const onCommit = (event) => save(event.target.value);
</script>
<template>
<eun-select
readonly-available
label="Country"
value="fr"
@euncommit="onCommit"
>
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
</template>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
@Component({
selector: "app-country-field",
template: `
<eun-select
readonly-available
label="Country"
value="fr"
(euncommit)="onCommit($event)"
>
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class CountryFieldComponent {
onCommit(event: any) {
save(event.target.value);
}
}
Customizing or hiding the confirm button
Set confirm-label for wording that fits the context better, or to an empty
string to remove the button entirely, for example when picking an option
already feels like enough confirmation on its own for that particular field,
and the extra button would just be clutter:
<eun-select
readonly-available
label="Country"
value="fr"
confirm-label="Confirm"
>
<option value="fr">France</option>
<option value="be">Belgium</option>
</eun-select>
Slot content into confirm instead for anything richer than plain text (an
icon, for instance), since it overrides confirm-label when present.
Theming
Colors come from the active theme's --eun-* tokens (see
--field-* custom properties instead (full list in the API
tab), shared with eun-input and future text-like field components.
<eun-select
label="Custom"
style="
--field-border-color: #b45309;
--field-border-color-hover: #92400e;
--field-border-color-focus: #92400e;
--field-border-radius: 2px;
"
>
<option value="fr">France</option>
</eun-select>
Dropdown panel height
--field-listbox-max-height (default 260px) caps the dropdown before it
scrolls internally:
<eun-select label="Country" style="--field-listbox-max-height: 160px">
...
</eun-select>
Reserved viewport space
A page with its own sticky/fixed chrome, most commonly a header, should
declare --eun-viewport-inset-top (and --eun-viewport-inset-bottom for a
sticky footer) on :root, matching that chrome's height. The dropdown
renders in the browser's top layer, above everything else on the page
including that chrome by default, so without this it can render straight
over it whenever there isn't enough room on its preferred side. With the
inset declared, the panel stops short of the reserved band instead. This
site's own sticky header is set up exactly this way, in main.css:
:root {
--eun-viewport-inset-top: 70px; /* matches this site's sticky header */
}
This is read once per reposition, inherited like any custom property, so no per-field configuration is needed once it's set at the page level.
Open and close animation
The panel's height transitions smoothly open and closed by default.
--field-listbox-transition-duration (default 200ms) controls how long
that takes:
<eun-select label="Country" style="--field-listbox-transition-duration: 350ms">
...
</eun-select>
Set no-animation to disable it: the panel then shows/hides instantly,
same as it already does automatically whenever the browser reports
prefers-reduced-motion: reduce:
<eun-select no-animation label="Country">...</eun-select>
This audit covers every mode the field can render in: the searchable and
non-searchable listbox, multiple, native, and readonly-available,
since each changes what's on screen and, in some cases, what accessibility
tree gets built.
Keyboard interactions
When focus is on the field (native unset):
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus in/out of the field (single tab stop) |
ArrowDown |
Opens the dropdown if closed and highlights the next option, in the same press, matching how a native <select> immediately shows a highlighted option on the first Arrow press rather than requiring an initial press just to open |
ArrowUp |
Opens the dropdown if closed and highlights the previous option, same as ArrowDown |
Home / End, while not searchable |
Opens the dropdown if closed and jumps the highlight to the first/last option. Left to the browser while searchable, where they're the native "move the caret" shortcuts instead |
Enter |
Selects the highlighted option, or, while multiple, toggles it and leaves the dropdown open instead |
Escape |
Closes the dropdown (native Popover API light-dismiss) |
Any character key, while searchable |
Filters the list live |
Any character key, while not searchable |
Type-ahead: jumps the highlight to the next option starting with the typed text, same as a native <select>. Several characters typed in quick succession extend the match, and a pause of around 600ms resets it |
While native is set, the field is a real <select>: all keyboard
interaction (Arrow keys, type-ahead, Home/End, opening/closing) comes
from the browser/OS for free, not from this component, including its own
native multi-select interaction, entirely unrelated to this component's own
multiple (see the Examples tab's "Native" section).
On the read-only click-to-edit display (readonly-available, not editing):
| Key | Action |
|---|---|
Enter |
Enters edit mode |
Space |
Enters edit mode |
While editing (or while multiple and open), the confirm button is a real
<button> reachable by Tab like any other focusable element, activated
with Enter/Space, shown by default (confirmLabel, "Confirm") unless
explicitly hidden by setting confirm-label="" with no confirm slot used
either.
Dismissal behavior
The dropdown closes on: Escape, a click outside it (both native Popover
API light-dismiss), picking an option, and the browser window itself
losing focus, for example Alt-Tabbing to another application. That last one
isn't something the Popover API handles on its own: light-dismiss only
reacts to clicks and Escape, neither of which fires on a window-level
blur. Without explicitly listening for it, switching away and back would
find the dropdown exactly as it was left, open on top of whatever's now
underneath it, which reads as broken far more than it reads as "still
mid-selection." Closing on blur matches how native OS menus and <select>
pickers already behave.
It also closes if the field itself scrolls behind a reserved
--eun-viewport-inset-top/-bottom band while open (see the next section),
since once the field a screen-reader or sighted keyboard user was just
interacting with is no longer visibly there, there's nothing left for the
dropdown to be meaningfully anchored to, so it's closed rather than left
floating disconnected from it.
While multiple, picking an option no longer closes the dropdown, since
several are meant to be picked in one visit, so two more ways to close it
exist specifically for that mode, on top of Escape/outside-click/window-blur
above:
- Clicking the confirm button (see the Examples tab), the only on-screen,
discoverable "done picking" control while
multiple, shown by default - Focus leaving the field, the dropdown, and the confirm link entirely, for
example
Tab-ing to whatever comes next in the page. The Popover API's own light-dismiss doesn't cover this on its own either: it reacts to clicks outside the popover, not to focus moving there via the keyboard alone, so without this, tabbing straight past the field would leave the dropdown open and detached from the no-longer-focused field. Checked via:focus-withina frame after everyfocusout, so moving focus to the confirm link itself (still inside the field's own wrapper) doesn't trigger a false close
Motion
The panel's open/close height transition is skipped automatically whenever
the browser reports prefers-reduced-motion: reduce, on top of the
explicit no-animation attribute/property covered in the Examples tab.
Either way, the panel just shows/hides instantly instead.
Focus indicator
The field renders a :focus-visible outline (--field-focus-outline-color,
falling back to --eun-color-primary-500) that switches to
--field-error-color while invalid, exactly like eun-input. The confirm
link gets the same outline treatment when it's present and focused.
Aria attributes
This component follows the WAI-ARIA APG combobox pattern (search: "Editable
Combobox With List Autocomplete" while searchable, "Select-Only Combobox"
otherwise): role="combobox" on the field, paired with role="listbox" on
the dropdown panel and role="option" on each choice. The combobox-specific
roles and states below don't apply while native: a real <select> already
has its own complete, browser-implemented accessibility tree (role, states,
and keyboard handling), so none of that is layered on top of it.
aria-invalid, aria-required, and aria-describedby still are, though
(see below), since those describe the field's state, not the combobox
interaction pattern, and apply just as much to a native <select>.
role="combobox"/aria-expanded="true"\|"false": reflectsisOpenaria-controls="listbox": points the field at the dropdown panelaria-autocomplete="list"whilesearchable,"none"otherwise: tells assistive technology whether typing filters the list or notaria-activedescendant: set to the highlighted option's id while one is highlighted, so a screen reader announces it without moving DOM focus off the fieldaria-multiselectable="true"\|"false"on the listbox: reflectsmultiplerole="option"/aria-selected="true"\|"false"on each choice: this alone communicates picked/not-picked whilemultiple, toggling on every pick without the dropdown closing, and a screen reader announces the updated selection state on each one, the same way it would for a native multi-select<select>role="presentation"on group-label rows (fromoptgroup) and on the "no results" row: decorative/informational only, never focusable or selectable, and not validoption/groupchildren of alistboxeitheraria-required="true"\|"false"(listbox modes) / nativerequired(native mode): kept in sync with therequiredproperty either way, so assistive technology announces it consistently regardless of modearia-invalid="true"\|"false": kept in sync withvalid, in every mode includingnativearia-label: set from thelabelproperty directly on the field and on thereadonly-availablestatic display. Notaria-labelledbypointing at the rendered<eun-label>:eun-labelrenders its text inside its own shadow root, a different shadow tree than this field, and ID references don't cross shadow-root boundaries in current browsers. Computing a plain-textaria-labelfrom the samelabelproperty sidesteps that entirely: seeeun-input's Accessibility tab for the full reasoning, identical herearia-describedby="description": set on the field whenever a hint or error message is actually rendered below it, so the message is announced together with the field, in every mode includingnative
The option checkbox is decorative, not a second interactive widget
Each option renders a small checkbox-shaped indicator while multiple, but
it's purely visual (aria-hidden, no role, no tabindex), and it isn't a
eun-checkbox, deliberately so. Nesting a genuinely interactive,
focusable, independently-role="checkbox" element inside a role="option"
row would give assistive technology two overlapping interactive things to
reconcile per row, which the WAI-ARIA Listbox pattern doesn't define and
no screen reader handles consistently, since an option is meant to be one
atomic, selectable unit, not a container for another widget. The row's own
role="option" / aria-selected above is already the complete, correct,
spec-compliant way to expose "picked or not" for a multi-selectable listbox
option (mirroring how a native <select multiple> needs no nested control
either), and the checkbox glyph is only there so sighted users get the same
affordance visually.
Dropdown panel: rendering, positioning, and sizing
The dropdown (popover="auto") renders in the browser's top layer: it's
never clipped by an ancestor's overflow: hidden or out-ranked by a
stacking context, and closes automatically on an outside click or Escape
with no manual document-level listener to maintain.
Its position tracks the field on every scroll and resize, flipping above
the field instead of below whenever there isn't enough room underneath,
same as a native <select>'s own dropdown, so the panel is never left
truncated or hanging off the edge of the viewport for the sighted keyboard
and screen-magnifier users who rely on that.
Two implementations produce that same result, chosen automatically per
browser (AnchorPositionMixin, shared with any future dropdown-style
component): CSS Anchor Positioning natively where supported, a JS fallback
everywhere else. Neither changes anything described above, since it's
purely an implementation/performance detail, except for one thing worth
knowing when placing a field on a page with its own fixed or sticky chrome
(a header, most commonly): rendering in the top layer means the panel is
always visually above that chrome by default, browser z-index rules
aside, which can mean covering it. --eun-viewport-inset-top (and
--eun-viewport-inset-bottom for a sticky footer), set once on :root,
reserve that space so the panel stops short of it instead, as covered in
the Theming section. This site's own sticky header is configured exactly this
way, which is what keeps the demos throughout this page from ever
rendering their dropdown over the top navigation bar.
That reservation covers the panel while the field itself is still on
screen. Scroll far enough that the field is now behind that reserved
band too, and there's no longer a sensible position left to keep the panel
in relative to it, so the dropdown closes instead (see Dismissal
behavior above), the same way it does on Escape or an outside click. This
is deliberately stricter than waiting for the field to leave the viewport
entirely: on browsers using the native CSS Anchor Positioning path, the
panel's own clamp against the reserved band is only fully re-applied when
its position is computed cold (opening, a resize). During a live,
continuous scroll it can otherwise visibly drift over the reserved band for
the scroll's duration before catching up. Closing at the inset boundary
itself avoids that window.
--field-listbox-max-height (default 260px) caps how tall the panel
gets before it scrolls internally. This matters here because a page
reserving a lot of top/bottom space with the custom properties above
effectively shrinks how much room is left for the panel to use without
covering that space, so a smaller max-height keeps it comfortably within
whatever's left.
None of this applies while native: the dropdown is the browser/OS's own
picker UI, positioned, sized, and kept clear of page chrome by the
platform itself.
Label accessibility
Always set label (or slot content into eun-label's label slot), since
a select with no accessible name is as disruptive for screen-reader users as
an unlabeled text field. placeholder is never a substitute: once a value
is picked, it's gone.
Disabled vs. readonly
disabled removes the field from the tab order and excludes its value
from form submission entirely. readonly keeps it focusable and its value
submitted, and additionally prevents the dropdown from opening at all
(there being nothing to change), communicating "you can't change this
right now", not "this doesn't apply".
While native, this distinction is implemented differently under the hood:
a real <select> has no readonly state at all, so readonly there
disables the inner <select> itself (styled to still read as distinctly
"readonly", not "disabled": see --field-* theming) and excludes the
host from the tab order, exactly like disabled does. From the keyboard
user's perspective the outcome is the same either way: a readonly field
never traps focus on an inert control, only the implementation differs
from listbox mode.
Readonly-available / click-to-edit flow
The static display exposes role="button" and full keyboard access (Tab
to reach it, Enter/Space to activate it). Picking an option from the
resulting dropdown commits immediately and exits edit mode, so there is no
separate confirm step to also activate, unlike a free-text field where a
typed value needs an explicit confirmation. To let the user confirm having
kept the current value without picking a different one, a confirm button,
a real <button> styled as a link, labeled confirmLabel ("Confirm" by
default), shows automatically while editing, reachable and activatable like
any other button (see Keyboard interactions and Focus indicator above). Set
confirm-label="" (with no confirm slot used either) to remove it for a
field where that isn't needed.
Reference links
WAI-ARIA Authoring Practices: Combobox Pattern WAI-ARIA Authoring Practices: Listbox Pattern WAI Web Accessibility Tutorials: Labeling Controls WHATWG HTML: Popover API CSS Anchor Positioning: Working Draft