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

@asnewyla/unstyled-select

v0.2.0

Published

Unstyled select/combobox primitive with single and multi-select, accessibility

Readme

@asnewyla/unstyled-select

Unstyled, accessible select/combobox primitive. Supports single- and multi-select, keyboard navigation, and full ARIA listbox semantics without imposing any visual styling.

Install

npm install @asnewyla/unstyled-select

Usage

import { UnstyledSelect } from '@asnewyla/unstyled-select';

const fruitOptions = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'cherry', label: 'Cherry', disabled: true },
];

// Uncontrolled, single-select
<UnstyledSelect
  aria-label="Fruit"
  options={fruitOptions}
  defaultValue="apple"
  placeholder="Choose a fruit"
/>

// Controlled, single-select
<UnstyledSelect
  aria-label="Fruit"
  options={fruitOptions}
  value={selected}
  onChange={setSelected}
/>

// Multi-select — value/defaultValue/onChange become array-typed
<UnstyledSelect
  aria-label="Fruit"
  options={fruitOptions}
  multiple
  defaultValue={['apple', 'banana']}
  onChange={(values) => console.log(values)}
/>

Props

| Prop | Type | Default | |---|---|---| | options | { value: string; label: string; disabled?: boolean }[] | — | | multiple | boolean | false | | value / defaultValue | string (single) or string[] (multiple) | — | | onChange | (value: string) => void (single) or (value: string[]) => void (multiple) | — | | placeholder | string | — | | disabled | boolean | false | | invalid | boolean | false | | renderValue | (selectedOptions: SelectOption[], helpers: { removeOption: (option: SelectOption) => void }) => ReactNode | — |

multiple gates which shape value/defaultValue/onChange accept — this is enforced at the type level (a discriminated union), not just at runtime, so passing an array defaultValue without multiple is a compile error rather than a silent bug.

Uncontrolled by default: pass value + onChange to control it yourself. Single-select closes the listbox and returns focus to the trigger on selection; multi-select keeps the listbox open so more options can be toggled.

By default the trigger shows the selected option's label (or every selected label, comma-joined, for multiple), falling back to placeholder when nothing's selected. Pass renderValue to replace that with your own rendering — e.g. removable chips for multiple — using the selectedOptions (real SelectOption objects, not just their values) and a removeOption helper that deselects one of them (calls onChange with the option removed, same as clicking it again in the listbox would).

Accessibility

Implements the WAI-ARIA listbox pattern: the trigger is a <div role="combobox"> with aria-expanded/aria-haspopup (not a <button> — see "Trigger element" below), the popup is role="listbox" (aria-multiselectable when multiple), and each option is role="option" with aria-selected. State is also mirrored via data-* attributes (data-open, data-selected, data-disabled) for styling and test hooks, same convention as every other @asnewyla/* primitive.

Keyboard support:

| Key | Behavior | |---|---| | Enter / Space on the trigger | Opens the listbox and focuses the search input | | ArrowDown | Moves focus to the next enabled option, skipping disabled ones, clamped at the end (no wrap) | | ArrowUp | Moves focus to the previous enabled option; from the first option (or with nothing focused), moves focus back to the search input | | Home / End | Jumps to the first/last enabled option | | Enter / Space on a focused option | Selects it | | Escape | Clears the search query, closes the listbox, and returns focus to the trigger | | Click outside | Closes the listbox |

Search / filter

Opening the listbox renders a search <input> (aria-label="Search options") as the first child of the popup, above the option list — the trigger itself does not become an editable input. Typing filters options client-side by a case-insensitive substring match on label; there's no async/remote search. Selecting an option or closing the listbox (Escape, click outside) clears the query.

Trigger element

The trigger renders as a <div role="combobox" tabIndex={0}>, not a <button>. A real <button> can't contain other interactive elements (nested buttons are invalid HTML), which renderValue needs to be able to do — chip-style multi-select values need a real, focusable remove button per chip. Native <button> semantics (Enter/Space activates, disabled removes it from the tab order) are reimplemented by hand for the div: Enter/Space open the listbox, and a disabled trigger gets tabIndex={-1} instead of the native disabled attribute.

Not yet supported

This primitive is still growing. Not implemented yet:

  • Native form participation — nothing here submits through FormData the way @asnewyla/form's FormFieldInput expects.
  • Portal rendering — the listbox renders inline, so it can be visually clipped by an overflow: hidden or scrolling ancestor.

License

MIT