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

@thkl/agrid

v0.1.12

Published

A signal-based, standalone data grid for Angular.

Readme

@thkl/agrid

A signal-based, standalone data grid for Angular 21 with virtual scrolling, editing, filtering, sorting, grouping, tree data, pinned columns, selection, clipboard operations, and pagination.

Install

npm install @thkl/agrid @angular/cdk

Usage

import { Component, viewChild } from '@angular/core';
import {
  AgridComponent,
  AgridControl,
  AgridDataSource,
  AgridProvider,
  ColDef,
  RowUpdateEvent,
} from '@thkl/agrid';

interface Person {
  id: number;
  name: string;
}

const columns: ColDef<Person>[] = [
  { field: 'id', header: 'ID', editable: false },
  {
    field: 'name',
    header: 'Name',
    filterable: true,
    infoIcon: ({ row }) => Boolean(row.name),
  },
];

@Component({
  selector: 'app-people',
  imports: [AgridComponent],
  template: '<agrid [provider]="provider" />',
})
export class PeopleComponent {
  readonly grid = viewChild(AgridComponent);
  readonly provider = new AgridProvider<Person>({
    columns,
    datasource: new AgridDataSource([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]),
    control: new AgridControl(),
    headerGroups: [{ id: 'employee', label: 'Employee' }],
    showChangedCellIndicator: true,
    enableRowMarking: true,
    confirmRowDelete: true,
  });
}

Set infoIcon: true to show a right-aligned ? action for every cell in a column, or use a predicate to enable it per row. Listen to (cellInfo) to receive the row, field, value, original row index, and column definition.

Set group: 'employee' on adjacent columns to render the Employee label above them. Dragging that grouped header moves its current contiguous column segment as one block. Reordering, hiding, or pinning may split one group ID into multiple headers. Segments containing locked columns cannot be dragged.

confirmRowDelete protects grid delete actions with a localized in-row Yes/No confirmation. Direct calls to AgridDataSource.removeRow() remain immediate.

enableRowMarking makes each control-cell row header clickable and adds a checkbox. Clicking the header outside its nested controls toggles the same mark state and emits (rowMark) with the row, its original datasource index, and the resulting marked state. Marked rows are included in keyboard, cell-context, and row-context copy operations. Read grid.markedRowIndices() or call grid.clearMarkedRows() when the host needs to inspect or reset the copy basket.

Marking is independent from row selection. Cell and range copy use the same copied columns for every marked row, while Copy row uses every visible column. Duplicate rows are omitted, and marked rows remain included when filters hide them.

Selecting numeric cells displays a live status bar with count, sum, average, minimum, and maximum. Read the same values from grid.selectionSummary(). The signal is null when the active selection contains no finite numeric values.

Set enableColumnMarking: true to toggle complete-column highlighting by clicking a header outside its menu and resize controls. The grid exposes markedColumnFields() and emits (columnMark).

Append custom commands to one column's menu and handle them through one typed output:

{ field: 'status', header: 'Status', headerMenuItems: [
  { key: 'archive', label: 'Archive', icon: '⌂' },
] }
<agrid [provider]="provider" (columnHeaderAction)="onColumnHeaderAction($event)" />

The event contains { column, key }.

Use (firstDataRendered) when host logic must wait until the grid has completed its first render containing datasource rows:

<agrid [provider]="provider" (firstDataRendered)="onGridReady($event)" />

The event fires once per grid component. An initially empty datasource delays it until rows arrive. Server-side loading placeholders do not count as rendered data. The payload contains rows, rowCount, provider, and datasource.

For commands that change textAlign at runtime, keep the writable signal in the host instead of mutating event.column:

readonly salaryAlignment = signal<'left' | 'center' | 'right'>('right');

// Column definition
{ field: 'salary', header: 'Salary', textAlign: this.salaryAlignment }

onColumnHeaderAction(event: ColumnHeaderActionEvent<Employee>): void {
  if (event.column.field !== 'salary') return;
  if (event.key === 'align-left') this.salaryAlignment.set('left');
  if (event.key === 'align-center') this.salaryAlignment.set('center');
  if (event.key === 'align-right') this.salaryAlignment.set('right');
}

Assigning event.column.textAlign = 'center' mutates plain configuration and does not trigger an Angular update. Calling .set() on event.column.textAlign is also not type-safe because the property may contain a static string or a read-only signal.

Menu bar

Set menuBarItems to render command buttons above the headers. An item with items becomes a split button: its label emits the parent id and its chevron opens additional commands. Every command emits through the single (menuBarAction) output.

readonly provider = new AgridProvider<Person>({
  columns,
  datasource,
  menuBarItems: [
    { id: 'refresh', label: 'Refresh', icon: '↻' },
    {
      id: 'selection',
      label: 'Selection',
      disabled: ({ selectedRows }) => selectedRows.length === 0,
      items: [
        { id: 'activate', label: 'Activate' },
        { id: 'deactivate', label: 'Deactivate', active: ({ rows }) => rows.some(row => !row.active) },
      ],
    },
  ],
});
<agrid [provider]="provider" (menuBarAction)="runCommand($event)" />

visible, active, and disabled may be booleans or callbacks. Callback context includes rows, selectedRows, selectedCell, provider, and datasource.

Input masks

Use inputMask to select a string mask for each row and cell. The callback receives { row, value, column }; return null for cells that should remain unrestricted.

{
  field: 'reference',
  header: 'Reference',
  inputMask: ({ row }) =>
    row.numeric
      ? /\d{0,3}(?:-\d{0,5}(?:-\d{0,5})?)?/
      : /[a-z0-9]{0,3}(?: [a-z0-9]{0,3}(?: [a-z0-9]{0,5})?)?/i,
}

The regex is matched against the complete proposed value and must allow partial input. Separators are typed explicitly rather than inserted automatically. Invalid edits revert to the last accepted value.

Boolean columns

Set type: 'boolean' to render a cell as an inline checkbox. Clicking it toggles the value and commits immediately — no edit mode, and the change is recorded in undo/redo like any other edit. Read-only columns (editable: false) or a read-only grid render the checkbox disabled. Values are truthy-coerced for display, so true, 1, 'true', and '1' all render as checked.

const columns: ColDef<Person>[] = [
  { field: 'name', header: 'Name' },
  { field: 'active', header: 'Active', type: 'boolean' },
];

Runtime readonly cells

Use cellReadonly when editability depends on the current row. Returning true blocks inline editing, boolean toggles, paste, fill, and sidebar edits for that cell.

const columns: ColDef<Order>[] = [
  { field: 'status', header: 'Status' },
  {
    field: 'approvedBy',
    header: 'Approved by',
    cellReadonly: ({ row }) => row.status !== 'Draft',
  },
];

Condition filters

Mark a column filterable: true and its column menu gains a condition filter in addition to the value picker. Text columns offer equals, not equal, like, starts with, ends with, includes, and does not include. Numbers offer =, , >, , <, , and between; dates offer on / before / after / between. Conditions combine with the header text filter, value picker, and other columns using AND semantics, and are included in AgridControl.toJSON() state.

The header arrow opens the complete column menu. The condition button beside the inline filter opens only the condition operator and operand controls; sorting, layout, grouping, custom commands, clear-all actions, and distinct-value selection remain in the complete menu.

const columns: ColDef<Order>[] = [
  { field: 'reference', header: 'Reference', filterable: true },
  { field: 'total', header: 'Total', type: 'number', filterable: true },
  { field: 'placedAt', header: 'Placed', type: 'date', filterable: true },
];

Set it programmatically with control.setRangeFilter(field, operator, operand, operand2?), where operator can also be 'like' | 'startsWith' | 'endsWith' | 'includes' | 'notIncludes' for text columns (pass null to clear). like uses % for any sequence and _ for one character.

Edit validation

Add a validate hook to a column to reject bad values. Return a message to block the edit (the value is not written and the message is shown), or null to accept it. It runs on inline commit, boolean-checkbox toggle, and sidebar save.

const columns: ColDef<Person>[] = [
  { field: 'email', header: 'Email',
    validate: v => /@/.test(String(v)) ? null : 'Enter a valid email' },
  { field: 'age', header: 'Age', type: 'number',
    validate: (v, row) => Number(v) >= 0 ? null : 'Age must be ≥ 0' },
];

A rejected inline edit keeps the cell in edit mode with the message shown beneath it, so the user can correct it; Tab/Enter won't leave the cell until the value is valid. Rejected sidebar edits show the message under the field. Listen to (validationFailed) for { rowIndex, field, value, message, source } (source is 'inline' or 'sidebar').

<agrid [provider]="provider" (validationFailed)="onInvalid($event)" />

Quick filter

Set enableQuickFilter: true to render a search box above the grid that keeps rows whose visible columns contain the text (resolved display values, so ValueOption labels and formatters count).

readonly provider = new AgridProvider<Person>({ columns, datasource, enableQuickFilter: true });

Drive it programmatically with control.setQuickFilter(text); it's part of toJSON() state and is cleared by control.clearAllFilters().

Server-side filtering

With serverSideFiltering: true the grid never filters locally — it emits events so the host can refetch:

  • (filterChange) — header text filters emit { field, value }; menu conditions emit { field, value: '', operator, operand, operand2 } (operator null clears the condition).
  • (sortChange){ field, direction }.
  • (quickFilterChange) — the quick-filter text (debounced by filterDebounceMs).
<agrid [provider]="provider"
  (filterChange)="onFilter($event)"
  (sortChange)="onSort($event)"
  (quickFilterChange)="onQuickFilter($event)" />

Text/range/quick events are debounced by filterDebounceMs (default 300 ms; 0 disables). The The distinct-value picker is hidden in this mode unless the column supplies an explicit values list representing the complete server-side value set.

Grouping and aggregates

Give a column an aggregate ('sum', 'avg', 'min', 'max', 'count', or a custom (values) => unknown function) and the grid renders a footer row with that column's total over all filtered rows. Set/clear it at runtime with control.setAggregate(field, fn).

const columns: ColDef<Order>[] = [
  { field: 'region', header: 'Region', groupable: true },
  { field: 'total', header: 'Total', type: 'number', aggregate: 'sum' },
];

When the grid is grouped (set groupable: true and group from the column menu, or call control.setGroupBy(field)), each group header row also shows that group's subtotals — the same aggregate functions applied to just the group's rows, displayed inline beside the group label and count. No extra configuration is needed; subtotals appear whenever grouping and at least one aggregated column are both active.

Tree data

Pass treeConfig to render rows as a hierarchical tree. Use stable id / parentId accessors for an existing hierarchy, or getPath to generate display-only branches from each row.

import { AgridTreeConfig } from '@thkl/agrid';

const treeConfig: AgridTreeConfig<OrgRow> = {
  getId: row => row.id,
  getParentId: row => row.parentId, // null / unknown id ⇒ root row
  treeField: 'name',                // column that shows the twisty
  aggregateTreeNodes: true,         // parent cells roll up descendant leaves
};

readonly provider = new AgridProvider<OrgRow>({
  columns,
  datasource: new AgridDataSource(rows),
  treeConfig,
});

With aggregateTreeNodes: true, every expandable row displays descendant-leaf rollups in columns that define aggregate (or have one set through AgridControl). For example, { field: 'amount', aggregate: 'sum' } shows the sum of a node's leaves in that node's amount cell. Collapsed leaves still contribute. Filtering recalculates the rollups over the matching tree.

For path-like values such as 01.01.0001, return the ordered segments:

const treeConfig: AgridTreeConfig<Row> = {
  getPath: row => row.oz.split('.'),
  nodeUuid: row => row.uuid,
  formatPathSegment: ({ row, segment, level, leaf }) =>
    leaf
      ? `${segment} ${row.description}`
      : `${segment} ${level === 0 ? row.areaLabel : row.groupLabel}`,
  treeField: 'oz',
};

This renders 01 / 01 / 0001, 0002. Generated 01 branch rows are display-only; the leaf remains the original datasource row. Its tree cell displays 0001, while editing still uses the complete 01.01.0001 value. formatPathSegment changes labels only; raw segments still control grouping, expansion identity, and sort order. The callback receives the original row; shared branch nodes use the first row encountered for that raw path prefix. nodeUuid uses the same source row and is included in generated branch-node click events.

The treeField column shows an indented expand/collapse twisty. Filtering and sorting behave as in a flat grid; with keepAncestorsOnFilter (default true) a match deep in the tree keeps its parents visible and force-opens the path to it. Tree mode takes precedence over grouping and disables pagination. Call grid.expandAllNodes() / grid.collapseAllNodes() to toggle the whole tree.

Tree mode can be combined with masterDetail: true and a detailRenderer. Detail expanders are shown only for leaf rows; parent rows retain their tree expand/collapse control.

Set detailColumnField to a column field when the panel should expose one larger multiline value. The formatted value is shown normally; click it or focus it and press Enter to open a textarea. Blur or Ctrl/Cmd+Enter commits, while Escape cancels. Editability, validation, history, and edit events follow the linked column's normal cell behavior. Set detailActions to add text-template buttons above the textarea. Each action has an id, label, and optional text string or row-aware resolver; clicking inserts at the current selection, or appends if the button opens the textarea.

Standalone tree control

Use <agrid-tree> for the same parent-ID or path hierarchy without grid columns. It adds tree keyboard navigation and optional single or multi-selection. Since this component has no columns, it ignores aggregateTreeNodes; descendant rollups apply only to tree mode in <agrid>.

readonly treeProvider = new AgridTreeProvider<OrgRow>({
  datasource: new AgridDataSource(rows),
  treeConfig: {
    getId: row => row.id,
    getParentId: row => row.parentId,
    treeField: 'name',
    defaultExpanded: true,
  },
  getDescription: row => row.role,
  selection: 'single',
  ariaLabel: 'Organization',
});
<agrid-tree [provider]="treeProvider"
  (nodeClick)="openNode($event)"
  (nodeDoubleClicked)="openNode($event)"
  (selectionChange)="selectionChanged($event)" />

Row and generated path-branch clicks emit AgridTreeNodeEvent<T>. Branch events include their configured or generated uuid. expandAllNodes() and collapseAllNodes() are public methods. The tree uses the shared --agrid-color-text, --agrid-color-text-muted, --agrid-color-accent, --agrid-color-accent-subtle, --agrid-color-accent-fg, --agrid-color-border, --agrid-color-bg, and --agrid-color-bg-muted CSS variables.

Pivot tables

Use pivotConfig to derive a read-only client-side cross-tabulation from a flat datasource. The first pivot release supports one row dimension, one column dimension, and one value field.

const provider = new AgridProvider<Sale>({
  columns: [
    { field: 'region', header: 'Region' },
    { field: 'quarter', header: 'Quarter' },
    { field: 'revenue', header: 'Revenue', type: 'number', formatter: currency },
  ],
  datasource: new AgridDataSource(sales),
  pivotConfig: {
    rowField: 'region',
    columnField: 'quarter',
    valueField: 'revenue',
    aggregate: 'sum',
  },
});

Each distinct rowField value becomes one row and each distinct columnField value becomes a generated column. Intersections use sum by default and also support avg, min, max, count, or a custom (values) => result function. Dimension labels and pivot values reuse source column formatters. Missing intersections render empty.

Pivot rows react to datasource replacement and updates. They are derived values, so editing, adding, reordering, master/detail, tree mode, server-side row models, and aggregate footers are disabled or rejected in pivot mode. Client-side filtering, sorting, selection, and pagination continue through the normal grid pipeline. Multi-level dimensions and totals are intentionally outside this first slice.

When showSidebar is enabled, pivot grids add a Pivot sidebar tab. It updates rowField, columnField, valueField, and built-in aggregate functions immediately without replacing the provider. Assigning provider.pivotConfig programmatically uses the same reactive path.

Saving and restoring settings

saveSettings() returns a detached, versioned object containing the pivot configuration and the existing control state (column visibility, width, order, pinning, filters, sorts, pagination, and aggregates). It is safe to JSON-encode and send to a backend. loadSettings() applies it to an already-rendered grid immediately.

const settings = provider.saveSettings();
await api.saveGridSettings(settings);

const restored = await api.loadGridSettings();
provider.loadSettings(restored);

The component exposes the same methods through viewChild(AgridComponent). Sidebar pivot and column-visibility changes emit the complete snapshot through (settingsChange):

<agrid #grid [provider]="provider" (settingsChange)="saveSettings($event)" />
saveSettings(settings: AgridSettings): void {
  void api.saveGridSettings(settings);
}

// Loading after the grid exists:
grid().loadSettings(savedSettings);

Custom aggregate functions are executable code and cannot be represented in JSON; attempting to save one throws an explicit error. Register custom functions in application code instead.

Page selector

Use <agrid-page-selector> to navigate pages by previous/next button, dropdown, or typed ID. All three interactions emit an AgridPageItem<TId> through the single (selectPage) output.

readonly pages: AgridPageItem<number>[] = [
  { id: 1, label: 'Cover' },
  { id: 2, label: 'Measurements' },
  { id: 3, label: 'Summary' },
];
<agrid-page-selector [items]="pages" [selectedId]="currentPageId()"
  (selectPage)="currentPageId.set($event.id)" />

IDs can be strings or numbers. Typed IDs are selected with Enter. Available inputs are disabled, previousLabel, nextLabel, inputLabel, menuLabel, and emptyText.

Saving edited rows

Use rowChanged to send one request after the user edits one or more fields in a row:

<agrid [provider]="provider" (rowChanged)="saveRow($event)" />
saveRow(event: RowUpdateEvent<Person>): void {
  this.http.patch(`/api/people/${event.row.id}`, event.row).subscribe(() => {
    this.provider.control.indicate(event.originalIndex, '#2da44e', 1000);
    this.provider.control.clearChangedCells(event.originalIndex);
  });
}

The event fires with the latest complete row when inline navigation leaves that row, or when the sidebar editor Save button is used. cellEdit and recordEdit remain available for every committed field change. With showChangedCellIndicator: true, changed cells keep a corner marker until the PATCH succeeds. Override --agrid-color-cell-changed to customize its color. Call control.indicate(index, color, durationMs) to flash a complete row for transient server-side feedback.

Full documentation and demos: https://thkl.github.io/agrid/