opengridv0.15.0

The public API

Elements, attributes, events, parts, custom properties and translatable texts — everything a page can rely on.

Everything a page can rely on. A test freezes this list (crates/opengrid-web-components/src/api.rs): when a name here changes, that test fails and the change has to be argued for in the diff.

Anything not on this page is internal, whatever its Rust visibility looks like.

Contents

Loading

import { loadOpengrid } from "@casoon/opengrid/loader.js";

const loader = await loadOpengrid();          // loads the WASM, registers the elements
loader.module.set_provider(host, provider);   // where the data comes from

loadOpengrid() answers { fallback, module }. fallback is true when the WebAssembly module could not be loaded and a plain-DOM stand-in was installed instead.

Function What it does
set_provider(host, provider) Attaches the data source. provider.execute(queryJson, mode, { signal }) answers the result as JSON or in the binary form (Uint8Array, ArrayBuffer), or a Promise of either; the third argument is optional, and so is everything else.
set_texts(host, texts) Overrides any subset of the texts; a second call adds to the first, a key left out keeps what it was, and null takes them all back to the English defaults. Call it before set_provider and the component renders the right words from its first paint.
set_formats(host, formats) Per-column display formatting — see <opengrid-grid>.
set_choices(host, choices) Per-column editor choices: { customer: ["Alpha", "Beta"] } turns that column’s editor into a type-ahead combobox; an entry may be { value, label }. A list column is edited only with choices — see Lists.
get_view(host) / set_view(host, view, options?) Reads and applies the whole view in one step; options.notice is said with the result.
set_columns(host, columns) Per-column presentation — see <opengrid-grid>.
get_query(host) The query of the current view, without a window — what an export sends. In a tree, every node flat (tree.flat), with its level and path.
get_pivot(host, options) The pivot as it is shown, as CSV — see exporting a pivot.
get_export_headers(host) The header texts of the banded columns shown, { column: "Band · Title" }, for exportRows’s headers — see Column bands.
prepare_print(host) Builds the grid’s print table — every match of the view — and answers a Promise of its row count; see printing the view.
set_cell_state(host, key, column, state, message?) What became of an edit the page saves: saving, saved or error — see Saving edits.
set_values(host, values) Values the page computes, [{ key, column, value }] — see Saving edits.
refresh(host) Runs the grid’s query again over a source that changed, keeping the view, the focus by record, the selection by key and the history — see A source that changes.
set_validators(host, validators) A function per column that runs while the reader types and says null or { level, message } — see Validation.
set_cell_templates(host, templates) A function per column that returns the DOM node a cell shows instead of its text — a link, a badge, a small bar — see Cell templates.
set_row_state(host, provisionalKey, state, detail?) What became of a row the reader added: saving, saved (with the record’s key) or error (with a message) — see Adding rows.
set_actions(host, [{ id, label }]) A trailing column per action, a button per row; fires opengrid-row-action — see Row actions.
register() Defines the three elements. loadOpengrid() calls it; a page that loads the module itself calls it once.

Types. The package ships loader.d.ts: every name on this page is typed — the view, the column configuration, the texts, the formats, the providers, and the attributes of each element as Opengrid…Attributes for adapters and JSX typings to build on — and the events are in the global event map, so grid.addEventListener("opengrid-view-change", e => e.detail.view) knows what detail holds, on the element and on the document alike.

A provider is a seam, not a class. Anything with an execute method fits, which is how the engine can sit in the tab, in a worker, behind HTTP, or be split across two of them without the elements knowing.

Data without JavaScript

The rows exist only once an element has run. A reader or crawler that runs no script, and a page whose module failed to load, would see none of them. So a page can write its data as a plain table inside the element:

<opengrid-table label="Orders" datasource="orders" columns="id,customer,amount">
  <table>
    <caption>Orders</caption>
    <thead><tr><th scope="col">id</th><th scope="col">customer</th><th scope="col">amount</th></tr></thead>
    <tbody><tr><td>1</td><td>Alpha</td><td>120.00</td></tr></tbody>
  </table>
</opengrid-table>
When What is read
No JavaScript The page’s table, as HTML: headers, rows, caption.
The element has rendered Its own shadow root. The page’s table is not rendered and not in the accessibility tree, so nothing is read twice.
The module could not load The page’s table again: the plain-DOM stand-in shows it in its place, and its empty skeleton only when there is none.

The element does not read that table: its data comes from the provider. What the page puts there is the page’s choice, such as the first page of rows or a summary, and it should say what it is, with a caption and scope on its headers.

Providers

All from loader.js, all the same shape:

createLocalProvider(engine) The engine on the main thread. load(name, bytes, schema), loadRows(name, rows, schema) (Data you already have), execute, pivot (see below), stats(), terminate() (a no-op here). watch(host) is what set_provider calls; a page does not.
createWorkerProvider({ moduleUrl?, wasmUrl?, workerUrl? }) The engine in a module worker; started lazily, once. Every option is optional: without moduleUrl it is the engine the package ships under engine/, without workerUrl the package’s worker.js. The same methods as the local provider; terminate() stops the worker. moduleUrl and wasmUrl are strings, since they travel to the worker by postMessage. A bundler follows the defaults; moduleUrl and workerUrl are for serving the files from elsewhere — see Frameworks → Bundlers.
createRestProvider({ url, source, token, watch }) POST /query/{source} of an opengrid-server; with watch: true it also follows the server’s change stream (terminate() closes it). Also offers describe() → { name, schema, capabilities, pivot_limits }, and export(query, options) → a Blob from POST /export/{source} (below), and exportPivot(pivotQuery, options) as the pivot provider does.
createHybridProvider({ remote, planner, mode, onPlan }) Splits each query between a remote source and the engine in the tab. onPlan receives the plan before anything is sent.
createPivotProvider({ url, source, token }) POST /pivot/{source} — a whole pivot in one request. Also offers exportPivot(pivotQuery, options) → the pivot as an XLSX Blob from POST /pivot/{source}?format=xlsx (exporting a pivot).

What the engine holds — stats() of the local and the worker provider (issue #70) answers { kind, memory, sources: [{ name, rows, columns, bytes }] }: the engine module’s WASM memory, which only grows, and what each loaded source’s columns take inside it. Together with the grid’s opengrid-query event it is a page’s resource report.

A pivot without a server. <opengrid-pivot> calls provider.pivot(pivotJson, mode, { signal }) when the provider has it, execute otherwise. createLocalProvider and createWorkerProvider have it — the engine answers pivots over its sources (Engine.pivot, Engine.pivot_columns), under the server’s default limits — and so does createPivotProvider.

Each of them carries a kind — "local", "worker", "remote" (REST and pivot) or "hybrid" — and a provider of a page’s own may too. The grid names it in its footer, beside the number of queries asked for what is shown (worker · wasm · 2 queries); a provider without a kind is shown with the count alone. The words are texts.

The engine — Engine for the tab, Planner for the hybrid provider — ships in the package under engine/, a module of its own next to the elements’. A worker needs no URL; a page that queries on the main thread imports it:

import init, { Engine, Planner } from "@casoon/opengrid/engine/opengrid_wasm.js";
import { createLocalProvider, createWorkerProvider } from "@casoon/opengrid";

await init();
const inTheTab = createLocalProvider(new Engine());
const inAWorker = createWorkerProvider();   // the same engine, off the main thread

Its types are generated with it and declare [Symbol.dispose](): a TypeScript project that checks library types needs ESNext or ESNext.Disposable in lib (TypeScript 5.2+), or skipLibCheck — only a project that imports the engine.

The binary result form

A result can travel as JSON or as bytes in application/vnd.opengrid.columns — the engine’s own columns, written out: no text per cell, decimals exact, and a worker hands the bytes over without copying them. The elements and exportRows read both forms, so a provider of your own may answer either.

The built-in providers use the bytes: createLocalProvider and createWorkerProvider answer them (Engine.execute_columns), createRestProvider and createPivotProvider ask the server for them with Accept and take JSON when the server answers JSON, and createHybridProvider finishes in the form its remote answered in (Planner.finish_columns). Engine.execute and Planner.finish still answer JSON. A page that calls a built-in provider’s execute itself gets bytes; the layout is described in crates/opengrid-columns/src/wire.rs.

Cancelling. execute takes an optional third argument, { signal }. The REST, pivot and hybrid providers hand the AbortSignal to fetch, so an aborted request stops; the tab and the worker cannot stop a query that has started and ignore it — the caller drops their answer. A provider written for two arguments still fits. exportRows is what passes one.

Connecting

The module functions have rules — wait for the module, texts before the provider, the view before the provider, a controlled view written back without a loop. connect knows them, so a page or a framework adapter supplies an element from one object and keeps it supplied:

import { connect } from "@casoon/opengrid";

const grid = document.querySelector("opengrid-grid");
const connection = connect(grid, {
  provider,
  texts: { lang: "de", loading: "Wird geladen …" },
  presentation: { amount: { align: "end", aggregate: "sum" } },
  view: saved,
  onViewChange: (view) => save(view),
});
connection.update({ view: other });   // applies only what changed
connection.disconnect();              // the listeners go; the element keeps its state
Option Module function
provider, texts, formats, choices, view set_provider, set_texts, set_formats, set_choices, set_view
defaultView set_view, once — the uncontrolled form: after that the grid leads
presentation set_columns — named apart from the columns attribute, which is the projection
onViewChange, onSelectionChange, onCellChange the events of the same names; each callback receives the detail (for the view: the view itself)
actions, validators, cellTemplates set_actions, set_validators, set_cell_templates — the grid only
onPaste, onBulkChange, onRowAction, onSourceReplaced opengrid-paste, opengrid-bulk-change, opengrid-row-action, opengrid-source-replaced; each callback receives the detail
onDrillDown opengrid-drill-down — the pivot only, with its drill-down attribute; the callback receives the detail
onRowAdd opengrid-row-add, and its answer is set_row_state: return { state: "saved", key } or { state: "error", message }, or a promise of one — the row shows saving until it settles; resolved with nothing, it is saved, and a rejection is an error with its message. No answer at all leaves the row unsaved for the page; an undone add is not answered

The connection also carries set_cell_state and set_values as setCellState(key, column, state, message?) and setValues(values) — the grid only; before the module has loaded, they wait for it.

const connection = connect(grid, {
  provider,
  actions: [{ id: "remove", label: "Remove" }],
  onRowAdd: async ({ values }) => ({ state: "saved", key: (await save(values)).id }),
  onCellChange: async ({ key, column, value }) => {
    await update(key, column, value);
    connection.setCellState(key, column, "saved");
  },
});
Order Texts, formats, presentation, choices, actions, validators, view, provider — so the first query is the only one, and the first paint is in the right words. An update that changes the texts or the presentation rebuilds the grid, and one that changes the view as well rebuilds it twice: the source is asked twice, and only the second answer is shown.
Changes update writes an option only when it differs from what the element has. A key left out keeps its value; a key given as undefined resets it — texts to English, no formats, no presentation, no choices, no actions, no validators. provider has no “none”; for view, undefined means the grid leads.
Controlled view is written whenever it differs from what the grid shows: after the reader sorted, passing the same saved view again restores it — and a page that keeps passing a view without taking the reader’s changes back holds the grid there, as a controlled input does. Writing back what the grid just reported costs nothing: onViewChange: (view) => connection.update({ view }). onViewChange hears the reader, not the views connect wrote. A view the grid refuses — a column it does not have yet — is tried again on the next update. Formats and validators compare functions by identity — keep them stable, or each update redraws.
Timing connect returns at once; connection.ready settles once the module is loaded and the options are applied. Updates before that are folded in. On the fallback, nothing is applied and ready says so.
The view needs the element in the document. On one that is not, the view and the provider wait for the next update, together, so the first query still asks for the view; the console says so. Everything else may come first.
Loading connect calls loadOpengrid(), whose first call decides the URLs: a page that needs its own calls loadOpengrid(options) first.
Attributes are not options. label, datasource, columns, group-by and the rest are set on the element, by the page or the framework, as always.

Importing the package touches no DOM, so it is safe in server-side rendering; connect belongs where the element exists.

<opengrid-grid>

An interactive <table role="grid">: virtualized, keyboard-driven, filterable.

<opengrid-grid label="Orders" datasource="orders" columns="id,customer,amount"
               window-size="40" mode="auto"></opengrid-grid>
Attribute Meaning
label The table’s accessible name (aria-label and <caption>).
datasource The source name; becomes the query’s source.
columns Comma-separated output fields, in order.
window-size How many rows are rendered and fetched at once while scrolling. Default 40.
page-size Switches from scrolling to paging. Mutually exclusive with virtualization.
mode local, remote, hybrid or auto, handed to the provider unchanged. Only a provider with more than one place to run a query reads it.
group-by Groups the rows by up to three columns, outermost first: group-by="country,customer". See Grouping.
tree Shows the rows as a tree: the field that holds a row’s parent key, tree="parent_id". See Tree.
tree-key The field the parent key refers to. id when absent.
row-key The field that names a record: opengrid-cell-change carries its value as key, and set_cell_state and set_values name rows by it. See Saving edits.
filter The page’s filter, as the wire’s JSON: {"field":"country","op":"eq","value":"DE"}, {"and":[…]}. And-ed onto every query the grid sends; not the reader’s and not in the view. See The page’s filter.
search Puts a search field above the grid: free text, or a filter written out. See Search.
facets Shows the facet sidebar — the facets a page configured with set_columns. See Facets.
toolbar Puts a toolbar above the grid: the active filters and the grouping as chips, a switch for the filter row, the column list, the density. Opt-in. See Toolbar.
add-row An empty new row after the last one: type a record into it. See Adding rows. Off while grouped, in a tree and with page-size.
filter-builder Puts Advanced filter into the toolbar: the button that opens the builder of the nested filter. Opt-in, and only with toolbar. Without it a nested filter still works — the search expression writes it, the view carries it — and its chip still opens the builder.
column-menu Gives every header a column menu: sort, filter, aggregate, group, hide. Opt-in. See Column menu.
selection Shows the selection column: a checkbox per row and one in the header. Opt-in — selecting rows works from the keyboard either way; what this adds is the column that shows it and the pointer path to it.
density compact, normal or comfortable — row height, cell padding and font size in one step. normal is the default, and a grid without the attribute is a normal one; an unknown value is normal too.
theme base, paper, violet, orange or dark — one of the built-in looks (Styling). base is the default; absent or unknown is base. Not part of the view: the look is the page’s choice, not the reader’s.

Keyboard. The WAI-ARIA grid pattern — arrows, Home/End, Ctrl+Home/End, PageUp/PageDown, and:

Keys On Does
Enter / Space header cell sort (Shift adds a second key)
Enter / F2 data cell open the editor (Enter commits, Escape discards)
a character data cell open the editor with it: it replaces the value in a text or number field, and in a select picks the one choice that is it or starts with it (the cell’s value when none or several do). Enter still commits. A date or a checkbox opens on the cell’s value
Space data cell select the row (Shift extends from the last one)
Space selection cell select the row (Shift extends)
Enter / Space selection header select every matching row, or clear it
Ctrl/Cmd+A anywhere select every matching row, not only the loaded page
Ctrl/Cmd+←/→ header cell move the column
Ctrl/Cmd+←/→ band cell move the band with all its columns (Column bands)
Ctrl/Cmd+Shift+←/→ header cell resize the column
Alt+Shift+←/→ header cell pin the column one step towards that side: let go of a pin at the other end, or pin it at this one (Pinned columns)
Alt+Shift+←/→ band cell, or the header cell of a column in a band pin the band one step towards that side
Delete band cell hide the whole band (Column bands)
↑ / ↓ header cell of a column in a band / band cell go to the band / back to the column (Column bands)
Alt+↓, Shift+F10, context-menu key header cell, or band cell open the column menu (with column-menu); a band’s offers Hide band

On a right-to-left page ← and → change places, and nothing else does: Right to left.

With selection set, the column is the start of the row: Home and Ctrl+Home go there, and ← from the first data column reaches it. Its header is a real checkbox inside the header cell — a columnheader may not carry aria-checked — and it is tri-state: empty, mixed, checked. The per-row mark is decoration; the row itself says aria-selected, and a second voice per row would double every announcement. The table carries aria-multiselectable="true" (Shift+Space and Ctrl+A select several). While grouped the data rows say it and the group headers and the total never do — and without a row-key, where a grouped grid selects nothing, neither the table nor a row says anything of a selection.

What a selection names. With a row-key the selection names records, by their key; in a tree it names nodes by tree-key; without either it names positions in the result. The keys are the same in all three; what differs — what survives a sort, what the event and the view carry — is in Selecting by key.

Very long results (issue #284). A browser caps the height of an element, so the scroll area is never more than 10 000 000 px (312 500 rows at 32 px, 238 095 at 42 px; measured: Chromium and WebKit stop at 33.5 million, Firefox at about 17.9 million). A result that fits is drawn exactly as it always was. A taller one is scrolled through two maps, and aria-rowindex and aria-rowcount stay the logical rows throughout:

  • Dragging the scroll bar, scrollTop = x and Ctrl+Home/End map proportionally: the thumb at the middle is the middle of the result, its two ends are the first row and the last row (the last one at the bottom edge). One pixel of the thumb is many rows.
  • Everything smaller — the wheel, touch, the track, PageUp/PageDown, the arrow keys — moves the browser’s own scroll position, so one notch of the wheel is a few rows, not thousands. Near either end of the scroll bar the grid puts the thumb back to the proportional place of where the rows actually are (the rows do not move on screen), which is what lets the wheel reach the first and the last row from anywhere. In between the thumb is therefore not exactly proportional while the wheel is used.

The query is the same window of limit and offset as for a short result. The focused row stays pinned while the reader drags away from it; it is parked outside the scroll area and is back in place when the reader returns.

Column visibility is a panel under its button (Columns, in the filter row, or in the toolbar when there is one): a checkbox per column, including the hidden ones — otherwise there would be no way back. It stays open while columns are ticked, each tick takes effect at once and the focus stays on the checkbox; Escape closes it and returns the focus to the button, and so does a click outside (without the focus).

The filter row is one field per column, as wide as the column (issue #96). Typing filters with a comparison that fits the type: contains for text, is for numbers and dates. A small button inside the field (filter-operator) shows the comparison as a sign and names it in words (customer operator: contains); it opens a menu (operator-menu, menuitemradio items) of the comparisons the type allows, with the column menu’s keys — ↓/↑ also open it from the button, Escape closes it and returns the focus. A comparison picked while the field has a value, or one that takes none (has no value), filters at once. Enter in the field applies as before.

The filter row’s fields follow the column’s type: text for text, a number field for numbers, a date field for dates (a timestamp is typed as text), a choice of any / yes / no for booleans, which has no comparison button. The date field is the browser’s own <input type="date">, so it shows dates the way the reader’s system writes them (dd.mm.yyyy on a German one), whatever the page’s language, and it brings the browser’s picker and its keyboard and screen-reader support. The value it gives the query is always YYYY-MM-DD. This is deliberate: the reader’s own format is the one they read and type fastest.

Formatting is display only and never reaches a query:

loader.module.set_formats(host, {
  amount: { kind: "number", locale: "de-DE", style: "currency", currency: "EUR" },
  ordered_on: { kind: "date", locale: "de-DE" },
  qty: (text, value) => `${value} pcs`,
});

Which locale an Intl format reads is decided in this order: the format’s own locale, then the nearest lang attribute above the element (through shadow roots), then the browser’s. A page with lang="de" therefore formats 1.234,5 in an English browser too, in agreement with the voice a screen reader uses. When a lang changes, the formats are resolved again and the element redraws; a format with an explicit locale does not move. A function format is the page’s own and is never touched.

A column without a format is rendered in Rust and never crosses the WASM/JS boundary. Intl takes a JavaScript number, so a decimal beyond 2^53 loses digits through the options form — such a column takes a function, which receives the exact text.

Presentation is what the schema cannot say:

loader.module.set_columns(host, {
  id:       { width: 96, mono: true, muted: true },
  customer: { title: "Customer", emphasis: true },
  amount:   { width: 150, align: "end", aggregate: "sum", facet: "range" },
});
Key Meaning
title What the column is called where a reader reads it: the header, the column list, the filter row’s labels, the column menu, the “+ Filter” dialog and “+ Group” menu, group rows, chips, facet headings, search suggestions and every announcement. The page’s words, in the page’s language. The field name stays the identifier — the query, the view, the search expressions (country = DE), exports and events. An empty title is reported.
width The column’s starting width in pixels. A reader’s resize leads after that.
pin start or end: where the column starts pinned while the others scroll sideways. A reader’s own pin leads after that. See Pinned columns. The grid only; a table refuses it.
band The band the column stands under: a header above it that it shares with the other columns naming the same band, band: "Address". The page’s words, like title. See Column bands. The grid only; a table refuses it.
align start, end or center. Numbers default to end, everything else to start.
mono Draw the values monospaced, so they line up character by character.
emphasis / muted Bold, or the muted ink.
aggregate sum, avg, count, min, max or range — the column’s aggregate in groups.
facet list, pills, range or period — how the column is offered as a facet.
lookup A map from the stored key to the label a reader reads, { 1: "Alpha GmbH", 2: "Beta AG" }: the cell, the editor, the filter row and the facets show and choose by label; the query, the sort, the groups and every export keep the key. See Lookup columns. The grid only; a table refuses it.
readonly The page fills this column with set_values: it never opens an editor, and its cells say aria-readonly. The grid only; a table refuses it.

The configuration narrows; it never widens. sum over a text column, a range facet over text, a name the grid does not have — each is reported in the status line and none of the call is applied. A grid that looks configured and is not hides the typo that caused it. Every problem in one call is reported together.

What the type already answers is not configurable: which filter operators a column offers is meaning, not taste. The alignment is taste, so it may be overridden. A column that is declared in columns but currently hidden keeps its configuration — its type-dependent checks run again when it is shown.

Toolbar

With toolbar, a labelled group of ordinary buttons sits above the filter row — outside role="grid", like the filter row, so the grid’s keys never reach it. It is one row: the search field first (with search), then the switches, the column list and the density as a segmented control.

On a narrow grid — under 560 px of its own width, whatever the window — the search field takes a line of its own and the buttons stay in one row that scrolls sideways, instead of wrapping into several; a button that takes the focus is brought into view. An opened column list gets a line of its own below the row. At a 320 px viewport, the reflow case of WCAG 1.4.10, the toolbar wraps instead.

Filter row A switch (aria-pressed). Hiding the row gives its height to the viewport, and PageUp/PageDown step by what is really there. Whether the row shows is part of the view as filterRow, on by default — there is no attribute for it, because a boolean attribute is off by default and the row has always been there.
Columns The column list moves here from the filter row, so it stays reachable when the row is hidden.
Density Three buttons, the pressed one is the grid’s density.
+ Filter Opens Add filter, a role="dialog" with aria-modal="true" (Tab does not leave it): Column, Condition (the operators the column’s type allows), Value, Cancel, Apply. The focus moves to Column; Tab cycles inside; Apply or Enter in Value writes the column’s entry in the filter row — the one place a filter lives — and closes; Escape, Cancel or a click outside close without a change. A value the column cannot take is named in the dialog, which stays open, and said in the status line — the one live region. The focus returns to the button.
Advanced filter With filter-builder. Opens the builder of the nested filter: conditions joined by all or any, in groups. A native modal <dialog>.
+ Group Opens a role="menu" of the groupable columns not grouped yet, with the column menu’s keys (↓/↑, Home/End, Enter/Space, Escape, Tab). A pick adds the next grouping level, as group-by does. With three levels the button is aria-disabled and says why.
Chips One per active filter, in words (country is DE), one for the nested filter as a whole, and one for the grouping. Each has a remove button named for its filter — Remove country is DE — and “Remove all” clears filters and grouping. A removal is said once, with the result that follows; the focus moves to the next chip, never to the document.

The chips are a display of the view, not a second truth about the filters: they are drawn from it, and redrawn only when what they say changed.

The page’s filter

<opengrid-grid datasource="orders" columns="id,customer,qty"
               filter='{"field":"country","op":"eq","value":"DE"}'></opengrid-grid>

filter restricts the grid to a part of its source — the orders of one country, the rows behind a pivot’s cell (Drilling down). It is a filter object as the protocol writes it, the same notation as the pivot’s filter: a comparison, is_null, and, or, not, nested as needed; the empty string is a value ("op":"eq","value":"") and not NULL.

Where it holds In every query the grid sends: the rows, the counts, group rows and their aggregates, a tree’s levels, the facets’ values and counts, the keys behind “select all”, and get_query — so an export is the same rows. It is and-ed with what the reader filters; the reader works inside it.
Whose it is The page’s. It is not shown in the filter row or as a chip, “clear filters” leaves it, the empty state offers no reset for it, it is not in get_view/set_view, and changing it fires no opengrid-view-change.
Changing it Asks again, as a filter of the reader’s does: the first rows, the selection dropped (and said), the facets’ values listed anew. Removing the attribute removes the filter.
What does not hold Text that is not a filter object is said in the status line and nothing is asked — never a grid that silently shows every row. A filter the source refuses (an unknown field, a value of the wrong type) is the provider’s error, in the status line like any refused query.
Not access control The filter is written by the page and readable and changeable in the browser. What a reader may see is decided by the server’s row filter; this only chooses what the page shows.

A framework component takes it as the filter prop, a string, as the pivot’s.

Empty state

When a result has no rows, the viewport says why, in one of two sentences: No row matches these filters. with a Reset filters button — the same as the toolbar’s “Remove all” — or, when the source itself is empty, There are no rows. and no button, because a reset would promise what it cannot do. The panel is silent: the status line already says No matches, and a second voice would say it twice.

With search, a field above the grid takes two kinds of input, told apart by how they start:

Typed Enter does
country = DE and amount ≥ 10 Writes the filter row’s own entries — the same fields, the same view, the same chips — and empties the field. One place a filter lives.
country = DE or amount ≥ 1000, amount > 1 and amount < 5 Sets the grid’s nested filter — the one place a filter lives that the row cannot hold — and empties the field.
Alpha A free-text search: contains on every shown text column, or-ed.

Input that starts with a word and an operator is read as a filter even when the word is not a column: colour = red says “colour is not a column of this grid” rather than searching for the words and finding nothing.

The expression, written down:

expression = term   { OR  term }
term       = factor { AND factor }
factor     = "(" expression ")" | condition
condition  = column operator value
column     = a field name: letters, digits and "_"
operator   = "="  "≠" "!="  ">"  "≥" ">="  "<"  "≤" "<="  "~" (contains)  "^" (starts with)
value      = text, or "text" in double quotes
AND        = "and", or the queryAnd word
OR         = "or",  or the queryOr word
Precedence and binds tighter than or, as everywhere: a = 1 or b = 2 and c = 3 is a = 1 or (b = 2 and c = 3). Parentheses say otherwise: (a = 1 or b = 2) and c = 3. They nest.
The joining words Stand between whitespace and are read in any case: AND, Or. The English words always work beside the page’s (queryAnd, queryOr), so an English habit holds on a German page.
A value Runs to the next joining word, to the parenthesis that closes its group, or to the end. Parentheses that pair up inside it are its own: customer = Acme (EU). In double quotes it is taken whole — customer = "Rock and Roll", note ~ ":)" — up to the next "; there is no escape for a quote inside.
Only and, no column twice The conditions become the filter row’s entries, one per column, as before — parentheses that group nothing are dropped.
With or, or a column twice What the filter row cannot hold becomes the nested filter, whole, and replaces the one there was: an expression with an or, and one with two conditions on one column — amount > 1 and amount < 5 keeps both.
What is wrong Is said in the status line and stays in the field: an unknown column, a missing value, an operator the type does not take, a value that is not the column’s — and A parenthesis is not closed, A closing parenthesis has no opening one, A condition is missing (()), “and” or “or” is missing before “…” ((a = 1) b = 2).
  • Enter searches, not every keystroke: a query and an announcement per letter would be a barrage for a screen-reader user and a round trip per letter for a remote source.
  • An expression that does not parse — an unknown column, a missing value, an operator the type does not take, a value that is not the column’s — is said in the status line and stays in the field for correcting. It never falls back to a free-text search: that would look like it worked and show the wrong rows.
  • Free text searches values, not what a format prints: 30.00 does not find an amount, and 31.12. does not find a date. It ignores case (icontains, rule S5a): de finds DE. The filter row offers both forms, contains (exact) and contains (any case).
  • The field is an ARIA combobox: while the last word is a bare prefix of a column, a listbox offers the columns. ↓/↑ move through it (the focus stays in the field), Enter takes one, Escape closes it — and, closed, empties the field and the search.
  • A free-text search shows as a chip. It is not part of the view, and restoring a view empties it.

Nested filter

A filter with or across columns — country is DE or amount ≥ 1000 — cannot live in the filter row, which holds one condition per column and joins them with and; neither can two conditions on one column. The grid holds one such filter beside the row: a tree of conditions joined by and and or. It has two editors — the search expression and the builder dialog — and both write the same tree.

The chip One chip says it in words: country is DE or amount greater or equal 1000; a group inside a group stands in parentheses, country is DE and (amount greater or equal 1000 or qty less than 5). Its text is a button named Edit … that opens the builder; its remove button takes the whole filter, and so does “Remove all”.
With the rest Everything is and-ed: the page’s filter, the filter row, the facets, the free-text search and the nested filter. A row shows when it passes all of them. The facets count under it, like under the filter row; select-all, get_query, the exports and the print table run under it, and the print table’s caption says it.
The view where holds the tree, in the wire form of a query’s filter.
Setting it Is said once, with the result: 66 matches · Filter set: country is DE or amount greater or equal 1000. The selection is treated as under any filter: one by position goes, one by key stays. opengrid-view-change fires.

The builder opens from the chip, and — on a grid with filter-builder — from Advanced filter in the toolbar. The button is opt-in; the chip’s is not: a filter a reader can see, a reader can edit. It is a native modal <dialog>: the page behind it is inert, Tab stays inside, Escape and Cancel close it without a change.

A group Rows match all conditions or any condition — a <select> — then its members, then + Condition and + Group. The dialog is the outermost group; a group inside is a <fieldset> named Group 1, with its own choice, members and buttons, and a Remove Group 1 button. Groups nest as deep as the expression wrote them.
A condition A group named Condition 1 with three labelled fields — Column, Condition (the operators the column’s type allows), Value (typed by the column: a number, a date, a checkbox) — and a Remove Condition 1 button. Numbers follow the order on the screen and are given anew when a member goes.
Opening With no nested filter: one empty condition. From the chip: the tree as it stands. The focus is on the first field.
Adding and removing + Condition puts the focus on the new condition’s Column. A removal puts it on the group’s + Condition — never on the document.
Apply Also Enter in a value. Reads the tree: a group with one member is that member, a group inside a group of the same kind joins it, an empty group is left out. No condition left removes the nested filter. Then the dialog closes and the result says the filter.
What is wrong A missing value, or one the column cannot take, keeps the dialog open: the sentence stands above the buttons, the field is aria-invalid, described by the sentence, and takes the focus — so it is read once, with the field.
A hidden column A condition on a column the grid does not show is kept as it is: its fields are disabled, it can be removed. Show the column to edit it.
The focus afterwards On the chip’s button when the builder was opened from it and it is still there, else on Advanced filter — and without that button on the toolbar’s Filter row switch, where the focus goes when the last chip is removed.

Facets

loader.module.set_columns(host, {
  customer:   { facet: "list" },    // checkboxes with counts
  country:    { facet: "pills" },   // toggle buttons with counts
  amount:     { facet: "range" },   // a From and a To
  ordered_on: { facet: "period" },  // two dates
});

With facets, a sidebar beside the rows offers them. Only configured columns get a facet — a range over the ids would be a control nobody asked for.

Counting A facet counts its values without its own restriction: with “Alpha” ticked, Beta still shows its own count. One group query per counted facet, not one per value — and always (F4). The sidebar’s head says what the counts cost: Counted with 2 queries.
Filtering Facets are and-ed onto the filter row. The values of one facet are an or; NULL is (no value) and filters with is_null (S1), the empty string is (empty) — two different values (S14).
Bounds A bound that is not a value of its column (abc as an amount) is named in the status line, not dropped. Every bound has a visible label — From, To — inside a fieldset named for its column.
Chips With the toolbar, each active facet is a chip: customer is one of Alpha, Beta.
View facets: { customer: { values: ["Alpha", null] }, amount: { min: "5", max: "" } }.
Keys The sidebar holds ordinary checkboxes, buttons and fields; the grid’s keys never reach them.

Column menu

With column-menu, each header has a menu. It is a second door to things that all have a first: sorting from the header, filtering in the filter row, hiding in the column list, pinning with the header’s keys, grouping and aggregates through group-by, set_columns or the view.

Keys In the menu
↓ / ↑ next / previous entry, wrapping
Home / End first / last entry
Enter / Space do it, close, focus back on the header
Escape close, focus back on the header
Tab close, and move on from the header — no trap

The ⋯ in the header is the pointer path, 24px square. It is not a second tab stop inside the grid: the header cell holds the roving tabindex and says its shortcut through aria-keyshortcuts.

The menu is role="menu" and holds no form: “Filter …” moves the focus to the column’s field in the filter row, because a field inside a menu breaks the role. Entries appear only where they do something — aggregates for numbers and dates, grouping for columns that can be grouped by, “hide” unless it is the last column. The Pin group — Not pinned, At the start, At the end — is in every menu (Pinned columns). The menu opens below its header and never covers it.

Pinned columns

A pinned column stays in place while the others scroll sideways: at the start of the row or at its end — by reading direction, so on a right-to-left page the start is the right edge.

loader.module.set_columns(grid, {
  id:     { pin: "start", width: 96 },
  amount: { pin: "end" },
});
The page pin in set_columns is where a column starts, like width. It asks nothing of the column’s type; a value that is neither start nor end is reported like every other mistake in the call.
The reader On the header cell, Alt+Shift+←/→ takes the column one step towards that side: from a pin at the other end to not pinned, from not pinned to a pin at this one. With column-menu, the menu’s Pin group does the same in one go. The reader’s choice leads over the page’s. Every change is said in the status line (customer pinned at the start); a key that changes nothing says where the column is.
The view columns.pinned holds what the reader changed, { customer: "start", id: "none" } — none is a pin of the page’s the reader took away. A change fires opengrid-view-change, like a width or a move; set_columns does not, it is the page’s.
The order The columns pinned at the start are the first of the row and the ones pinned at the end the last, each run in the order its columns had. It is one <table>, and the DOM, the arrow keys and a screen reader go by the same order as the screen. Pinning a column therefore moves it: one query, around the view the reader has — sort, filters, grouping and the search stay; a selection by position is dropped and said, as for every new order. Ctrl/Cmd+←/→ moves a column among the columns pinned like it and stops at the edge of that run.
Selection column Pinned at the start whenever a column is.
Row actions Pinned at the end whenever a column is.
The focus A cell that takes the focus is scrolled clear of the pinned columns, never under them (WCAG 2.4.11): they are the viewport’s scroll-padding. A pinned cell, or a pinned column’s filter field, is in sight already: the focus on it scrolls nothing sideways.
Too narrow When the pinned columns — the selection column and the action columns that are pinned along counted — would take more than half of the grid’s width, the pins let go and every column scrolls; the status line says so once, when they let go — not again while the grid stays that narrow: Too narrow for the pinned columns: they scroll with the others. The setting stays, in the view too, and holds again as soon as there is room. Half, so that the columns that scroll keep the other half: at 320 px (WCAG 1.4.10) pinned columns alone would be the whole grid.
The filter row A pinned column’s field stays above it, and a field that takes the focus is scrolled clear of the pinned ones, like a cell.
Groups and totals Their cells are pinned with their columns. The label of a group or a total runs across the empty cells beside it, as without a pin; while the grid is scrolled sideways and the first column is pinned, the label keeps to that column — it is whole again when its cell has the focus.
Looks A line marks where the pinned columns end. The cells take their row’s background, so ::part(cell) styles them as before.
On paper Nothing is pinned (Printing the view); the columns print in the order shown.
get_query, exports The columns in the order shown, the pinned ones at their end of the row.

Not here: pinned rows, and a total row that stays in place.

Column bands

A band is a header above several columns — Address above street, city and postcode. A column names its band in set_columns, and the columns that name the same one share its header:

loader.module.set_columns(host, {
  street:   { band: "Address" },
  city:     { band: "Address" },
  postcode: { band: "Address", width: 96 },
});

There is one level of bands. A band is always in one piece: its columns stand side by side.

The page band is the band’s name as the reader reads it, in the page’s language. It asks nothing of the column’s type. An empty name is reported like every other mistake in the call, and so are two columns of one band that pin at different ends.
The header Two rows as soon as one shown column has a band. The band’s cell stands in the first row, across its columns; the header cells of its columns are in the second. The header cell of a column without a band spans both rows. Without a band the header is the one row it always is.
The order The columns of a band stand together where the first of them stands, in the order they have — also when columns or a view’s columns.order names them apart.
Moving Ctrl/Cmd+←/→ on the header cell of a column in a band moves the column inside its band and stops at the band’s edge (city is already at the end). On the band’s cell the same keys move the band with all its columns, past the column or the band beside it. A column without a band steps over a band as over one column; it never lands inside. A moved band is said like a moved column, with the place of its first column: Address moved to position 2 of 6.
Hiding A hidden column leaves its band; the band’s cell spans the columns that are left. Hiding the last shown column of a band hides the band, and showing one again brings it back. The band’s cell hides the whole band: Delete on it, or Hide band in its menu (Alt+↓, Shift+F10 or the context-menu key, with column-menu). Every column it spans is hidden in one step and the status line names the band (Address hidden, 3 of 6 columns shown); the view stores it per column, as for any hidden column (columns.hidden). A band that is all there is stays (Address is all that is shown and stays). It comes back through the column list (columns-toggle), which lists its columns: ticking one shows the band again. Sorting, resizing and filtering stay off the band’s cell.
Pinning A band is pinned as a whole. pin on one of its columns in set_columns, Alt+Shift+←/→ on the band’s cell or on the header cell of one of its columns, and the column menu’s Pin group all pin the band: its cell stays in place above its columns, in one piece. The status line names the band (Address pinned at the start), and the view holds the choice for each of its columns, columns.pinned: { street: "start", city: "start", postcode: "start" }.
Widths, sorting, the menu These belong to the columns: a band has no width of its own and sorts nothing. Enter, Space and the resize keys do nothing on a band’s cell, and the column menu opens from the columns’ header cells.
The view Nothing new: a band is the page’s configuration, not the reader’s choice. A view saved without bands is arranged under them when it is restored.
The filter row, groups, totals One field and one cell per column, as without bands.
On paper, exports, get_query One header row, Band · Column (issue #353): the print table writes Address · City over a banded column’s cell; the exports do it when the page hands them the texts, exportRows(provider, get_query(grid), { headers: get_export_headers(grid) }) — CSV and XLSX; a column without a band keeps its field name, and JSON has no header row. A server takes each as a parameter, POST /export/{source}?header.city=Address%20%C2%B7%20City. The query and the order of the columns are unchanged.
Looks The band’s cell is the part band; a line at either side shows which columns it spans.

Keys in the header. With bands the header is two rows, and its cells span: a band’s cell several columns, the header cell of a column without a band both rows. The arrow keys move through it by what is on the screen.

Keys On Goes to
↑ header cell of a column in a band the band’s cell
↑ band cell, or the header cell of a column without a band nowhere: it is the first row
↓ band cell the header cell of the column the reader came up from — the band’s first column when the cell was reached sideways or with the pointer
↓ header cell the first data row, same column
← / → band cell the cell beside it in the first row: the next band’s cell, or the header cell of a column without a band
← / → header cell the header cell of the column beside it, in a band or not
Home / End band cell the first / last cell of the first row
Home / End header cell the first / last header cell of the columns
Ctrl+Home anywhere the first cell of the first row
↑ a cell of the first data row the header cell of its column

← and → change places on a right-to-left page, as everywhere (Right to left). Exactly one cell of the grid is in the tab sequence, a band’s cell included.

What a screen reader is given. The band’s cell is a column header for its group of columns (<th scope="colgroup">, spanning them), and each data cell under a band names both its headers, the band first (headers): a cell is announced with its band and its column — Address, City. The header rows are counted: with bands aria-rowcount is the number of rows plus two and the first data row is aria-rowindex="3". Every header cell says its aria-colindex, since the cells of the two header rows are not the columns in DOM order.

Not here: more than one level of bands, and bands in <opengrid-pivot>, which has its own two-level header.

Grouping

<opengrid-grid datasource="orders" columns="id,customer,country,amount"
               group-by="country,customer"></opengrid-grid>

While group-by is set, the grid is a treegrid: group headers carry aria-level, aria-expanded and aria-posinset / aria-setsize (the group’s place among the groups of its level, so a pooled window of rows still says “group 3 of 7”), data rows sit one level below the innermost group with their own place among that group’s rows. The total row is no group and carries neither. It stays virtualized — the group query answers each group’s row count, and from the counts and the open groups the display list is arithmetic, so aria-rowcount is still one number and scrolling still adds no DOM rows. The rows of a visible window are fetched per group they belong to: one query per group the window touches, not one per row. With two or three levels, a group above the innermost one asks once more, when it is first opened: for the groups under it.

Keys Enter / Space open and close a group; on its first cell → opens and ← closes.
Pointer A click on a group row opens or closes it.
Label One sentence per header, groupRow: country: DE (52 rows). NULL is (no value) and the empty string (empty) — two different groups (S10, S14).
Status line Counts rows, not display positions — five headers are not five matches. Opening and closing is said once, with the result that follows.
Groupable Text, booleans, whole numbers and dates. A decimal, a float or a timestamp repeats too rarely to group by and is refused.
Refused More than three levels, a column the grid does not show, or page-size alongside: the grid says why in the status line and stays ungrouped.
View group and expanded (paths of keys; a NULL key is null) travel in the view.

Aggregates go into the group rows and into a grand total, the last row of the list. A column shows one only when a page chose it with set_columns (aggregate) or a reader chose it in the view (aggregates: { amount: "sum" }, which leads) — there is no default, because “sum every number” would sum the ids.

Allowed count on every column; sum and avg on numbers; min, max and range on numbers and dates. Anything else is named in the status line.
Range The smallest and the largest value, 1.1.2026 – 31.12.2026 — the dates a group spans. Asked as min and max; a group whose rows all hold one value shows it once.
Exact A sum over a decimal is a decimal — exact past 2^53 (S8). An avg is a float, by the query model’s result types (S12).
NULL Skipped by sum, avg, min, max; not counted by count (S11). An aggregate over nothing is empty on screen and says (no value).
Spoken The cell shows a glyph (Σ ⌀ # min max) and says a word: Sum: 1,234.00. The glyph has an empty alternative.
Total A row at the end of the list, not a sticky footer: the arrow keys and a screen reader reach it like any row. It opens nothing and cannot be selected.

While grouped, a row number names nothing: it would be a display position — a header as often as a row — and would move on every toggle. So a grouped grid edits and selects by row-key, and without one it does neither.

Editing (issue #190): the data rows of a group edit, and the change names its record by key; the row in the event is a display position and not for the page to use. Group headers and the total stay read-only. A fresh result (a replaced source) brings fresh aggregates for every group.

Selecting by key

A grid whose records have a key selects by key (issue #322):

The grid The selection names opengrid-selection-change In the view
row-key, a field among the columns — grouped or not, with or without page-size records, by their row-key value { count, keys } selected
a tree nodes, by their tree-key value { count, keys } selected
neither positions in the result { rows, ranges, count } —
group-by without row-key nothing: a display position is a header as often as a row — —

What selecting by key does, with row-key:

Space on a data row, or its selection cell Selects or deselects the record.
Shift+Space Selects every data row from the last toggled row to this one, keeping what is selected. While grouped these are the rows in the display list: group headers in between are passed over, and a closed group has no rows in the list, so none of its rows are taken. When the last toggled row is not in the list — nothing toggled yet, its group closed, or another record in its place after a sort or a filter — it is a plain toggle.
Ctrl/Cmd+A, the selection header Every row of the filter — the page’s and the reader’s together, and while grouped, whether its group is open or not. The header clears the selection when all of them are selected already, and says the count (selectedAll).
A group header Is not selected and selects nothing: Enter, Space and a click open and close it. The total is not selected either. aria-selected is on data rows only.
Opening and closing a group Never changes what is selected. The rows of a closed group stay selected, and show it when it opens again.
Sort, filter, a scroll, another page, grouping, ungrouping, another group-by The selection stays: a key names a record, not a position. After a filter it can hold records that match no longer; the header then shows mixed.
Another row-key, a row-key added or taken away, a replaced source, another page filter The selection is dropped, said in the status line (selectionCleared) and reported with an opengrid-selection-change that holds nothing — keys of another field name nothing, positions and keys do not turn into each other, and whether a record is in a new source, or under another page filter, nobody has asked.
The event { count, keys }: keys are the row-key values of the selected records, in no particular order, and count counts them. There is no rows and no ranges — a position names no record, and most selected records are not loaded.
The source A selected record is known by its key alone, so the keys behind a Shift range that reaches past the loaded rows and behind “every row” are asked of the source, in the grid’s order: one query per group a range touches, the rows of the filter in pieces of 10 000.

A tree keeps its own rules for what Shift and “all” take — the nodes shown — and the same event and view.

The view carries the selection by key as selected, and set_view restores it:

get_view selected is the list of selected keys — a snapshot, like the rest of the view. It is left out while nothing is selected, and always for a selection by position.
Selecting fires opengrid-selection-change and never opengrid-view-change: a page that stores the view on every view change stores the selection as it was then.
set_view replaces the selection with the view’s. A view without selected selects nothing, the rule of every absent field of a view — the selection there was is dropped and said. In a grid that selects by position selected is not used.
Untrusted keys The keys are checked before anything is selected: selected must be a list of strings, numbers and booleans, or the view is refused like one that names an unknown column. Then the source is asked which of them name a record — only keys of the key column’s type are asked, in pieces of 1 000 — and only those are selected. The check runs under the page’s filter, which says which records this grid has: a record it hides names none here. The reader’s filter does not count — a selected record may be filtered out of sight.
What is missing Keys that name no record are dropped, and said once with the result: 17 matches · 3 selected rows no longer exist (selectionMissing). opengrid-selection-change fires with what remains.
A failed check is said as an error with the result, and nothing is selected — not a silently empty selection.
Cost A view with selected costs the check beside its one query: one query per 1 000 keys, and one for the key column’s type when no result has told it yet.

Setting a value for the selected rows stays off while grouped.

Adding rows

<opengrid-grid datasource="people" columns="id,name,room" row-key="id" add-row></opengrid-grid>
grid.addEventListener("opengrid-row-add", async ({ detail }) => {
  try {
    const saved = await post(detail.values);
    loader.module.set_row_state(grid, detail.provisionalKey, "saved", saved.id);
  } catch (error) {
    loader.module.set_row_state(grid, detail.provisionalKey, "error", `Not saved: ${error.message}`);
  }
});

With add-row the grid shows an empty new row after the last one. Typing into it opens the editor as on any cell, Enter goes on to the next column, and Enter in the last column — or Ctrl/Cmd+Enter anywhere in the row — adds the record: opengrid-row-add fires with the values by field name and a provisionalKey, the row appears at once marked unsaved, and a fresh new row holds the focus in its first column. The page answers with set_row_state; the message of an error is said in the status line, as set_cell_state says one. An added row is read-only; the page loads fresh rows (loadRows) and the saved ones are the source’s from then on. Added rows and the undo history stay when the columns are moved or hidden; another datasource ends them.

A field that is not nullable in the schema is marked required in the new row. Adding without a value for one is refused: nothing fires, the cell is marked invalid and the status line says {column} needs a value. Off while grouped, in a tree and with page-size.

Lists

A column of type list<utf8> or list<int64> holds an ordered list of values. The cell shows them as chips (part="list-item" inside part="list"; role="list" and listitem). The editor is the ARIA combobox over the page’s set_choices: the text field filters the choices as you type (aria-expanded, aria-activedescendant, the number of choices said in the status line), ↓/↑ move over them, Enter picks, Backspace on an empty field takes the last chip back, and Enter on an empty field commits. A text that is none of the choices is refused with the reason under the editor, never dropped. { value, label } pairs show the label in chips and options and give the page the value.

loader.module.set_choices(grid, {
  subjects: [{ value: "m", label: "Maths" }, { value: "p", label: "Physics" }],
});

A list column with no choices has no editor. Emptied, a list becomes the empty list — a NULL cell stays NULL if nothing was picked. The opengrid-cell-change for a list column carries the JSON array as its value. A plain column with choices uses the same combobox in place of the old <select>: one pick, committed with it.

Filtering a list column: contains (has), contains all (has_all, values separated by commas) and is empty (is_empty), besides the null checks. It is not sorted, grouped or faceted; its only aggregate is count. In CSV a list is the JSON array in the cell; see Query semantics for what a list is.

Lookup columns

A column that stores a key — customer_id, a country code — shows the key. With a lookup in set_columns, the page gives the labels, and the reader reads and chooses by them:

loader.module.set_columns(grid, {
  customer_id: { title: "Customer", lookup: { 1: "Alpha GmbH", 2: "Beta AG", 3: "Gamma Ltd" } },
  country:     { lookup: { DE: "Germany", FR: "France" }, facet: "list" },
});

The map is the only source of the labels: one plain object per column, the stored key on the left, the label on the right. The grid asks no second source for them, and the query model has no join. The label is display. The value is the key — in the query, on the wire, in the view and in every event.

Sorting a lookup column sorts by the key, not by the label. Sorted ascending, 1, 2, 3 stand in that order whatever their labels say — Zeta before Alpha if those are the labels of 1 and 2 — and a reader who sees only labels sees an order that is not the alphabet’s. The header says “sorted ascending” all the same: it is, by the key. A page that needs the order of the labels gives the source a column that holds them and sorts by that one, or uses keys that sort like their labels.

By
Cell label A key the map does not name shows the key itself; NULL shows nothing. The label leads over a format of set_formats for the column. Labels are text, so the column lines up at the start, number keys included, unless align says otherwise.
Sort key See above.
Grouping key The groups are the keys, in the keys’ order; a group’s header names it by its label, Customer: Alpha GmbH (12 rows).
Aggregate key count only: a key is not an amount, and the smallest key is not the first label. Any other aggregate is refused for the column — in set_columns, and from a view — and the column menu offers none.
Editor label The ARIA combobox of Lists over the map’s labels, in the map’s order: typing filters them, Enter picks, and a text that is none of the labels is refused under the editor. What is committed is the key, and opengrid-cell-change carries the key as its value. Where the page also gave the column set_choices, those choices are what the editor offers, named by the map’s labels where they have none of their own.
Filter row label The column’s value is a choice among the map’s labels, with is and is not (and the null checks where the column may be null). The filter it writes compares the key, and the view’s filters hold the key. The “+ Filter” dialog and the filter builder offer the same choice, and a filter’s chip — the nested filter’s too — names the label. A filter on a key the map does not name — a view saved under other labels — stays, named by the key.
Facets label list and pills fit a lookup column of either type, and list the keys the rows hold, by label, in the keys’ order. The selection in the view is the keys. range is refused: a span of keys is not a span of labels.
Search key An expression compares the stored value — customer_id = 2, with = and ≠ only, joined by and or or like any other — and the free-text search reads values, as everywhere (Search): a label is not found by it.
get_query, export key get_query and exportRows hand out the stored values, as for every column: what a file is read back from.
Print label prepare_print writes the values as the grid shows them, the caption as the chips say it.
Paste, set_values, validators key What the page or the clipboard writes into the column is the stored value.

A lookup fits a column of type utf8 or int64. The keys of the map are JavaScript property names, so text: for an int64 column each is the number’s digits ({ 7: "Seven" } and { "7": "Seven" } are the same map), and one that is not a whole number is reported. So are an empty key, a label that is not a non-empty string, and a lookup that is not a plain object — as every mistake in set_columns, in the status line, with none of the call applied. The options of the editor and of the filter row stand in the order JavaScript lists the object’s keys: whole numbers ascending, then the others as written. The labels are the page’s words, in the page’s language. To change them, call set_columns again with the whole configuration.

Undo and redo

Ctrl/Cmd+Z undoes the last thing the reader did in the grid, Shift+Ctrl/Cmd+Z (and Ctrl+Y) does it again. The history is the grid’s own — cell edits, rows added and a paste as one step — not the page’s saves. Each step fires the events the original fired — opengrid-cell-change, opengrid-row-add, opengrid-paste — with undo: true (redo: true going forward), so the page saves it as it saves an edit; an undone row add means “delete that provisionalKey”, and a redone one is a new add under a new key. The cell shows the old value, marked unsaved, and the focus goes to it; the status line says Undone: amount in row 12. A new edit ends the way forward. The history ends when the source is replaced (loadRows), and without a row-key also when the sort or the filter changes, since a position then names another record. With a row-key the record decides: when it is not among the loaded rows (a filter took it out, a scroll moved on), the undo does not write into the grid — its old position holds another record now — and the event still reaches the page with the record’s key. A value the page sets with set_values ends the steps it overrides, so the grid does not take back what the page has just said. A bulk change is not a step: it ends the history. An open editor keeps the browser’s own undo of its text.

Validation

loader.module.set_validators(grid, {
  // a function: it never blocks
  seats: (value, row) => (Number(value) > 60 ? { level: "warning", message: "Over 60 seats is a hall." } : null),
  // an object: an error blocks the commit
  name: {
    validate: (value) => (taken.has(value) ? { level: "error", message: "That name is taken." } : null),
    block: true,
  },
});

The function runs on every keystroke in the editor and once more on commit; value is the text as it stands (as opengrid-cell-change gives it) and row the record as loaded, by field name. It answers null, or { level: "warning" | "error", message }. The message shows under the editor, stays on the cell (data-validation, described by a hidden node through aria-describedby; an error is also aria-invalid) until the next result, and is said once, politely, in the status line.

A finding does not block the commit — the reader may save a duplicate and fix it later — unless the column is given as { validate, block: true } and the answer is an error: then the editor stays open with the message. What the schema refuses (a letter in a number) always blocks. For a finding the page only knows after saving, set_cell_state(host, key, column, "invalid", message) marks the record’s cell the same way and keeps it across results.

Setting a value for the selected rows

With rows selected, F2 — or Set for selected rows in a column’s menu — opens that column’s editor once, in a small panel above the rows. Enter (or the button) sets the value, Escape drops the panel. One opengrid-bulk-change fires with { keys, column, value }: the row-key values of the selected records, the column and the value as text. The cells in view show it, marked unsaved; the page saves and answers with set_cell_state per key. It needs row-key, and a column the page fills (readonly in set_columns) refuses it, said in the status line. Off while grouped and in a tree. The keys are the selection’s own.

Editing a whole record

Shift+F2 on a data cell opens the focused record as a form: a native modal <dialog> (part="record-dialog") the grid builds from the schema, one labelled field per column in column order, each the editor its cell would open (a checkbox, a date, a combobox over the page’s choices). A column the page fills (readonly in set_columns) is shown, not editable. The page behind the dialog is inert and Tab stays inside it; Escape or Cancel closes it and changes nothing; Enter in a field, or Save, saves. The focus goes back to the cell it came from.

Before it opens, the grid fires a cancelable opengrid-record-open with { key, row }: a page that wants its own form calls preventDefault() and the grid opens nothing. It needs row-key (the page names the record, as for setting a value for the selected rows), and is off while grouped and in a tree.

Saving is the edits it holds, fired as cell edits. Each field is read as the cell editor reads it (an empty field is NULL where the column allows it) and checked first: a value the column cannot hold, an empty required field, or an error from a block: true validator stays in the dialog with the sentence under the field (aria-invalid, described by it) and the focus on the first such field; nothing is saved until every field is fine. Then one opengrid-cell-change fires per field whose value changed, in column order, exactly as for a cell edit — the page saves as it already does — and the cells show the values, marked unsaved. A record saved with nothing changed fires nothing and says so in the status line. The edits are one step of the history: Ctrl/Cmd+Z puts all of them back, firing one opengrid-cell-change per field with undo: true.

Copying a range of cells

Shift+arrow keys on a data cell mark the block between where it began and where the focus is; Shift+click and dragging with the pointer do the same. The live region says its size — „3 by 4 cells“ — and each cell in it carries data-range (a tint with an outline, which holds in forced colours). The row selection is another thing: Shift+Space and the checkboxes select rows, and no cell says aria-selected for a range. Any key that moves or edits, a click elsewhere, and another result for the whole query end the range. Off while grouped and in a tree.

Ctrl/Cmd+C puts the block on the clipboard as a spreadsheet reads it: tab-separated, a line break after each row, a cell holding a tab, a line break or a double quote in double quotes. A cell is its value, not what the column’s format shows: 1234.5, 2026-10-26, true, a list as its elements joined by commas, NULL as nothing. Ctrl/Cmd+V reads it back — here, into a block of the same shape, and into a spreadsheet — with NULL as an empty cell (an empty text stays empty).

The formula guard of the export applies: a text cell that starts with =, +, -, @, a tab or a carriage return gets a leading ', so a spreadsheet runs nothing a user typed. Pasting into a text column of the grid takes that ' off again. A copy covers the rows that are loaded: a range that reaches past them says so (rangeUnloaded) and copies nothing — scroll, or narrow the range. From a field (the filter row, the search, an open editor) the browser copies as it always did.

Pasting

Ctrl/Cmd+V on a data cell reads the clipboard’s text — tab-separated, as every spreadsheet copies, or CSV — and anchors it at the focused cell. Nothing changes yet: a preview (part="paste-preview") shows each incoming value read against its column’s type, in the number notation of the grid’s language (1.234,5 in German; the wire notation 1234.5 is understood too). A value that cannot be read is marked with the reason, and the live region says it: „12 rows, 2 values not readable“. Enter applies, Escape discards. A block with unreadable values is not applied. A column the page fills (readonly in set_columns) is not pasted into: its cells stay in the preview marked aria-readonly with the reason („price is read-only“), the sentence names the column, and the rest of the block is applied. The preview belongs to the rows it was read from: a new result for the whole query — another sort or filter, a source replaced, a rebuilt grid — discards it.

Applying fires one opengrid-paste for the whole block: changes has one entry per cell of an existing row (key with row-key, row always, column, value as text, previous), and the cells are marked unsaved as an edit’s are; added holds the rows past the end, which become added rows when add-row is set and are reported as cut off otherwise. The page saves as for single edits (set_cell_state, set_row_state). A cell that looks like a formula (=SUM(…)) is text in a text column and unreadable in a number column — never evaluated. The existing rows a block covers are asked of the source, so a block longer than the window is the same as one inside it; of those, the cells in the loaded window show the new values at once. Off while grouped and in a tree.

Cell templates

loader.module.set_cell_templates(grid, {
  status: ({ value, text, row, key }) => {
    const badge = document.createElement("span");
    badge.className = value === "open" ? "badge badge-open" : "badge";
    badge.textContent = text;
    return badge;
  },
  customer: ({ text, row }) => {
    const link = document.createElement("a");
    link.href = `/customers/${row.customer_id}`;
    link.textContent = text;
    return link;
  },
});

A function per column returns a DOM node — never an HTML string, so nothing the page did not build is parsed. It gets { value, text, row, key }: the cell’s value as it is on the wire, the text the column’s format makes of it, the record as loaded (by field name) and its row-key value (without row-key, undefined). A string it returns is shown as text; null, undefined or a throw leaves the cell’s own text. Replace the functions with another call; an empty object takes them all away. A column that is a list, an action or in a tree or a grouping is not templated.

The node is rebuilt, not kept. The grid pools its rows: a cell is drawn again when its row changes under it (scrolling, sorting, a result) and when anything in its record changed, so the function must build from its argument and keep no state. A cell that did not change is not drawn again.

What stays the value. Search, filters, sorting, export, the copy of a range and every event read the source’s value, never the template: the DOM is a view of it. The cell’s accessible name is what the node says — text inside it, or an aria-label on it — so a bar with no text needs role="img" and a label from the page.

Keys. The cell is still one stop of the grid pattern. A focusable control inside it — a link, a button — is taken out of the tab order; Enter on the cell goes into it (the first control gets the focus, Tab reaches the others) and Escape comes back to the cell. While the focus is inside, the keys are the control’s own. F2 still edits an editable column; the cell’s text is what the editor starts from. An uneditable template cell opens nothing on Enter but its controls. A row is as tall as the grid’s row height: a node that is taller is cut off.

A source that changes

createRestProvider({ url, source, token, watch: true }) opens the server’s change stream and tells the grids on it with a cancelable opengrid-source-changed. Rows that move under the focus are a barrier, so by default the grid moves nothing: the status line says once, politely, „The data changed. Refresh to see it.“, and a Refresh button (part="refresh") sits above the rows. Another change while the notice stands says nothing more. The button — or the page, with refresh(grid) — runs the same query again and keeps the view, the focus (it follows its record by row-key, or stays at its place), the selection by key and the history. A page that wants its own handling calls preventDefault() on the event:

grid.addEventListener("opengrid-source-changed", (event) => {
  event.preventDefault();      // no notice, no button
  if (!editing) loader.module.refresh(grid);
});

The stream is notification only and needs the same token as a query; it is reconnected after the server’s retry, not at all after a 4xx. provider.terminate() closes it.

Row actions

loader.module.set_actions(grid, [
  { id: "open", label: "Open" },
  { id: "remove", label: "Remove" },
]);
grid.addEventListener("opengrid-row-action", ({ detail }) => run(detail.id, detail.key));

Each action is a trailing column named by its label, with a button on every row — a cell the arrow keys reach like any other. Enter or Space on the cell (or a click on the button) fires opengrid-row-action with { id, row, key }; key is the record’s row-key value (row is a position, and in a grouped grid a display position). The label is the accessible name. Group headers and the total have no button. Icons are the page’s: the label is text.

Tree

<opengrid-grid datasource="staff" columns="name,title,region"
               tree="parent_id" tree-key="id"></opengrid-grid>

With tree set, the rows are a hierarchy — each row names its parent in the tree field, by the key in tree-key — and the grid is a treegrid that loads a level at a time: the roots first, a node’s children when it is opened. Each level comes with every node’s child count, so a leaf shows no chevron and offers nothing to open. Rows are indented by depth in the first column. A walk-through is the guide A tree in the grid.

Keys On a node’s first cell → opens it, and on an open node goes to its first child; ← closes an open node, and on a closed node or a leaf goes to its parent. Enter and Space keep their grid meaning — Space selects the node.
Pointer A click on a node’s first cell, where the chevron is, opens or closes it.
Spoken aria-level (depth), aria-posinset / aria-setsize (its place among its siblings), aria-expanded only where there is something to open, aria-busy while the children load. Opening and closing is said once, with the result, as groupExpanded / groupCollapsed.
Filter A match keeps the path to it: its ancestors are shown as context, muted and described with treeContext, through aria-describedby. The status line counts matches, not the context.
Orphans A row whose parent does not exist is a root, and the status line says how many there are (treeOrphans).
Order Siblings are sorted by the grid’s sort; the hierarchy is never broken by it.
Summaries A column’s aggregate — from set_columns, the column menu or the view’s aggregates, as in groups — is shown on every node with children as its subtree’s summary beside its own value: 120 · Σ 1,450, said subtreeCell (“120 · Σ 1,450, Sum of the subtree”). Over the node and all its descendants, from the raw rows, and with a filter over the matches only — context does not count (rule T7). A leaf shows its own value.
Size A level holds at most 10 000 nodes — a larger one is refused with treeLevelTooLarge; narrow it with a filter.
Refused page-size alongside: a tree scrolls (treeRefused). group-by alongside is not used (treeGroupIgnored), and the toolbar’s + Group is aria-disabled and says so.
View expanded holds the open nodes, each as [key], and travels in the view.
Export get_query asks for the whole tree, flat: every node depth-first, with level, path (its keys from the root) and, with a filter, match — see Exporting.

In a tree, the selection names nodes by their key (tree-key), so it stays when nodes close over it and when the grid is sorted or filtered — a key names a record, not a position. A node is selected by itself, never with its children. Shift extends over the nodes shown; Ctrl/Cmd+A and the selection header select every node shown, since a closed level was never loaded. opengrid-selection-change is { count, keys } — the selected nodes, the shown ones first, and their number; it has no rows and no ranges. The view carries the nodes as selected, and set_view restores them — see Selecting by key.

Editing is off in a tree: an edit would be reported against a display position, which moves on every toggle.

<opengrid-table>

A plain native <table> for display: maximum semantics, ordinary copy and paste, browser find. No virtualization, no roving tabindex.

<opengrid-table label="Orders" datasource="orders" columns="id,customer,amount">
</opengrid-table>
Attribute Meaning
label, datasource, columns, theme As in the grid.

Header buttons sort a single column, none → ascending → descending → none.

Which of the two? Use <opengrid-table> when the answer is read and <opengrid-grid> when it is worked with. A role="grid" announces itself as an interactive widget and takes over the arrow keys; for a report table that is the wrong promise, and the W3C says to use native HTML where it suffices.

<opengrid-pivot>

A native <table> with a two-level column header. Deliberately not virtualized: an accessible virtual pivot is the highest risk in this project, and the row and column limits are what make rendering the whole thing safe.

<opengrid-pivot label="By country and year" datasource="orders"
                rows="country" columns="ordered_year"
                values='[{"field":"qty","fn":"sum","as":"total"}]'></opengrid-pivot>
Attribute Meaning
label, datasource, theme As in the grid.
rows Comma-separated row dimensions, outermost first.
columns Comma-separated column dimensions, outermost first. V1 allows two.
values The measures, as the contract’s own JSON — not an invented shorthand. A measure with calc is calculated.
sort How each level is ordered, as the wire’s JSON: [{"field":"country","by":"total","direction":"desc"}]. The reader’s sort buttons write it.
collapsed The folded groups, a JSON list of group paths: [["DE"],["FR","Beta"]], NULL as null. The reader’s fold buttons write it.
toolbar Shows the field toolbar — see below. Presence is what counts.
fields The fields a reader may pivot by, comma-separated.
measures The measures a reader may add, as the contract’s JSON: [{"field":"amount","fn":"sum","as":"revenue"}].
filter The filter on the raw rows, as the wire’s JSON: {"field":"country","op":"eq","value":"DE"}, {"and":[…]}. The reader’s filter field and chips write it.
drill-down Makes every value a button that fires opengrid-drill-down with the rows behind it — see Drilling down. Presence is what counts. Opt-in, and not part of the view.

rows/columns are the two axes here, as in every pivot; in the grid columns is the projection. The words are standard in their own context, so they were kept rather than made unique and worse.

Subtotal rows carry data-level and data-total, and their row header says so in words. A group whose dimension value is NULL is named (no value), and one whose value is the empty string (empty) — they are different groups, and an empty header cell is silence to a screen reader.

Titles and formats are the page’s, as at the grid and the table. set_columns gives a row dimension or a measure (by its alias) its title; a title for a name the pivot does not show now is fine, since its dimensions change with its attributes. A pivot takes nothing but a title — width, align and the rest are reported in the status line. set_formats formats a dimension’s values in the row, subtotal and column-group headers and a measure’s cells, keyed by the field name or the measure alias. NULL and the empty string keep their words: a format never blanks a header.

loader.module.set_columns(pivot, { country: { title: "Country" }, total: { title: "Quantity" } });
loader.module.set_formats(pivot, { total: { kind: "number", locale: "de-DE" } });

The view of a pivot is what it pivots by and measures — its attributes as one value, get_view/set_view as at the grid:

const view = loader.module.get_view(pivot);
// { rows: ["country"], columns: ["ordered_year"],
//   values: [{ field: "qty", fn: "sum", as: "total" }], sort: [], collapsed: [],
//   filter: null }
loader.module.set_view(pivot, { ...view, rows: ["customer"] });

The attributes are the view, so there is no second copy to fall out of step: set_view writes all of them at once and costs one query, and every change of them — by set_view, a sort button or the page setting an attribute — fires opengrid-view-change. Setting what it has says nothing. A view whose rows or columns is not a list of names, whose values is not a list of measures or whose sort is not a list of orders or collapsed not a list of paths or filter not a filter object is reported in the status line and applied not at all. A part left out is empty; a key the pivot does not know is ignored. In TypeScript, get_view<PivotView>(pivot); the adapters’ view on OpengridPivot is a PivotView. opengrid-query fires after every answer, as at the grid.

Sorting orders each level’s rows among their siblings; a subtotal stays after its group and the grand total last. Every row-dimension header is a sort button: it toggles its level between ascending — the default — and descending by its values, with aria-sort on the <th>. Without a column dimension a measure’s header is a sort button too and orders every level by that measure. With one it is not: a measure orders by the whole row, across every column value, and a header over one column value would promise an order its cells do not have. NULL sorts last either way. The pivot keeps the focus on the pressed button once the new answer is drawn. See the protocol for the sort a server takes.

Folding. With two row dimensions or more, a group’s subtotal row header is a button with aria-expanded, named by the header’s own words (“Total DE”): it folds the group’s rows away, so only its subtotal stays, and opens them again. Folding asks nothing — the pivot draws what it holds again — and the status line says it with the grid’s texts groupCollapsed / groupExpanded. The focus stays on the button. The folded groups are the collapsed attribute and part of the view; a path that matches no group folds nothing. The export stays the whole pivot, as the grid’s export ignores its open groups.

Calculated measures

A measure is an aggregate; with calc it is shown as a share or as a running total instead:

<opengrid-pivot datasource="orders" rows="country" columns="ordered_year"
  values='[{"field":"qty","fn":"sum","as":"total"},
           {"field":"qty","fn":"sum","as":"share","calc":"share_of_row"}]'></opengrid-pivot>
calc The cell shows
share_of_row its part of the row’s total across every column
share_of_column its part of the column’s total — the column’s cell in the grand total row
share_of_total its part of the grand total
running_total the sum of the row’s cells up to and including its column; it starts again in every row

The source computes it — the engine in the tab as a server does, by the same rules (P10–P12) — so a calculated value is a value like any other: in the table, in get_pivot’s CSV and in the XLSX. A share goes with fn sum and count; a running total also with avg, min and max over a number, where it adds up what the cells show. Anything else the source refuses, and the pivot’s status line says so. Subtotals and the total row follow the same rule as the rows above them, over their own cells.

  • A share is a fraction, 0.25, and NULL — an empty cell — where its total is NULL or zero. Percent and digits are the page’s: set_formats(pivot, { share: { kind: "number", style: "percent", maximumFractionDigits: 1 } }), by the measure’s alias. A running total keeps its measure’s type and takes the format the measure would.
  • The header says it. A calculated measure’s column header is its title followed by what it shows, in the element’s texts: Quantity · share of row. The CSV and the XLSX head the column the same way after its path and alias, 2025 · share · share of row.
  • Sorting by a calculated measure orders the rows by its aggregate, as for any measure.
  • Drilling down from a calculated cell names the cell’s own rows, as from any cell — for a running total the rows of its column, the last part added, not the columns before it — and value is the calculated value.

With toolbar, a measure’s chip has a button that names what the measure is shown as — value at first; its name is “Show Quantity as: value”. It opens a menu of the choices its function has, the one in use checked (menuitemradio), with the keys of the add menu: the value and the four calculations for sum and count, the value and the running total for avg, min and max. The pivot does not know the source’s schema, so it offers the running total of a min or max over text as well; the source refuses it, in the status line. Choosing writes calc into values, so it is one query and one opengrid-view-change, and the focus comes back to the button, which now names the new choice. A page that formats shares as percentages listens to that event and sets the format of a measure whose calc changed.

Choosing the fields. With toolbar, the reader chooses the rows, the column and the measures — without dragging, from the keyboard. Per axis a labelled group holds the chosen fields as chips — each with “Move … earlier” and “Move … later” where it has a neighbour, and “Remove …” — and an add button (“+ Row”, “+ Column”, “+ Measure”) whose menu offers what the page offers and the pivot does not use yet: fields for the dimensions, measures for the measures, whole. The pivot does not know the source’s schema, and a measure needs a function the page chooses. “+ Column” is aria-disabled while there are two column fields (V1’s limit), and so is an add button with nothing left; its name then says why. The menu is the grid’s: arrows, Home/End, Enter or Space to pick, Escape back to the button. Every change is one write of the view — one query, one opengrid-view-change — and a sort or folded groups that name what went go with it. The focus comes back to the control pressed.

<opengrid-pivot toolbar datasource="orders" rows="country" values='[{"fn":"count","as":"orders"}]'
  fields="country,status,order_year"
  measures='[{"fn":"count","as":"orders"},{"field":"amount","fn":"sum","as":"revenue"}]'></opengrid-pivot>

Filtering. The filter acts on the raw rows, before the pivot, so every subtotal is still computed from what passes it. A page sets it with filter; with toolbar the reader has a “Filters” group: a field for an expression in the grid’s language — country = DE and qty ≥ 3, and or the page’s word for it — and a chip per clause of the filter (“country is DE”), each removable, and “Remove all”. Enter adds the clauses — one view, one query — and empties the field. The pivot has no schema of its own, so it asks for one (a query with limit 0) over the fields it knows: fields, its rows and columns, and the measures’ fields. What does not parse, and free text without an operator, is said in the status line with the grid’s sentences, and nothing changes. The field needs the grid’s parser, so it is in the default module; a pivot-only build keeps the attribute.

How large a pivot gets. It is drawn whole — no paging, no virtualization, since an accessible virtual pivot is the highest risk in this project — so it is bounded by what a browser draws as a table in about a second: 131 072 cells (rows × generated columns), at most 10 000 rows and 256 columns. The time to draw grows with the cells alone, not with their shape (just measure-pivot measures it: about 6 µs a cell in Chromium, 10 µs in WebKit). Above a limit the pivot says so in its status line and what to narrow; it never shows part of an answer.

Two column dimensions (columns="ordered_year,status") give a header row per dimension, outermost first, then the measures: a year spans all its statuses, a status its measures. That makes the pivot a complex table, so every header cell carries an id and every value cell headers naming its row and column headers — a screen reader reads “DE, 2025, open, revenue” without inferring which spanning header is meant. The combinations are the ones that occur (P3); there are no column subtotals.

get_pivot(host) exports the table as it is shown, as CSV — see exporting a pivot. Its headers and values stay the raw names and values: titles and formats are display.

Drilling down

A pivot cell is a number; drill-down lets a reader ask which rows it is made of. The pivot shows no rows itself: it fires opengrid-drill-down with the cell’s filter, and the page shows the rows — where and how is the page’s.

<opengrid-pivot drill-down datasource="orders" rows="country" columns="ordered_year"
                values='[{"fn":"count","as":"orders"}]'></opengrid-pivot>

With the attribute every value is a native button (part="drill-down"): a click, Enter or Space fires the event, and the buttons are in the tab order like the sort and fold buttons — a native table has no arrow keys of its own. A button is named by the value and what pressing it does, 12, show rows (the text pivotDrillDown). A cell without a value has no button. Without the attribute a pivot has none at all — a button nobody listens to would be a control that does nothing — so a page sets it together with its listener.

detail
query The rows behind the cell, as the wire writes a query: { source, filter }. source is the pivot’s datasource.
query.filter The pivot’s own filter, clause by clause, and-ed with one clause per dimension the cell lies in: the row dimensions down to the row’s level, then every column dimension — {"and":[{"field":"country","op":"eq","value":"DE"},{"field":"ordered_year","op":"eq","value":2025}]}. One clause alone is that clause; none — the grand total of a pivot without a column dimension and without a filter — and query has no filter.
row, column The cell’s row and column group, as paths of keys like collapsed has them, NULL as null. A subtotal’s row is shorter than rows, the grand total’s is [].
measure, value The measure’s alias and the cell’s value as the answer carried it — of a calculated measure, the calculated value.
  • NULL and the empty string stay two groups. A NULL key is {"field":"country","op":"is_null"} — eq null would match nothing (S1) — and the empty string is {"field":"country","op":"eq","value":""} (S14).
  • A total filters by what it lies in. A subtotal names its group’s dimensions and not the ones below it; the grand total names no row dimension. A subtotal row never becomes an is_null on the dimensions it sums over.
  • The count. The rows query answers are the rows the cell was computed from, so a count without a field is their number. A measure over a field skips that field’s NULLs, as in every aggregate; the rows include them.

Showing the rows in a grid. query.filter is what the grid’s filter attribute takes: the grid then shows the cell’s rows, and what the reader sorts and filters stays inside them.

let cell;
pivot.addEventListener("opengrid-drill-down", ({ detail }) => {
  cell = pivot.shadowRoot.activeElement;           // the pressed button
  panel.hidden = false;                            // first: a grid measures its viewport
  const { filter } = detail.query;
  if (filter) grid.setAttribute("filter", JSON.stringify(filter));
  else grid.removeAttribute("filter");             // the grand total, nothing filtered
  heading.focus();                                 // tabindex="-1", names the cell
});
back.addEventListener("click", () => {
  panel.hidden = true;
  (cell?.isConnected ? cell : pivot.shadowRoot.querySelector('[part="drill-down"]'))?.focus();
});

A grid that has its provider from the start asks for every row before the first cell is pressed; the demo gives it its provider with the first event. A pivot on createPivotProvider answers pivots only: its grid takes a createRestProvider of the same source.

The focus is on the pressed button when the event fires — the pivot gives it the focus where a browser does not on a click — and the pivot draws nothing, so a page that shows the rows without moving the focus leaves the reader where they were. A page that moves it — to a heading over the rows, as above, since the rows may be far from the cell in the tab order — remembers pivot.shadowRoot.activeElement in its listener and focuses it again when the reader leaves the rows. A new answer makes the buttons anew: the remembered one is then no longer connected, and the pivot’s first [part="drill-down"] is where to go back to. examples/pivot-demo/browser.html does all of it.

The view

Sort, filters, column layout and density are one value:

const view = loader.module.get_view(grid);
// { sort: [{field, direction}], filters: [{column, op, value}],
//   columns: { order, hidden, widths, pinned }, density,
//   group: [], expanded: [], facets: {}, selected: [4, 9],
//   where: { or: [{ field: "country", op: "eq", value: "DE" },
//                 { field: "amount", op: "gte", value: 1000 }] } }
loader.module.set_view(grid, view);

A saved view is this value with a name on it. That is why the element has no view management of its own: naming, storing, deleting, putting one in a URL are the page’s, the same line drawn for editing below. opengrid-view-change fires whenever the reader changes any of it.

Attributes vs. the value density, columns and the rest set the first view; set_view leads after that — the relationship value has to <input>.
One query Applying a view costs one query, not one per field. Field by field, a restore would flash through intermediate results and announce each of them.
Nothing partial A view naming a column this grid does not have is reported in the status line and applied not at all. A grid that looks restored and is not is the worse failure.
The focus Stays where it is. A page applies a view from its own control — a tab, a menu — and keeps the focus there; only a grid that had the focus gets it back, on its active cell.
Setting what it has Costs nothing and says nothing — a page that writes the view back on every event must not make the grid talk to itself.
Why it changed set_view(host, view, { notice }): the page’s sentence — “Saved view Q3 applied”, “Filtered by the assistant” — is said once, with the result, in the one live region: 17 matches · Filtered by the assistant. A second live region of the page would be a second utterance. A view the element already has says nothing, notice or not. The same on <opengrid-pivot>.
columns.pinned The reader’s pins, { customer: "start", id: "none" }: start, end, or none for a pin of set_columns taken away (Pinned columns). Leads over set_columns. A view saved without it leaves the pins to the page.
group, expanded The grouping and its open groups. A path of keys that matches no group opens nothing.
aggregates The reader’s aggregate per column, { amount: "sum" }. Leads over set_columns.
filterRow Whether the filter row shows. true unless turned off.
facets The facet selections, by column.
selected The keys of the selected records, where the grid selects by key; left out while there are none. set_view restores them after asking the source which of them exist.
where The nested filter; left out while there is none, and a view without it has none.

where is a query’s filter, as the wire writes it — the form of the page’s filter, so values are typed: a number is a JSON number, a decimal and a date are strings.

where     = { "and": [where, …] } | { "or": [where, …] } | condition
condition = { "field", "op", "value" }      op: eq ne gt gte lt lte contains starts_with icontains istarts_with has
          | { "field", "op": "has_all", "value": [ … ] }
          | { "field", "op" }               op: is_null is_not_null is_empty
What it takes What its two editors can write: and and or with at least one member, and conditions. not and the operator in are refused — a page that needs them has the filter attribute.
Columns Every field is one of the grid’s columns, or the view is refused like any view naming an unknown column, and nothing of it is applied.
Values and operators Are checked by the source, as the filter attribute’s are: a value of the wrong type or an operator the column does not take is the provider’s error, in the status line.
As written set_view keeps the tree as the page wrote it — it does not flatten or reorder — so get_view gives it back. The builder tidies what it applies.
Next to filters Both hold: filters is the filter row, where the nested filter, and-ed.

A selection by key is in the view; a selection by position is not. Without a row-key the grid names a row by its number in the result, and sorting or filtering drops such a selection precisely because after a different sort those positions hold different records: a restored view carrying positions would not be incomplete — it would be wrong. Applying a view drops it, and says so. A key names a record under any sort, so selected travels with the view — and because a saved key can outlive its record, set_view asks the source before it selects (Selecting by key). Selecting is still not a view change: it has its own event.

Exporting the view

What a page exports is what the reader sees — and the grid knows that better than the page: the filter row, the facets and the free-text search and-ed together, the sort, the shown columns in their order. get_query(host) hands it out, exactly as the grid asks its provider, but without a window: no offset, no limit, every match.

const query = loader.module.get_query(grid);
// { source, select: ["id", "customer", …], filter: {…}, sort: [{ field, direction }, …] }
Hidden columns are not in select; moved ones are in their new place.
Grouped The rows, not the group or total rows, ordered by their groups first (ascending, NULL last) and then by the sort — the order the reader sees.
The selection is not in it. It names positions under exactly this query’s sort, so an export of the selection is this query plus the positions from opengrid-selection-change — or, where the grid selects by key, plus its keys.

Without an element (issue #144). The engine answers the query of a view it is handed: engine.view_query(source, columns, view) — columns is the grid’s columns attribute as a JSON array, view a view as JSON — returns what get_query() returns on that grid once the view is applied; the free-text search, not being part of a view, is not in it. A server can count or export a saved view this way, without a browser. A view naming a column the grid does not have is refused as a whole, every problem named; so is a filter value that is not a value of its column.

const query = JSON.parse(engine.view_query("orders", JSON.stringify(["id", "country", "amount"]), JSON.stringify(view)));
const { total_count } = JSON.parse(engine.execute(JSON.stringify({ ...query, limit: 0 })));

| null | A grid without a query yet (not connected, no datasource, no columns), one whose filter does not hold — its status line says why — and <opengrid-table> and <opengrid-pivot>. |

The grid has no export button: what to export, in which format, under which name, is the page’s (the same line as for saving an edit). How a page uses what follows — a button, the formula guard, reading a file back into opengrid, the errors to handle — is the Exporting guide.

exportRows(provider, query, options)

Fetches every match of a query through any provider, in pieces, and answers a Blob:

import { exportRows } from "@casoon/opengrid";

const controller = new AbortController();
const blob = await exportRows(provider, loader.module.get_query(grid), {
  format: "csv",                       // or "json", or "xlsx" — by the server, or in the tab
  signal: controller.signal,
  onProgress: ({ rows, total }) => { /* rows written so far, of total */ },
});
// The file name is the page's:
const link = Object.assign(document.createElement("a"), {
  href: URL.createObjectURL(blob),
  download: "orders.csv",
});
link.click();
// Revoked once the download has taken the URL, not in the same task.
setTimeout(() => URL.revokeObjectURL(link.href), 0);
Option
format "csv" (default), "json" or "xlsx". A workbook is written by the server where the provider exports there, and in the tab otherwise, by a module of its own that is loaded the first time (XLSX); it needs the rows to fit maxRows and an Excel sheet (1 048 575), and holds them in memory.
chunkSize Rows per request; 10 000 by default — the server’s max_limit. Not used when the provider exports by itself.
maxRows 1 000 000 by default — 250 000 for an XLSX written in the tab, which holds the workbook in memory. More matches than that is an error with a sentence and the code too_many_rows (Errors), before anything else is fetched — never a truncated file.
onProgress Called after each piece with { rows, total }.
signal An AbortSignal. An abort rejects with an AbortError at once, hands the signal to the provider so an HTTP request stops, and gives no Blob.
delimiter, bom, protectFormulas, null The CSV options, each optional, for a CSV only — get_pivot takes the same: delimiter, one character, , by default (; for a German Excel); bom, a UTF-8 byte order mark, on; protectFormulas, the guard against formula injection, on; null, how NULL is written, empty (\N reads back into opengrid) — under the guard not starting with =, +, -, @ or a tab, since it is written into every empty cell unguarded.

Any other key is an error, and so is a CSV option on a JSON export.

Values Raw, in the wire notation — not the display formats: a decimal exact, a date YYYY-MM-DD, a timestamp ISO in UTC with microseconds, NaN/Infinity/-Infinity spelled out. The CSV header and the JSON keys are the field names, in column order.
CSV RFC 4180, UTF-8 with a byte order mark, CRLF, , or ;. NULL is an empty unquoted field, the empty string "". The formula guard applies to text columns only. text/csv;charset=utf-8.
JSON One array of row objects. application/json.
Pieces offset/limit windows of chunkSize under the query’s sort. The total is the first piece’s total_count; the pieces stop at it.
Order A window is only stable under a total order, and the grid’s sort may tie — against PostgreSQL a row could repeat or go missing between two pieces. So every selected column not yet in the sort is appended, ascending. The export is then deterministic, and rows equal in every selected column look the same whichever comes first. Within a tie the export follows the columns, not the grid: two rows the grid showed in one order may come in the other. One tie is left: -0.0 and 0.0 compare equal, so a source that does not order them may swap them between two pieces.
A changing source The tie-breaker fixes ties, not rows that come or go while the export runs — they shift the windows. So it is detected and refused: a piece whose total_count differs from the first one’s, or that ends before the total, rejects the export with an error (code source_changed), and there is no Blob. A change that keeps the count and only moves a row is not visible to the export; for such a source, export from a snapshot.
mode The provider is asked with the mode "" — its own default.
query Without offset and limit, with a select, without group and aggregate — the rows of a view. get_query gives exactly that; null from it is an error here.

Over a server: one request

A provider with an export(query, options) method exports by itself, and exportRows lets it: createRestProvider has one, POST /export/{source} of an opengrid-server. Then the whole export is one streamed request instead of pieces — against PostgreSQL one statement read through a cursor, instead of OFFSET windows that get dearer with every piece.

const rest = createRestProvider({ url, source: "orders", token });
const blob = await exportRows(rest, loader.module.get_query(grid), { format: "csv" });
// or directly:
const same = await rest.export(query, { format: "csv", delimiter: ";" });
The file The same bytes the pieces would have made: exportRows checks the options the same way and sends the same query, tie-breaker included; the server writes it with the same opengrid-export.
Options format, signal, maxRows, onProgress and the CSV options — exportRows’ own, without chunkSize. Any other key is an error.
maxRows The server sends the row count before the rows (X-Total-Count); more than maxRows is an error (too_many_rows) before the body is read, and so is an answer without a readable count. The server has its own bound, max_export_rows — more is its 413 (limit_exceeded), with a sentence, before the first byte; too many exports at once are its 503 (busy).
onProgress Called once, at the end, with { rows, total }.
signal Aborts the request, the download included: an AbortError, no Blob. The server notices at its next piece and ends the database query.
Rules The server’s for /query, unchanged: the token, allowed_fields, the tenant’s row_filter.
A break A failure after the first byte cannot be a status any more; the server breaks the connection off, and the export rejects — never a shorter file. A client that takes no piece for the server’s timeout is broken off too.
Redirects Not followed, by any request of createRestProvider or createPivotProvider: the token goes to the configured URL and nowhere else.

Exporting a pivot

A pivot is exported by the element, as it is shown: get_pivot(host, options) answers the table as CSV text, or null while nothing is shown.

const csv = loader.module.get_pivot(pivot, { delimiter: ";" });
// "\uFEFFcountry;2025 · total;2026 · total;(no value) · total\r\n(empty);;114;\r\n…"
const blob = csv && new Blob([csv], { type: "text/csv;charset=utf-8" }); // the file name is the page's
Columns The row dimensions, then one column per generated column, in the table’s order.
Header One line. A generated column is named by its value and its measure, 2025 · total; without a column dimension, by its measure. A calculated measure is followed by what it shows, in the element’s texts: 2025 · share · share of row.
Rows Every row the table shows, in its order: data rows, subtotals, the grand total.
Subtotals Their label — Total DE, Total — in the first dimension column; the dimension columns it spans are empty fields. There is no level column, as the table has none: a subtotal is known by its label alone, so a group literally named Total looks like the grand total.
Labels The element’s own texts: NULL is (no value), the empty string (empty), as in the table, and a page’s set_texts changes both. With non-empty texts a data row’s dimension cell is never empty, so an empty one means “spanned by the total”; a text set to "" gives that up.
Values As every export writes them: the wire notation, NULL as the null option, the formula guard — which covers every header and label, since a dimension value is data. The notation is the canonical one, whatever the provider sent: a custom provider’s float 2 reads 2.0, a decimal 12.5 at scale 2 reads 12.50, where the table shows the text as it came.
options The CSV options — delimiter, bom, protectFormulas, null — as exportRows takes them. Any other key is an error.
Errors A wrong option, and an answer the export cannot read — one whose cells do not match its row dimensions and columns, as a page’s own provider could send. Both throw with a sentence.
null Before the first answer, while one loads, after an error, and for the grid and the table.

Why the element, not a query. A pivot is bounded — 256 columns, 10 000 rows, 131 072 cells — and the element holds all of it, so there is no window and nothing a second request could add. It could only answer differently, if the data moved since the table was drawn, and it would need the element’s texts handed to it. So get_query stays null for a pivot, and get_pivot is synchronous and needs no provider.

Why one header line. Every CSV reader — a spreadsheet’s filter, pandas, a database’s COPY — takes the first line as the names and the second as data; a second header line would arrive as a row of text in number columns. A CSV has no merged cells either, so a two-line header would repeat each value over its measures anyway, or leave header cells empty — the silence the element refuses. 2025 · total is also what a screen reader announces for such a cell: the group’s header, then the column’s.

exportPivot(provider, host, options)

The pivot as an XLSX file. A workbook is written by opengrid-server, so this is the one export of a pivot that asks: the element’s view goes to the server as a pivot query, and the answer is the file.

import { exportPivot } from "@casoon/opengrid";

const blob = await exportPivot(provider, pivot, { format: "xlsx" }); // a Blob; the file name is the page's
provider One that exports on the server: createPivotProvider or createRestProvider, both with exportPivot(pivotQuery, options) → POST /pivot/{source}?format=xlsx. Any other — the engine in the tab or a worker, the hybrid provider — is a TypeError before anything is sent. The method has a query and no element, so it alone also takes texts (total, subtotal, noValue, emptyValue, and for calculated measures shareOfRow, shareOfColumn, shareOfTotal, runningTotal); a page that calls it with a query of its own hands them over, or gets the English defaults.
host An <opengrid-pivot> in the document, with a datasource. Its view is the query: rows, columns, values, sort, filter. Folded groups are exported, as in the CSV.
format "xlsx", and required; anything else is a TypeError — a pivot’s CSV is get_pivot.
Labels The element’s own texts, as in the table and in the CSV: Total, Total DE, (no value), (empty), or what set_texts made of them. They are read from the element and sent with the request; the page passes nothing, and texts is not an option here.
signal Aborts the request: an AbortError, no Blob.
The sheet The CSV’s table: one header row with its names (2025 · total), the same columns and rows in the same order, a total’s label in the first dimension column and the columns it spans empty. No merged cells, no outline levels. Named after the source, the header row frozen.
Cells Typed as in every workbook: a number, a decimal with its scale, a date, in a measure and in a dimension column alike. A NULL measure is an empty cell; a NULL or empty dimension value is its label, as text. A text is a string, never a formula.
Rules The server’s for /pivot, unchanged — the token, allowed_fields, the tenant’s row_filter, the pivot’s limits — and an export’s: max_xlsx_rows, and busy when too many exports run.
The answer The server’s, now: if the data moved since the table was drawn, the file is the newer pivot. A server that does not write a pivot as XLSX is an Error saying so.

Printing the view

A grid holds a window of rows inside a scroller, so printing the page prints that window. For paper — and for a PDF, which the browser’s print dialog writes — the grid builds a plain table of every match, and the page prints:

const rows = await loader.module.prepare_print(grid);   // 200 — or null: nothing to prepare
window.print();

prepare_print(host) asks the provider for every match of the view — the query of get_query, in one request — and builds a native <table> in the grid’s shadow root. Only @media print shows it, in place of the grid; on the screen nothing changes, so the rows in view, the focus and the scroll position are what they were. The afterprint event removes the table. It has to be asked for, because a browser does not wait in beforeprint for a query to answer.

The table A <caption>, a <thead> with a <th scope="col"> per column — a browser repeats it on every page — and one <tr> per match, none split over two pages. No ARIA grid roles: on paper it is a table.
Caption The label, the number of matches and the view in the grid’s own texts, as its chips say them: Orders · 42 matches · country is DE. The page’s filter holds for the table as for every query; it has no chip, so the caption does not name it.
Columns The shown columns in their order, under their title. The selection column, the action columns and every control are not printed. Nothing is pinned on paper.
Values As the grid shows them, through set_formats — not the raw values of an export. They are the source’s: an edit the page has not saved is not in them.
Colours Black on white, with lines, whatever the look — a dark theme and the page’s own tokens included.
Grouped Every group open: a header row per group, country: DE (12 rows), then its rows. Group and total summaries are not printed.
A tree Whole and flat, every node open, the first column indented by depth. With a filter, a node that is only the way to a match says so after its name: Sales (context). Subtree summaries are not printed.
page-size Is not a page of paper: every match is printed, not the page the reader is on.
Resolves With the number of rows in the table. With null — and nothing is built — where get_query answers null, and for <opengrid-table> and <opengrid-pivot>, which hold all their rows and print as they are.
Rejects With the provider’s error; and with an Error whose code is too_many_rows when the view has more than 10 000 matches, before a table is built — narrow the view, or export it. A rejection leaves no table.
Lifetime Until afterprint, or the next prepare_print. It is the view of the moment it was asked for: call it right before window.print().
Without it Printing the page prints the grid as the screen shows it — the rows in view, with its controls — in black on white.
Paper Size, orientation and margins are the print dialog’s and the page’s @page rule; the grid has no option for them.

<opengrid-table> and <opengrid-pivot> need no preparation: they are tables already. On paper they are black on white as well, and the pivot’s field toolbar is left out.

Errors

A rejection of createRestProvider (describe, execute, export), of createPivotProvider or of exportRows is a plain Error. Its message is a sentence for the developer — the server’s own, or the loader’s — and a page does not parse it: to tell failures apart, the Error carries fields.

Field
status The HTTP status, when a server refused the request.
code What went wrong, from the closed list below. Absent when nobody named it: a body that is not the server’s error form (a proxy’s 502 page), a server that did not say how many rows follow.
path Where in the query, when the server knows: select[1], filter.and[1].value. The message ends with it in parentheses, as it always has.
import { exportRows, type CodedError } from "@casoon/opengrid";

try {
  blob = await exportRows(provider, query);
} catch (error) {
  if ((error as Error).name === "AbortError") return; // the reader cancelled
  switch ((error as CodedError).code) {
    case "busy": status.textContent = "The server is busy. Try again in a moment."; break;
    case "limit_exceeded":
    case "too_many_rows": status.textContent = "Too many rows. Narrow the view."; break;
    case "unauthorized": signIn(); break;
    // Also a code this loader does not know yet, from a newer server.
    default: status.textContent = "The export failed.";
  }
}

There is no error class: test error.code, not instanceof. In TypeScript the shape is CodedError and the list ErrorCode. A wrong option is a TypeError, an abort a DOMException named "AbortError" — test its name: its code is the DOM’s legacy number 20, never one of the strings below. The hybrid provider hands its remote’s rejection on as it came. The tab and the worker reject with the engine’s sentence and no fields — the engine has no error form.

The server’s codes come from its error form, { "error": { "code", "message", "path" } }, the same for /query, /pivot, /source and /export:

code Status What happened What a page does
validation 422 The query does not hold against the source’s schema — a field outside allowed_fields is the same unknown field as a typo — or breaks a limit of its own, such as a limit above max_limit or a pivot too wide. Nothing to tell the reader; a bug in the page.
malformed 400 The body or a parameter is not readable: not JSON, an unknown export parameter, a delimiter of two characters. The same.
unauthorized 401 No token, or one the server does not accept. Sign in again.
unknown_source 404 No source of that name. A bug in the configuration.
limit_exceeded 413 Too big for the server: a body over max_payload_bytes, a query over the server’s timeout, more rows than max_export_rows, an export without its first byte within the timeout. The same request usually fails again — a timeout may pass under less load. Narrow the view.
busy 503 max_concurrent_exports exports are already running. The request itself is fine. Try again in a moment.
backend 502 The source behind the server failed. Try again later; the operator’s log says why.
data 422 The data breaks a rule the query relies on — a tree with a key twice or a cycle. The message names the rows. Retrying does not help. Show the message to whoever owns the data. A client that does not know the code treats it like any failure.

exportRows’ own codes never come from a server (prepare_print rejects with too_many_rows too, at its own bound):

code What happened What a page does
too_many_rows More matches than maxRows — the first piece’s total_count, or the server’s X-Total-Count before the body. Nothing more is fetched. Narrow the view.
source_changed A piece reported a different total_count than the first, or ended before the total. Export again, or from a server, which reads one snapshot.
module_not_loaded The WebAssembly module did not load, and the export notation is in it. Say the export is not available.

Events

The events fire on the host, bubbles and composed (without composed they would not leave a shadow root the page wrapped the element in), and none is cancelable — they report what has already happened. opengrid-source-replaced is the exception to the first part: loader.js dispatches it on the element, without bubbles.

Event detail
opengrid-selection-change One of two shapes, by what the selection names (Selecting by key). By position (no row-key, no tree): { rows: number[], ranges: { from: number, to: number }[], count: number } — logical row numbers, ascending, and the same rows as ranges: runs of adjacent rows, ascending, both ends inclusive (select-all over 100 000 rows is one range, { from: 0, to: 99999 }). By key (row-key, or a tree): { count: number, keys: unknown[] } — the keys of the selected records, and neither rows nor ranges. "keys" in detail tells them apart.
opengrid-bulk-change { keys, column, value } — one value was set for the selected rows; see Setting a value for the selected rows.
opengrid-record-open { key, row } — cancelable: the reader asked for the record as a form; preventDefault() keeps the grid from opening its own. See Editing a whole record.
opengrid-paste { changes: [{ key?, row, column, value, previous }], added: [{ values, provisionalKey }] } — a pasted block was applied; see Pasting.
opengrid-row-add { values, provisionalKey, undo?, redo? } — a record was entered in the new row; see Adding rows.
opengrid-row-action { id, row, key? } — an action was activated; key is the row-key value.
opengrid-cell-change { row, column, value, previous } — everything needed to persist it. With row-key also key: the record’s value of that field, null when the result does not carry it.
opengrid-drill-down { query: { source, filter? }, row, column, measure, value } — a value of an <opengrid-pivot> with drill-down was pressed; query is the rows behind the cell, as the wire writes a query. See Drilling down.
opengrid-view-change { view } — the whole view after the change. Scrolling and selecting are not view changes. On <opengrid-pivot> the view is its rows, columns, values, sort, collapsed and filter.
opengrid-source-changed { name } — cancelable: the server behind a REST provider created with watch: true said the source changed; see A source that changes. Dispatched by loader.js on the grid, not bubbling.
opengrid-source-replaced { name } — an engine provider replaced the source this element shows; the grid, table and pivot re-run their query, the grid clears its selection and history and asks a tree and the facets again. Dispatched by loader.js on every element on that datasource, not bubbling; connect hands it to onSourceReplaced.
opengrid-query { kind, ms, rows, total, bytes, form, memory } after every answer of the provider, on the grid and the pivot (issue #70): where it ran (the provider’s kind), the round trip in milliseconds measured in the tab, rows answered and matches before paging, the answer’s size as it arrived and its form (binary or json), and the element module’s WASM memory. Measured always, sent nowhere — what the page does with it is the page’s.

The component edits; the page saves. There is no write path: the engine’s contract is a query. An edited value is shown at once and marked unsaved; a fresh result from the source clears the marks.

Saving edits

A page that saves each edit on its own says back what became of it (issue #153). row-key names the field that identifies a record; opengrid-cell-change then carries its value as key, which stays right while the reader sorts, filters or the grid reloads — row does not.

grid.setAttribute("row-key", "id");
grid.addEventListener("opengrid-cell-change", async ({ detail }) => {
  const { key, column, value } = detail;
  loader.module.set_cell_state(grid, key, column, "saving");
  try {
    await save(key, column, value);
    loader.module.set_cell_state(grid, key, column, "saved");
  } catch (error) {
    loader.module.set_cell_state(grid, key, column, "error", `Grade for ${key} not saved: ${error.message}`);
  }
});
state What the grid does
saving The cell is part="cell cell-saving" (the muted ink by default).
saved The unsaved mark goes; the cell is part="cell cell-saved".
error The value and the unsaved mark stay; the cell is part="cell cell-error" with aria-invalid="true", and message is said in the grid’s one live region (polite).

A page styles the states through those parts — opengrid-grid::part(cell-error) { … }. Inside the shadow root the cell also carries data-state.

set_values(grid, [{ key, column, value }]) shows values the page computes — a running average, a proposed grade. They are not edits: no unsaved mark, no opengrid-cell-change. Such a column is readonly in set_columns, so it never opens an editor and is announced as read-only.

Both name records: a state or a value stays with its record through sorting, filtering and every result that follows, until the page says something else. Another row-key or another datasource forgets them. A fresh result still replaces an edited value with the source’s, as above — the state stays. Neither call moves the focus. Without row-key, or for a key that is not the field’s type, nothing happens. A walk-through is in the guide Saving edits from the page.

Sorting and filtering drop the selection, and say so. That is not a preference: a selection names positions, the grid has no key column, and after a different sort those positions hold different records. Where the selection names keys instead — with a row-key, and in a tree — it stays (Selecting by key).

Styling

Shadow DOM, so the page reaches in through parts and custom properties.

Custom properties. A page sets these; the grid computes the accented ones from them, so picking one accent is enough. Set them on the element (opengrid-grid { … }), not only on an ancestor: the grid declares every default on :host, and a declaration on the element wins over an inherited value.

Set Default What it paints
--og-font / --og-font-size "Geist", system-ui, sans-serif / 0.875rem everything the grid writes
--og-font-mono "Geist Mono", ui-monospace, … the values of a column marked mono
--og-surface #ffffff rows, the body of the grid
--og-surface-2 #fafbfc header, filter row, pager, the facet sidebar, the search field
--og-ink #14161a the text
--og-ink-muted #646b78 text that is there but not the point
--og-line #eceef2 the rules between rows
--og-line-strong #e0e3e9 the rules between regions
--og-accent #3d5fd6 the one colour a page picks — drawn as text too, so a colour meant for text
--og-on-accent #ffffff text drawn on the accent (the tick of a checked box)
--og-radius 12px the corners of the grid’s boxes
--og-pad 8px horizontal padding inside a cell
--og-focus-width 2px the focus ring
--og-row-height 42px a data row; goes into the window math, a change at runtime is followed, a fractional length is rounded up
--og-header-height --og-row-height the header row
--og-filter-height 40px the filter row
--og-status-height 24px the footer with the status line, as a minimum
Computed From
--og-accent-soft 13 % accent on surface — a pressed switch, a chip
--og-accent-ink 80 % accent on ink — the accent as readable text
--og-selected 9 % accent on surface — a selected row
--og-hover 4 % ink on surface — a hovered row

Density sets three of these at once:

density --og-row-height --og-pad --og-font-size
compact 34px 10px 0.8125rem
normal (default) 42px 12px 0.875rem
comfortable 50px 16px 0.875rem

The row height is pixels and the font size is rem, and that is not an oversight. The row height is the virtualization contract: the element parses <number>px out of the property, because an unregistered custom property is not resolved for getComputedStyle. The font size has no such reader, so it can be relative — and it should be, or a reader who raised their browser’s font size would be overruled. The consequence a page has to know: raising the font size means raising --og-row-height with it.

The defaults are the look called Base — the one of the design prototype — so a grid, table or pivot with no page CSS already looks finished. theme picks another built-in look; the defaults in the table above are Base’s:

theme Font Surface Ink Accent Radius
base (default) Geist #ffffff #14161a #3d5fd6 12px light
paper IBM Plex Sans #fffdf8 #1f1d19 #2a7a59 8px light
violet Geist #ffffff #17161f #6b4bc8 14px light
orange Geist #ffffff #111111 #b44c1c 10px light
dark Geist #15181c #e6e8eb #4fd1d1 12px dark

A look sets every colour token, the fonts, the radius and color-scheme. What a page sets on the element wins over it, so a look is where a page’s own starts: <opengrid-grid theme="dark" style="--og-accent: #f0b429">. A look does not follow the reader’s light or dark preference by itself — a page that does sets theme.

The fonts are named, not loaded: the element makes no request to a font service. A page that wants Geist or IBM Plex Sans loads it; without it the stack falls back to the system UI font.

<opengrid-table> and <opengrid-pivot> take the same theme and tokens, apply the ink and the font to their text, and are styled beyond that through their parts.

Under forced-colors every colour here resolves to a system colour, whatever the look: a color-mix of two system colours resolves unpredictably, and the user’s palette is the one that has to win.

Three things a theme cannot switch off, because they are accessibility rather than decoration: the focus ring never uses --og-accent (a pale accent would make it invisible), a selected row carries an inset accent bar as well as the tint (colour alone would be 1.4.1), and prefers-reduced-motion beats a theme that animates a part.

Parts: action, action-cell, action-header, add-field, add-filter, add-grouping, band, body, bulk, bulk-apply, bulk-cancel, caption, cell, cell-error, cell-saved, cell-saving, chip, chip-calc, chip-edit, chip-move, chip-remove, chips, chips-clear, column-menu, column-menu-button, column-toggle, columns, columns-toggle, density, drill-down, editor, empty, empty-reset, empty-text, facet, facet-bounds, facet-cost, facet-count, facet-pill, facet-pills, facet-value, facets, facets-head, facets-toggle, filter, filter-builder, filter-builder-dialog, filter-clear, filter-dialog, filter-operator, filter-row-toggle, filter-value, footer, field-group, field-menu, group-mark, group-toggle, grouping-menu, header, layout, list, list-item, list-item-remove, listbox, menu-label, operator-menu, page-first, page-label, page-last, page-next, page-previous, pager, paste-apply, paste-discard, paste-preview, paste-summary, record-dialog, refresh, row, row-header, search, search-hint, search-input, search-list, select, select-all, select-mark, sort-button, sort-direction, sort-index, source, status, table, toolbar, total-row, validation, viewport.

<opengrid-table> and <opengrid-pivot> ship no stylesheet beyond the look’s ink and font and the alignment of a column (above) — they are plain tables, and the rest of their look is the page’s, through their parts (issue #29):

Part <opengrid-table> <opengrid-pivot>
table, caption the table and its caption the same
header a column header cell a column header cell, both header rows
sort-button, sort-direction the button in a header, its mark the same, in a row-dimension header, and in a measure header without a column dimension
row-header — a row’s header cell, the total rows’ included
group-toggle, group-mark — a group’s fold button in its subtotal’s row header, and its mark
toolbar, field-group, menu-label — the field toolbar, one axis’s group, its visible name
chip, chip-move, chip-remove — a chosen field, its move buttons, its remove button
chip-calc — a measure chip’s button for what the measure is shown as
add-field, field-menu — an axis’s add button, and its menu
row, total-row a body row a body row; total-row a subtotal or the grand total
cell a value a value
drill-down — a value’s button, with drill-down
opengrid-table::part(cell) { padding: 0.25rem 0.5rem; border-bottom: 1px solid #ddd; }
opengrid-pivot::part(total-row) { font-weight: 600; }

<opengrid-table> shows its values as the grid does: set_formats applies to its cells, and set_columns gives a column its title and its align, mono, emphasis and muted. The grid’s own options — width, aggregate, facet — are refused for a table, in the same alert as any other mistake, not ignored.

Right to left

The three elements take the direction from the platform: dir="rtl" on the element or on any ancestor, usually on <html>. There is no attribute of their own for it.

<html lang="ar" dir="rtl">
  …
  <opengrid-grid label="…" datasource="orders" columns="id,customer,amount"></opengrid-grid>

Right to left, the first column stands on the right and the row reads towards the left; the selection column, a group’s or a node’s indent and mark, the bar of a selected row, the sort mark and the column menu’s button, the toolbar, the facet sidebar and the popups all follow. A column with align: "start" is right-aligned and one with align: "end" — every number — left-aligned. A grid wider than its columns’ room scrolls from the right edge, and a column pinned at the start stays at the right edge. In <opengrid-table> and <opengrid-pivot> the columns, the alignment, a group’s fold mark and the field toolbar follow the same way; neither has keys of its own that name a side.

Keys in <opengrid-grid>. ← and → follow what is on the screen, as the WAI-ARIA grid and treegrid patterns ask; every other key names a place in reading order and is the same in both directions.

Keys On Left to right Right to left
← cell the cell before the next cell
→ cell the next cell the cell before
Ctrl/Cmd+← header cell move the column one place towards the start one place towards the end
Ctrl/Cmd+→ header cell move the column one place towards the end one place towards the start
Ctrl/Cmd+Shift+← header cell narrow the column widen the column
Ctrl/Cmd+Shift+→ header cell widen the column narrow the column
Alt+Shift+← header cell pin one step towards the start one step towards the end
Alt+Shift+→ header cell pin one step towards the end one step towards the start
← / → band cell the cell before / the next cell of the first header row (Column bands) the next cell / the cell before
Ctrl/Cmd+← / → band cell move the band one place towards the start / the end towards the end / the start
← a group’s or node’s first cell close; on a closed node or a leaf, go to the parent open; on an open node, go to its first child
→ a group’s or node’s first cell open; on an open node, go to its first child close; on a closed node or a leaf, go to the parent
Home / End cell the first / last cell of the row the same: the first cell is the one on the right
Ctrl+Home / Ctrl+End cell the first cell / the last cell of the last row the same
↑ ↓ PageUp PageDown Tab, and every other key the same

What is supported.

  • The direction is read from the computed direction whenever it is needed, so a dir that changes while the element is in the page is followed: the layout at once, the keys with the next key, a popup the next time it opens, the filter row’s place under the columns the next time the grid lays it out (a resize, a column shown or resized).
  • Set the direction with dir, not with the CSS property direction alone: the marks that point sideways and the alignment are mirrored with :dir(rtl), which reads the attribute.
  • The texts are not translated by the direction; they are the page’s (Texts). The default English ones in a right-to-left line are ordered by the browser, like any Latin text.
  • The order of letters inside a cell is the browser’s (the Unicode bidirectional algorithm); the elements add nothing to it.

Texts

Everything the components write themselves is English and overridable. A lang given along with the texts is written onto the elements that carry those texts — never onto the data, which is the page’s, in the page’s language. A group that holds the page’s words — the toolbar, the column list, the filter row, the facets, the search field and its suggestions — is named by aria-labelledby pointing at a hidden element that carries the lang, so the name is ours and the column names inside stay the page’s.

loader.module.set_texts(host, { lang: "de", loading: "Wird geladen …" });

Translations. The package ships German as a module of its own, @casoon/opengrid/texts/de — every key below with lang: "de", for all three elements. A page that does not import it does not load it.

import de from "@casoon/opengrid/texts/de";

loader.module.set_texts(host, de);
// With the page's own words on top — here an example that names its columns:
loader.module.set_texts(host, { ...de, searchPlaceholder: "Suchen oder filtern: kunde = Krause" });

The same object is the texts of connect and of the framework components. What a translation does not reach is what is not the component’s: column names and cell values are the page’s, and so is how a number, a date or a boolean reads — that is set_formats.

Key Default Placeholders
lang —
loading Loading …
matchesOne / matchesOther {count} match / {count} matches {count}
empty No matches
error / errorUnknown The data could not be loaded: {cause} / The data could not be loaded. {cause}
filterGroup Filter
operatorLabel / valueLabel {column} operator / {column} value {column}
clear Clear
selectAll Select all matching rows
selectedAll {count} rows selected {count}
groupRow {column}: {value} ({rows}) {column}, {value}, {rows}
rowsOne / rowsOther 1 row / {count} rows {count}
groupExpanded / groupCollapsed {group} expanded, {rows} / {group} collapsed {group}, {rows}
groupInvalid Cannot group by {column} {column}
totalRow Total ({rows}) {rows}
aggregateCell {aggregate}: {value} {aggregate}, {value}
aggregateSum / aggregateAvg / aggregateCount / aggregateMin / aggregateMax / aggregateRange Sum / Average / Count / Minimum / Maximum / Range
columnMenu {column} column menu {column}
sortAscending / sortDescending Sort ascending / Sort descending
sortKey Sort key {position} — a further sort key’s place (1-based), said by its column header through aria-describedby; the primary key carries aria-sort {position}
filterColumn Filter …
aggregateGroup / aggregateNone Aggregate in groups / No aggregate
groupByColumn / groupSecondLevel / ungroupColumn Group by this column / Group as second level / Remove this grouping
hideColumn / hideBand Hide column / Hide band
bandKeep {band} is all that is shown and stays {band}
pinGroup / pinNone / pinStart / pinEnd Pin / Not pinned / At the start / At the end — the column menu’s group and its three choices
toolbarGroup / filterRowToggle Grid tools / Filter row
densityGroup / densityCompact / densityNormal / densityComfortable Density / Compact / Normal / Comfortable
chipsGroup / chipsClear Active filters / Remove all
chipRemove / filterRemoved Remove {filter} / {filter} removed {filter}
filtersCleared All filters removed
groupChip Grouped by {columns} {columns}
facetsGroup / facetsToggle / facetsReset Facets / Facets / Reset facets
facetFrom / facetTo From / To
facetQueries Counted with {count} queries {count}
facetChipValues {column} is one of {values} {column}, {values}
addFilter / addFilterTitle + Filter / Add filter — the toolbar’s button and the dialog’s title
filterColumnLabel / filterConditionLabel / filterValueLabel Column / Condition / Value
cancel / apply Cancel / Apply
addGrouping / groupingFull + Group / Grouped by three columns already — the button, and the reason that follows its text in its name when no level can be added (+ Group. Grouped by three columns already)
pivotToolbar / pivotRows / pivotColumns / pivotMeasures Pivot fields / Rows / Columns / Measures — the pivot’s field toolbar and its three groups
addRow / addColumn / addMeasure + Row / + Column / + Measure
columnFull / nothingToAdd Two column fields already / Nothing left to add — the reason that follows an add button’s text in its name when it cannot add (+ Row. Nothing left to add)
fieldRemove / fieldEarlier / fieldLater Remove {field} / Move {field} earlier / Move {field} later {field}
pivotFilters / pivotFilterLabel / pivotFilterHint Filters / Add a filter / field = value · Enter — the pivot’s filter group, its field’s name and hint
pivotDrillDown {value}, show rows — the name of a value’s button in a pivot with drill-down: “12, show rows”. {value} is the cell’s visible text, so the name contains it (WCAG 2.5.3) {value}
pivotShowAs Show {field} as: {calc} — the name of a measure chip’s button in the pivot’s toolbar, and of its menu: “Show Quantity as: share of row”. {calc} is the button’s visible text, so the name contains it (WCAG 2.5.3) {field}, {calc}
calcValue value — a measure shown as it is, in that button and its menu
shareOfRow / shareOfColumn / shareOfTotal / runningTotal share of row / share of column / share of total / running total — what a calculated measure is shown as: in the button and its menu, after the measure’s title in its column header, and in the headers of the CSV and the XLSX
treeContext context — a tree’s row shown only because a match is below it
subtreeCell {shown}, {aggregate} of the subtree — what a node’s cell says when it also shows its subtree’s summary: “120 · Σ 1,450, Sum of the subtree”. {shown} is the cell’s visible text, so the name contains it (WCAG 2.5.3); German: {shown}, {aggregate} im Teilbaum. A text you set without {shown} is used exactly as written, and its name then does not contain the visible text — your decision. {shown}, {value}, {aggregate}, {total}
subtreeOnly {shown}, {aggregate} of the subtree — the same for a node without a value of its own: “Σ 1,450, Sum of the subtree” {shown}, {aggregate}, {total}
treeOrphans Without a parent, shown at the top: {count} {count}
treeLevelTooLarge {count} rows at one level; a level holds at most {max} — choose a filter {count}, {max}
treeRefused / treeGroupIgnored no tree with page-size: a tree scrolls / group-by is not used in a tree
filterAny / booleanTrue / booleanFalse any / yes / no — the filter row’s choice for a boolean column
sourceLocal / sourceWorker / sourceRemote / sourceHybrid local · wasm / worker · wasm / server / hybrid — the footer’s source, by the provider’s kind
queriesOne / queriesOther {count} query / {count} queries — the footer’s count {count}
searchLabel / searchPlaceholder Search or filter / Search, or filter: country = DE and amount ≥ 10
queryAnd / queryOr and / or — the joining words of the search expression and of the nested filter’s chip
searchHint / searchSuggestions Query · Enter / Columns
typeText / typeBool / typeInteger / typeNumber / typeDate / typeTime text / yes/no / integer / number / date / time — the type beside a suggested column
searchChip Text contains “{text}” {text}
queryUnknownColumn / queryMissingValue {column} is not a column of this grid / {column}: the value is missing {column}
queryWrongOperator {column} does not take {operator} {column}, {operator}
queryUnclosed / queryUnopened A parenthesis is not closed / A closing parenthesis has no opening one
queryMissingCondition A condition is missing
queryMissingJoin “{and}” or “{or}” is missing before “{text}” {and}, {or}, {text}
filterBuilder Advanced filter — the toolbar’s button and the builder’s title
filterBuilderJoin / filterBuilderAll / filterBuilderAny Rows match / all conditions / any condition — a group’s choice and its two options
filterBuilderCondition / filterBuilderGroup Condition {number} / Group {number} — the name of a condition and of a group inside a group; with chipRemove, the name of its remove button {number}
filterBuilderAddCondition / filterBuilderAddGroup + Condition / + Group
filterBuilderEdit Edit {filter} — the name of the nested filter’s chip button {filter}
filterApplied Filter set: {filter} — said with the result when the nested filter was set {filter}
emptyFiltered / emptySource No row matches these filters. / There are no rows.
emptyReset Reset filters
operators the thirteen filter operators, keyed by wire token (has, has_all and is_empty are the list’s)
choicesOne / choicesOther 1 choice / {count} choices — said while typing in a combobox {count}
choiceRefused “{value}” is not one of the choices {value}
listItemAdded / listItemRemoved / listItemRemove {value} added / {value} removed / Remove {value} — the chip’s button {value}
typeList list — the type beside a suggested column
filterInvalid {column}: {value} is not a value for this column {column}, {value}
cellRequired {column} needs a value {column}
validationWarning / validationError Warning: {message} / Error: {message} {message}
undoCell / redoCell Undone: {column} in row {row} / Redone: … {column}, {row}
undoAdded / redoAdded Undone: row added / Redone: row added
undoPaste / redoPaste Undone: paste, {count} values / Redone: … {count}
undoRecord / redoRecord Undone: record {row}, {count} fields / Redone: … {row}, {count}
recordTitle / recordSave / recordDone / recordNothing Edit record {row} / Save / Record {row} saved, {count} fields changed / Record {row}: no field changed {row}, {count}
rangeSize / rangeCopied / rangeUnloaded {rows} by {cols} cells / Copied {rows} by {cols} cells / Not copied: the range reaches rows that are not loaded {rows}, {cols}
sourceChanged / sourceRefresh The data changed. Refresh to see it. / Refresh
undoNothing / redoNothing Nothing to undo / Nothing to redo
bulkCaption / bulkApply / bulkMenu Set {column} for {rows} selected / Set / Set for selected rows {column}, {rows}
bulkReadonly / bulkDone {column} is read-only / {column} set for {rows} {column}, {rows}
pastePreview / pasteApply / pasteDiscard Paste preview / Paste / Discard
pasteUnreadable “{value}” cannot be read as {type} {value}, {type}
pasteBadOne / pasteBadOther 1 value not readable / {count} values not readable {count}
pasteCutOff / pasteApplied {count} values beyond the end are not pasted / {count} values pasted {count}
pasteDiscarded / pasteBlocked Paste discarded / Not pasted: {problems}. Discard it and paste again. {problems}
newRow / rowAdded New row / Row added, {count} not saved yet — the empty row of add-row, and what is said once a row is added {count}
selectionCleared Selection cleared
selectionMissing {count} selected rows no longer exist {count}
columnWidth {column} is {width} pixels wide {column}, {width}
columnMoved {column} moved to position {position} of {count} {column}, {position}, {count}
columnAtEdge {column} is already at the end {column}
columnPinnedStart / columnPinnedEnd / columnUnpinned {column} pinned at the start / {column} pinned at the end / {column} no longer pinned {column}
pinsReleased Too narrow for the pinned columns: they scroll with the others — said once when the pins let go
columnHidden / columnShown {column} hidden, {visible} of {count} columns shown {column}, {visible}, {count}
columnsGroup Columns
pageFirst / pagePrevious / pageNext / pageLast First page / Previous page / Next page / Last page
pageOf Page {page} of {pages} {page}, {pages}
total / subtotal Total / Total {value} {value}
noValue / emptyValue (no value) / (empty)

Edit this page on GitHub · Docs for v0.15.0