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

@dloizides/ui-tables

v1.18.0

Published

Themable, brand-agnostic React Native (RN-web) table/stat components for the dloizides.com portfolio. Starts with StatCard; DataTable/StatGrid join when a 2nd consumer appears. Shares the @dloizides/ui-feedback UI context.

Readme

@dloizides/ui-tables

Themable, brand-agnostic React Native (RN-web) table/stat components for the dloizides.com portfolio. Reads theme + translations from the shared @dloizides/ui-feedback UI context (useUi).

Components

| Component | Status | |-----------|--------| | StatCard | ✅ Available — labelled metric tile (label + locale-formatted value). | | DataTable | ✅ Available — the shared, tokenized RN-web grid (the GRID.md contract): columns + rows API, sticky header, zebra striping, per-row tint, pressable rows, per-row testID + a11y, optional expandable rows, and a responsive label:value card-stack below stackBreakpoint. | | FilterBar | ✅ Available — the .ui-filters shell: wrapping field row + live results count + actions slot. | | Filters | ✅ Available — the declarative filter bar built ON FilterBar: pass a fields schema (select / text / number / dateRange / typeahead / boolean) + a value map + onChange. Two value models (LIVE, or DRAFT/APPLY via useFilterDraft + an Apply button). The superset of every portal's hand-rolled filters; only the theme changes. See Filters. | | Pager | ✅ Available — the .ui-pager control: from–to of N page-info + rows-per-page control + Prev/Next (+ optional First/Last via showFirstLast). Pass an optional, already-translated unitLabel (e.g. "leadership terms") — plus unitLabelSingular for 1 result singularisation — to render 1–50 of 3,023 leadership terms. responsive collapses to a compact Prev/Next-only nav below stackBreakpoint (mobile fallback). The rows-per-page control defaults to size pills (rowsVariant="pills"); pass rowsVariant="dropdown" for the compact <select>-style anchored dropdown. | | StatGrid | ⏳ Deferred — exists only in kefi-web (n=1); moves in when a 2nd consumer appears. |

DataTable / FilterBar / Pager

import { DataTable, FilterBar, Pager, type DataTableColumn } from '@dloizides/ui-tables';

const columns: DataTableColumn<Row>[] = [
  { key: 'name', header: 'Name', weight: 2, render: (r) => r.name },
  { key: 'score', header: 'Score', numeric: true, render: (r) => String(r.score) },
];

<FilterBar resultsCount={rows.length} actions={<ApplyButton />}>{fields}</FilterBar>
<DataTable columns={columns} rows={rows} keyExtractor={(r) => r.id} zebra stickyHeader testID="grid" />
<Pager page={page} pageSize={size} total={total} onPageChange={setPage} onPageSizeChange={setSize} />

Filters — declarative filter bar

FilterBar is the shell (layout + results count + actions). Filters is the declarative bar built on it: describe your filters as data and the bar renders the same structure + behaviour everywhere — only the theme changes. Field kinds: select, text, number, dateRange, typeahead (search-as-you-type), boolean.

Every user-facing string is pre-localized and passed in (labels, option labels, placeholders, errors) — the package never calls FM/i18n for content, touches a router, or reads a store.

Since 1.14.0 the controls themselves live in @dloizides/ui-forms as SelectControl, TypeaheadControl and DateRangeControl. They used to be private to this bar, which is why the fleet grew 6 selects, 5 date fields and 2 typeaheads — apps could see them working and could not import them. If you need a select or a date range OUTSIDE a filter bar, import it from ui-forms — do not rebuild it, and do not reach into this package. Filters is unchanged and still the right thing for a filter bar; it now composes the shared controls.

import { Filters, useFilterDraft, type FilterField, type FilterValues } from '@dloizides/ui-tables';

const fields: FilterField[] = [
  { key: 'q', kind: 'text', label: FM('cases.q'), grow: true, placeholder: FM('cases.qHint') },
  { key: 'status', kind: 'select', label: FM('cases.status'),
    options: [{ label: FM('common.any'), value: '' }, { label: FM('status.open'), value: 'open' }] },
  { key: 'dates', kind: 'dateRange', label: FM('cases.valueDate') },
  { key: 'country', kind: 'typeahead', label: FM('leaders.country'), options: countryOptions, minChars: 1 },
  { key: 'active', kind: 'boolean', label: FM('cases.activeOnly') },
];

Live model (agora / zygos) — each edit applies immediately, no Apply button:

const [values, setValues] = useState<FilterValues>(EMPTY);
<Filters fields={fields} values={values} onChange={setValues} resultsCount={total} resultsLabel={FM('cases.results')} />

Draft / apply model (aml / kefi audit) — edits accumulate in a draft; Apply (or Enter in a field) commits it, and page resets to 1 via your onApply. useFilterDraft is the state engine:

const draft = useFilterDraft({ initial: EMPTY, onApply: () => setPage(1) });
<Filters
  fields={fields}
  values={draft.draft}
  onChange={draft.setDraft}
  onApply={draft.apply}          // presence of onApply → shows the Apply button + Enter-to-commit
  applyDisabled={datesInvalid}   // aml's validity gate
  onClear={draft.reset}          // presence of onClear → shows Clear/Reset
  actions={<ExportButtons query={draft.committed} />}  // custom actions (Export CSV/PDF, bulk bar)
  resultsCount={total}
  resultsLabel={total === 1 ? FM('cases.resultOne') : FM('cases.resultMany')}  // caller singularises
/>

Injecting ModalDropdown — the built-in select is a dependency-free in-tree dropdown. To use @dloizides/ui-layout's responsive ModalDropdown (modal on mobile/native) instead, pass renderSelect:

<Filters
  fields={fields}
  values={values}
  onChange={setValues}
  renderSelect={({ field, value, onChange, testID }) => (
    <ModalDropdown testID={testID} accessibilityLabel={field.label} accessibilityHint={field.label}
      value={value} options={field.options} onChange={onChange} />
  )}
/>

Expandable rows (optional)

For surfaces that open a full-width detail panel under a row (e.g. an audit log's before/after JSON snapshots), pass renderRowDetail + expandedRowKeys. The panel renders between that row and the next — spanning all columns on desktop, and full-width beneath the card in the card-stack mode. Expansion is controlled by you: the table keeps no internal expand state and renders no chevron; toggle from the existing onRowPress.

const [expanded, setExpanded] = useState<readonly string[]>([]);
const toggle = (r: Row) =>
  setExpanded((keys) => (keys.includes(r.id) ? keys.filter((k) => k !== r.id) : [...keys, r.id]));

<DataTable
  columns={columns}
  rows={rows}
  keyExtractor={(r) => r.id}       // the SAME key is matched against expandedRowKeys
  onRowPress={toggle}
  expandedRowKeys={expanded}
  renderRowDetail={(r) => <AuditDetail entry={r} />}
  testID="audit"
/>

The panel's test id is `${testID}-row-detail-${key}` (e.g. audit-row-detail-42) — build it with the exported rowDetailTestID(tableTestID, key) (rows: rowTestID(tableTestID, key)). It is exposed to assistive tech as a labelled region (provide the uiTables.rowDetail key), and the row reports aria-expanded. Omit both props and nothing about the table changes.

Bulk-select (optional)

Pass onSelectionChange to add a checkbox gutter (header + every row). Selection is controlled by you, exactly like expandedRowKeys: the table keeps no internal selection state. The header checkbox is tri-state and reports aria-checked="mixed" when only part of the page is ticked.

const [selected, setSelected] = useState<readonly string[]>([]);

<DataTable
  columns={columns}
  rows={rows}
  keyExtractor={(r) => r.id}      // the SAME key is matched against selectedRowKeys
  selectedRowKeys={selected}
  onSelectionChange={setSelected}
  testID="grid"
/>

The header only ever adds or removes this page's keys — any off-page keys you hold are preserved, because the table cannot see the rows they belong to. Test ids: rowSelectTestID(tableTestID, key) and selectAllTestID(tableTestID).

Select all matching the filter — a flag, not ids (optional)

This table is server-paged and deliberately not virtualized (see the ZY-02 grid spike: render cost is linear at ~2–3 ms and ~16 DOM nodes per row, so a huge page is indistinguishable from an outage). "Select all 3,023 matching" therefore cannot and must not be an id list — the other pages were never fetched.

Add matchingCount (the server's total) + onSelectAllMatchingChange and, once the whole page is ticked and more rows match, a banner offers the flag:

<DataTable
  /* …selection props… */
  matchingCount={total}                       // same number your Pager shows
  allMatchingSelected={allMatching}           // controlled FLAG
  onSelectAllMatchingChange={setAllMatching}  // emits `true` / `false` — never ids
/>

Resolve the flag server-side against the same filter your list endpoint took. That is a better design anyway: the work survives the operator closing the tab. While the flag is set every row reads as selected with an empty selectedRowKeys; toggling any single row drops the flag (the operator is no longer acting on the filter).

Keyboard navigation (optional)

keyboardNavigation turns the table into a real ARIA grid using the roving tabindex pattern — exactly one row is tabbable, so Tab crosses the grid in one hop instead of 100.

| Key | Action | |---|---| | ↑ / ↓ | Move the focused row (clamped — never wraps) | | Home / End | First / last row on the page | | Space | Toggle selection (when selectable) | | Enter | Activate the row, via the existing onRowPress |

<DataTable columns={columns} rows={rows} keyExtractor={(r) => r.id} keyboardNavigation onRowPress={open} />

Off by default, because adding tabIndex to rows changes a page's tab order. Web-only in effect (focus movement and key events are DOM concerns); inert on native. Keys the table does not handle are never preventDefaulted, so Tab and browser shortcuts keep working. When the page changes underneath it, the tab stop re-homes onto the new first row rather than disappearing.

Pixel-perfect overrides (optional)

The defaults are opinionated but never mandatory. When a surface must match an existing look exactly, pass styleOverrides — a per-slot map that is merged LAST into each slot's style array, so it beats both the base StyleSheet and the inline theme colours (this kit keeps colours out of the StyleSheet and applies them from useUi().theme at render time, so an override that only beat the base would still lose to the theme).

import type { DataTableStyleOverrides } from '@dloizides/ui-tables';

const overrides: DataTableStyleOverrides = {
  wrap: { borderRadius: 10 },                       // beats the kit's 12 (base StyleSheet)
  headRow: { backgroundColor: theme.surfaceMuted }, // beats theme.colors.background (INLINE colour)
};

<DataTable columns={columns} rows={rows} keyExtractor={(r) => r.id}
  stackBreakpoint={0}          // ← keeps the desktop grid at EVERY width (no card-stack)
  styleOverrides={overrides} />

| Component | Type | Slots | |-----------|------|-------| | DataTable | DataTableStyleOverrides | wrap, headRow, headCell, row, cell, numCell, state, stateText, rowDetail, card, cardLine, cardLabel, cardValue | | Pager | PagerStyleOverrides | pager, pagerInfo, pagerNav, pagerRowsLabel, sizeGroup, control, controlText, sizePill, sizePillText | | FilterBar | FilterBarStyleOverrides | filters, filtersSpacer, results, filtersActions |

The responsive card-stack (default breakpoint 768, aligned with @dloizides/ui-layout's MENU_BREAKPOINT): below stackBreakpoint each row collapses to a card where every column's header sits above its value (left-aligned label:value pairs), so a narrow phone never crams a multi-column table into a side-scrolling row. The desktop grid above the breakpoint is unchanged.

  • Test hooks survive both layouts. The head test id (ui-data-table-head), the row test ids (${testID}-row-${key}) and the per-cell test ids (${testID}-row-${key}-${col.key}) all resolve in the card-stack exactly as they do on the desktop grid — so an E2E suite written against the wide layout keeps working when a table goes card-stack on mobile. A column's render node (badge, link, action button) renders identically in a card cell.
  • Disabling the card-stack: it renders when width < stackBreakpoint, so stackBreakpoint={0} (never true) keeps the desktop grid at every width — the opt-out for a table that never had one.

Omit styleOverrides entirely and every component renders exactly as it always has.

Colours come entirely from useUi().theme (drive it with @dloizides/design-tokens via tokensToUiTheme), so the grid re-themes per tenant. Every component-authored string is routed through the UiProvider t — provide the uiTables.* keys (see TABLE_I18N) in your locale files; a caller may also pass already-translated loadingLabel / emptyLabel / resultsLabel directly.

⚠️ New in 1.12.0 — three translation keys every host app must add

1.12.0 gives three controls a real accessible name instead of a bare number. Those names are user-facing strings, so they come from your locale files. Add these three keys.

| Key (constant) | Key (string) | Params | Suggested English | |---|---|---|---| | TABLE_I18N.pagerRowsTriggerLabel | uiTables.pager.rowsTriggerLabel | {{p1}} = current page size | Rows per page, currently {{p1}} | | TABLE_I18N.pagerRowsOptionLabel | uiTables.pager.rowsOptionLabel | {{p1}} = that option's size | Show {{p1}} rows per page | | FILTERS_I18N.selectTriggerLabel | uiTables.filters.selectTriggerLabel | {{p1}} = field label, {{p2}} = selected option | {{p1}}: {{p2}} |

Also newly listed in TABLE_I18N (not new keys — StatCard has always called them, they were just inlined in the component where a key-coverage guard could not see them). If your app renders StatCard, check you actually define them:

| Key (constant) | Key (string) | Params | |---|---|---| | TABLE_I18N.statCardLabel | analytics.statCardLabel | {{p1}} = label, {{p2}} = value | | TABLE_I18N.statHint | analytics.statHint | {{p1}} = label |

Your app will not visibly break if you skip this. A key you have not defined degrades to the value it replaced (the bare number, the field label alone) rather than to the raw dotted key — so upgrading is safe, but until you add the keys your screen-reader users keep the old, worse experience. Derive your required-key list from the exported maps rather than hand-maintaining it:

import { TABLE_I18N, FILTERS_I18N } from '@dloizides/ui-tables';

// Guard test: every key the kit can ask for must exist in your locale file.
const required = [...Object.values(TABLE_I18N), ...Object.values(FILTERS_I18N)];
required.forEach((key) => expect(en).toHaveProperty(key));

Install

npm install @dloizides/ui-tables @dloizides/ui-feedback @dloizides/ui-forms @dloizides/ui-buttons

Peer dependencies: @dloizides/ui-feedback >= 1.1.0, @dloizides/ui-forms >= 1.8.0, @dloizides/ui-buttons >= 1.4.0 (inherited through ui-forms), react >= 18, react-native >= 0.74.

Usage

import { StatCard } from '@dloizides/ui-tables';

<StatCard label="Total responses" value={1234} testID="stat-total" />

Mount a FeedbackUiProvider / UiProvider (from @dloizides/ui-feedback) at your app root so the component picks up your theme + translations. The injected t is called with TABLE_I18N.statHint (analytics.statHint) and TABLE_I18N.statCardLabel (analytics.statCardLabel) — provide these keys in your locale files.

License

MIT