@lekoala/combobox
v0.5.0
Published
Native-first <combo-box> combobox/filterable-select
Maintainers
Readme
@lekoala/combobox
A small, native-first combobox library for searchable selects, multiple values, tags and remote suggestions.
It enhances regular <input>, <datalist> and <select> controls instead of replacing them with a custom form model.
<link rel="stylesheet" href="@lekoala/combobox/combobox.css">
<script type="module">
import "@lekoala/combobox/define";
</script>
<combo-box create placeholder="Search or create a framework…">
<select name="frameworks[]" multiple>
<option value="react">React</option>
<option value="vue">Vue</option>
</select>
</combo-box>The library uses the browser where it can:
- native form controls keep owning the value;
- Popover handles the picker top layer;
@lekoala/floatinghandles placement and viewport updates;- ARIA combobox/listbox semantics handle keyboard interaction;
- native
input,change, validation and form reset keep working.
There is no CSS Anchor Positioning, no Bootstrap dependency and no global window.* API.
Install
npm install @lekoala/comboboxor:
bun add @lekoala/comboboxFour ways to use it
<combo-box>
This is the simplest option for most applications.
<link rel="stylesheet" href="@lekoala/combobox/combobox.css">
<script type="module">
import "@lekoala/combobox/define";
</script>
<combo-box search="fuzzy" placeholder="Choose a country…">
<select name="country">
<option value="">Choose…</option>
<option value="be">Belgium</option>
<option value="fr">France</option>
</select>
</combo-box>Importing @lekoala/combobox/define registers <combo-box>.
Importing the main package does not register anything automatically. The ESM
entries never inject styles: load @lekoala/combobox/combobox.css yourself (a
<link> as above, or import "@lekoala/combobox/combobox.css" in a bundler).
Only the standalone build carries its CSS inline.
JavaScript
You can enhance a native control directly — load the CSS the same way, then:
import "@lekoala/combobox/combobox.css";
import Combobox from "@lekoala/combobox";
const combo = new Combobox(document.querySelector("select"), {
match: "fuzzy",
minChars: 1,
});Classic script + separate CSS / file://
The classic build works without an ESM loader:
<link rel="stylesheet" href="dist/combobox.css">
<script src="dist/combobox.js"></script>
<combo-box>
<select>
...
</select>
</combo-box>It registers <combo-box>, but does not expose a global Combobox object. Styles stay
in a separate file, which is useful when your Content Security Policy forbids injected
inline styles.
All-in-one standalone
For a single minified browser asset, use the standalone build:
<script src="dist/combobox.standalone.min.js"></script>
<combo-box>
<select>
...
</select>
</combo-box>The npm package exposes the same file as @lekoala/combobox/standalone. It registers
<combo-box> and injects the component CSS once as
<style id="lekoala-combobox-style">. Loading the script again does not duplicate
that style, and no library global is created.
Content Security Policy
The standalone script copies its own script nonce to the generated <style> element:
<script nonce="your-request-nonce" src="dist/combobox.standalone.min.js"></script>Your policy must accept that nonce for both script-src and style-src. If inline
style injection is not allowed, use the classic script plus combobox.css, or the ESM
entry plus separately imported CSS. Avoid loading combobox.css alongside the
standalone build unless duplicate CSS rules are intentional.
Native controls stay native
The original control remains the source of truth.
For an input:
<input list="cities">
│
└── owns the submitted valueFor selects:
<select>
<select multiple>
│
└── own the submitted value(s)The searchable input added around a <select> is only there for interaction. It has no name and never replaces the select in FormData.
This also means things such as:
required;disabled;form.reset();- native
inputandchange; - server-rendered selections;
continue to behave like normal form controls.
Browser autofill is not handled
Enhancement defaults the field you type in to autocomplete="off" (restored on dispose()), and the input added around a <select> carries no name. That is the whole of it: the browser's own autofill is out of scope.
It is not a guarantee. Chrome deliberately ignores autocomplete="off" on fields its heuristics classify as address or payment, and those heuristics read the name, the id, the associated <label>, the placeholder and any authored autocomplete token. A field named city, labelled City or declaring autocomplete="address-level2" can therefore keep showing the browser's saved address suggestions on top of the picker (demo 1 does all three).
Avoiding that is the page's call, not the component's: keep address-like wording out of the enhanced field's name, id and label, and do not declare an address token on it. off is only a default — an autocomplete authored on the field is kept as-is, so a page that really must suppress the browser's suggestions can declare the usual escape token, autocomplete="new-password".
Searchable selects
A regular select can be filtered without changing its value model:
<combo-box search="includes">
<select name="doctor">
<option value="1">Dr Jane Smith</option>
<option value="2">Dr John Martin</option>
</select>
</combo-box>You can also provide your own interaction input:
<input data-filter-for="doctor" placeholder="Search doctors…">
<select id="doctor" name="doctor">
...
</select>The input is only used for filtering. The select still owns the value.
Multiple values and tags
Multiple selects are rendered as removable chips:
<combo-box>
<select name="specialties[]" multiple>
<option value="cardiology">Cardiology</option>
<option value="neurology">Neurology</option>
</select>
</combo-box>Enable creation when users may enter new values:
<combo-box create>
<select name="tags[]" multiple></select>
</combo-box>Created options are added to the native <select> just like normal options.
Matching
Built-in search modes are:
includes
startswith
fuzzy
patternSearch can cover several fields:
<combo-box
search="fuzzy"
search-fields="label, city, specialty"
>
...
</combo-box>Each field is matched independently. Search never matches by accidentally joining fields together.
Matching is case- and accent-friendly where appropriate, so values such as:
Liège
liege
LIEGE
liègebehave as expected.
More specialized matching can be provided from JavaScript.
Remote results
Remote search stays deliberately simple:
<combo-box class="patients">
<select name="patient"></select>
</combo-box>const box = document.querySelector("combo-box.patients");
box.configure({
minChars: 2,
async load(query, { signal }) {
const response = await fetch(`/api/patients?q=${encodeURIComponent(query)}`, {
signal,
});
return response.json();
},
});configure() lives on the <combo-box> element (box), not on the engine.
It merges options and rebuilds the engine instance on a microtask, so a
reference obtained before the rebuild goes stale — read box.combobox again
after the next tick (await box.whenReady() is only for an element that has
not been upgraded yet).
Remote results are temporary suggestions. They do not immediately become native <option> elements.
Once a remote result is selected, it is added to the select so normal form submission continues to work.
That distinction is intentional:
catalogue persistent native options
results temporary search results
selection native selected optionssetResults() and clearResults() only deal with temporary results.
setOptions() replaces the catalogue while keeping currently selected native options, including selected values that originally came from remote results or creation.
Empty values
Empty option values are supported when explicitly enabled:
<combo-box allow-empty-option>
<select>
<option value="">None</option>
<option value="a">Option A</option>
</select>
</combo-box>Without allow-empty-option, "" is not treated as a normal selectable item when options are added programmatically.
JavaScript-only behavior
Simple options have HTML attributes where that makes sense:
<combo-box
create
search="fuzzy"
min-chars="2"
max-items="5"
max-options="20"
tab-select
>Behavior that requires functions stays in JavaScript:
const box = document.querySelector("combo-box");
box.configure({
async load(query, context) {
// ...
},
guards: {
async remove(item) {
return confirm(`Remove ${item.label}?`);
},
},
render: {
option(item) {
const strong = document.createElement("strong");
strong.textContent = item.label;
return strong;
},
},
});Strings are always rendered as text. Rich rendering uses DOM nodes rather than an allowHtml switch.
See API for the full option and method reference.
Filtering events
Filtering can be intercepted:
combo.input.addEventListener("beforefilter", (event) => {
if (somethingSpecial) {
event.preventDefault();
// Application-defined behavior...
}
});beforefilter is cancellable and exposes the current query.
Filtering events belong to the interaction input. combobox:* lifecycle events belong to the native source control.
The full event table is documented in API.
Selection order
For multiple selects, source order and selection order do not have to mean the same thing.
When explicit selection order is enabled, values can be reordered with:
combo.move("value", 0);Drag-and-drop is intentionally not built into the core. An application can add whatever UI it wants and call move().
Progressive fallback
If the browser does not support the Popover API needed by the enhanced picker, the original controls remain usable.
input + dataliststays a native datalist;selectstays a native select;select multiplestays a native multiple select;- creatable multiple selects get a small native Add input/button.
There is no second JavaScript picker implementation for older browsers.
You can force this mode in the demo with:
?native=1Browser baseline
The enhanced mode floor is determined by the showPopover()/hidePopover() API:
Safari 17+
Firefox 125+
Chromium 114+Below this floor the component automatically switches to mode = "fallback" and the native control remains fully functional.
The showPopover({ source }) parameter is a supplementary enhancement — it establishes the invoker→popover relationship for focus navigation and implicit CSS anchoring. It is optional and does not affect the floor, since Combobox uses @lekoala/floating and autoUpdate() for placement rather than CSS anchor positioning.
This means the enhanced mode shares the same core Popover manual baseline as Actual 0.8:
Actual full runtime ┐
├─ core Popover manual baseline
Combobox enhanced ┘The difference is that Combobox can fall back to native controls below the floor, while Actual ships a complete runtime above it.
Styling
The component ships with a small default stylesheet and is designed to be easy to theme with CSS custom properties.
Defaults are neutral and deterministic: the border, muted and hover colors are derived from --cb-color/--cb-bg, --cb-focus-color is the single interaction accent (#2563eb), and the active option is a neutral navigation cursor while the selected option keeps a soft accent plus its check mark.
Select-based controls draw a decorative caret with pure CSS (a ::after flex item, no injected SVG or extra button) that flips when the picker opens. input+datalist stays a real autocomplete and gets no caret.
For example:
combo-box.compact {
--cb-chip-font-size: 0.75em;
}
combo-box.pills {
--cb-chip-border-radius: 999px;
}
combo-box.solid {
--cb-chip-bg: #6d28d9;
--cb-chip-color: white;
}Applications can also return marker elements from renderers and style them with normal CSS:
render: {
item(item) {
const label = document.createElement("span");
label.className = `tag-tone-${item.data.tone}`;
label.textContent = item.label;
return label;
},
}.cb-chip:has(.tag-tone-success) {
--cb-chip-bg: #dcfce7;
--cb-chip-color: #15803d;
}demo/actual-css.html shows the same component themed entirely with Actual CSS tokens.
Demo
The main demo covers:
- input + datalist;
- searchable single selects;
- multiple values and chips;
- created values;
- fuzzy and multi-field search;
- remote loading;
- custom renderers;
- selection order;
- guards;
- separators;
- maximum items/results;
- RTL;
- runtime disabled states;
- form reset;
- custom clear controls.
Standalone recipe pages: demo/query-builder.html (scoped search tokens) and
demo/service-options.html (gated single select with rich rows and
application-owned tooltips).
demo/date-picker.html places the combobox next to a CDN-loaded
@lekoala/date-picker (GitHub master build) on their default neutral tokens, to
check the two components stay visually raccord — same field chrome, focus ring,
radius and overlay shadow.
Run it locally with:
bun install
bun run devThen open:
http://127.0.0.1:4173/The main demo uses the generated distribution build, not a special development-only
version. demo/dist-standalone.html is the equivalent smoke page for the all-in-one
artifact.
The demos load the committed dist/ build (dist/combobox.js + dist/combobox.css)
rather than node_modules, so the same files also publish as a static site. The
published demos live at https://lekoala.github.io/combobox/; after a source change,
regenerate the build with bun run sync and commit, or the drift gate in verify/CI
fails.
API Coverage
The main API covers:
- input + datalist;
- single and multiple selects;
<combo-box>;- filtering and matching;
- multiple search fields;
- creation;
- remote loading;
- chips;
- separators;
maxItemsandmaxOptions;- selection order and
move(); clear();- form semantics;
- exact cleanup with
dispose().
A few more advanced APIs are intentionally still experimental in 0.x:
observeSource;- custom
tokenize; - cursor pagination /
loadMore(); - rich
render.*customization.
Things that are deliberately not part of the library:
- a plugin framework;
- virtualization;
- built-in drag/drop;
- checkbox dropdowns;
- Bootstrap JavaScript;
- automatic DOM observation by default;
- a built-in clear button;
- a second positioning implementation.
The goal is not to become another all-purpose Select2 clone. The library should stay small enough that native controls and browser APIs remain visible underneath it.
Custom element registration
Registration is explicit:
import { defineCombobox } from "@lekoala/combobox";
defineCombobox();Calling defineCombobox() more than once is safe.
You can also build your own element name:
import { ComboBoxElement } from "@lekoala/combobox";
customElements.define(
"app-combobox",
class extends ComboBoxElement {},
);<combo-box> uses no Shadow DOM and does not become the form control itself.
Development
The source is pure ESM; @lekoala/floating is its only runtime dependency (used
for picker placement). Nothing else runs at runtime.
bun install
bunx playwright install chromium firefox webkit
bun run check
bun run test:browser
bun run sync
bun run verifyA few useful commands:
bun run check
lint + typecheck + unit tests
bun run test:browser
browser behavior tests against the ESM source
bun run sync
regenerate dist JS/CSS, declarations and custom-elements.json
bun run verify
run the full consistency/package checks
bun run check:all
bun run test:browser:all
include Firefox and WebKitGenerated distribution files are committed so the demo, package contents and published artifacts can be checked directly.
The package ships:
- pure ESM entry points;
- an opt-in
<combo-box>registration entry; - a classic self-registering build;
- CSS;
- generated TypeScript declarations;
custom-elements.json.
There are no runtime source maps. Declaration maps are kept for TypeScript editor navigation.
Documentation
More detail lives here:
- API — options, methods, attributes and events
- Architecture — internal model and design decisions
- Use cases — practical examples
- Migration — moving from
bootstrap5-tags/bootstrap5-autocomplete - Testing — browser and behavior coverage
- References — related browser and Open UI work
Design principles
A few rules keep the library intentionally small:
- The native control owns the value.
- Remote results stay temporary until selected.
- Option identity comes from the actual
<option>, not only its string value. - Form behavior should remain native whenever possible.
- The browser handles placement and top-layer behavior.
- Rich rendering uses DOM nodes, not HTML strings.
- Application-specific transport and UI stay application-specific.
That is most of the design.
