v1.0.0

<bmx-select>

A list of options behind a closed control: one choice or several, grouped or flat, and windowed so that ten thousand of them cost the same as ten.

27 properties · 5 events · 8 methods · 14 parts

Example

Show markup
<div class="row">
  <bmx-select id="ex-select-country" label="Country" placeholder="Choose a country" description="Type a letter with the list closed and it jumps straight there, as a native select does."></bmx-select>
</div>
<div class="row">
  <bmx-select id="ex-select-team" label="Assign to" multiple="true" clearable="true" max-tags="2" description="Several choices, collapsed into a count once there are more than two."></bmx-select>
</div>
<div class="row">
  <bmx-select id="ex-select-big" label="Reference" placeholder="One of five thousand" description="Windowed: about twenty rows exist at a time, whatever the scrollbar says."></bmx-select>
</div>
<script type="module">
  // `options` is a property rather than an attribute, because it is an array.
  // Waiting for the element to be defined means the assignment cannot land on
  // a plain <bmx-select> that has not upgraded yet.
  await customElements.whenDefined('bmx-select');

  document.getElementById('ex-select-country').options = [
    { value: 'gb', label: 'United Kingdom', group: 'Europe' },
    { value: 'ie', label: 'Ireland', group: 'Europe' },
    { value: 'de', label: 'Germany', group: 'Europe' },
    { value: 'us', label: 'United States', group: 'Americas' },
    { value: 'ca', label: 'Canada', group: 'Americas' },
    { value: 'br', label: 'Brazil', group: 'Americas', disabled: true },
  ];

  document.getElementById('ex-select-team').options = [
    { value: 'ana', label: 'Ana Silva', description: 'Platform' },
    { value: 'ben', label: 'Ben Okafor', description: 'Platform' },
    { value: 'cara', label: 'Cara Ellis', description: 'Design' },
    { value: 'dev', label: 'Dev Patel', description: 'Design' },
  ];

  document.getElementById('ex-select-big').options = Array.from({ length: 5000 }, (_, index) => ({
    value: `REF-${index}`,
    label: `REF-${String(index).padStart(5, '0')}`,
  }));
</script>

WHAT IT IS NOT

It is not searchable, and that is a decision rather than an omission. A control the user can type into is a combobox: its trigger is a text field, its keyboard is filtering rather than typeahead, and its ARIA is a different shape. bmx-combobox is that component, and a combobox that refuses values outside its list is the "searchable select" this one is sometimes asked to be. Folding both into one tag behind a flag would mean a property that silently changes an element's role, which is the thing this library keeps declining to do.

What it does have is typeahead, which is what a native <select> has: type a few characters and the active option jumps, and repeat one character to cycle through everything starting with it. That second half is in src/core/listbox.ts with the rest of the arithmetic.

WHY OPTIONS ARE DATA

options is an array, because a virtualised list has to own its data - a thousand slotted elements are the exact cost windowing exists to avoid, paid before the component starts. <bmx-option> children are supported as a declarative front door for hand-written markup, and are read once into the same array. A non-empty options property wins; the two are never merged, because two sources of truth for a list is how a select ends up showing something the application does not think is there.

VIRTUALISATION, AND WHEN IT TURNS ON

Below virtualThreshold rows every row is rendered and each is whatever height its content needs. Above it the list is windowed, which requires rows of a uniform height - so a long list gets one line per option whatever the label. Short lists, which is nearly all of them, pay nothing for any of this and are free to have two-line options.

The height is measured off a rendered row rather than read from the custom property that sets it. getComputedStyle hands a custom property back as the token that was written - 2.25rem, not 36px - and a virtualiser told its rows are two pixels tall renders the entire list, which is exactly what happened the first time this was built.

The keyboard has to be windowing-aware too. scrollIntoView needs an element, and the row an arrow key just moved to may not be rendered - so the scroll position is computed from the row offsets instead, in scrollOffsetFor.

ACCESSIBILITY

The APG select-only combobox pattern: role="combobox" on the trigger with aria-expanded and aria-controls, a role="listbox" surface, and aria-activedescendant moving the visual focus while real focus stays on the trigger. Arrow keys move the active option and Enter commits it - not the native <select>'s "arrowing changes the value", which cannot be expressed in this pattern and is not what a screen reader user expects from something announcing itself as a combobox.

Groups survive windowing: each run of consecutive same-group rows in the window is wrapped in a role="group", and a window that begins in the middle of a group is still wrapped in it, using the heading it scrolled past. Every option carries aria-setsize and aria-posinset counted over the whole list, because the browser can only count what is rendered.

Properties

PropertyAttributeTypeDefaultDescription
appearance appearance BmxSelectAppearance 'outline' Visual treatment of the closed control.
autoFocus auto-focus boolean false Focus the control once it has rendered.
clearable clearable boolean false Show a clear button once something is chosen.
closeOnSelect close-on-select boolean Close the list after a choice. Defaults to true for single, false for multiple.
description description string Help text below the field.
disabled disabled boolean false Disable the control.
emptyText empty-text string 'No options' What the list says when it has nothing in it.
errorText error-text string An error supplied by the consumer - a server response, typically.
fullWidth full-width boolean false Stretch to the width of the container.
hideLabel hide-label boolean false Hide the label visually while keeping it as the accessible name.
label label string The field's label. Required unless the label slot is used.
maxTags max-tags number 3 How many tags to show before the rest collapse into a count. A closed control that grows to four lines because somebody chose eight things pushes the rest of the form down the page every time it is used.
maxVisibleRows max-visible-rows number 8 How many rows the open list shows before it scrolls.
messages property only BmxFieldMessages Replacements for the default wording, by reason. Accepts the JSON spelling of the object as well, for templates that can only write attributes. See src/core/markup.ts.
multiple multiple boolean false Allow more than one choice. Changes what the closed control shows to tags.
name name string The field's name in the form it belongs to.
options property only BmxListboxOption[] [] The options. A property, because it is an array. See the note on the class. Accepts the JSON spelling of the list as well, because an attribute is the only channel some templates have. See src/core/markup.ts.
placeholder placeholder string 'Select…' Text shown when nothing is chosen. Never a substitute for a label.
placement placement BmxPlacement 'bottom-start' Which side the list opens on when there is room.
required required boolean false Require a choice before the form will submit.
shape shape BmxShape 'rounded' Corner treatment. circle is not meaningful here and behaves as pill.
size size BmxSize 'md' Size step.
tone tone BmxTone 'primary' Semantic colour role, used for the focus ring and the chosen option.
validateOn validate-on BmxValidateOn 'blur' When the field is willing to reveal a problem.
value value string | null null The chosen value in single mode. Two-way: the component writes back to it.
values property only string[] [] The chosen values in multiple mode. Two-way. Accepts a comma-separated or JSON string as well, because an attribute is the only channel some templates have. See src/core/markup.ts.
virtualThreshold virtual-threshold number 100 The row count above which the list is windowed. Below it, rows are rendered in full and may be any height. Above it, the two height custom properties decide the geometry - see the note on the class. Set it to 0 to always window, or to Infinity never to.

Events

EventDetailDescription
bmxBlur void Fired when the control loses focus.
bmxChange BmxSelectChangeDetail Fired when the selection changes.
bmxFocus void Fired when the control gains focus.
bmxOpenChange boolean Fired when the list opens or closes.
bmxValidityChange BmxSelectValidityDetail Fired whenever the resolved validity changes.

Methods

MethodSignatureDescription
checkValidity checkValidity() => Promise<boolean> Validate now and return whether the field passed, without revealing anything.
clear clear() => Promise<void> Empty the selection.
closeList closeList() => Promise<void> Close the list. Focus returns to the control.
getSelectedOptions getSelectedOptions() => Promise<BmxListboxOption[]> The options behind the current selection, in the list's order.
openList openList() => Promise<void> Open the list.
removeFocus removeFocus() => Promise<void> Remove focus from the control.
reportValidity reportValidity() => Promise<boolean> Validate, reveal any problem, and focus the field if it has one.
setFocus setFocus(options?: FocusOptions) => Promise<void> Focus the control.

Slots

SlotDescription
(default) bmx-option elements, when options are written rather than passed.
description Rich help text, in place of the description property.
label Rich label content, in place of the label property.

CSS shadow parts

PartDescription
chevron The disclosure arrow.
clear The clear button.
control The trigger carrying role="combobox".
description The help text.
empty The message shown when there are no options.
error The error message.
field The bordered box that is the closed control.
group A group heading.
label The label element.
listbox The floating surface.
option One option row.
tag One chosen value in multiple mode.
tag-remove A tag's remove button.
value The chosen label, or the placeholder.

CSS custom properties

PropertyDescription
--bmx-select-background The control's background. Set by appearance.
--bmx-select-border-color The control's border colour at rest.
--bmx-select-border-width Border width of the control.
--bmx-select-font-size The value's font size.
--bmx-select-gap Space between the value, the tags and the chevron.
--bmx-select-group-height A group heading's height. Load-bearing in the same way.
--bmx-select-height The closed control's height.
--bmx-select-label-font-size The label's font size.
--bmx-select-list-background The floating list's background.
--bmx-select-list-shadow The list's shadow, which is what lifts it off the page.
--bmx-select-option-active-background The background of the option the keyboard is on.
--bmx-select-option-height A row's height. Load-bearing above the virtualisation threshold — see the note above.
--bmx-select-option-selected-background The chosen option's background.
--bmx-select-padding-inline Horizontal padding inside the control.
--bmx-select-placeholder-color Placeholder colour. Dimmer than a value, and still AA against the control.
--bmx-select-radius Corner radius of the control and the list.
--bmx-select-stack-gap Space between the label, the control and the supporting text.
--bmx-select-support-font-size Font size of the description and error message.