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

@speles7172/controls

v0.4.0

Published

Reusable React building blocks — a data table, searchable selects, a modal, badges and the loading and empty states — over static options or a transport you supply.

Readme

@speles7172/controls

Reusable React form controls. One package that grows: a control needed in two places belongs here rather than being copied into the second one.

Today that is <SearchableMultiSelect> and its single-select sibling <SearchableSelect>, the typed field <ValueInput>, <KeyValueList> for a setting that is really several, and <Modal> — the dialog shell every console was otherwise going to write for itself.

npm install @speles7172/controls

React 18 or 19, as a peer dependency.

A static list

The common case, and the one people forget is allowed. Eight statuses do not need a network request between a keystroke and a list.

import { SearchableMultiSelect } from '@speles7172/controls';
import '@speles7172/controls/styles.css';

<SearchableMultiSelect
  label="Status"
  options={[
    { value: 'draft', label: 'Draft' },
    { value: 'sent', label: 'Sent' },
    { value: 'paid', label: 'Paid' },
  ]}
  value={statuses}
  onChange={setStatuses}
/>;

Forty thousand vendors

The same control. The difference is one prop.

<SearchableMultiSelect
  label="Vendor"
  transport={{
    search: (query) =>
      fetch(`/api/options/vendors?${new URLSearchParams(query as never)}`).then((r) => r.json()),
    resolve: (values) =>
      fetch(`/api/options/vendors/resolve?values=${values.join(',')}`)
        .then((r) => r.json())
        .then((body) => body.options),
  }}
  value={vendorIds}
  onChange={setVendorIds}
  minSearchLength={2}
/>

Both endpoints are four lines with @speles7172/controls-client.

Implement resolve. It is the difference between a form reopened three months later showing "Acme Corp, Beta Ltd" and one showing two raw uuids: the selected values are not in the first page of options, so there is nothing else to render them from.

Props worth knowing

| Prop | | |---|---| | options / transport | Where options come from. Passing both uses the transport and treats the list as labels already known — how you seed the current selection without a resolve round trip. | | single | One value at a time. <SearchableSelect> wraps this with a string \| null value. | | minSearchLength | Search nothing until this many characters. For a source with no meaningful first page. | | allowCustomValue | Offer a term that matches nothing as its own option, last in the list. For a field whose options are a convenience rather than the permitted set — a log source filter, say. Off by default: a picker over real records should not invent ids. | | clearable | Default true. Set false for a field that must always hold something — a console's current database. Hides the clear affordances and stops the actions behind them, so re-choosing the selected value cannot empty it either. | | maxSelected | Enforced in the state machine, not only in the styling — a control that merely looks disabled is one a keyboard walks straight past. | | chips | Removable chips under the trigger. On by default past one selected value. | | name | Emits one hidden input per value, so the control works in a plain <form> that reads FormData. | | renderOption | Render an option yourself. Gets the option and the current term. | | theme | light (default), dark, or auto. Light by default because the control sits in someone else's page, and the host decides what that page looks like. |

A field whose type varies

<ValueInput> is the control every admin form was writing a switch for: a checkbox for a boolean, a date picker for a date, a textarea for JSON, a searchable select for a reference to one of your records.

import { ValueInput } from '@speles7172/controls';

<ValueInput
  type="number"          // text | multiline | number | boolean | date
  label="Payment window" //  | datetime | json | choice | reference
  value={value}          // always a string, whatever the type
  onChange={setValue}
  min={1}
  max={365}
  hint="Days until an invoice is due."
  error={error}
/>;

The value is always a string, in every mode. One representation is what lets a caller hold a whole form in one shape and lets the control stay ignorant of what a value means — the caller encodes and decodes. @speles7172/config-client/core has those functions if you want them.

choice and reference delegate to <SearchableSelect>, so a field over forty thousand records is the same component as a field over three.

A list of labelled entries

<KeyValueList> edits a setting that is really several — a feature-flag set, a set of named thresholds, an enum-like list somebody maintains.

import { KeyValueList } from '@speles7172/controls';

<KeyValueList
  label="Feature flags"
  itemType="boolean"
  value={items}     // [{ label: 'Invoice scanning', key: 'invoice_scan', value: 'true' }]
  onChange={setItems}
/>;

label is for a person, key is for code, value is the setting — separate because they change independently: renaming "Invoice scanning" must not break the invoice_scan every consumer matches on.

Two entries sharing a key are flagged on the row, because one of them is unreachable whichever way the list is read. Reordering is two buttons rather than a drag handle, and unlike the implementation this generalises, they work.

A dialog

<Modal> is the shell, not the content: a titled panel over a backdrop, with the parts that get skipped when a dialog is written for the fourth time.

import { Modal } from '@speles7172/controls';

{open && (
  <Modal title="Log record" onClose={close} wide theme="dark">
    <RecordFields record={record} />
  </Modal>
)}

It closes on Escape and on a click on the backdrop — not on a click that merely bubbles out of the panel, and not on a drag that started inside it. It announces itself as role="dialog" aria-modal="true", named by its title unless labelledBy names something else. It focuses the panel rather than the first control, because focusing a text box scrolls a long dialog to it and reads the wrong thing aloud. It traps Tab at both ends, so the third Tab is not on a field behind the overlay. It returns focus to whatever opened it, so a record opened from a table row hands the row back. And it stops the page behind from scrolling, restoring whatever overflow the host had rather than clearing it.

Rendered in place rather than through a portal, deliberately: a console bridges its palette onto --ctl-* with a descendant selector, and a portal to document.body escapes it — a dark console would open a light dialog. position: fixed covers the viewport from wherever the element sits. The one thing that breaks that is an ancestor with transform, filter or will-change, which becomes the containing block for fixed descendants; the fix for that is on the ancestor.

Keyboard and screen readers

move, wrapping. Home / End jump to the ends. Enter toggles the active option — and does not submit the surrounding form. Escape closes. Tab leaves: the popover is not a trap.

The trigger announces as "label, current value". While the popover is open the search box is the combobox and owns aria-activedescendant, which is what lets a screen reader follow the arrow keys.

Styling

Plain semantic HTML with ctl- class names and no styling dependency. Import @speles7172/controls/styles.css for the default look, or write your own against the same classes.

The stylesheet is driven entirely by custom properties on .ctl, so re-theming is a few lines rather than a fork:

.my-app .ctl {
  --ctl-accent: rebeccapurple;
  --ctl-radius: 4px;
}

An application with its own design system should skip the components and use useOptionSelect directly — it is the whole state machine (debounce, discarding stale responses, paging, label resolution) with none of the markup.

Why not a native <select multiple>

Because it is unusable for the job. No search, a scroll box four rows tall, and a selection you lose the moment you ctrl-click without meaning to. That is what @speles7172/audit-console's facet filters used to be, and an application with fifty entity types had a filter it could not reasonably use.

Licence

MIT