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

@19h47/combobox

v2.0.0

Published

Accessible editable combobox with list autocomplete (W3C APG)

Readme

@19h47/combobox

Editable combobox with list autocomplete (manual selection), aligned with the W3C APG pattern.

HTML is the source of truth for roles and structure. The library does not invent missing attributes or “fix” incomplete markup — the consumer delivers clean HTML. The library only orchestrates interaction state: keyboard behaviour, popup visibility, aria-expanded, aria-selected, aria-activedescendant, and aria-busy.

| Mode | Who owns the listbox DOM | Typical use | | --- | --- | --- | | managed (default) | Library rebuilds options from string[] via render | Classic autocomplete | | external | Consumer injects HTML / mutates the listbox | Rich results, grouped options, server-rendered markup |

Install

pnpm add @19h47/combobox

HTML

The listbox must have a stable id (referenced by aria-controls). In managed mode, option ids are derived from it ({listboxId}-option-{n}).

<label for="monster">Monster</label>
<div class="combobox">
	<input
		id="monster"
		type="text"
		role="combobox"
		aria-autocomplete="list"
		aria-expanded="false"
		aria-controls="monster-listbox"
		autocomplete="off"
	/>
	<button
		type="button"
		tabindex="-1"
		aria-label="Monsters"
		aria-controls="monster-listbox"
		aria-expanded="false"
	>
		▼
	</button>
	<ul id="monster-listbox" role="listbox" aria-label="Monsters" hidden></ul>
</div>

Accessibility checklist

  • role="combobox" + aria-autocomplete="list" on the input
  • aria-controls / aria-expanded on the input (and optional button)
  • role="listbox" with an accessible name (aria-label or aria-labelledby)
  • Each [role="option"] has a stable, unique id (required in external mode; generated in managed mode from the listbox id)
  • Optional open button: tabindex="-1", same aria-controls / aria-expanded as the input
  • Announce empty / loading states with a live region wired to combobox:empty / combobox:loading (the library sets aria-busy on the listbox while searching)

JavaScript

Managed (default)

import Combobox, { EVENTS } from '@19h47/combobox';

const input = document.querySelector('#monster');
const listbox = document.querySelector('#monster-listbox');
const button = document.querySelector('button[aria-controls="monster-listbox"]');

const combobox = new Combobox(input, listbox, {
	button,
	search: (value, { signal }) => {
		if (value.length < 1) return monsters;
		return monsters.filter(name =>
			name.toLowerCase().startsWith(value.toLowerCase()),
		);
	},
	// Optional — e.g. Bootstrap list group
	render: (result, props) =>
		`<li class="list-group-item"${props}>${result}</li>`,
});

combobox.init();

search may return an array or a Promise. Stale responses are ignored; the previous AbortSignal is aborted when a newer search starts.

External (rich / server HTML)

Each [role="option"] must already have a stable id. Group headers must not use role="option".

const combobox = new Combobox(input, listbox, {
	mode: 'external',
	selectMode: 'custom',
	debounce: 280,
	minLength: 2,
	openOnEmpty: true,
	search: async (query, { signal }) => {
		const html = await fetchSection(query, signal);
		return { html };
	},
	onSelect: ({ option }) => {
		option?.element.querySelector('a')?.click();
	},
});

combobox.init();

input.addEventListener(EVENTS.empty, ({ detail }) => {
	// detail.value is the current query — pair with a polite live region
	showNoResults(detail.value);
});

Optional data-value on options overrides the string used for selectMode: 'value'.

search return shapes

| Return | Mode | Effect | | --- | --- | --- | | string[] | managed | Rebuild options via render | | HTMLElement[] | either | Replace listbox children, then sync ARIA | | { html } | external | Set listbox.innerHTML, then sync | | { options } | either | Same as string[] / HTMLElement[] | | [] / empty | either | Clear options; see openOnEmpty |

Keyboard

| Key | Function | | --- | --- | | | Open list and move to first / next option | | Alt + | Open list without moving visual focus | | | Open list and move to last / previous option | | Alt + | Open list without moving visual focus | | Enter | Accept focused option; without focus, close and allow form submit | | Escape | Close list, or clear field if already closed | | Tab | Accept focused option, otherwise close | | Home / End / / | Return editing to the textbox when an option has visual focus |

DOM focus stays on the textbox; visual focus uses aria-activedescendant.

Options

| Option | Type | Default | Description | | --- | --- | --- | --- | | search | (value, context) => SearchResult \| Promise<…> | — | Required. context.signal is an AbortSignal. | | mode | 'managed' \| 'external' | 'managed' | Who owns listbox markup | | selectMode | 'value' \| 'custom' | 'value' | Fill the textbox, or only fire onSelect / combobox:submit | | onSelect | (detail) => void | — | Called when an option is accepted | | debounce | number | 0 | Debounce (ms) for input-driven searches | | minLength | number | 0 | Minimum trimmed length before searching | | openOnEmpty | boolean | false | Keep popup open (aria-expanded + visible listbox) on empty results — pair with a live region on combobox:empty | | autoselect | boolean | false | Highlight the first suggestion | | button | HTMLButtonElement \| null | null | Optional open button (also auto-discovered via aria-controls) | | write | (input, value) => void | sets input.value | How a chosen option is written (selectMode: 'value') | | render | (result, props) => string | <li …>…</li> | HTML for each managed option (props serializes ARIA attributes) |

Methods

| Method | Description | | --- | --- | | init() | Bind events and collapse the listbox | | destroy() | Remove listeners, clear debounce, abort in-flight searches | | show() / hide(options?) | Open or close the listbox (hide: { force?, clear? }) | | select() | Accept the focused option |

Readable state: index, options, value, loading, expanded, focused.

Events

Dispatched on the input (EVENTS map exported):

| Event | detail | When | | --- | --- | --- | | combobox:loading | — | Before search resolves | | combobox:loaded | — | After a non-stale search resolves | | combobox:update | { options, index, value } | After options / ARIA are synced | | combobox:submit | { option, index, value } | When an option is chosen | | combobox:empty | { value } | No options (or below minLength); value is the current query |

import { EVENTS } from '@19h47/combobox';

input.addEventListener(EVENTS.submit, ({ detail }) => {
	console.log(detail.option, detail.value);
});

Styling hooks

Prefer ARIA when possible; classes are optional conveniences.

| Hook | Element | When | | --- | --- | --- | | [aria-expanded="true"] | input / button | Popup is open | | [aria-busy="true"] | listbox | search in flight | | is-active | listbox | Popup is open (mirrors expanded) | | is-loading | input + listbox | Same window as aria-busy |

ul[role='listbox']:not(.is-active) {
	display: none;
}

ul[role='listbox'].is-active {
	display: flex;
	flex-direction: column;
	max-height: 16rem;
	overflow: auto;
}

[role='option'][aria-selected='true'] {
	background: #e9ecef;
}

Migrating from 1.x

Breaking changes in 2.0:

  • writeValuewrite
  • renderOptionrender
  • Markup is no longer “fixed up”: provide complete ARIA HTML (listbox id, option ids in external mode)
  • aria-activedescendant is removed when inactive (never set to "")
  • Managed option ids are {listboxId}-option-{n} (listbox id required for uniqueness)
  • New: mode, selectMode, debounce, minLength, openOnEmpty, AbortSignal on search, EVENTS map

Example

Interactive demo: 19h47.github.io/19h47-combobox.

pnpm test
pnpm build

References