A lightweight, themeable multi-select web component with typeahead search, RTL support, rich content, and full keyboard navigation.
@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 --base-* variables from the page if @keenmate/theme-designer is present, 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 (pills, count, compact, partial, none) with positioning on any side.
- 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).
- Internationalization — a
labelsmap for every ARIA / UI string — The component's accessibility labels and short UI strings were hardcoded English, so a non-English screen-reader user still heard "Close", "Clear selection", "Remove …", "N selected", and the navigate-mode "Previous/Next match" in English. The newlabelsproperty (typeMultiSelectLabels, exported from the package entry) takes a partial i18n map — set only the keys you need and the rest fall back to their English defaults. Values interpolate{item}(an option's label) and{count}(a number), soremoveItem: 'Odebrat {item}'orandMore: '…a {count} dalších'read naturally. Covered keys:close,clearSelection,clearAllSelections,clearSearch,showFullLabel,removeItem,removeHiddenItems,groupSelectedLabel,andMore,prevMatch,nextMatch. - Translatable selected-items popover title — The popover header was a hardcoded "Selected Items (N)", the one visible string with no override. The new
selected-popover-titleattribute (andselectedPopoverTitleproperty) makes it configurable, with a{count}placeholder so translators control both the wording and where the count sits — e.g.selected-popover-title="Vybrané položky ({count})". Defaults toSelected Items ({count}), so nothing changes unless you set it. - Aligned icon gutter —
reserve-icon-slot— When a list mixes rows that have an icon with rows that don't, the icon-less rows used to pull their label into the gutter, so labels started at ragged horizontal positions. The newreserve-icon-slotattribute (andisIconSlotReservedproperty) reserves a fixed-width icon slot on every row — an empty placeholder where a row has no icon — so every label lines up in one start column. It's off by default (nothing changes unless you set it) and option-level, applying identically to a flat list and a tree, where the depth indent already offsets each level. The slot reuses the existing--ms-option-icon-sizebox and--ms-option-content-gap, so it scales with--ms-remand honours your theme. - Builder playground on the examples site — A new interactive Builder page lets you configure a live multiselect from a Visual-Studio-style grouped property panel — toggle any attribute, see it apply instantly, paste your own JSON options, and copy the generated markup. Each property has a Floating-UI tooltip explaining it. Where an attribute needs a JS companion the grid can't express —
allow-add-new(needsaddNewCallback),selected-order="custom"(needsselectedOrderCompareCallback) — the Builder wires a demo callback and flags the dependency right in the generated markup, so it doubles as a lesson in the attribute-vs-callback boundary.
optionsupdates in place — the dropdown no longer closes after every pick — Assigning theoptionsproperty used to tear down and rebuild the whole component, which snapped an open dropdown shut and reset scroll and search. That made the common framework pattern — handing the component a fresh array on every render (React/Vue/Lit, or a Phoenix LiveView re-render) — unusable for multi-selection, because each pick that triggered a parent re-render closed the list. The property is now routed through the core's in-place update path instead of a re-init, so an open dropdown stays open and the selection, scroll offset, and search term all survive the swap. It's also far cheaper: replacing a 5000-item list is now an O(n) in-place update measured at ~2 ms, versus a full teardown with a Floating-UI re-anchor and a fresh virtual scroller. Settingoptionstonullclears the list in place.- New
refresh()method — re-render without re-assigningoptions— There was no way to tell the component "the data you already hold changed, repaint it" short of faking a new array.refresh()is the companion to theoptionssetter: call it when you mutate an option object in place (e.g. flipping adisabledflag) or when external state a render callback reads changes (getDisabledCallback, an i18n label map, a custom option renderer). It re-projects the filtered/tree view from the current options honouring the active search term, reconciles the selection, and repaints — all silently, firing noselect/deselect/change. The rule is simple: assignoptionswhen the set changed, callrefresh()when the same objects did. - New
prune-missing-selectionoption — drop selections whose option disappears — When you replace the option list, a value that was selected but is no longer offered used to linger as a phantom: still ingetValue(), still a badge, but not pickable. That's the right default for search and paged lists (an item can leave the current page yet stay valid), so it's unchanged. The newprune-missing-selectionattribute (andisPruneMissingSelectionEnabledproperty) opts into the opposite behavior for when a list replacement means the domain itself changed — e.g. an item was deleted server-side — dropping any selected value that has no matching option, silently.
- 🚀 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.
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, ATTRIBUTE_TABLE-driven |
| Logic | src/multiselect.ts |
WebMultiSelect<T> — framework-agnostic core |
| Service | src/tooltip.ts, src/virtual-scroll.ts |
single-purpose helpers (Tooltip, VirtualScroll) |
| Side | src/types.ts, src/logger.ts, src/vendor/ |
types, logging, vendored deps |
Two deviations from the canonical shape, both intentional:
MultiSelectElement extends BaseElement, notHTMLElementdirectly.BaseElementis a localconstresolving 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.