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

@iyulab/flex-table

v0.31.5

Published

A minimalist, input-centric data grid web component

Readme

flex-table

A lightweight, schema-agnostic data grid web component built with Lit.

Designed for effortless data input and crystal-clear visibility. Bridges the gap between spreadsheet freedom and database structural integrity.

flex-table vs. URichTable (@iyulab/data-components): both can consume external data sources such as OData — the choice is about scale and interaction, not the data source.

  • flex-table — large datasets, cell-level editing, spreadsheet-grade interaction
  • URichTable — small-to-medium datasets, row-level CRUD, selection/filter-focused UX

Install

npm install @iyulab/flex-table

Quick Start

<flex-table id="table" row-height="32" show-row-numbers></flex-table>

<script type="module">
  import '@iyulab/flex-table';

  const table = document.getElementById('table');

  table.columns = [
    { key: 'name', header: 'Name', type: 'text', width: 200 },
    { key: 'age', header: 'Age', type: 'number', width: 100 },
    { key: 'active', header: 'Active', type: 'boolean', width: 80 },
  ];

  table.data = [
    { name: 'Alice', age: 30, active: true },
    { name: 'Bob', age: 25, active: false },
  ];
</script>

Features

  • Virtual Scroll — Smooth scrolling through 100,000+ rows (horizontal + vertical)
  • Keyboard Navigation — Arrow, Tab, Home, End, Ctrl+Home/End
  • Inline Editing — Enter/F2 to edit, Escape to cancel, type-aware editors
  • Custom Editoreditor callback for fully custom cell editing UI
  • Validationvalidator callback with visual feedback (red border + aria-invalid)
  • Range Selection — Shift+Arrow, Shift+Click for multi-cell selection
  • Column Selection — Ctrl+Click header or selectColumn() API
  • Row Selection — Checkbox-based row selection (selectable, single/multi mode)
  • Clipboard — Ctrl+C/X/V with TSV format (Excel/Google Sheets compatible, RFC 4180)
  • Sorting — Click header to sort (asc/desc/none), Shift+click for multi-sort
  • Column Resize — Drag header border, double-click to auto-fit, Alt+Arrow keyboard resize
  • Column OperationsaddColumn(), deleteColumn(), moveColumn() with undo
  • Pinned Columns — Freeze columns to left or right (pinned: 'left' | 'right')
  • Filtering — Programmatic API + built-in header filter UI (show-filters)
  • Filter Types — Text search, number range, boolean toggle, date/datetime range picker
  • Row OperationsaddRow(), deleteRows(), updateRows() with undo
  • Undo/Redo — Ctrl+Z / Ctrl+Y for all operations; configurable stack size
  • Export — CSV, TSV, JSON; full data or selection-only
  • Dark Theme — Auto via prefers-color-scheme, or manual theme="dark"
  • Row Numbers — Optional show-row-numbers attribute with sticky positioning
  • Footer Row — Summary/aggregate row via footer-data property
  • Data Mode — Client-side or server-side sorting/filtering (dataMode)
  • Context Menucontext-menu event for custom right-click menus
  • React Wrapper@iyulab/flex-table/react subpath for idiomatic React usage
  • ARIArole="grid", aria-sort, aria-selected, aria-readonly, aria-invalid, aria-rowcount, aria-colcount

Properties

| Property | Attribute | Type | Default | Description | |----------|-----------|------|---------|-------------| | columns | — | ColumnDefinition[] | [] | Column definitions | | data | — | DataRow[] | [] | Data rows (Record<string, unknown>[]) | | rowHeight | row-height | number | 32 | Row height in pixels. Falls back to the --ft-row-height token when not set — see Density | | showRowNumbers | show-row-numbers | boolean | false | Show row number column | | theme | theme | 'light' \| 'dark' | auto | Force theme; auto-detects prefers-color-scheme | | editable | editable | boolean | true | Global read-only mode when false. Defaults to true — a purely read-only grid should set this explicitly rather than relying on per-column editable: false alone, since it's also what makes Enter fire row-activate (see Events) instead of entering edit mode | | showFilters | show-filters | boolean | false | Show built-in header filter dropdowns | | maxRows | max-rows | number | 0 | Max row count (0 = unlimited); blocks addRow() and paste expansion | | maxUndoSize | max-undo-size | number | 100 | Max undo history stack size | | selectable | selectable | boolean | false | Enable row-level checkbox selection | | selectionMode | selection-mode | 'single' \| 'multi' | 'multi' | Row selection mode | | dataMode | data-mode | 'client' \| 'server' | 'client' | Client-side or server-side data processing | | footerData | footer-data | Record<string, string> | null | Footer/summary row data (keys match column keys) | | emptyMessage | empty-message | string | 'No data' | Shown when data is empty | | noMatchingMessage | no-matching-message | string | 'No matching data' | Shown when data has rows but every one is hidden by an active column filter | | stylesheets | — | CSSStyleSheet[] | [] | Constructable stylesheets adopted into the shadow root alongside the grid's own styles — the escape hatch for styling content a renderer inserts, since document CSS doesn't cross the shadow boundary. Reassigning swaps the previous set, it doesn't accumulate |

Read-only Properties

| Property | Type | Description | |----------|------|-------------| | visibleColumns | ColumnDefinition[] | Columns where hidden !== true | | filteredRowCount | number | Number of rows after filtering | | canUndo | boolean | Whether undo is available | | canRedo | boolean | Whether redo is available | | activeCell | CellPosition \| null | Currently focused cell { row, col } | | editingCell | CellPosition \| null | Currently editing cell { row, col } | | sortCriteria | SortCriteria[] | Active sort criteria [{ key, direction }] | | filterKeys | string[] | Column keys with active filters |

Column Definition

interface ColumnDefinition {
  key: string;             // Unique key matching data property names
  header: string;          // Display header text
  type?: ColumnType;       // 'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'select' (any other string falls back to 'text')
  width?: number;          // Column width in pixels (default: auto)
  minWidth?: number;       // Minimum width in pixels (default: 40, enforced in rendering)
  hidden?: boolean;        // Hide column from view
  sortable?: boolean;      // Enable sorting (default: true)
  headerAlign?: 'start' | 'center' | 'end'; // Header label alignment (default: 'start'), independent of cell content alignment
  editable?: boolean;      // Per-column edit control (follows global editable)
  pinned?: 'left' | 'right'; // Freeze column during horizontal scroll
  options?: string[] | SelectOption[]; // Allowed values for type: 'select' (SelectOption = { label, value })
  autocomplete?: boolean | 'strict'; // Suggest existing column values while editing; 'strict' rejects values not in the list
  format?: string | ((value, row, col) => string); // Display format, see "format vs renderer" below
  renderer?: CellRenderer; // Custom cell render: (value, row, col) => TemplateResult | string
  editor?: CellEditor;     // Custom cell editor: (value, row, col) => TemplateResult
  validator?: CellValidator; // Validate before commit: (value, row, col) => string | null
  conditionalRules?: ConditionalRule[]; // Per-cell style rules, see below
}

The editor callback must return a Lit TemplateResult containing an input element with class "ft-editor". The component reads .value from that element on commit. See Custom Editor for details.

The validator callback returns null if valid, or an error message string. On failure, the cell shows a red border for 3 seconds and a validation-error event is dispatched.

format vs renderer

Both control how a cell's raw value is displayed, but they differ in what they replace:

  • format: a plain string pattern (Excel-style, e.g. '#,##0.00', '0.00%', '$#,##0', 'yyyy-MM-dd') or a (value) => string function. Only the displayed text changes — editing, sorting, filtering, and export all keep operating on the raw underlying value. Use this for number/date/currency display formatting.
  • renderer: a (value, row, col) => TemplateResult | string function that replaces the cell's rendered content entirely — badges, links, icons, multi-field composites. Sorting/filtering still use the raw value, but the visual output is fully custom.
const columns: ColumnDefinition<Order>[] = [
  { key: 'total', header: 'Total', format: '#,##0.00' },                 // "1,234.50"
  { key: 'placedAt', header: 'Placed', format: 'yyyy-MM-dd' },           // date pattern
  { key: 'status', header: 'Status', renderer: (v) => html`<span class="badge badge-${v}">${v}</span>` },
];

If both are set on the same column, renderer takes precedence — format has no effect once a custom renderer fully controls the cell's output.

Conditional Formatting

conditionalRules applies a style to a cell when its when predicate matches — a declarative alternative to writing a renderer just to color-code status/threshold values:

const columns: ColumnDefinition<Order>[] = [
  {
    key: 'status',
    header: 'Status',
    conditionalRules: [
      { when: (v) => v === 'overdue', style: { color: '#dc2626', fontWeight: 'bold' } },
      { when: (v) => v === 'paid', style: { color: '#16a34a' } },
    ],
  },
];

Rules are evaluated in order and combined; later matching rules override earlier ones for overlapping style properties.

Methods

Row Operations

| Method | Returns | Description | |--------|---------|-------------| | addRow(row?, index?) | DataRow \| null | Add a row. Returns null if maxRows reached | | deleteRows(indices?) | void | Delete rows by data index (default: selected rows) | | updateRows(changes) | void | Batch update cells as single undo action. changes: Array<{ row, key, value }> | | refreshData() | void | Force re-render after in-place data mutation |

Column Operations

| Method | Returns | Description | |--------|---------|-------------| | addColumn(def, index?) | ColumnDefinition | Add column at position (default: end) | | deleteColumn(key) | void | Remove column + cleanup filters/sort/widths | | moveColumn(key, newIndex) | void | Reorder column to target index (clamped) | | getColumnWidth(key) | number \| undefined | Get internal resize width for column | | selectColumn(colIndex) | void | Select entire column (range selection) |

Row Selection

| Method | Returns | Description | |--------|---------|-------------| | selectAll() | void | Select all visible rows (multi mode only) | | deselectAll() | void | Deselect all rows | | getSelectedRows() | { selectedIndices, selectedRows } | Get selected row data |

Row selection is index-based (there is no row-key concept), so replacing data with a same-length but different set of rows leaves the selection pointing at the new rows occupying the old indices. If selection drives a bulk action (status changes, bulk delete, etc.), set clear-selection-on-data-change so a data swap always resets selection and re-fires selection-change with an empty selection:

<flex-table selectable clear-selection-on-data-change></flex-table>

Default is false, matching clear-undo-on-data-change.

Filtering

| Method | Returns | Description | |--------|---------|-------------| | setFilter(key, predicate) | void | Set column filter. predicate: (value, row) => boolean | | removeFilter(key) | void | Remove filter for a column | | clearFilters() | void | Remove all filters |

Export

| Method | Returns | Description | |--------|---------|-------------| | exportToString(format, options?) | string | Export to 'csv' / 'tsv' / 'json'. Pass { selectionOnly: true } for selection range | | exportToFile(format, filename?) | void | Export and trigger browser file download |

Events

All events use CustomEvent with bubbles: true, composed: true.

Cell Events

| Event | Detail | Description | |-------|--------|-------------| | cell-select | { row, col } | Cell focus changed | | cell-edit-start | { row, col, key, value } | Cell editing started | | cell-edit-commit | { row, col, key, oldValue, newValue } | Cell value committed | | cell-edit-cancel | { row, col } | Cell edit cancelled (Escape) | | validation-error | { row, col, key, value, error } | Cell validator rejected value |

Data Events

| Event | Detail | Description | |-------|--------|-------------| | row-add | { row, index } | Row added | | row-delete | { indices, rows } | Rows deleted | | row-activate | { row, index, col, key } | Enter pressed on a non-editable cell — the grid's own contract for "activate this row" (e.g. navigate to a detail view), guaranteed even though the internal Enter handler prevents the keystroke from reliably reaching a listener the host attaches to the same element | | batch-update | { changes: [{ row, key, oldValue, newValue }] } | Batch update applied |

Column Events

| Event | Detail | Description | |-------|--------|-------------| | column-add | { column, index } | Column added | | column-delete | { column, key, index } | Column removed | | column-reorder | { key, oldIndex, newIndex } | Column moved | | column-resize | { key, width, colIndex } | Column resized (drag, auto-fit, or keyboard) | | column-select | { colIndex, key, rowCount } | Entire column selected |

Sort & Filter Events

| Event | Detail | Description | |-------|--------|-------------| | sort-change | { criteria: [{ key, direction }] } | Sort criteria changed | | filter-change | { keys, filteredCount } | Filter added/removed | | filter-error | { error, row, filterKey } | Filter predicate threw an error |

Selection Events

| Event | Detail | Description | |-------|--------|-------------| | selection-change | { selectedIndices, selectedRows } | Row checkbox selection changed |

Clipboard Events

| Event | Detail | Description | |-------|--------|-------------| | clipboard-copy | { range, text } | Range copied as TSV | | clipboard-cut | { range, text } | Range cut as TSV | | clipboard-paste | { changes, addedRows } | Data pasted from clipboard | | clipboard-error | { action, error } | Clipboard API failed (action: 'copy' or 'paste') |

State Events

| Event | Detail | Description | |-------|--------|-------------| | undo-state-change | { canUndo, canRedo } | Undo/redo availability changed | | context-menu | { x, y, row, col, dataRow, column } | Right-click on cell |

CSS Custom Properties

All colors and styles are customizable via CSS custom properties.

Two levels, pick whichever fits. Since 0.22.0 every --ft-* colour is derived from the @iyulab/components design tokens, so if you already load that token sheet the table follows your brand with no per-table configuration:

:root { --u-primary-color: #7b1fa2; }   /* the table's accent, selection and boolean
                                           markers follow — so do the buttons and shell */

Override an individual --ft-* when you want the table to differ from the rest of the app:

flex-table {
  --ft-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --ft-font-size: 14px;

  /* Surfaces and text            derived from */
  --ft-bg: #fff;                     /* --u-bg-color */
  --ft-text-color: #202124;          /* --u-txt-color */
  --ft-border-color: #e0e0e0;        /* --u-border-color */
  --ft-header-bg: #f8f9fa;           /* --u-bg-color-raised */
  --ft-header-hover-bg: #e8eaed;     /* --u-bg-color-active */
  --ft-header-text-color: #202124;   /* --u-txt-color */
  --ft-row-even-bg: #fff;            /* --u-bg-color */
  --ft-row-odd-bg: #fafafa;          /* --u-bg-color-raised */
  --ft-row-hover-bg: #f0f4ff;        /* --u-bg-color-hover */
  --ft-row-odd-hover-bg: #f0f4ff;    /* --u-bg-color-raised-hover — odd row's hover, independent of --ft-row-hover-bg */
  --ft-editor-bg: #fff;              /* --u-input-bg-color */
  --ft-sort-indicator-color: #5f6368;/* --u-txt-color-weak */
  --ft-empty-color: #5f6368;         /* --u-txt-color-weak */

  /* Accent */
  --ft-active-color: #1a73e8;        /* --u-primary-color */
  --ft-selection-bg: #e8f0fe;        /* --u-primary-bg-color */
  --ft-bool-color: #2196f3;          /* --u-primary-color */

  /* State overlays — these are translucent on purpose, so the row striping and
     selection underneath stay visible. Set the *-color and the background follows. */
  --ft-invalid-color: #d93025;       /* --u-danger-color */
  --ft-drop-color: #1a73e8;          /* --u-primary-color */
  --ft-find-color: #f9a825;          /* --u-warning-color */
}

Density and header hierarchy

Since 0.23.0 the vertical rhythm and the header's typographic weight are adjustable, so a table can be matched to the other tables on the page:

flex-table {
  --ft-row-height: 32px;           /* body row height */
  --ft-cell-padding-block: 6px;    /* text placement inside that height */
  --ft-cell-padding-inline: 12px;
  --ft-header-font-size: 14px;     /* defaults to --ft-font-size */
  --ft-header-font-weight: 600;
}

--ft-row-height is read once, at first render. The grid is virtualised — rows are positioned at index × rowHeight, so the height cannot come from the cascade the way padding does. Declare it in a stylesheet that is in effect before the table is attached; to change it later, set the rowHeight property (or the row-height attribute) instead. An explicitly set property or attribute always wins over the token, and a value in any unit other than px is ignored.

Row height is fixed, and padding places the text within it. Cells are single-line (nowrap + ellipsis) by design. If you reduce --ft-row-height, reduce --ft-cell-padding-block to match — keep padding-block × 2 + line box ≤ row-height, or the text is clipped at the bottom.

Without the token sheet nothing changes. Every reference carries the literal above as its fallback, and the built-in dark theme (prefers-color-scheme or theme="dark") still applies — the table remains usable standalone. Referencing the tokens does not add a package dependency; CSS custom properties are resolved at render time, not imported.

Keyboard Shortcuts

| Key | Action | |-----|--------| | Arrow keys | Navigate cells | | Tab / Shift+Tab | Move to next/previous cell | | Enter / F2 | Start editing | | Escape | Cancel edit / clear selection | | Home / End | Row start/end | | Ctrl+Home / Ctrl+End | Table start/end | | Shift+Arrow | Extend selection range | | Ctrl+C / Ctrl+X | Copy/Cut selection as TSV | | Ctrl+V | Paste TSV data | | Delete / Backspace | Clear selected cells | | Ctrl+Z | Undo | | Ctrl+Shift+Z / Ctrl+Y | Redo | | Alt+ArrowLeft / Alt+ArrowRight | Resize current column (±20px) | | Ctrl+Click header | Select entire column |

Usage Guide

React

Install peer dependencies and import the React wrapper:

npm install @iyulab/flex-table @lit/react react
import { FlexTableReact } from '@iyulab/flex-table/react';

function App() {
  const columns = [
    { key: 'name', header: 'Name', type: 'text' },
    { key: 'age', header: 'Age', type: 'number' },
  ];

  const data = [
    { name: 'Alice', age: 30 },
    { name: 'Bob', age: 25 },
  ];

  return (
    <FlexTableReact
      columns={columns}
      data={data}
      showRowNumbers
      onCellEditCommit={(e) => console.log('Edited:', e.detail)}
      onSortChange={(e) => console.log('Sort:', e.detail)}
    />
  );
}

All <flex-table> properties are available as React props, and all custom events are mapped to on* callbacks (e.g., cell-edit-commitonCellEditCommit).

Imperative API via ref

FlexTableReact forwards ref to the underlying FlexTable custom element instance, so all Methods (addRow, deleteRows, selectAll, setFilter, etc.) are reachable without re-rendering the whole table:

import { useRef } from 'react';
import { FlexTableReact, type FlexTable } from '@iyulab/flex-table/react';

function App() {
  const tableRef = useRef<FlexTable>(null);

  return (
    <>
      <button onClick={() => tableRef.current?.addRow({ name: '', age: 0 })}>Add row</button>
      <button onClick={() => tableRef.current?.deleteRows()}>Delete selected</button>
      <FlexTableReact ref={tableRef} columns={columns} data={data} selectable />
    </>
  );
}

Typed rows (generics)

FlexTableReact and ColumnDefinition are generic over your row type — no as unknown as casts needed in data, columns, or callbacks:

import { FlexTableReact, type ColumnDefinition } from '@iyulab/flex-table/react';

interface Order {
  id: string;
  total: number;
  currency: string;
}

const columns: ColumnDefinition<Order>[] = [
  { key: 'id', header: 'ID' },
  { key: 'total', header: 'Total', renderer: (_value, row) => `${row.total} ${row.currency}` },
];

<FlexTableReact<Order> data={orders} columns={columns} />

Omitting the type argument defaults to the previous DataRow (Record<string, unknown>) behavior — fully backward compatible.

Custom Editor

The editor callback lets you provide a fully custom editing UI. The component reads .value from the element with class ft-editor when committing.

import { html } from 'lit';

table.columns = [
  {
    key: 'color',
    header: 'Color',
    type: 'text',
    editor: (value) => html`
      <input class="ft-editor" type="color" .value=${String(value ?? '#000000')}
        @blur=${(e) => e.target.dispatchEvent(new Event('change', { bubbles: true }))}
        @keydown=${(e) => {
          if (e.key === 'Escape') e.target.blur();
        }}>
    `,
  },
];

Key rules:

  • Must include an element with class ft-editor — the component reads its .value on commit
  • Clicking another cell auto-commits the editor
  • For Enter/Escape support, handle @keydown in your template
  • For blur-to-commit, handle @blur in your template

Validation

Use the validator callback to validate input before committing. Returns null if valid, or an error message:

table.columns = [
  {
    key: 'age',
    header: 'Age',
    type: 'number',
    validator: (value) => {
      const n = Number(value);
      if (n < 0 || n > 150) return 'Age must be 0–150';
      return null;
    },
  },
];

When validation fails, the cell displays a red border for 3 seconds and the validation-error event fires.

Pinned Columns

Freeze columns on either side during horizontal scroll:

table.columns = [
  { key: 'id', header: 'ID', pinned: 'left' },
  { key: 'name', header: 'Name' },
  // ... many columns ...
  { key: 'actions', header: 'Actions', pinned: 'right' },
];

Data Mutation

The data property uses in-place mutation for performance. Direct changes to data objects are not automatically detected:

// Will NOT trigger re-render:
table.data[0].name = 'Alice';

// Options to trigger re-render:
table.refreshData();             // Force re-render
table.updateRows([               // Recommended — includes undo support
  { row: 0, key: 'name', value: 'Alice' }
]);

Use updateRows() for programmatic edits — it provides undo/redo and dispatches the batch-update event.

Built-in Filter UI

Enable with show-filters attribute. Filter dropdowns appear in column headers:

  • text: case-insensitive substring search
  • number: min/max range inputs
  • boolean: All / True / False select
  • date: from/to date range picker (<input type="date">)
  • datetime: from/to datetime range picker (<input type="datetime-local">)

Filters set via the UI and the programmatic API (setFilter()) share the same filter state. Filter dropdowns automatically flip upward when near the viewport bottom.

Server-Side Mode

Set data-mode="server" to disable client-side sorting/filtering. The component dispatches sort-change and filter-change events but does not recompute data — your server provides pre-sorted/filtered data:

table.dataMode = 'server';
table.addEventListener('sort-change', (e) => {
  fetchData({ sort: e.detail.criteria }).then(data => {
    table.data = data;
  });
});

OData Source Hook (React)

useODataSource(url, options) fetches paginated/sorted/filtered data from an OData v4 endpoint and returns props ready to bind to <FlexTableReact dataMode="server" ...>.

import { useODataSource } from '@iyulab/flex-table/odata';

const source = useODataSource('/api/orders', {
  pageSize: 20,
  fetcher: httpClient.fetch,          // custom transport, e.g. an HttpClient instance
  onUnauthorized: () => navigate('/login'),
});

| Option | Default | Description | |---|---|---| | pageSize | 20 | Rows per page | | defaultOrderBy | — | Initial $orderby (e.g. 'name asc') | | fixedFilter | — | Filter always applied in addition to search | | baseUrl | window.location.origin | Override the request origin (proxy/BFF setups) | | fetcher | global fetch | Custom transport — pass a wrapper that injects auth headers | | onUnauthorized | — | Called on 401/403 responses, before the generic error is set |

fetcher/onUnauthorized should be stable references (e.g. wrap in useCallback) — they are intentionally excluded from the hook's internal effect dependencies to avoid refetch loops on every render.

The hook returns:

| Field | Description | |---|---| | data / totalCount | Current page rows and the server's total (@odata.count) | | loading | A request is in flight | | error | Message of the last failed request, or null. Render this — a failed request otherwise leaves the grid silently empty | | page / setPage | Zero-based page index | | sortCriteria / onSortChange | Bind onSortChange to the table's sort-change event | | search / setSearch | Current search term and its setter (resets to page 0) | | refresh | Re-run the current request |

Search semantics

setSearch takes literal text, not an OData search expression. Each whitespace-separated token is sent as a quoted $search phrase joined with ANDred shirt becomes $search="red" AND "shirt", matching rows that contain both terms.

Terms are always quoted because OData 4.0 only allows letters in an unquoted searchWord, so 2026 or ZT-E2E-A would be rejected by servers that follow it (4.01 relaxed this, but Microsoft.OData still lexes as 4.0). Quoting keeps any term valid regardless of server version. Since a $search phrase cannot contain " and OData defines no escape for it, double quotes are stripped from the term.

The quoting/escaping logic above is also available standalone as buildSearchExpression(term), for consumers that need the same $search encoding without the pagination hook (e.g. a typeahead/combobox that isn't a table). parseOrderBy(orderBy) ('a asc, b desc'SortCriteria[]) is exported the same way, for consumers driving a sort UI that isn't useODataSource either:

import { buildSearchExpression, parseOrderBy } from '@iyulab/flex-table/odata';

buildSearchExpression('red shirt'); // '"red" AND "shirt"'
buildSearchExpression('');          // undefined
parseOrderBy('name desc');          // [{ key: 'name', direction: 'desc' }]

Array Source Hook (React)

useArraySource(data, options) runs search/sort/pagination over an in-memory array and returns the same shape as useODataSourcedata/totalCount/loading/error/ page/setPage/sortCriteria/onSortChange/search/setSearch/refresh — so the same <FlexTableReact dataMode="server" ...> binding code works with either source.

Reach for this when the rows come from a client-side join a server query can't express — e.g. a lookup table whose display name lives on a different endpoint than the row itself, so search/sort has to run after the join, in memory:

import { useArraySource } from '@iyulab/flex-table/array';

const joined = useMemo(
  () => seasonPrices.map(p => ({ ...p, productName: productsById[p.productId]?.name ?? '' })),
  [seasonPrices, productsById]
);

const source = useArraySource(joined, {
  pageSize: 20,
  columns, // pass the same ColumnDefinition[] used by <FlexTableReact> for value-aware sort
});

| Option | Default | Description | |---|---|---| | pageSize | 20 | Rows per page | | defaultOrderBy | — | Initial sort (e.g. 'name asc'), same syntax as useODataSource | | columns | — | ColumnDefinition[] — enables value-aware sort (numbers/dates/booleans compared by value, not as text). Omit and every column sorts as text. | | searchFields | all values | (row) => value[] — narrows or widens what free-text search matches; the default searches every property on the row, including client-joined ones |

The returned fields mean the same thing as useODataSource's, except totalCount is the count after search (not a server-reported total), and loading/error are always false/null — there's no request to fail. refresh is a no-op kept only so a "refresh" button wired unconditionally against either hook doesn't need a branch.

Development

npm install
npm run dev      # Dev server with demo
npm test         # Run tests
npm run build    # Build library
npm run lint     # ESLint check

License

MIT