Select
<aparte-select> — with 2 parts: <aparte-option>, <aparte-optgroup>
Dropdown select for aparté — a vanilla web component with optional grouping, a search filter and a keyboard-driven listbox.
The element is light DOM and takes <aparte-option> / <aparte-optgroup> children, in
the order they should appear. On its first render it captures its children, keeps those
two kinds and moves them into the role="listbox" container it builds, then rewrites
its own innerHTML — so any other child is dropped, and a wrapper element around your
options takes the options down with it: only DIRECT children are captured.
Children written later are picked up by a subtree MutationObserver and moved into that
same container, and the keyboard highlight is re-asserted on the new elements. The move
EMPTIES the container first, so a later write replaces the list instead of adding to it:
write the whole list, not one option. Writing straight into .aparte-select-options
skips the move (the observer then only re-asserts the highlight), and that is the path
@aparte/plugin-model-selector takes to refresh a live list in place.
It is not a form control: no name, no multiple, no participation in form submission.
It holds exactly one value and reports it through aparte-select-change.
The combobox is whichever element holds focus. Without searchable that is the
trigger. With it, opening focuses the filter field, so the field carries
role="combobox", aria-expanded, aria-controls and the roving
aria-activedescendant, and the trigger is a role="button" — one combobox either
way, and always the focused one.
The dropdown is position: fixed and placed from script so it escapes an
overflow: hidden ancestor — which is why its stacking order is a variable
(--aparte-select-z) rather than a fixed rule, and why an open dropdown does not
scroll with the trigger.
Example
Section titled “Example”<aparte-select placeholder="Pick a model" searchable value="gpt-4o-mini"> <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option> <aparte-option value="llama-3.1-8b">Llama 3.1 8B</aparte-option></aparte-select>
<script> document.querySelector('aparte-select').addEventListener('aparte-select-change', (e) => { console.log(e.detail.value, e.detail.label, e.detail.previousValue); });</script>Option
Section titled “Option”<aparte-option>
AparteOption
Option element for aparte-select dropdown.
One selectable row. Only meaningful inside <aparte-select> (directly, or nested in an
<aparte-optgroup>): the parent owns selected outright — on its first render, and on
every value change after it, it sets that attribute on the option whose value matches
its own and strips it from all the others. So selected written by hand does not
survive; set value on the select instead. Outside a select nothing selects it: it only
styles the row and sets role="option" / aria-selected from its own attributes.
It is not an <option>. It carries no form value, disabled blocks the click and the
keyboard walk but is not a form-disabled state, and when the value attribute is
absent the trimmed text content is used as the value instead.
Keep the label one text node: the label property reads only the FIRST text node — that
is what keeps the injected status dot out of it — so wrapping the label in an element
makes label fall back to value. The select’s own trigger label and its search filter
read the full textContent, so a wrapped label still displays and still matches.
data-status is a rendering hook, not state core interprets: any non-empty value
appends an aria-hidden .aparte-status-dot span as the last child, and only
ready, cached and not-downloaded have a colour in the stylesheet — anything else
renders an uncoloured dot until you style it.
<aparte-select placeholder="Pick a model" value="gpt-4o-mini"> <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option> <aparte-option value="o3" disabled>o3 (no access)</aparte-option></aparte-select>Optgroup
Section titled “Optgroup”<aparte-optgroup>
AparteOptgroup
Option group element for aparte-select dropdown.
A labelled band of options inside <aparte-select>. Presentational only: the group
holds no value, and collapsing it just sets display: none on its <aparte-option>
descendants — they stay in the DOM, which is what keeps the select’s keyboard walk
skipping them, since it works off display. The label header is inserted before the
children and the loading row appended after them, so both live inside the group, and
neither is hidden when it collapses: only the options are.
Two consequences of that worth knowing before you reach for it. The select’s search
writes that same display property on every option in the select, so filtering can
reveal matches inside a collapsed group. And the header is built once, on the first
render that finds a label: changing label afterwards does not rewrite it — set the
label before inserting the group, or replace the group.
loading is a display state, not a fetch. It appends a spinner row to the group and
nothing else happens — the host still owns the request. “Fetch on expand” is driven by
aparte-optgroup-toggle, whose detail.collapsed says which way the group just went;
the attribute and the options’ display are already updated when it fires, since
setting the attribute runs attributeChangedCallback synchronously.
<!-- Collapsed groups keep a long list readable; the label is the group's header. --><aparte-select placeholder="Pick a model"> <aparte-optgroup label="Ollama" collapsible collapsed> <aparte-option value="ollama::llama3">Llama 3</aparte-option> </aparte-optgroup> <aparte-optgroup label="OpenRouter" collapsible> <aparte-option value="openrouter::gpt-4o-mini">GPT-4o mini</aparte-option> </aparte-optgroup></aparte-select><aparte-select>
Section titled “<aparte-select>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
value | The selected option’s value. |
placeholder | Shown while nothing is selected. |
disabled | Blocks opening the dropdown. |
searchable | Adds a filter field above the options. Read on the first render only. |
open | Reflects (and controls) whether the dropdown is open. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
value | string | |
open | boolean |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-select-open | CustomEvent | The dropdown opened. No detail. |
aparte-select-close | CustomEvent | The dropdown closed. No detail. |
aparte-select-change | CustomEvent<AparteSelectChangeDetail> | The selection changed; carries the new value, its label and the previous value. |
<aparte-option>
Section titled “<aparte-option>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
value | Option value |
disabled | Disabled state |
selected | Selected state |
data-status | Free-form status the host sets; styled, never read by core. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
value | string | |
label (readonly) | string | |
disabled | boolean | |
selected | boolean |
<aparte-optgroup>
Section titled “<aparte-optgroup>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
label | Group label |
collapsible | Adds the chevron and the click handler to the header — so it needs a label, and it is read only when that header is first built. |
collapsed | Collapsed state |
loading | Appends a spinner row to the group; the options stay visible. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
label | string | |
collapsible (readonly) | boolean | |
collapsed | boolean | |
loading | boolean |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-optgroup-toggle | CustomEvent<AparteOptgroupToggleEventDetail> | The group was collapsed or expanded. |
Theming
Section titled “Theming”Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.
<aparte-select>
Section titled “<aparte-select>”| Variable | Default | Description |
|---|---|---|
--aparte-select-bg | var(--aparte-surface-1) | Trigger background. (The dropdown panel has its own token, --aparte-select-dropdown-bg, in both themes.) |
--aparte-select-border | var(--aparte-border-control) | Border of the trigger and of the dropdown. |
--aparte-select-border-hover | var(--aparte-primary) | Trigger border on hover. |
--aparte-radius-select | var(--aparte-radius-md) | Corner radius of the trigger and the dropdown — the theme’s knob, beside --aparte-radius-input and the others. (It used to exist twice, as this and a private --aparte-select-radius with a different value.) |
--aparte-select-text | var(--aparte-text) | Colour of the trigger label (and of the options). |
--aparte-select-chevron | var(--aparte-text-muted) | Colour of the chevron, which rotates 180° while open. |
--aparte-select-dropdown-bg | var(--aparte-surface-1) | Dropdown panel background, in both themes (--aparte-surface-1 is dark-aware). It used to be read in the light theme only, the dark rule taking the TRIGGER’s background instead — so a transparent trigger made the panel see-through in the dark. |
--aparte-select-shadow | 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16) | Dropdown panel shadow. |
--aparte-select-z | var(--aparte-z-dropdown) | z-index of the dropdown. It is position: fixed, so this is the one knob that decides whether it lands above the rest of your page. |
<aparte-option>
Section titled “<aparte-option>”| Variable | Default | Description |
|---|---|---|
--aparte-select-text | var(--aparte-text) | Option text colour. |
--aparte-select-option-hover | var(--aparte-surface-2) | Background on hover, and for the keyboard-active row ([data-active]), which adds an inset --aparte-primary ring on top so the two are distinguishable. |
--aparte-select-option-selected | color-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), transparent) | Background of the selected row. A tint by default: a solid accent fill with white text failed WCAG AA in both themes. |
--aparte-select-option-selected-text | var(--aparte-select-text, var(--aparte-text)) | Text colour of the selected row. Set both this and the background to go back to a solid fill. |
<aparte-optgroup>
Section titled “<aparte-optgroup>”| Variable | Default | Description |
|---|---|---|
--aparte-text-muted | — | Colour of the group header label and of the loading row. The shared theme token: there is no optgroup-specific override, so restyling one group’s header means setting this on that element. |
In a framework
Section titled “In a framework”The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.
<aparte-select disabled=""></aparte-select>el.addEventListener('aparte-select-change', (e) => use(e.detail));<aparte-select disabled=""></aparte-select>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and disabled={false} would render disabled="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-select disabled="" @aparte-select-change="(e) => use(e.detail)" ></aparte-select></template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-select disabled="" on:aparte-select-change={(e) => use(e.detail)}></aparte-select>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteSelectDirective } from '@aparte/angular';<aparte-select [disabled]="true" (selectChange)="use($event)"></aparte-select>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.