A lightweight, themeable multi-select web component with typeahead search, RTL support, rich content, and full keyboard navigation.
- π Showcase
- π§ͺ Live demo
- π Usage / API reference β attributes, properties, methods, events.
- π¨ Theming β
--ms-*variables, dark mode, cascade layers, Theme Designer integration. - π Examples / cookbook β rich content, async search, virtual scroll, custom rendering, forms.
- βΏ Accessibility β keyboard model, ARIA labels, focus behavior.
@keenmate/web-multiselect is a custom element (<web-multiselect>) that turns a list of options into a searchable, themeable multi-select dropdown. Framework-agnostic β works in React, Vue, Svelte, Blazor, plain HTML.
Reads the --base-* tokens from @keenmate/base-css-variables 2.0 when the page loads them (the shared layer behind pure-admin and the Theme Designer), falls back to sensible OS-aware defaults otherwise, and ships with first-class dark-mode and per-instance theming.
Headline features:
- Declarative
<option>/<optgroup>markup β no JavaScript required for simple cases. - Virtual scrolling for 10,000+ option datasets (25Γ faster opening, 99.8% memory reduction).
- Filter or navigate search modes; async / hybrid search.
- Five badge display modes (badges, count, compact, partial, none) with positioning on any side.
- Tree mode with cascade / independent checkboxes; phone-friendly fullscreen presentation.
- Full keyboard navigation, RTL language support, badge tooltips.
- Custom rendering callbacks for options, badges, and group headers.
- Form integration via standard hidden inputs (FormData-compatible).
Keyboard form flow for single-select, plus a visible highlight. See the release notes; one behaviour change: single-select now clears its search when it closes.
- Keyboard β Tab can select β Tab (and Shift+Tab) used to close the dropdown and move on, dropping the highlighted option, so typing
cesky kruand tabbing to the next field left it empty. The newtab-behaviorattribute decides what Tab selects before focus moves:select-highlightedtakes the highlighted option, including the first match highlighted while typing;select-navigatedonly takes an option the user moved to with the arrows (typing resets it);closeselects nothing, as in 2.5. Unset meansselect-highlightedin single-select andclosein multi-select. Tab only ever adds, so it never un-picks in multi-select, and abeforeSelectCallbackveto still applies. A Tab selection firesselect/changeand updates the form value like Enter. - Keyboard β single-select opens at its value β Opening a single-select with no search term now highlights the selected option and scrolls it to the middle of the list, like a native
<select>: with Item 30 of 50 selected, ArrowUp goes to 29 instead of starting from the top. The scroll is instant and moves only the list, never the page; with virtual scrolling the row is rendered first. Typing still highlights the first match, and tabbing straight through changes nothing. - Search β single-select clears the term on close β
should-keep-search-on-closeis now tristate: unset keeps the term in multi-select, where you reopen and keep picking, but clears it in single-select, where it is finished once a value is picked or focus leaves. Before, tabbing out after typingceskand coming back with Shift+Tab reopened the field withceskin the box and the list still filtered β in a cascade of selects you had to delete it every time. Behaviour change:should-keep-search-on-close="true"restores the old single-select behaviour. - Theming β the focused option is filled again β 2.6.0-rc01 marked keyboard focus with the shared contract's 1px inset ring and no fill, as in the tree components. In a combobox the focused option is what Enter / Tab will pick, and with a single result its ring sat on the panel border, so nothing looked highlighted.
--ms-option-bg-focusednow uses the hover fill under the ring and deliberately no longer reads--base-focused-bgβ a documented exception to the contract; override--ms-option-bg-focusedto change it. - Search β primitives now live in web-components-core β Normalization, matching and highlight mapping moved to
@keenmate/web-components-core/search(core 1.0.0-rc14), so other Keenmate components (web-treeview next) match and highlight exactly the same way.src/search.tsis gone;normalizeSearchTextand the search types are re-exported under the same names. No API or behaviour change: the e2e suite passed unchanged and the bundle size is flat (412 kB).
The first 2.6 release on npm (rc01 never reached it), so it also carries everything from
2.6.0-rc01 (see the CHANGELOG). Read the release notes before upgrading from
2.5: search now ignores accents by default, and a theme signal on a wrapper <div> needs
theme-source.
- Search β accent-insensitive by default β Built-in search used to lowercase and nothing more, so
cesnever foundΔeskΓ½andmullernever foundMΓΌller. The newsearch-behaviorattribute picks the normalization:normalized(the new default) ignores case and diacritics,case-insensitiverestores the previous behavior, andexactignores nothing. Every mode compares Unicode NFC, so composed and decomposed accents match each other, and the<mark>highlight follows the same rules, soceshighlightsΔes. The setting is live: a page-level switch re-runs the open search in place. The normalization is exported asnormalizeSearchText(text, behavior?)for your own code. - Search β choose where the term matches β The new
search-matchattribute sets where built-in search looks:contains(the default, as before),equals(the whole value, for codes:haxkl324findsHAxKL324but notHAxKL3245),starts-with/ends-with(the whole value),word-starts-with/word-ends-with(any word, sokrufindsΔeskΓ½ Krumlov), orregex. A regex runs against the raw search value without normalization, so case and accents are up to the pattern, and an unfinished pattern keeps the last valid result while you type. - Search β prepare the term, or bring your own engine β
getSearchTermCallback(term, context)turns typed text into what search looks for. For built-in search it returns a string or aRegExp, e.g. soha-324/56findsHA 324-56andHA32456. For your ownsearchCallbackit can return anything, objects included.searchCallbackgets a thirdcontextargument with that preparedsearchTermandentries, every option paired with its search value. That value may now be an object such as{ title, zip }fromgetSearchValueCallback, so a FlexSearch or server search can match postal codes.entriesis built on first access and stays the same array until the options change, so an engine can key its index on it. Existing(term, signal)callbacks keep working, and results may be returned synchronously. - Search β values prepared once, not per keystroke β Each option's search value, from
searchValueMember/getSearchValueCallbackor the display value, is computed and normalized once, then cached. A keystroke over 2,000 options is now 2,000 lookups, where it used to re-read and re-lowercase every value. Replacingoptions, callingrefresh(), or changing a member or callback re-prepares the values. - Dark mode β works in Firefox and Safari β The component no longer relies on
:host-context(), which Firefox doesn't support and Safari only partly supports. It reads thedata-context-themeattribute that@keenmate/web-components-core1.0.0-rc13 sets after detecting the page theme once (data-theme,data-bs-theme, or.dark/.lighton<html>/<body>), shared by every Keenmate component. A per-instancedata-themestill wins. Behaviour change: a theme signal on a wrapper<div>now needstheme-source="closest"or a selector.setThemeMode(),getThemeContext()andobserveThemeContext()are re-exported from core. - Theming β follows base-css-variables 2.0.0-rc03 β Selected options read
--base-selected-*(renamed from--base-highlighted-*). The placeholder and hint use text step 3, because step 4 was too faint to read. Primary used as text (badges, "add new", the fullscreen info icon) reads--base-primary-text-colorfor at least 4.5:1 contrast. Disabled controls share--base-disabled-opacity. Standalone fallbacks now match the contract defaults (accent#0ea5e9). - Fixes β fullscreen in Firefox/Safari, group count chip, trailing spaces β
mobile-presentation="fullscreen"closed itself the moment it opened in Firefox and WebKit, because a zero-delay timer cleared the open-gesture guard before the follow-upclick. The guard now lasts the whole gesture. The per-group count chip gets vertical padding so its[N]no longer touches the edges, and group headers grow about 4px to fit it without shifting when it toggles. Flat-list search now trims the term like tree search does, soyork(with a trailing space) findsNew York.
npm install @keenmate/web-multiselectDeclarative β no JavaScript required:
<script type="module">
import '@keenmate/web-multiselect';
</script>
<web-multiselect placeholder="Pick a country">
<option value="cz">Czech Republic</option>
<option value="sk">Slovakia</option>
<option value="at">Austria</option>
</web-multiselect>Programmatic β dynamic data + events:
<web-multiselect id="picker" search-placeholder="Searchβ¦"></web-multiselect>
<script type="module">
import '@keenmate/web-multiselect';
const picker = document.getElementById('picker');
picker.options = [
{ value: 'js', label: 'JavaScript', icon: 'π¨' },
{ value: 'ts', label: 'TypeScript', icon: 'π·' },
{ value: 'py', label: 'Python', icon: 'π' }
];
picker.addEventListener('change', (e) => {
console.log('Selected:', e.detail.selectedValues);
});
</script>See docs/usage.md for the full API and docs/examples.md for advanced patterns (async data, virtual scrolling, custom rendering, form integration).
The package ships editor metadata so you get autocomplete and hover docs for the
element's attributes, events, and all --ms-* CSS custom properties. All of it is
generated from the component's source on every build, so it never drifts.
-
JetBrains (WebStorm / IntelliJ) β works automatically. The IDE discovers
web-types.jsonvia theweb-typesfield inpackage.json; no setup needed. -
VS Code β the data files ship but VS Code doesn't auto-discover them from a dependency, so point your workspace at them once in
.vscode/settings.json:{ "html.customData": [ "./node_modules/@keenmate/web-multiselect/vscode.html-custom-data.json" ], "css.customData": [ "./node_modules/@keenmate/web-multiselect/vscode.css-custom-data.json" ] }html.customDatapowers tag/attribute completion on<web-multiselect>;css.customDatapowers completion for the--ms-*theming variables. Reload the window after adding them.
Modern evergreen browsers β anything with native customElements, Shadow DOM, and CSS @layer support:
- Chrome / Edge 99+
- Firefox 97+
- Safari 15.4+
No polyfills are shipped. SSR-safe: the module imports without crashing in Node, but renders only after hydration in the browser.
# Install dependencies
npm install
# Start dev server (HMR)
npm run dev
# Build for production
npm run build
# Create package tarball
npm run package
# Run tests
npm run test:unit # Vitest (happy-dom) β fast logic checks
npm run test:e2e # Playwright (browser) β interaction/visual
npm test # bothFollows the BlissFramework four-layer web-component layout:
| Layer | File | Role |
|---|---|---|
| Element | src/web-component.ts |
MultiSelectElement β custom-element I/O wrapper, driven by the INPUTS table (attributes, properties, callbacks) and the events table |
| Logic | src/multiselect.ts |
WebMultiSelect<T> β framework-agnostic core (search, selection, keyboard, rendering) |
| Service | src/virtual-scroll.ts, src/tree/ |
VirtualScroll; the ltree option model and checkbox cascade for tree mode |
| Side | src/types.ts, src/option-formats.ts, src/converters.ts, src/logger.ts, src/index.ts |
types, data-options parsers, component-local converters, logging shim, package entry |
Positioning (dropdown, popovers, tooltips), attribute parsing and reflection, and the logging engine come from @keenmate/web-components-core.
Two deviations from the canonical shape, both intentional:
MultiSelectElement extends BlissElement, notHTMLElementdirectly. Core'sBlissElementresolves toHTMLElementin the browser and to a stub class under SSR (typeof HTMLElement === 'undefined'), so importing the module in Node doesn't throw. A literalgrep "extends HTMLElement"structure check will not match here by design.src/vite-env.d.tsis a standard Vite ambient-types file, not part of the four-layer model.
MIT β see LICENSE.
Follows the BlissFramework component guidelines for structure, theming, color-scheme, and accessibility. Per-check verifications run via /validate-web-component.
Created by Keenmate as part of the Pure Admin design system.