opengridv0.3.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 JSON, or a Promise of it; the third argument is optional, and so is everything else.
set_texts(host, texts) Overrides any subset of the texts. 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 <select>.
get_view(host) / set_view(host, view) Reads and applies the whole view in one step.
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.
get_pivot(host, options) The pivot as it is shown, as CSV — see exporting a pivot.
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 three 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.

Providers

All from loader.js, all the same shape:

createLocalProvider(engine) The engine on the main thread.
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. moduleUrl and wasmUrl are strings, since they travel to the worker by postMessage. Under a production bundler, pass moduleUrl and workerUrl — see Frameworks → Bundlers.
createRestProvider({ url, source, token }) POST /query/{source} of an opengrid-server. Also offers describe() → { name, schema, capabilities, pivot_limits }, and export(query, options) → a Blob from POST /export/{source} (below).
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.

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.

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 three events; each callback receives the detail (for the view: the view itself)
Order Texts, formats, presentation, choices, 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. 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 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 two columns, outermost first: group-by="country,customer". See Grouping.
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.
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 data cell open the editor (Enter commits, Escape discards)
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+Shift+←/→ header cell resize the column
Alt+↓, Shift+F10, context-menu key header cell open the column menu (with column-menu)

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.

Column visibility is a disclosure in the filter row: a checkbox per column, including the hidden ones — otherwise there would be no way back.

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`,
});

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: { emphasis: true },
  amount:   { width: 150, align: "end", aggregate: "sum", facet: "range" },
});
Key Meaning
width The column’s starting width in pixels. A reader’s resize leads after that.
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.

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.

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 non-modal role="dialog": 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. The focus returns to the button.
+ 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 two levels the button is aria-disabled and says why.
Chips One per active filter, in words (country is DE), 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.

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.
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.

Operators: = ≠ (!=) > ≥ (>=) < ≤ (<=) ~ (contains) ^ (starts with). Clauses are joined by the queryAnd word — and by default — and and always works as well.

  • 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 is case-sensitive, like every string operator in V1 (S5).
  • 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.

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, 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 menu opens below its header and never covers it.

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 and aria-expanded, data rows sit one level below the innermost group. 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.

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 two 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, selection and editing are off. A row number would name display positions — headers as well as rows — and would move on every toggle; the page could not map a reported change to anything.

<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. V1 allows one.
values The measures, as the contract’s own JSON — not an invented shorthand.

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.

get_pivot(host) exports the table as it is shown, as CSV — see exporting a pivot.

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 }, density,
//   group: [], expanded: [], facets: {} }
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.
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.

The selection is deliberately not in a view. It names positions, the grid has no key column, and sorting or filtering drops it precisely because after a different sort those positions hold different records. A restored view carrying a selection would not be incomplete — it would be wrong. Applying a view drops the selection, and says so.

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.
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"
  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) or "json".
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. 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_ms 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.
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, 2 000 rows — 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.

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 timeout_ms, more rows than max_export_rows, an export without its first byte within timeout_ms. 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.

exportRows’ own codes never come from a server:

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

All three fire on the host, bubbles and composed (without composed they would not leave a shadow root the page wrapped the element in), and neither is cancelable — they report what has already happened.

Event detail
opengrid-selection-change { rows: number[], count: number } — logical row numbers, ascending.
opengrid-cell-change { row, column, value, previous } — everything needed to persist it.
opengrid-view-change { view } — the whole view after the change. Scrolling and selecting are not view changes.

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.

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.

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
--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; until they have parts (issue #29) they apply the ink and the font to their text.

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: add-filter, add-grouping, body, cell, chip, chip-remove, chips, chips-clear, column-menu, column-menu-button, column-toggle, columns, columns-toggle, editor, filter, filter-clear, filter-dialog, filter-operator, filter-value, footer, grouping-menu, header, density, empty, empty-reset, empty-text, facet, facet-bounds, facet-cost, facet-count, facet-pill, facet-pills, facet-value, facets, facets-head, facets-toggle, filter-row-toggle, layout, menu-label, page-first, page-label, page-last, page-next, page-previous, pager, row, search, search-hint, search-input, search-list, select, select-all, select-mark, sort-direction, sort-index, source, status, toolbar, total-row, viewport.

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

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 …" });
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
filterColumn Filter …
aggregateGroup / aggregateNone Aggregate in groups / No aggregate
groupByColumn / groupSecondLevel / ungroupColumn Group by this column / Group as second level / Remove this grouping
hideColumn Hide column
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 two columns already — the button, and its name when no level can be added
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 and
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}
emptyFiltered / emptySource No row matches these filters. / There are no rows.
emptyReset Reset filters
operators the ten filter operators, keyed by wire token
filterInvalid {column}: {value} is not a value for this column {column}, {value}
cellRequired {column} needs a value {column}
selectionCleared Selection cleared
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}
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.3.0