select-lab
v1.0.0
Published
Framework-agnostic super-select web components (single & multi) with server-side pagination, search, virtualization and data-driven theming.
Maintainers
Readme
select-lab
Framework-agnostic super-select web components — one codebase, usable from Angular, Vue, Svelte and plain HTML/JS via the <select-lab> / <select-lab-multi> custom elements.
Built for the case a native <select> (and even select2 / ng-select) struggles with:
- Continuous server-side pagination over 10k–15k+ rows (infinite scroll + row virtualization, so the DOM stays ~30 nodes).
- Debounced field search with in-flight request cancellation and page caching.
- Data-driven icons & colors per option (icon, icon color, text color, background — from your data, not hardcoded) — with adapters for emoji, raw SVG, any icon library that ships a custom element (
<ion-icon>,<iconify-icon>, …), and ligature icon fonts. See Icons. - Display ≠ value: show
name + description + icon, return just theid. - Closes on anything outside the flow — click outside, Escape, or scrolling the page/an ancestor — on by default. No detach, no manual scroll listeners to wire up yourself.
- Real form control (
ElementInternals,formAssociated) + ARIA combobox keyboard support. - Themeable via CSS custom properties and
::part()— no!important, plugs into whatever design system the host app already has.
Install
npm install select-lab lit @floating-ui/domlit and @floating-ui/dom are peer runtime deps (keeps the lib lean and dedupes across your app).
Quick start (vanilla)
<select-lab id="clients" value-field="id" label-field="name"
description-field="email" icon-field="icon" icon-color-field="color"
placeholder="Search client…"></select-lab>
<script type="module">
import 'select-lab/single'; // registers <select-lab>
const el = document.getElementById('clients');
el.dataSource = async ({ search, offset, limit, signal }) => {
const r = await fetch(`/api/clients?search=${search}&skip=${offset}&limit=${limit}`, { signal });
return { items: await r.json() }; // bare array also accepted; `total` optional
};
el.addEventListener('change', (e) => console.log(e.detail.value)); // the id
</script>Pure-HTML alternative (library does the fetch):
<select-lab src="/api/clients" search-param="search" offset-param="skip"
limit-param="limit" value-field="id" label-field="name"></select-lab>Entry points
| Import | Registers | Value |
| --- | --- | --- |
| select-lab/single | <select-lab> | scalar (the chosen value-field) |
| select-lab/multi | <select-lab-multi> | array of value-fields (renders chips) |
| select-lab | both | — |
| select-lab/core | — | SelectLabBase + types (build your own) |
Data contract
type LoadParams = { search: string; offset: number; limit: number; signal: AbortSignal };
type DataSource = (p: LoadParams) => Promise<{ items: T[]; total?: number } | T[]>;- Return
{ items, total }for an exact end-of-list; or a bareT[](compatible with services that already return arrays) — end-of-list is then inferred from a short page. .items = [...](a static array property) turns it into a normal in-memory select (client-side filter, no pagination).
Icons
Works with whatever icon system your app already has — no rewrite needed. icon-field auto-detects the shape of the value; for full control, .iconRenderer calls straight into your own icon library's API.
| Your icon data looks like… | How to wire it |
| --- | --- |
| Emoji / plain glyph ("🧑💼") | Nothing to configure — just icon-field. |
| An image URL / data: URI | Auto-detected, rendered as <img>. |
| Inline SVG markup ("<svg…>…</svg>") | Auto-detected, injected as real DOM (not escaped text). |
| A tag from a custom-element icon library ("<ion-icon name='home'>", <iconify-icon>, …) | Same auto-detection — if that library registered its element globally, it upgrades inside the shadow root for free (the custom element registry isn't scoped per shadow tree). |
| A ligature icon font (Material Symbols/Icons, …) | icon-font-family="Material Symbols Rounded" — no CSS to write. Unlike a plain CSS class, this correctly reaches through the Shadow DOM. |
| A class-based icon font (Font Awesome, Bootstrap Icons, …) | inherit-styles clones the page's stylesheets into the shadow root, or resolve the glyph yourself upstream. |
| Anything your own JS API can render | .iconRenderer = (item, iconValue) => … — return a Lit TemplateResult or a markup string; full control, any icon library. |
Framework components (Angular <mat-icon>, a Vue/Svelte icon component) can't be injected this way — they need their framework's own compiler attached to the node. Resolve them to one of the forms above upstream (most icon libraries already expose a raw-SVG or unicode form for exactly this reason).
Key attributes / properties
| Attr / prop | Default | Purpose |
| --- | --- | --- |
| value-field | id | field returned as the value |
| label-field | label | primary text |
| description-field | — | optional second line |
| icon-field / icon-color-field | — | per-item icon + its color |
| text-color-field / bg-field | — | per-item text color / row background |
| icon-type | auto | auto | image | font | emoji |
| icon-font-family | — | ligature icon font by family name (see Icons) |
| icon-font-class | — | extra class on the icon glyph, for use with inherit-styles |
| inherit-styles | false | clone the page's stylesheets into the shadow root (class-based icon fonts) |
| page-size | 50 | rows per request |
| debounce | 300 | search debounce (ms) |
| option-height | 44 | fixed row height (px) — used by virtualization |
| close-on-scroll | true | close when the page/an ancestor scrolls outside the select; set false to have the panel follow the control instead |
| .dataSource / src / .items | — | data (see Data contract) |
| .renderOption / .renderLabel | — | full custom row/label templates (power users) |
| .iconRenderer(item, iconValue) | — | full custom icon rendering (power users, any icon library's own API) |
| .resolveValue(value) | — | hydrate a preselected value not in the buffer |
Events: change ({ value, item } or { value, items }), search, open, close.
Theming
Override CSS custom properties, or target internals with ::part():
select-lab { --sl-accent: #10b981; --sl-radius: 12px; }
select-lab::part(panel) { border-radius: 16px; }
select-lab::part(option) { padding-inline: 16px; }Dark mode follows prefers-color-scheme; force it with theme="dark" / theme="light".
Framework usage
Angular — allow the custom element, bind the .dataSource property and the change event:
@NgModule({ schemas: [CUSTOM_ELEMENTS_SCHEMA] }) // or in a standalone component<select-lab [dataSource]="loadClients" value-field="id" label-field="name"
(change)="onChange($event)"></select-lab>loadClients = ({ search, offset, limit }) =>
firstValueFrom(this.clientService.getClients(offset, limit, search)); // already returns Client[]A
ControlValueAccessoradapter (for reactive forms /ngModel) is planned asselect-lab/angular.
Vue — treat it as a custom element, then v-model-style bind:
// vite: app.config.compilerOptions.isCustomElement = t => t.startsWith('select-lab')<select-lab :value-field="'id'" label-field="name"
:.dataSource="loadClients" @change="v = $event.detail.value" />Svelte — works natively:
<select-lab label-field="name" bind:this={el} on:change={(e) => (v = e.detail.value)} />
<script>onMount(() => (el.dataSource = loadClients));</script>Develop & test
npm run dev # demo harness at http://localhost:5173 (10k-row mock API)
npm run build # tree-shakeable ESM library → dist/
npm run typecheck
npm test # DataController engine (pagination/search/cache/abort)
npm run test:browser # drives the real UI in Chrome (needs the dev server running)Contributing
Bug reports, fixes and ideas are welcome — see CONTRIBUTING.md for how to get set up and what to run before opening a PR.
License
Security
See SECURITY.md for how to report a vulnerability.
Links
- Repository: github.com/MonsDz/select_lab
- Issues: github.com/MonsDz/select_lab/issues
