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

@pilates/widgets

v0.1.0-rc.4

Published

Interactive widgets for Pilates: TextInput, TextArea, Select, MultiSelect, Tabs, Table, Spinner, ProgressBar. Built on @pilates/react.

Readme

@pilates/widgets

Interactive widgets for Pilates terminal UIs:

  • <TextInput> — single-line text input with cursor, password mask, placeholder
  • <TextArea> — multi-line text editor with grapheme-aware cursor, paste preserves newlines
  • <Select> — single-select menu with keyboard navigation
  • <MultiSelect> — multi-select checklist; Space toggles, Enter submits the selection
  • <Tabs> — horizontal tab strip; arrow keys cycle through, controlled by activeKey
  • <Table> — fixed-column tabular display with header, divider, alignment, ellipsis truncation
  • <Spinner> — animated progress indicator with built-in frame catalog
  • <ProgressBar> — determinate or indeterminate progress bar with custom colors and characters

Built on @pilates/react. Zero runtime dependencies.

Install

npm install @pilates/widgets @pilates/react react

Status: 0.1.0-rc.3 — release candidate baking until ~2026-05-15 ahead of the 0.2.0 promotion. All eight widgets listed above ship in this rc.

Quick example

import { render, Box, Text } from '@pilates/react';
import { TextInput, Select, Spinner } from '@pilates/widgets';
import { useState } from 'react';

function Wizard() {
  const [name, setName] = useState('');
  const [size, setSize] = useState<'sm' | 'md' | 'lg' | null>(null);

  if (!name) {
    return (
      <Box flexDirection="column">
        <Text>What's your name?</Text>
        <TextInput value={name} onChange={setName} onSubmit={(v) => setName(v)} />
      </Box>
    );
  }

  if (!size) {
    return (
      <Box flexDirection="column">
        <Text>Hi {name}. Pick a size:</Text>
        <Select
          items={[
            { label: 'Small', value: 'sm' as const },
            { label: 'Medium', value: 'md' as const },
            { label: 'Large', value: 'lg' as const },
          ]}
          onSelect={(item) => setSize(item.value)}
        />
      </Box>
    );
  }

  return <Text>Done — {name}, {size}.</Text>;
}

render(<Wizard />);

<TextInput>

<TextInput
  value={value}                    // required, controlled
  onChange={setValue}              // required
  onSubmit={(v) => ...}            // optional, fires on Enter
  placeholder="Type something"     // optional
  mask="*"                         // optional, for passwords (single character)
  focus={true}                     // optional, default true (ignored when focusId is set)
  focusId="name"                   // optional — Tab cycling via useFocus
  autoFocus                        // optional — paired with focusId
/>

Key bindings: printable chars insert at cursor; / move; Home/End (or Ctrl+A/Ctrl+E) jump; Backspace/Delete delete; Ctrl+U/Ctrl+K clear to start/end; Ctrl+W delete previous word; Enter calls onSubmit.

Paste: xterm bracketed paste (DEC mode 2004) is consumed via usePaste — the entire pasted block inserts at the cursor as a single onChange call. Newlines and carriage returns are stripped (single-line input). Emoji / ZWJ clusters in the paste survive intact.

<TextArea>

Multi-line editor. Auto-grows vertically with content (no fixed-height / scrolling viewport in v1 — wrap the textarea in a <Box> to constrain visually).

<TextArea
  value={value}                    // required, controlled (may contain '\n')
  onChange={setValue}              // required
  placeholder="Notes…"             // optional
  focus={true}                     // optional, default true (ignored when focusId is set)
  focusId="notes"                  // optional — Tab cycling via useFocus
  autoFocus                        // optional — paired with focusId
/>

Key bindings: printable chars insert at cursor; Enter inserts a newline; / move across line boundaries; / move to prev/next line at the same column (clamped to line length); Home/End (or Ctrl+A/Ctrl+E) jump to start/end of the current line; Backspace removes the previous grapheme (joins lines when at column 0); Delete removes the next grapheme (joins lines when at end-of-line); Ctrl+U/Ctrl+K clear current line to start/end; Ctrl+W deletes the previous word.

Paste: preserves newlines verbatim — multi-line clipboard contents land as multiple lines.

Tab inside the textarea: <FocusProvider> (auto-installed by render()) eats Tab for focus cycling. To disable focus cycling and let Tab insert a literal tab character, call useFocusManager().disableFocus() while the textarea is focused (or wrap the area in a custom <FocusProvider autoTab={false}>).

<Select>

<Select
  items={[
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Disabled', value: 'd', disabled: true },
  ]}
  onSelect={(item) => ...}         // required, fires on Enter
  onHighlight={(item) => ...}      // optional, fires on cursor move
  initialIndex={0}                 // optional, default 0
  focus={true}                     // optional, default true (ignored when focusId is set)
  focusId="size"                   // optional — Tab cycling via useFocus
  autoFocus                        // optional — paired with focusId
  indicator={...}                  // optional, custom marker function
/>

Key bindings: / move (skipping disabled, with wrap-around); Home/End jump to first/last enabled item; Enter calls onSelect (no-op if disabled).

<MultiSelect>

<MultiSelect
  items={[
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Cherry', value: 'cherry' },
  ]}
  selectedKeys={selected}             // Set<string>, controlled
  onChange={setSelected}              // (next: Set<string>) => void — fires on Space toggle
  onSubmit={(items) => ...}           // optional, fires on Enter, receives selected items
  onHighlight={(item) => ...}         // optional
  initialIndex={0}                    // optional
  focus={true}                        // optional, default true (ignored when focusId is set)
  focusId="checks"                    // optional — Tab cycling via useFocus
  autoFocus                           // optional — paired with focusId
  indicator={...}                     // optional, custom marker per row
/>

Key bindings: / move highlight (skip disabled, wrap-around); Home/End jump to first/last enabled; Space toggles the highlighted item's selection; Enter calls onSubmit(selectedItems). The selection set is keyed by item.key ?? String(item.value), and the array passed to onSubmit is ordered to match items.

<Tabs>

Horizontal tab strip. Renders only the strip itself — the panel body is wired by the consumer based on activeKey.

<Tabs
  items={[
    { key: 'overview', label: 'Overview' },
    { key: 'logs', label: 'Logs' },
    { key: 'settings', label: 'Settings', disabled: true },
  ]}
  activeKey={active}                  // controlled
  onChange={setActive}                // (key: string) => void
  focus={true}                        // optional, default true (ignored when focusId is set)
  focusId="primary-tabs"              // optional — Tab cycling via useFocus
  autoFocus                           // optional — paired with focusId
/>

{active === 'overview' && <OverviewPanel />}
{active === 'logs'     && <LogsPanel />}

Visual: active tab renders as [Label] in cyan + bold; inactive tabs render as Label; disabled tabs render dim. Tabs are separated by a single space.

Key bindings: / cycle the active tab (skip disabled, wrap-around); Home/End jump to the first / last enabled tab. Activation is immediate — no separate highlight + commit step like <Select>. If activeKey matches no item (e.g., consumer passed a stale key), the next arrow press jumps to the first / last enabled tab.

<Table>

Tabular data display: bold headers, a horizontal divider, then one row per record.

<Table
  columns={[
    { key: 'name', header: 'Name', width: 20 },
    { key: 'age',  header: 'Age',  width: 4, align: 'right' },
    { key: 'role', header: 'Role', width: 16,
      render: (val, row) => `${val} (${row.team})` },
  ]}
  rows={people}
/>

Each column declares:

| Field | Notes | |---|---| | key | Property of the row used to look up this column's raw value. | | header | Bold text in the top row. | | width? | Cells. When omitted, the column flexes — 16-cell fallback in v1; a parent <Box width=…> constrains the visible area. | | align? | 'left' (default), 'right', or 'center'. | | render? | (value, row) => string. Receives the raw value and the full row; returns the cell's displayed text. Plain strings only in v1 — Table pads / truncates the result. |

Layout: values longer than the column width are truncated to width − 1 cells with a trailing . Wide-character values (CJK, emoji) are measured via stringWidth from @pilates/core so truncation never overshoots a wide grapheme.

Out of v1: vertical separators between columns, multi-line cells (wrap), per-row selection / highlight, sorting / filtering. Wrap selection-friendly variants in your own component or wait for a future <DataTable>.

<Spinner>

<Spinner type="dots" />              // built-in frame set
<Spinner frames={['◐','◓','◑','◒']} interval={120} />  // custom

Built-in types: dots, line, arrow, bouncingBar, bouncingBall. Default interval is 80 ms.

<ProgressBar>

<ProgressBar value={42} total={100} width={20} />          // determinate
<ProgressBar indeterminate width={20} />                   // bouncing scanner
<ProgressBar
  value={3}
  total={10}
  width={20}
  fillChar="="
  emptyChar="-"
  color="cyan"
  trackColor="gray"
/>

| Prop | Default | Notes | |---|---|---| | value | 0 | Current progress. Clamped to [0, total]. Ignored if indeterminate. | | total | 100 | If <= 0, the bar renders fully empty. | | width | 20 | Bar width in terminal cells. | | fillChar | '█' | Single grapheme assumed. | | emptyChar | '░' | Single grapheme assumed. | | color | — | Color for filled cells. Any @pilates/react Color (named, #rrggbb, or 256-color number). | | trackColor | — | Color for empty cells. | | indeterminate | false | When true, animates a bouncing scanner. | | interval | 80 | Indeterminate scanner step interval (ms). | | scannerWidth | 3 | Indeterminate scanner cell width. Clamped to width. |

To compose with a label, wrap in a row:

<Box flexDirection="row" gap={1}>
  <ProgressBar value={done} total={total} width={20} color="green" />
  <Text>{done}/{total}</Text>
</Box>

Composing focus

The recommended approach is focusId — pair it with useFocus / useFocusManager from @pilates/react. A <FocusProvider> is auto-installed by render(), so Tab cycles through widgets that opt in by id; no parent-side bookkeeping needed.

<TextInput value={name}  onChange={setName}  focusId="name"  autoFocus />
<TextInput value={email} onChange={setEmail} focusId="email" />
<Select items={sizes} onSelect={…} focusId="size" />

The boolean focus prop still works (back-compat) and is silently ignored when focusId is set.

License

MIT — see LICENSE.