npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@michaeljfalk/liveselect

v4.1.0

Published

Framework-agnostic, dependency-free searchable dropdown / combobox with array or MongoDB sources, a [+ Add new] create flow, and a drop-in <select> replacement.

Readme

liveselect

CI Release License: MIT Dependencies

A framework-agnostic, dependency-free searchable dropdown / combobox — one control to replace native <select>s so every input looks uniform.

No framework and zero runtime dependencies — nothing for you to compile. The same prebuilt dist/liveselect.js + .css run in plain HTML + vanilla JS, Node/Express, EJS templates, and Blaze. (The dist/ files are generated from a single source in src/ by a dev-only esbuild step; see Building from source.)

Features

  • 🔎 Live search — debounced, keyboard-navigable (↑/↓/Enter/Esc), touch-friendly two-line options.
  • 🗂 Any data source — a plain array or an async function (wire it to MongoDB via the included Express backend, or anything else).
  • [+ Add new] row — appears when the typed text has no match; your onCreate can do anything (open a modal, POST to a server, push to an array) and return the new option to auto-select it.
  • 🔁 Drop-in <select> replacementLiveSelect.enhance(selectEl) upgrades an existing <select> in place; a hidden <input name> means it submits inside a plain <form> like a native control. Also upgrades <select multiple>.
  • Reactive live modeenhance(selectEl, { live: true }) keeps the native <select> as the source of truth and stays in two-way sync with a MutationObserver while a reactive host (Blaze/React/Vue) re-renders it; write-backs fire bubbling input+change so existing handlers and <form> serialization keep working.
  • 🏷 Multiple selectionmultiple: true for removable chips, array values, maxItems, and configurable form submission (repeat/bracket/delimited). See Multiple selection.
  • 🎨 Fully themeable — restyle with --liveselect-* CSS custom properties or target the BEM-ish classes; ships a light and dark theme.
  • 🧩 Custom item templates — render each result row and the [+ Add] row however you like with renderOption / renderCreate; return a DOM node (XSS-safe) or an HTML string. See Custom item templates.
  • Accessible by default — full ARIA combobox/listbox wiring (aria-activedescendant, aria-selected, live-region announcements) so keyboard nav is screen-reader friendly. Optional grouped options with <optgroup>-style headings.
  • 🧰 Polish where it counts — real required form validation, disabled (non-selectable) options, match highlighting, a “Showing N of M” hint, async result caching, and open/close/search lifecycle events.
  • 🔒 Security-hardened server — registry-gated collection access, field allow-listing, ReDoS-capped regex, scope filters, tenant isolation hook, prototype-pollution guards.
  • 📦 Zero dependencies, ~14 KB gzipped (JS) + ~2 KB CSS. Works as a <script> tag (window.LiveSelect), a CommonJS require, or an ES module import.

Install

The library has zero runtime dependencies and ships prebuilt — there is nothing for you to compile. Reference the files directly:

<link rel="stylesheet" href="/dist/liveselect.css">
<script src="/dist/liveselect.js"></script>
<!-- optional declarative auto-mount helper -->
<script src="/dist/liveselect-auto.js"></script>

ES module / bundler:

import LiveSelect from './dist/liveselect.mjs';

Consuming it in another project

The npm package name is @michaeljfalk/liveselect (scoped). Pick whichever path fits the consuming project — there's nothing to build for the component itself.

The only thing a host app needs to install is its own express + mongodb if you use the MongoDB server helper (server/liveselect-mongo.js). The browser side needs nothing.

Option 1 — npm (best for bundler apps)

npm install @michaeljfalk/liveselect
import LiveSelect from '@michaeljfalk/liveselect';                 // → dist/.mjs
import '@michaeljfalk/liveselect/css';                                     // if your bundler imports CSS
// server side:
const { registerEntry, createLiveSelectRouter } = require('@michaeljfalk/liveselect/server');

The class is exported as LiveSelect (the package is @michaeljfalk/liveselect).

Option 2 — npm install straight from GitHub (no registry needed)

package.json declares main/module/exports/files, so npm can install it directly from the public GitHub repo:

npm install github:michaeljfalk/liveselect
# or pin to a tag/commit for reproducible installs:
npm install github:michaeljfalk/liveselect#v1.0.0

Option 3 — Copy the files (simplest for plain HTML / EJS / Blaze)

The front-end is just <script> + <link>, so drop the files into your static assets and reference them. To grab them without cloning the whole repo:

mkdir -p public/vendor/liveselect
for f in liveselect.js liveselect.mjs liveselect.css liveselect-auto.js; do
  curl -fsSL "https://raw.githubusercontent.com/michaeljfalk/liveselect/main/dist/$f" \
    -o "public/vendor/liveselect/$f"
done
# using the MongoDB backend too? also copy server/liveselect-mongo.js

Option 4 — git submodule (track it and git pull updates)

git submodule add https://github.com/michaeljfalk/liveselect.git vendor/liveselect
git submodule update --remote   # pull updates later

Quick start

Array source

new LiveSelect('#picker', {
  name: 'fruit',                 // hidden input name → submits in a form
  label: 'Favourite fruit',
  source: [
    { value: 'apple',  label: 'Apple',  sublabel: 'Pomaceous' },
    { value: 'banana', label: 'Banana' },
    'cherry',                    // bare strings are accepted too
  ],
  allowCreate: true,
  onCreate: (query) => ({ value: query, label: query }), // return option → auto-selects
  onChange: (value, option) => console.log(value, option),
});

Replace an existing <select>

<select id="country" name="country">
  <option value="">Choose…</option>
  <option value="ca">Canada</option>
  <option value="us">United States</option>
</select>
<script>
  LiveSelect.enhance('#country'); // existing change listeners keep working
</script>

Reactive / live enhance() — skin a framework-managed <select>

In a reactive host (Meteor Blaze, React, Vue, …) the <select> is continuously re-rendered: options are added/removed from a data loop and the selected option flips as state changes. The default enhance() snapshots once and goes stale. Pass { live: true } to keep the native <select> in the DOM as the source of truth (visually hidden, still submitting and validating) with the liveselect UI as a live skin:

LiveSelect.enhance('#country', { live: true });
  • Observes the <select> with a MutationObserver and reflects host changes into the UI live — options added/removed/reordered, changed option text/value/disabled, the reactively-flipped selected value, and disabled on the select itself (a controlled-input host that sets .value + dispatches change is also picked up).
  • Writes back: a UI pick sets the native select.value / matching option.selected and dispatches bubbling input + change from the native <select>, so your existing delegated listeners and plain-<form> serialization keep working untouched. <select multiple> is supported.
  • No feedback loops: reflecting from the observer never re-fires a write-back, and a write-back is shielded from the observer — a host that re-renders to the same value in its change handler won't ping-pong, and an open menu / input focus survives a host re-render.
  • Idempotent: enhance() on an already-enhanced select returns the existing instance, so a host can safely re-scan and re-enhance. destroy() (or removing the <select> from the DOM) disconnects the observer and tears down the UI.

Tag selects declaratively and let liveselect-auto.js enhance them — on load and for framework-inserted rows added later:

<select data-liveselect name="country">…</select>
<script src="liveselect.js"></script>
<script src="liveselect-auto.js"></script>

MongoDB-backed (async source)

const api = LiveSelect.remoteSource({
  baseUrl: '/api/dropdown', key: 'customers', create: true,
});
new LiveSelect('#customer', {
  name: 'customerId',
  source: api.source,      // GET /api/dropdown/customers/search?q=
  resolve: api.resolve,    // GET .../option/:id  (edit-mode label lookup)
  allowCreate: true,
  onCreate: api.onCreate,  // POST .../create
});

See IMPLEMENTATION.md for the full server setup and per-framework integration (HTML, Express, EJS, Blaze).

Options

| Option | Type | Default | Notes | |---|---|---|---| | source (required) | Array | async (q, ctx) => options[] | — | Array filters locally; function = async source. | | name | string | '' | Hidden-input name for plain-form submission. | | value | string | — | Initial/controlled selected value. | | valueLabel | string | — | Label for value (skips a resolve round-trip). | | resolve | async (value, ctx) => option | — | Resolve a value → option (async sources, edit mode). | | label | string | — | Field label above the control. | | placeholder | string | 'Search…' | | | required | boolean | false | Adds * marker + hidden input required. | | disabled | boolean | false | | | clearable | boolean | true | Show the × clear button. | | openOnFocus | boolean | true | Run an empty search on focus. | | minChars | number | 0 | Min query length before searching. | | debounce | number | 250 | ms. | | limit | number | 20 | Max results shown/requested. | | scope | object | {} | Passed to async source / onCreate as ctx.scope. | | allowCreate | boolean | false | Show the [+ Add] row when no exact match. | | createLabel | (q) => string | + Add "q" | Add-row label (plain text). | | renderOption | (option, ctx) => Node\|string\|null | — | Custom template for each result row. See Custom item templates. | | renderCreate | (q, ctx) => Node\|string\|null | — | Custom template for the [+ Add] row. | | onCreate | async (q, ctx) => option\|null | — | Do anything; return an option to auto-select. | | onChange | (value, option) => void | — | Fires on every selection/clear. | | groupBy | (option) => string | — | Group results under headings. See Grouped options. | | highlight | boolean | false | Wrap the matched query substring in each result with <mark>. Ignored for renderOption rows. | | cache | boolean | false | Cache async results by query+scope+limit so repeats skip the network. Cleared by setSource()/setScope(). | | multiple | boolean | false | Multi-select mode — chips, array value. See Multiple selection. | | maxItems | number | — | Cap the number of selections (multiple mode). | | submitFormat | 'repeat'|'bracket'|'delimited' | 'repeat' | How multiple values submit in a plain form. | | delimiter | string | ',' | Joiner for submitFormat: 'delimited'. | | classPrefix | string | 'liveselect' | CSS class prefix. | | texts | object | — | { searching, noResults, searchFailed, required }, plus optional more(shown, total) => string. |

Option shape: { value, label, sublabel?, group?, disabled?, raw? }. Loose input is normalized — a bare string becomes { value, label }; _id/id map to value; name/title/text map to label. disabled: true makes a row non-selectable (dimmed, skipped by keyboard nav).

Async source ctx: { scope, limit, query, signal }. signal is an AbortSignal that fires when a newer search supersedes the current one — pass it to fetch (the built-in remoteSource already does) to cancel stale requests. An async source may resolve to a bare array or { items, total } — the total drives the “Showing N of M” footer when results are capped by limit.

Custom item templates

By default each row renders an escaped two-line label / sublabel. To render anything else — avatars, badges, multi-column layouts — pass renderOption (for result rows) and/or renderCreate (for the [+ Add] row). The control still owns the outer <button> (ARIA roles, keyboard navigation, click handling); your function only supplies the inner content.

Each function may return:

  • a DOM Node — appended as-is, XSS-safe by construction (recommended);
  • a string — set as the row's innerHTML; you own escaping here, so run untrusted data through ctx.escapeHtml (also exposed as LiveSelect.escapeHtml);
  • null / undefined — fall back to the default escaped rendering for that row.
new LiveSelect('#customer', {
  source: customers,                       // options carry the full record on `raw`
  // ctx = { index, query, active, escapeHtml }
  renderOption: (opt, ctx) => {
    const row = document.createElement('div');
    row.className = 'cust-row';
    row.innerHTML =
      `<img class="cust-avatar" src="${ctx.escapeHtml(opt.raw.avatarUrl)}" alt="">` +
      `<span class="cust-name">${ctx.escapeHtml(opt.label)}</span>` +
      `<span class="cust-tier">${ctx.escapeHtml(opt.raw.tier)}</span>`;
    return row;                            // DOM Node → safe
  },
  allowCreate: true,
  onCreate: (q) => ({ value: q, label: q }),
  // ctx = { query, active, escapeHtml }
  renderCreate: (q, ctx) => `➕ Add new customer “<strong>${ctx.escapeHtml(q)}</strong>”`,
});

The normalized option passed in is { value, label, sublabel, raw }, where raw is the original source record — use it for any fields beyond label/sublabel. The createLabel option still works for a plain-text add row; renderCreate supersedes it when both are set.

Multiple selection

Set multiple: true for a tags/chips multi-select. Selections render as removable chips; the value becomes an array throughout the API.

const ms = new LiveSelect('#tags', {
  name: 'tags',
  multiple: true,
  source: ['react', 'vue', 'svelte', 'angular', 'solid'],
  maxItems: 3,                 // optional cap
  onChange: (values, options) => console.log(values), // ['react','vue']
});
ms.getValue();   // → ['react', 'vue']   (an array in multiple mode)
ms.setValue(['react', 'svelte']);        // value is an array too
  • Add: click a row, or type + Enter. Remove: click a chip’s ×, or press Backspace on an empty input. Re-selecting a chosen row toggles it off.
  • Form submission (submitFormat): 'repeat' (default) emits one hidden input per value sharing name — exactly like a native <select multiple>, so Express/most frameworks parse req.body.tags as an array. 'bracket' uses name="tags[]" (PHP/Rails); 'delimited' joins into one input via delimiter.
  • enhance() auto-upgrades a <select multiple> to this mode and keeps the original element’s selected options in sync.
  • The liveselect:change detail carries { name, value: string[], options: [] }.

Grouped options

Render results under headings (like <optgroup>) by giving options a group field, or by passing a groupBy(option) => string function (which takes precedence). Results are stably reordered so same-group items sit together, preserving the order each group was first seen — so your source doesn't have to pre-sort.

new LiveSelect('#picker', {
  source: [
    { value: 'ca', label: 'California', group: 'US' },
    { value: 'on', label: 'Ontario',    group: 'Canada' },
    { value: 'tx', label: 'Texas',      group: 'US' },
  ],
  // or, instead of per-option group:
  // groupBy: (opt) => opt.raw.country,
});

Group headings render as .liveselect__group (a non-selectable, sticky label) — style them like any other token.

Accessibility

The control implements the ARIA combobox/listbox pattern:

  • the input is role="combobox" with aria-expanded, aria-controls, and aria-autocomplete="list";
  • the menu is role="listbox"; each row is role="option" with a stable id;
  • the active row carries aria-selected="true" and the input's aria-activedescendant points at it, so arrow-key navigation is announced;
  • a visually-hidden aria-live="polite" region announces result counts, “Searching…”, and the no-matches / create state.

Full keyboard support: ↑/↓ to move, Enter to select (or trigger [+ Add]), Esc to close.

Instance API

getValue() · getOption() · setValue(v, option?) · clear()
focus() · open() · close() · setSource(src) · setScope(obj)
setDisabled(bool) · destroy()

In multiple mode, getValue() returns an array of values, getOption() an array of options, and setValue() accepts an array.

Events

Besides the onChange callback, the control dispatches a bubbling liveselect:change CustomEvent on its root element:

form.addEventListener('liveselect:change', (e) => {
  console.log(e.detail); // { name, value, option }
});

It also emits bubbling lifecycle events for integration hooks:

| Event | Detail | Fires when | |---|---|---| | liveselect:open | { name } | the menu opens | | liveselect:close | { name } | the menu closes | | liveselect:search | { name, query } | a search runs (after debounce / minChars) | | liveselect:change | { name, value, option } | a selection or clear happens (in multiple mode: { name, value: [], options: [] }) |

Validation

A required LiveSelect enforces selection through the Constraint Validation API on its visible input — so an empty required control blocks form submit with a focusable, on-screen validation bubble (override the message via texts.required). This also fixes enhance(): a display:none required <select> can’t be focused (browsers reject submit with “An invalid form control is not focusable”), so enhance() moves enforcement to the visible control.

Theming

Override any token, globally or scoped to one control:

.liveselect { --liveselect-border: #7c3aed; --liveselect-accent: #7c3aed; --liveselect-radius: 14px; }

Add class="liveselect--dark" for the built-in dark theme. Full token list is at the top of dist/liveselect.css.

Tests

npm install   # dev deps only: express, mongodb, mongodb-memory-server, jsdom
npm test      # Node's built-in runner (node --test)

The suite (in test/) covers the client in jsdom (HTML-escaping/XSS, the hidden input form mirror, liveselect:change, enhance(), templates, a11y wiring, grouping, validation, and multiple selection), the pure server helpers, and the router against an in-memory MongoDB — including the security regressions from the audit (no document disclosure, generic errors, NoSQL operator-injection, allow-listing, dedup). CI runs them on Node 18/20/22/24 (.github/workflows/ci.yml). The library itself ships with zero runtime dependencies.

Building from source

The control has one source of truth, src/liveselect.js (a real ES module). The two dist/ entry points are generated from it by a dev-only esbuild step — there is no hand-maintained second copy of the class:

npm install      # installs esbuild (a devDependency only)
npm run build    # src/liveselect.js → dist/liveselect.mjs (ESM) + dist/liveselect.js (UMD)
  • dist/liveselect.mjs is a genuine ES module — it export defaults the class directly, so bundlers (rspack/webpack/Vite/Meteor) and browser-native import get the real constructor, never undefined.
  • dist/liveselect.js is UMD — the same file works as a <script> tag (window.LiveSelect) and via require().

The shipped library still has zero runtime dependencies; esbuild is build-time only. The built dist/ files are committed and published, and CI fails if they drift from src/. dist/liveselect.css and dist/liveselect-auto.js are hand-authored static assets (the auto-mounter is a <script>-tag helper that reads window.LiveSelect, so load liveselect.js before it).

Releasing

Releases are tag-driven — pushing a vX.Y.Z git tag is the single action that publishes to npm and keeps GitHub and the registry in sync. The publish.yml workflow runs the tests, verifies the tag matches package.json, and runs npm publish --provenance using the NPM_TOKEN repository secret (an npm automation token, which bypasses 2FA so CI can publish non-interactively).

To cut a release:

# 1. Bump the version + add a CHANGELOG entry, land it on main (PR or direct).
npm version 3.2.0 --no-git-tag-version   # or edit package.json by hand
# 2. Commit, push to main, then tag and push the tag:
git tag -a v3.2.0 -m "v3.2.0: <summary>"
git push origin main
git push origin v3.2.0                    # ← this triggers the publish workflow

The tag must point at a commit on main (clean provenance), and its version must equal package.json's — CI fails the release if they differ. A plain git push of code (no tag) never publishes; only v* tags and the manual workflow_dispatch trigger do.

Local publish is supported too but not the normal path. It needs the automation token in ~/.npmrc (e.g. //registry.npmjs.org/:_authToken=${NPM_TOKEN} with NPM_TOKEN exported). Avoid npm login — it overwrites the token with a web session that re-introduces the interactive 2FA prompt.

Repository layout

src/
  liveselect.js        # SINGLE SOURCE OF TRUTH for the control (ES module)
scripts/
  build.mjs            # esbuild: src/ → dist/ UMD + ESM (dev-only)
dist/                  # generated by `npm run build`; committed + published
  liveselect.js        # UMD core (script tag / require) — built
  liveselect.mjs       # real ES module, `export default` the class — built
  liveselect.css       # themeable styles (hand-authored)
  liveselect-auto.js   # optional declarative data-* auto-mount (script-tag only)
server/
  liveselect-mongo.js  # Express + MongoDB backend (registry + router)
examples/
  vanilla.html                  # array source · theming · <select> enhance
  express-mongo/                # Node/Express + MongoDB + EJS demo
  blaze/                        # Meteor/Blaze adapter template
test/
  server.test.js                # pure helpers + router vs in-memory MongoDB
  client.test.js                # client behavior under jsdom
IMPLEMENTATION.md               # full integration guide

License

MIT