{"id":51294283,"url":"https://github.com/schappim/stimulus_grid","last_synced_at":"2026-06-30T13:01:34.560Z","repository":{"id":359278530,"uuid":"1244071247","full_name":"schappim/stimulus_grid","owner":"schappim","description":"An HTML-first data grid for Stimulus.js (Hotwire).","archived":false,"fork":false,"pushed_at":"2026-06-23T22:50:42.000Z","size":24139,"stargazers_count":12,"open_issues_count":6,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-06-28T01:05:26.454Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/schappim.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-20T00:05:26.000Z","updated_at":"2026-06-22T15:07:09.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/schappim/stimulus_grid","commit_stats":null,"previous_names":["schappim/stimulus_grid"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/schappim/stimulus_grid","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_grid","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_grid/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_grid/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_grid/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/schappim","download_url":"https://codeload.github.com/schappim/stimulus_grid/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_grid/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34967628,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-30T02:00:05.919Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-30T13:01:30.139Z","updated_at":"2026-06-30T13:01:34.546Z","avatar_url":"https://github.com/schappim.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# stimulus_grid\n\n[![CI](https://github.com/schappim/stimulus_grid/actions/workflows/ci.yml/badge.svg)](https://github.com/schappim/stimulus_grid/actions/workflows/ci.yml)\n[![npm version](https://img.shields.io/npm/v/@ninjaai/stimulus_grid?label=npm)](https://www.npmjs.com/package/@ninjaai/stimulus_grid)\n[![stimulus_grid_rails gem](https://img.shields.io/gem/v/stimulus_grid_rails?label=stimulus_grid_rails)](https://rubygems.org/gems/stimulus_grid_rails)\n\nAn **HTML-first data grid for [Stimulus.js](https://stimulus.hotwired.dev/) (Hotwire)**.\n\nYour `\u003ctable\u003e` is the source of truth. It renders without JavaScript, then progressively enhances into a full spreadsheet-style grid. No React, no build-time config object, no third-party grid framework — just `data-controller=\"grid\"` on a `\u003ctable\u003e` and `data-*` attributes on the `\u003cth\u003e`s.\n\n![stimulus_grid — sortable, filterable data grid with pinned columns, custom medal renderers, multi-row selection, and pagination](docs/images/grid-overview.png)\n\n## What you get\n\n- **Spreadsheet basics** — sort, filter, global search, single/multi selection, pagination, inline editing, and custom cell renderers **and editors**.\n\n- **Column control** — resize (double-click a handle to autosize), reorder, pin, hide, a right-click column menu for one-click pin/hide/group/pivot/aggregate, and **column state persisted to `localStorage`**.\n\n- **Scale \u0026 analysis** — virtual scrolling for large datasets, row grouping with per-group aggregates, a spreadsheet-style **status bar** with live range aggregates, **pivot mode** with a drag-driven side panel (groups / pivots / values), multi-row column header groups, and a sticky pinned bottom row for grand totals.\n\n- **Hierarchical \u0026 document layouts** — **master/detail rows** that expand to reveal a nested grid (orders → line items), **tree data** from a self-referential `parent_id` (org charts, file trees, BOMs), and **separator + merged-cell rows** for quote / invoice / report layouts.\n\n- **~200 built-in cell renderers** — core (email, URL, phone, currency, percent, progress bars, star ratings, status pills, sparklines, country flags…), editor-renderers (select, multiselect, combobox, date/time/date-range/color pickers, slider, textarea), row-action surfaces, and a deep bench of industry-domain renderers (FSM, AU compliance certificates, insurance, locksmith, fleet, and more). All pre-registered and reference-able by name — see [Built-in cell renderers](#built-in-cell-renderers).\n\n- **A public `gridApi`** for programmatic control, plus events you can hook into from any Stimulus controller.\n\n## With Rails\n\nPrefer a server-driven version with live multi-user editing? The optional [`stimulus_grid_rails`](gem/stimulus_grid_rails) companion streams edits to every connected client over Turbo Streams (Action Cable), with optimistic updates, server-side validation, and undo/redo.\n\nSee the **Rails \u0026 Hotwire** section below, or jump straight to [`RAILS.md`](RAILS.md). LLM-friendly usage docs live in [`skills/`](skills).\n\n---\n\n## Install\n\n**Option A — plain `\u003cscript\u003e` (no bundler).** Self-contained IIFE bundle with\nStimulus included; works over `file://`, a static server, anything. Vendor the\nfiles from `dist/`, or load them from a CDN:\n\n```html\n\u003clink rel=\"stylesheet\" href=\"https://unpkg.com/@ninjaai/stimulus_grid/dist/stimulus_grid.css\" /\u003e\n\u003cscript src=\"https://unpkg.com/@ninjaai/stimulus_grid/dist/stimulus_grid.js\"\u003e\u003c/script\u003e\n\u003cscript\u003e StimulusGrid.start() \u003c/script\u003e\n```\n\n**Option B — npm + a bundler (Vite, esbuild, webpack…).** Stimulus is a peer\ndependency, so install it alongside:\n\n```bash\nnpm install @ninjaai/stimulus_grid @hotwired/stimulus\n```\n\n```js\nimport { Application } from \"@hotwired/stimulus\"\nimport StimulusGrid from \"@ninjaai/stimulus_grid\"   // resolves to dist/stimulus_grid.esm.js\nimport \"@ninjaai/stimulus_grid/style.css\"\n\nconst app = Application.start()\nStimulusGrid.start(app)                     // registers grid, header-cell, pagination, …\n```\n\n`StimulusGrid.start(app?)` registers all controllers on the given Stimulus\n`Application` (or starts a new one) and returns it.\n\n**Option C — Rails / Hotwire (gem from RubyGems).** The\n[`stimulus_grid_rails`](https://rubygems.org/gems/stimulus_grid_rails) gem bundles\nthis grid *and* the live-sync layer, importmap-pinned — no JS build, no `dist/`\nto vendor:\n\n```bash\nbundle add stimulus_grid_rails\n```\n\nFull setup (importmap, stylesheet, routes, optional migration) is in the\n**Rails \u0026 Hotwire** section below.\n\n## Quick start\n\n```html\n\u003clink rel=\"stylesheet\" href=\"dist/stimulus_grid.css\" /\u003e\n\n\u003cdiv data-controller=\"grid\"\n     data-grid-pagination-value=\"true\"\n     data-grid-page-size-value=\"20\"\n     style=\"height: 480px\"\u003e\n  \u003ctable\u003e\n    \u003cthead\u003e\n      \u003ctr\u003e\n        \u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"name\"\n            data-header-cell-sortable-value=\"true\" data-header-cell-filter-value=\"text\"\n            data-header-cell-editable-value=\"true\"\u003eName\u003c/th\u003e\n        \u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"age\"\n            data-header-cell-type-value=\"number\" data-header-cell-sortable-value=\"true\"\n            data-header-cell-filter-value=\"number\"\u003eAge\u003c/th\u003e\n      \u003c/tr\u003e\n    \u003c/thead\u003e\n    \u003ctbody\u003e\n      \u003ctr data-row-id=\"1\"\u003e\u003ctd data-col-id=\"name\"\u003eAda\u003c/td\u003e\u003ctd data-col-id=\"age\"\u003e36\u003c/td\u003e\u003c/tr\u003e\n      \u003ctr data-row-id=\"2\"\u003e\u003ctd data-col-id=\"name\"\u003eLinus\u003c/td\u003e\u003ctd data-col-id=\"age\"\u003e54\u003c/td\u003e\u003c/tr\u003e\n    \u003c/tbody\u003e\n  \u003c/table\u003e\n\u003c/div\u003e\n\n\u003cscript src=\"dist/stimulus_grid.js\"\u003e\u003c/script\u003e\n\u003cscript\u003eStimulusGrid.start()\u003c/script\u003e\n```\n\nRows can be **server-rendered** (as above — parsed into the dataset on connect),\nloaded from a **URL** (`data-grid-row-data-url-value=\"/data.json\"`), or set in JS\nvia `element.gridApi.setRowData([...])`.\n\n## Screenshots\n\n**Spreadsheet-style cell selection** — click for an active cell, click-drag or\nshift-click for a range; `Cmd/Ctrl+C` copies the selection as TSV;\n`Cmd/Ctrl+V` pastes TSV back, parsing each cell through the column\nrenderer's `parseValue` (a single value tiles across the selection).\n\n![A block of cells selected by dragging, the active cell outlined and the range filled blue](docs/images/grid-cell-selection.png)\n\n**Per-column filtering** — hover a header for the filter icon; popovers adapt to\nthe column type (text / number / date / boolean).\n\n![A column filter popover open over the grid with a \"contains United\" condition](docs/images/grid-filter.png)\n\n**Inline editing** — double-click an editable cell; type-aware editors commit on\nEnter / Tab / blur and emit `grid:cellValueChanged`.\n\n![A grid cell being edited inline with a focused text input](docs/images/grid-editing.png)\n\n**Row grouping \u0026 aggregation** — group by one or more columns; each group row\nrolls up per-column aggregates (sum / avg / min / max / count) in an auto **Group**\ncolumn on the left. Collapsed here to country subtotals:\n\n![stimulus_grid grouped by country and collapsed to subtotals — the Group column on the left lists each country with its athlete count, and total medals + average age are aggregated under their headers](docs/images/grid-grouping-collapsed.png)\n\n## Grid attributes (`data-grid-*-value`)\n\n| Attribute | Meaning |\n|---|---|\n| `row-data-url` | URL returning a JSON array of row objects |\n| `row-selection` | `\"\"` \\| `\"single\"` \\| `\"multiple\"` |\n| `row-multi-select-with-click` | multi-select on plain click (no modifier) |\n| `suppress-row-click-selection` | don't select on row click |\n| `pagination` / `page-size` | enable paging + rows per page |\n| `row-height` / `header-height` | pixel sizes |\n| `virtual` / `virtual-threshold` | force virtual scrolling / auto-on threshold |\n| `height` | CSS height of the scroll viewport (e.g. `\"480px\"`) |\n| `get-row-id` | row-object field used as identity (default `id`) |\n| `dom-layout` | `\"\"` \\| `\"autoHeight\"` |\n| `server-side` / `row-count` | server-side row model: `rowData` is one page; `row-count` is the server total (drives pagination) |\n| `row-group-cols` / `agg-funcs` / `group-default-expanded` / `group-reorder-columns` | row grouping: fields to group by (JSON array), per-column aggregation `{field: fn}` (JSON), default expand depth (`-1` all · `0` none · `N` levels), and whether to float grouped columns to the front while grouping (default `true`) |\n| `status-bar` / `status-bar-aggs` | enable the bottom status bar (default `false`) and pick which range aggregates to show (default `[\"count\",\"sum\",\"avg\",\"min\",\"max\"]`) |\n| `pivot-mode` / `pivot-cols` | reshape into a pivot table (default `false`); `pivot-cols` is a JSON array of fields whose unique values become columns. Requires at least one `agg-funcs` entry to populate cells |\n| `side-panel` | render a right-side tool panel for drag-driven row groups / pivots / value aggregations + column visibility (default `false`) |\n| `column-groups` | JSON array of multi-row header groups: `[{\"headerName\":\"Medals\",\"children\":[\"gold\",\"silver\",\"bronze\"]}]`. Pivot mode auto-derives nested headers from `pivot-cols` + `agg-funcs`; this attribute is for non-pivot grids |\n| `pinned-bottom-row` | render a sticky bottom row holding grand totals over the currently filtered leaves, computed from `agg-funcs` (default `false`) |\n| `persist-key` | when non-empty, auto-save/restore column order, widths, pinning, visibility, row groups, pivot, value aggregations, header groups, sort, filter and pinned-bottom-row toggle to `localStorage[\"sgrid:\" + persistKey]` |\n| `master-detail` / `detail-template` / `detail-rows-key` / `detail-row-height` | enable expandable detail rows beneath each master row. `detail-template` is the id of a `\u003ctemplate\u003e` cloned into each detail panel; `detail-rows-key` is the master-row field holding the nested rows array (auto-seeds an inner `[data-controller=\"grid\"]` inside the template). `detail-row-height` is the panel's minimum pixel height (default `240`) |\n| `tree-data` / `tree-parent-field` / `tree-display-field` / `tree-default-expanded` | treat `rowData` as a self-referential parent/child tree (default `false`). `tree-parent-field` names the row attribute holding the parent's id (default `\"parent_id\"`). `tree-display-field` picks which column hosts the indent + chevron (default: first non-gutter column). `tree-default-expanded` is `-1` all · `0` only roots · `N` first-N levels. Mutually exclusive with row groups + pivot mode |\n| `accept-files` / `attachments-field` | drag-to-attach: when `true`, every data cell becomes a file drop target and dispatches a cancellable `grid:fileAttached` event with `{rowId, colId, files, row, dataTransfer}`. If the consumer doesn't `preventDefault()`, file metadata (`{filename, byte_size, content_type, url, …}`) is appended to `row[attachments-field]` (default: the drop target's own column). Per-column opt-out: `data-header-cell-accept-files-value=\"false\"` on the `\u003cth\u003e` |\n\n## Column attributes (`data-header-cell-*-value`, on each `\u003cth\u003e`)\n\n`field` · `header-name` · `type` (`text`\\|`number`\\|`date`\\|`datetime`\\|`boolean`\\|`color`\\|`email`\\|`url`\\|`tel`) ·\n`sortable` · `filter` (`text`\\|`number`\\|`date`\\|`boolean`\\|`set`) · `editable` ·\n`width` / `min-width` / `max-width` · `pinned` (`left`\\|`right`) · `hidden` ·\n`resizable` · `cell-renderer` (template id) · `cell-editor` (template id) ·\n`checkbox` (selection checkbox column) · `accept-files` (`\"true\"`\\|`\"false\"` — per-column opt-in / opt-out of the grid-wide drag-to-attach behaviour).\n\nHeaders whose `type` is `number` get their title text right-aligned in line\nwith the value cells underneath, so columns of currency / counts read with\na clean right edge.\n\n## Public API — `element.gridApi`\n\nAvailable after the `grid:ready` event. Highlights:\n\n- **Data:** `setRowData(rows)`, `getRowData()`, `applyTransaction({add,update,remove})`, `setRowCount(total)` / `getRowCount()` (server-side)\n- **Cell selection:** `getCellSelection()` (active + range), `getCellRangeValues()`, `getRangeAggregates()` (`{count,sum,avg,min,max}` for the current range, or `null`) — click for an active cell, drag/shift+click for a range, `Cmd/Ctrl+C` copies it as TSV, `Cmd/Ctrl+V` pastes back (each renderer's `copyValue` / `parseValue` keeps structured cells — selects, multi-selects, dates, JSON, addresses — round-trippable; rejected cells surface as `grid:pasteRejected`)\n- **Columns:** `setColumnDefs`, `getColumnDefs`, `setColumnVisible`, `setColumnPinned`, `setColumnWidth`, `moveColumn`, `autoSizeColumn`, `autoSizeAllColumns`, `sizeColumnsToFit`\n- **Sort:** `setSortModel`, `getSortModel`\n- **Filter:** `setFilterModel`, `getFilterModel`, `setColumnFilter`, `setQuickFilter`, `getQuickFilter`\n- **Selection:** `selectAll`, `deselectAll`, `selectRow`, `deselectRow`, `getSelectedRows`, `getSelectedRowIds`\n- **Pagination:** `paginationGoToPage/FirstPage/NextPage/PreviousPage/LastPage`, `paginationSetPageSize`, `paginationGetCurrentPage/TotalPages/RowCount/PageSize`\n- **Editing:** `startEditingCell({rowId, colId})`, `stopEditing(cancel?)`\n- **Export:** `getDataAsCsv(opts)`, `exportDataAsCsv(opts)`\n- **Row grouping:** `setRowGroupColumns([...])`, `addRowGroupColumn`, `removeRowGroupColumn`, `getRowGroupColumns`, `setColumnAggFunc(field, fn)` (`sum`/`avg`/`min`/`max`/`count`/`first`/`last`), `expandAll`, `collapseAll`\n- **Pivot:** `setPivotMode(on)`, `isPivotMode()`, `setPivotColumns([...])`, `addPivotColumn`, `removePivotColumn`, `getPivotColumns`, `getPivotResultColumns()` (current synthetic pivot cols — `{field, headerName, pivotKeys, valueField, aggFunc}` — useful for driving `setSortModel` against a specific pivot)\n- **Value columns** (aggregations — shared with grouping): `setValueColumns([{field,aggFunc}])`, `addValueColumn(field, aggFunc?)`, `removeValueColumn`, `getValueColumns`\n- **Column header groups:** `setColumnGroups([{headerName, children:[field,...]}])`, `getColumnGroups()`\n- **Pinned bottom row:** `setPinnedBottomRow(on)`, `isPinnedBottomRow()`\n- **Column state:** `getColumnState()` returns a JSON-serializable snapshot (cols + groups + pivot + values + sort + filter + pinnedBottomRow); `applyColumnState(state)` restores it; with `persist-key` set, `clearPersistedState()` wipes the saved blob and `getPersistKey()` reads back the key\n- **Master/detail:** `setMasterDetail(on)`, `isMasterDetail()`, `expandDetailRow(rowId)`, `collapseDetailRow(rowId)`, `toggleDetailRow(rowId)`, `expandAllDetails()`, `collapseAllDetails()`, `getDetailExpandedRowIds()`\n- **Tree data:** `setTreeData(on)`, `isTreeData()`, `setTreeParentField(field)`, `expandTreeRow(rowId)`, `collapseTreeRow(rowId)`, `toggleTreeRow(rowId)`, `expandAllTreeRows()`, `collapseAllTreeRows()`, `getTreeExpandedRowIds()`\n\n## Events (dispatched on the grid element)\n\n`grid:ready` · `grid:rowDataChanged` · `grid:cellClicked` · `grid:rowClicked` ·\n`grid:cellValueChanged` (`{rowId, colId, oldValue, newValue}`) ·\n`grid:selectionChanged` · `grid:cellSelectionChanged` · `grid:rangeAggsChanged`\n(`{aggs}`) · `grid:filterChanged` · `grid:sortChanged` ·\n`grid:paginationChanged` · `grid:columnMoved/Pinned/Resized/Visible` ·\n`grid:columnRowGroupChanged` · `grid:groupToggled` ·\n`grid:columnPivotChanged` (`{pivotCols}`) · `grid:pivotModeChanged` (`{pivot}`) ·\n`grid:columnValueChanged` (`{valueCols}`) ·\n`grid:columnGroupsChanged` (`{columnGroups}`) ·\n`grid:columnMenuOpened` (`{colId}`) · `grid:columnStateApplied` (`{state}`) ·\n`grid:detailRowExpanded` / `grid:detailRowCollapsed` (`{rowId, masterRow}`) ·\n`grid:detailRowMounted` (`{rowId, masterRow, detailEl, nestedGridApi}`) ·\n`grid:treeRowExpanded` / `grid:treeRowCollapsed` (`{rowId, row}`) ·\n`grid:treeDataChanged` (`{treeData}`) ·\n`grid:fileAttached` (`{rowId, colId, files, row, dataTransfer}` — cancellable\nwith `preventDefault()` to suppress the built-in append-to-row default).\n\n```js\ngrid.addEventListener(\"grid:ready\", (e) =\u003e e.detail.api.setRowData(rows))\ngrid.addEventListener(\"grid:cellValueChanged\", (e) =\u003e console.log(e.detail))\n```\n\n## Custom cell renderers \u0026 editors (via `\u003ctemplate\u003e`)\n\n```html\n\u003ctemplate id=\"badge\"\u003e\n  \u003cspan class=\"badge\" data-bind=\"status\" data-bind-attr=\"data-status\"\u003e\u003c/span\u003e\n\u003c/template\u003e\n\u003ctemplate id=\"status-editor\"\u003e\n  \u003cselect data-editor-input\u003e\n    \u003coption\u003eactive\u003c/option\u003e\u003coption\u003epaused\u003c/option\u003e\n  \u003c/select\u003e\n\u003c/template\u003e\n\n\u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"status\"\n    data-header-cell-editable-value=\"true\"\n    data-header-cell-cell-renderer-value=\"badge\"\n    data-header-cell-cell-editor-value=\"status-editor\"\u003eStatus\u003c/th\u003e\n```\n\n- **Renderer** clones the template per cell. `data-bind=\"field\"` → element text =\n  `row.field`; `data-bind-text` → formatted value; `data-bind-attr=\"name\"` → set\n  attribute to the cell value. Works on the root node and any descendant.\n- **Editor** clones the template on edit. The control marked `[data-editor-input]`\n  (or the first `input`/`select`/`textarea`) is seeded with the current value,\n  focused, and read back on commit (Enter / Tab / blur).\n\n## Built-in cell renderers\n\n**~200 functional renderers ship pre-registered** — reference them by name from\n`data-header-cell-cell-renderer-value`. `cell-renderer` first resolves as a\n`\u003ctemplate\u003e` id (the section above); when no template matches, it falls\nthrough to the renderer registry, so templates and named renderers coexist\nwithout conflict. Run `listRenderers()` (or import it from\n`@ninjaai/stimulus_grid`) for the full registry; the table below covers the\ncore / general-purpose set, and the **renderer library index** at the end\nof this section catalogues the rest by domain.\n\n![grid showing every built-in renderer in one row: avatar with initials, mailto email link, formatted phone, flag + country code, hostname-only URL, ABN with spaces (or red INVALID), USD currency, progress bar, percent, half-star rating, tag chips, plus four status pills — Subscribed/Delivered/Shopify/Express](docs/images/grid-renderers.png)\n\n| Renderer | Use for | Notes |\n|---|---|---|\n| `email` | email addresses | `mailto:` anchor when valid; red text otherwise |\n| `url` | links | Anchor showing `hostname[/path]`, opens in a new tab |\n| `phone` | phone numbers | `tel:` anchor with AU-aware formatting |\n| `currency` | money | USD by default; right-aligned, tabular-nums |\n| `percent` | percentages | `N%` suffix, right-aligned |\n| `progress-bar` | 0-100 values | Clamped bar; configurable colour |\n| `star-rating` | ratings | Half-star precision, SVG glyphs |\n| `tags` | CSV / arrays | Splits on commas → pill chips |\n| `country-flag` | 2-letter ISO codes | Emoji flag + code label |\n| `abn` | Australian Business Numbers | Checksum-validated → ABR lookup link; red on invalid |\n| `avatar` | user references | Image (or initials) + name; reads from `window.__sgUsers`, `row[avatarField]`, or a custom `lookup` function |\n| `date` | dates | `Intl.DateTimeFormat`-formatted; configurable locale + `dateStyle` |\n| `datetime` | dates with time | Date + time via `Intl.DateTimeFormat`; configurable locale / `dateStyle` / `timeStyle` |\n| `relative-time` | timestamps | \"3 days ago\" / \"in 2 hours\" via `Intl.RelativeTimeFormat`; absolute timestamp on hover |\n| `duration` | elapsed time | `2h 14m` (compact), `02:14:32` (clock), or `2 hours 14 minutes` (words) from ms / sec / min |\n| `number` | plain numbers | `Intl.NumberFormat`-formatted (comma-grouped); right-aligned, tabular-nums |\n| `compact-number` | big numbers | `1.2K` / `3.4M` / `1.2B` (or `1.2 million` with `compactDisplay: 'long'`) |\n| `file-size` | byte counts | Bytes → `KiB` / `MiB` / `GiB` (binary by default); pass `{ binary: false }` for `KB` / `MB` / `GB` |\n| `boolean` | true / false / null | Green check / muted X / dash; recognises `true`, `1`, `\"yes\"`, `\"on\"`, etc.; `{ falseStyle: 'hidden' }` blanks false; `{ nullLabel: 'N/A' }` overrides the dash |\n| `delta` | WoW / MoM / change | Signed value with up/down arrow + green/red text; `style: 'percent'` (default) / `'number'` / `'currency'`; `{ inverted: true }` flips colours for churn/error-rate columns |\n| `truncate` | long text | Single-line ellipsis at the cell width, full value in `title=`; pass `{ chars: 60 }` to clip by character count instead |\n| `copyable` | IDs / tokens / handles | Value + tiny copy-to-clipboard button (visible on row hover); flips to a check mark briefly on copy |\n| `image` | thumbnails | Inline thumbnail (`loading=\"lazy\"`, `decoding=\"async\"`); `size`, `rounded: 'sm'/'lg'/'full'/'none'`, optional `clickToZoom` overlay (closes on click or Escape) |\n| `color-swatch` | colours / theme tokens | Coloured chip + label; accepts any CSS colour value (hex, `rgb()`, `hsl()`, `oklch()`, named); `shape: 'circle'/'square'`, `label: 'value'/'name'/fn`, `showLabel: false` for chip-only |\n| `sparkline` | numeric arrays / trends | Mini SVG `line` / `area` / `bar` chart per row; auto-scales y, or pass `baseline: 0`; configurable colour from a 7-key palette or any CSS colour; pure SVG, no library |\n| `heatmap-cell` | KPIs / SLOs | Colour-grade the **cell background** on a numeric scale; configurable `min`/`max` + 2-or-3-stop palette; `inverted` flips \"lower is better\"; text colour auto-flips between dark + white for legibility |\n| `mask` | credit cards / phones / tokens / SSNs | Mask sensitive values: format presets `cc-last4`, `cc-bin-last4`, `phone-last4`, `email`, `last4`, or a generic `{ showFirst, showLast, char }` — masking groups from the right so the visible last-N always forms a clean trailing block (handles Amex's 15-digit cards correctly). Numeric formats auto-right-align in monospace |\n| `highlight` | search-result grids | Wraps matches of the grid's active `quickFilter` in `\u003cmark\u003e` tags so users see *why* a row matched; case-insensitive by default; pass a fixed `query` to highlight regardless of filter |\n| `multi-line` | notes / descriptions / commit messages | Preserves `\\n` newlines via `white-space: pre-line`; pass `{ lines: N }` for `-webkit-line-clamp` truncation with the full value in `title=`. Pair with a taller `data-grid-row-height-value` (~64 for 2 lines, 84 for 3) |\n| `attachments` | files on a record (Active Storage / S3 / arbitrary) | Airtable-style strip of thumbs for images + kind-tinted icons for PDFs/docs/audio/video/zips; click an image to open a keyboard-navigable lightbox carousel; click a non-image to open in a new tab; collapses to `+N` past `maxThumbs`. Pass `{ editable: true }` to enable a popover editor (dblclick or `+` button) with drag-drop, paste, and per-file × remove; supply `onUpload(files, ctx)` / `onRemove(att, ctx)` to wire it to your server (Rails Active Storage, S3 presigned, etc.). Falls back to `URL.createObjectURL` when no callbacks are given |\n| `address-au` | Australian street addresses | One-line formatted display — `12 Smith Street, Bondi NSW 2026` — with the state shown as a colour-coded badge (NSW sky, VIC navy, QLD maroon, WA gold, SA red, TAS forest, ACT ochre, NT ochre). Value shape: `{ address1, address2, address3, suburb, state, postcode, country }`. Dblclick a cell to open a multi-field popover editor with all seven fields laid out for AU conventions (state dropdown, 4-digit postcode, the third address line revealed only when needed). Commits via `applyTransaction` and fires `grid:cellValueChanged`. Pass `{ editable: false }` for display-only |\n| `audio-attachment` | call recordings / voice notes / podcasts | Compact play-circle icon (+ optional filename / duration) in-cell; dblclick (or single-click) opens an anchored popover with play/pause, draggable scrub bar, current / total time, and ±10s skip buttons. Keyboard: ←/→ seek 5s (Shift = 30s), Home/End jump, Space toggles play. Value shape: a URL string, or `{ url, filename?, byte_size?, duration? }`. Uses [howler.js](https://howlerjs.com/) when `window.Howl` is defined (richer codec fallbacks + cross-browser consistency); otherwise falls back to native `\u003caudio\u003e` — same UI either way. Only one player is active at a time — opening a second cell closes the first. No audio is preloaded on cell render — the request fires only when the popover opens |\n\n```html\n\u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"email\"\n    data-header-cell-cell-renderer-value=\"email\"\u003eEmail\u003c/th\u003e\n\u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"rating\"\n    data-header-cell-cell-renderer-value=\"star-rating\"\n    data-header-cell-type-value=\"number\"\u003eRating\u003c/th\u003e\n\u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"completion\"\n    data-header-cell-cell-renderer-value=\"progress-bar\"\n    data-header-cell-type-value=\"number\"\u003eOnboarding\u003c/th\u003e\n```\n\n### Status pills (`statusPill(colorMap, iconMap?)`)\n\nEvery \"status\" column in this codebase converges on the same shape — a\ncoloured pill with an optional icon. Register one per status column, then\nreference it like any other renderer. The colour map keys are lower-cased\nbefore lookup, so input data can be sloppy.\n\n```js\nimport { registerRenderer, renderers } from \"@ninjaai/stimulus_grid\"\n\nregisterRenderer(\"subscription\", renderers.statusPill({\n  subscribed:       \"green\",\n  unsubscribed:     \"yellow\",\n  \"not-subscribed\": \"gray\",\n}))\n\nregisterRenderer(\"fulfillment\", renderers.statusPill({\n  fulfilled:    \"gray\",  delivered: \"green\", \"in-transit\": \"blue\",\n  pending:      \"yellow\", partial:   \"orange\", rejected:   \"red\",\n}, {\n  fulfilled:    \"check-circle\", delivered: \"check-circle\",\n  \"in-transit\": \"truck\",        pending:   \"clock\",\n  partial:      \"half-circle\",  rejected:  \"x-circle\",\n}))\n```\n\nBuilt-in icon names: `check`, `check-circle`, `x-circle`, `clock`, `truck`,\n`dot`, `circle`, `half-circle`, `alert`, `cart`. Pass a raw SVG string for\nanything custom. Pill colours: `gray`, `red`, `orange`, `yellow`, `green`,\n`blue`, `indigo`, `purple`, `pink`.\n\n### Writing your own renderer\n\nA renderer is one function. Return an element / HTML string and the grid\ndrops it into the `\u003ctd\u003e`; return nothing and the renderer is assumed to\nhave mutated `td` directly. The grid passes the `td` so you can add\nclasses (right-align, etc.) without wrapping the cell content.\n\n```js\nimport { registerRenderer } from \"@ninjaai/stimulus_grid\"\n\nregisterRenderer(\"severity\", ({ value, td }) =\u003e {\n  td.classList.add(`severity-${value}`)\n  return value.toUpperCase()\n})\n```\n\nSee **[demo 19](demo/19-cell-renderers.html)** for every built-in side\nby side.\n\n### Structured-value renderers (`address-au`)\n\nMost built-ins take a scalar value. `address-au` takes an **object**\nand ships its own popover editor, since a multi-field form is the only\nsensible edit affordance for a postal address.\n\n![stimulus_grid address-au renderer: one-line display \"12 Smith Street, Bondi NSW 2026\" with the NSW state code shown as a colour-coded badge; alongside it, a multi-field popover editor titled \"Edit address\" with labelled inputs for address line 1, address line 2, suburb, state (dropdown), postcode, and country](docs/images/grid-address-au-editor.png)\n\nValue shape:\n\n```js\n{\n  address1: '12 Smith Street',\n  address2: 'Unit 4',          // optional\n  address3: 'Level 2',         // optional, revealed in the editor only when needed\n  suburb:   'Bondi',\n  state:    'NSW',             // NSW / VIC / QLD / WA / SA / TAS / ACT / NT\n  postcode: '2026',            // 4 digits\n  country:  'Australia',       // defaults to Australia; non-AU countries get a pill\n}\n```\n\nDisplay: `address1[, address2], suburb STATE postcode` on one line —\nstate rendered as a colour-coded badge (each state takes its\ntraditional sporting / coat-of-arms colour, so locals recognise it at a\nglance). The full multi-line address (including `address3`) appears in\nthe cell `title` for hover.\n\nEditing: double-click any address cell to open a popover with all\nseven fields. The third address line is hidden until the user types\ninto line 2 (or clicks \"+ Add another line\"). Commit with **Save** or\n**Enter**; cancel with **Cancel** or **Esc**. The grid writes the new\nobject through `applyTransaction` and fires the standard\n`grid:cellValueChanged` event — wire it to your server for\npersistence:\n\n```js\ngrid.addEventListener('grid:cellValueChanged', (e) =\u003e {\n  if (e.detail.colId !== 'shipping_address') return\n  fetch(`/customers/${e.detail.rowId}`, {\n    method: 'PATCH',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ shipping_address: e.detail.newValue }),\n  })\n})\n```\n\nSet `{ editable: false }` for a display-only column. See\n**[demo 34](demo/34-address-au-renderer.html)** for a working example.\n\n### Media renderers (`audio-attachment`)\n\n`audio-attachment` is the audio counterpart of `attachments` — for the\nsingle-file case where the cell is a recording (sales calls, voice\nnotes, podcast episodes) rather than an arbitrary file strip. The cell\nrenders as a compact play-circle icon with the filename next to it;\ndouble-click (or single-click) the icon and a popover opens directly\nunder the cell with a play/pause toggle, a draggable scrub bar,\ncurrent / total time, and ±10s skip buttons. Only one player is active\nat a time — opening a second cell closes the first.\n\nValue shape:\n\n```js\n\"https://cdn.example.com/calls/2026-05-22-chen.mp3\"\n// or\n{\n  url: 'https://cdn.example.com/calls/2026-05-22-chen.mp3',\n  filename: 'call-chen-2026-05-22.mp3',  // shown in cell + popover header\n  byte_size: 3_280_000,                  // optional — shown in popover\n  duration: 204,                         // optional — populates the time\n                                         //   display before audio metadata loads\n}\n```\n\n**howler.js integration is opt-in by presence.** If `window.Howl` is\ndefined when the popover opens, the renderer delegates playback /\nscrub / duration to a `Howl` instance (cross-browser consistency,\ncodec fallbacks, the `playing()` / `seek()` getters); otherwise it\nuses a plain `new Audio()` — same player UI either way. To enable\nHowler, drop in the CDN script tag before `stimulus_grid.js`:\n\n```html\n\u003cscript src=\"https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js\"\u003e\u003c/script\u003e\n\u003cscript src=\"/path/dist/stimulus_grid.js\"\u003e\u003c/script\u003e\n```\n\nNo audio is preloaded on cell render — the network request fires only\nwhen the popover opens. A grid of 100 phone calls won't issue 100\nrange requests on page load.\n\nKeyboard inside the popover: **Space** toggles play, **←/→** scrub 5s\n(Shift = 30s), **Home/End** jump to start / end, **Esc** closes.\n\n```html\n\u003cth data-controller=\"header-cell\"\n    data-header-cell-field-value=\"recording\"\n    data-header-cell-cell-renderer-value=\"audio-attachment\"\u003eRecording\u003c/th\u003e\n```\n\nFor custom variants:\n\n```js\nregisterRenderer('voicemail',\n  renderers.audioAttachment({\n    iconOnly: true,        // hide the filename, render the icon only\n    skipSeconds: 15,       // ±15s instead of the default 10\n  })\n)\n```\n\nSee **[demo 37](demo/37-audio-attachment-renderer.html)** for a CRM\ncall-log example paired with `datetime`, `duration`, and `relative-time`\ncolumns from the date-renderers demo.\n\n### Editing renderer cells\n\nEvery renderer except `sparkline` (multi-value) and the `statusPill`\nfamily (no canonical input) supports inline editing. Set\n`editable: true` on the column and pair the renderer with a `type` that\npicks the right native input — the grid does the rest. On commit\n(Enter / Tab / blur) the cell re-renders through the renderer with the\nnew value.\n\n| Renderer | Column `type` | Native editor |\n|---|---|---|\n| `email` | `email` | `\u003cinput type=\"email\"\u003e` |\n| `url` / `image` | `url` | `\u003cinput type=\"url\"\u003e` |\n| `phone` | `tel` | `\u003cinput type=\"tel\"\u003e` |\n| `currency` / `percent` / `number` / `compact-number` / `file-size` / `progress-bar` / `star-rating` / `duration` / `delta` | `number` | `\u003cinput type=\"number\"\u003e` |\n| `date` | `date` | `\u003cinput type=\"date\"\u003e` |\n| `datetime` / `relative-time` | `datetime` | `\u003cinput type=\"datetime-local\"\u003e` |\n| `boolean` | `boolean` | `\u003cselect\u003e` with `true`/`false`/`—` |\n| `color-swatch` | `color` | `\u003cinput type=\"color\"\u003e` |\n| `tags` / `country-flag` / `abn` / `avatar` / `truncate` / `copyable` | _(default)_ | `\u003cinput type=\"text\"\u003e` |\n\nNative pickers auto-open on dblclick — `color`, `date`, `datetime`,\n`time`, `month`, and `week` editors call `showPicker()` as soon as the\ninput mounts, so the user goes straight from \"dblclick the cell\" to \"the\nOS picker is up\", no second click required.\n\n### Renderer library index\n\nThe table further up covers the core renderers in detail. The rest of the\nlibrary is organised below by category, with the demo number where each is\nshowcased. Open `http://localhost:5173/demo/` for the live index, or jump\nstraight to a numbered file in `demo/`.\n\n**Editor renderers** (popover / inline editors, single registration each):\n`select` (70), `multiselect` (71), `combobox` (72), `slider` (73),\n`date-picker` (74), `time-picker` (75), `date-range` (76), `color-picker` (77),\n`textarea` (78).\n\n**Row-action surfaces**: `action-button` (79), `menu` (80), `split-button` (81),\n`row-actions` (82), `drag-handle` (83), `row-number` (84), `expand-toggle` (85).\n\n**People \u0026 presence**: `avatar-stack` (86), `presence` (87), `assignee` (88),\n`linked-record` (40), `mention` (53), `reactions` (63), `comment-count` (64).\n\n**Content \u0026 document**: `markdown` (38), `json` (39), `html` (130), `yaml` (131),\n`xml` (132), `code` (46), `autolink` (133), `diff` (43), `redacted` (134),\n`spoiler` (135), `expand` (54).\n\n**Numbers \u0026 math**: `ordinal` (65), `plural` (66), `fraction` (136),\n`scientific` (137), `hex`/`binary`/`octal` (138), `percentile` (139),\n`units` (55).\n\n**Charts in a cell**: `bullet` (48), `donut` (49), `histogram` (50), `gauge` (105),\n`win-loss` (106), `mini-bar-chart` (107), `mini-line-chart` (108), `trend` (109),\n`rag` (51), `timeline-steps` (52), `waveform` (129).\n\n**Time, schedule, lifecycle**: `time` (42), `countdown` (110), `age` (111),\n`fiscal-period` (112), `timezone` (113), `cron` (114).\n\n**Async / data-state cells**: `spinner` (115), `error` (116), `sync-status` (117),\n`stale` (118), `fresh` (119), `loading-shimmer` (69), `empty` (67).\n\n**Links \u0026 media**: `favicon` (120), `domain` (121), `social-link` (122),\n`tracking-number` (123), `video-link` (124), `file` (125), `download-link` (126),\n`mime-icon` (127), `gallery` (128), `audio` (61), `video` (62), `qr` (45),\n`geo` (44).\n\n**Geo / identity / regulated**: `mac-address` (100), `vin` (101), `isbn` (102),\n`license-key` (103), `barcode` (104), `iban` (91), `swift` (92), `ssn` (93),\n`ein` (94), `vat` (95), `nin` (UK, 96), `postal-code` (97),\n`address-us` (98), `address-generic` (99), `uuid` (89), `git-sha` (90),\n`credit-card` (68), `ip-address` (56), `bsb` (57), `acn` (58), `tfn` (59),\n`medicare` (60), `email-thread` (200).\n\n**Device telemetry**: `battery` (140), `signal-bars` (141), `volume` (142).\n\n**Australian trade compliance** (143–160): `trade-licence`, `white-card`,\n`blue-card`, `wwcc`, `high-risk-licence`, `coes`, `coc`, `qbcc-licence`,\n`vba-licence`, `gas-certificate`, `asbestos-licence`, `refrigerant-licence`,\n`pool-safety-cert`, `test-and-tag`, `insurance-cert`, `gst-status`,\n`abn-status`, `hbcf-cert`.\n\n**Field service / dispatch** (161–193): `job-status`, `arrival-window`,\n`route-stop`, `travel-time`, `technician-slot`, `progress-claim`, `variation`,\n`defect`/`snag`, `signature`, `job-photo`, `callout-fee`, `payment-terms`,\n`invoice-status`, `retention`, `materials-pick`, `swms-status`, `jsa-status`,\n`toolbox-talk`, `ppe-checklist`, `incident-severity`, `hazard-rating`,\n`site-induction`, `trade-type`, `skill-endorsement`, `subcontractor`, `crew`,\n`rego-plate`, `rego-status`, `ctp-status`, `service-due`, `fuel-card`,\n`odometer`.\n\n**Property / cadastral** (194–199, 242–249): `customer-type`, `strata-plan`,\n`lot-plan`, `council-lga`, `region-classifier`, `suburb-postcode-au`,\n`property-type`, `storeys-level-unit`, `switchboard-phase`, `hot-water-system`,\n`ncc-class`, `bal-rating`, `heritage-flag`, `nabers-stars`, `watermark-rcm` (283),\n`nmi` (240), `dbyd` (241).\n\n**Quote / job workflow** (204–220, 230–239, 284–295): `sla-countdown`,\n`quote-status`, `reattendance-counter`, `job-priority`, `booking-source`,\n`tradie-tags`, `recurring-service`, `pc-dlp-countdown`, `inspection-result`,\n`punch-list`, `timesheet-entry`, `penalty-rate`, `award-classification`,\n`apprentice-supervision`, `visa-work-rights`, `lsl-portable-scheme`,\n`clock-in-geostamp`, `bas-period`, `progress-claim-sop`, `refund-chargeback`,\n`cost-code`, `authority-limit`, `insurance-claim`, `make-safe`,\n`excess-collected`, `loss-adjuster`, `cause-of-loss`, `sms-status`,\n`call-log`, `voicemail`, `portal-last-login`, `reminder-cadence`,\n`group-by-date-sticky`, `saved-views-chips`, `offline-sync-row`,\n`optimistic-save-cell`, `sync-conflict`, `row-freshness`, `quick-add-fab`.\n\n**Inventory \u0026 pricing** (221–229): `idle-time`, `stock-level`,\n`allocated-available`, `markup`, `uom-converter`, `supplier-price-rank`,\n`special-order-eta`, `aged-receivables`, `payment-method`.\n\n**Locksmith / physical security** (250–282): `keyway-profile`, `bitting-code`,\n`pin-stack`, `cylinder-size`, `as4145-grade`, `en1303-grid`, `mks-hierarchy`,\n`restricted-patent`, `key-authority-signatory`, `key-holder-register`,\n`key-blank-xref`, `safe-cash-rating`, `safe-attack-rating`, `safe-fire-rating`,\n`combo-change-log`, `time-lock-window`, `transponder-chip`, `cut-style`,\n`fob-frequency`, `all-keys-lost`, `immobiliser-system`, `card-credential`,\n`ansi-fcode`, `failsafe-rex-dps`, `loto-state`, `mlaa-membership`,\n`scec-endorsement`, `probity-check`, `insurance-approved`, `lockout-urgency`,\n`rekey-vs-replace`, `cylinder-condition`, `cutting-machine`.\n\n**Other shapes**: `coloured-tags` (41), `rating` (heart / thumb / smiley / NPS — 47),\n`switch` (sliding on/off pill — 36), `checkbox` (interactive — 35).\n\nEvery renderer above is a factory — call it without args for the default\nshape, or pass an options object to tune it (e.g. `currency({ currency:\n'EUR' })`, `rating({ icon: 'heart', max: 5 })`). The same factories are\nre-exported under `renderers.*` for use in `registerRenderer('my-name',\nrenderers.X({…}))`.\n\n## Row grouping \u0026 aggregation\n\nGroup rows by one or more columns and roll up per-group aggregates — turning the\ngrid into a lightweight reporting view. Grouping runs **client-side** and composes\nwith sort, filter, pagination and virtual scrolling.\n\n![stimulus_grid grouped by country then sport — the auto Group column on the left holds the indented hierarchy with leaf counts, and per-column aggregates (medal sums, average age) line up under their headers; the grouped columns themselves are hidden from the main display](docs/images/grid-grouping.png)\n\nDeclare it on the grid element:\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-row-data-url-value=\"/athletes.json\"\n     data-grid-row-group-cols-value='[\"country\", \"sport\"]'\n     data-grid-agg-funcs-value='{\"gold\": \"sum\", \"silver\": \"sum\", \"age\": \"avg\"}'\n     data-grid-group-default-expanded-value=\"-1\"\u003e\n  \u003c!-- …columns… --\u003e\n\u003c/div\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `row-group-cols` | JSON array of fields to group by, in hierarchy order (`[\"country\",\"sport\"]` nests sport under country) |\n| `agg-funcs` | JSON map of `{ field: fn }`, where `fn` is `sum`, `avg`, `min`, `max`, `count`, `first`, or `last` |\n| `group-default-expanded` | `-1` all expanded (default) · `0` all collapsed · `N` expand the first N levels |\n| `group-display-type` | `'singleColumn'` (default) — auto **Group** column on the left, grouped columns hidden — or `'inline'` to put the label in the grouped column's own cell and keep it visible |\n| `group-reorder-columns` | (`'inline'` mode only) float grouped columns to the front while grouping (default `true`; `false` keeps your column order) |\n\n…or drive it at runtime through the API:\n\n```js\nconst api = el.gridApi\napi.setRowGroupColumns([\"country\"])   // [] to ungroup; multiple fields nest\napi.addRowGroupColumn(\"sport\")        // append a level\napi.removeRowGroupColumn(\"sport\")\napi.setColumnAggFunc(\"gold\", \"sum\")   // sum · avg · min · max · count · first · last\napi.expandAll(); api.collapseAll()    // …or click a group row to toggle it\n```\n\n**How it renders.** By default the grid inserts an **auto Group column** on the\nleft whose cells hold the indented value + leaf `(count)` for each group row;\nthe columns being grouped are hidden from the main display (their values live in\nthe Group column). Aggregates line up under every other column on the group row.\nNumeric aggregates skip non-numeric values; `count` counts leaves. Leaves stay\nsorted within their group by the active sort model. Switch to\n`data-grid-group-display-type-value=\"inline\"` to put the label in the grouped\ncolumn's own cell instead and keep that column visible.\n\n**Events:** `grid:columnRowGroupChanged` (`{ rowGroupCols }`) fires when the\ngrouping changes; `grid:groupToggled` (`{ groupId, expanded }`) when a group is\nexpanded or collapsed.\n\nGroup rows are display-only — they aren't selectable or editable, and cell\nselection, CSV export and `getSelectedRows()` operate on leaf rows. Since grouping\nis client-side, under the server-side row model it groups the rows currently\nloaded. See **[demo 11](demo/11-row-grouping.html)** for a full example.\n\n## Status bar\n\nA spreadsheet-style footer that shows the row count (with the filtered/total\nsplit when a filter is active), the selection count, and — whenever you select\na cell range — live aggregates over that range: count, sum, avg, min, max.\nThe same building blocks as group aggregations, scoped to the user's selection.\n\n![stimulus_grid status bar — \"Rows: 35 of 100\" on the left (Country filtered to \"United\") and \"Count: 18  Sum: 32  Avg: 1.78  Min: 0  Max: 8\" on the right, computed live from a dragged Gold/Silver/Bronze selection](docs/images/grid-status-bar.png)\n\nEnable it on the grid element:\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-row-data-url-value=\"/athletes.json\"\n     data-grid-status-bar-value=\"true\"\n     data-grid-status-bar-aggs-value='[\"count\",\"sum\",\"avg\",\"min\",\"max\"]'\u003e\n  \u003c!-- …columns… --\u003e\n\u003c/div\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `status-bar` | `true` to render the footer (default `false`) |\n| `status-bar-aggs` | JSON array picking which range aggregates to show, in order. Subset of `count`, `sum`, `avg`, `min`, `max` (default: all five) |\n\nNumeric aggregates skip non-numeric cells (booleans, dates, text); `count` is\nthe number of non-empty cells in the selection. With multi-range selection\n(`Cmd/Ctrl+click` to add ranges), the union is summarised. Group rows are not\nincluded. Read the same numbers programmatically with `gridApi.getRangeAggregates()`\n(returns `null` when there's no range), or subscribe to `grid:rangeAggsChanged`\nto render your own UI. See **[demo 12](demo/12-status-bar.html)** for a working\nexample.\n\n## Pivot mode \u0026 side panel\n\nReshape the data into a pivot table: row-group fields form the vertical axis,\nthe unique values of `pivot-cols` become columns, and the value fields (the\nones with an `agg-funcs` entry) aggregate into each cell. A synthetic **(All)**\ntotals row sits at the top; group rows underneath hold per-group aggregates;\nleaf rows are aggregated away. The **side panel** on the right is a drag-driven\ntool drawer that drives groups / pivot columns / value aggregations + column\nvisibility — the same controls as Excel pivot tables or AG-Grid's tool panel,\nall going through the public `gridApi`.\n\n![stimulus_grid in pivot mode — sport on the vertical axis, country on the horizontal, summed gold/silver/bronze in every cell; the side panel on the right shows the Columns list with PIVOT/GROUP/SUM tags, the Row Groups drop zone with \"Sport\", the Values zone with three SUM chips (Gold/Silver/Bronze), and the Column Labels zone with \"Country\"](docs/images/grid-pivot.png)\n\nEnable both on the grid element:\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-row-data-url-value=\"/athletes.json\"\n     data-grid-row-group-cols-value='[\"country\"]'\n     data-grid-pivot-cols-value='[\"sport\"]'\n     data-grid-agg-funcs-value='{\"gold\":\"sum\"}'\n     data-grid-pivot-mode-value=\"true\"\n     data-grid-side-panel-value=\"true\"\u003e\n  \u003c!-- …columns… --\u003e\n\u003c/div\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `pivot-mode` | `true` to pivot, `false` to render normally (default `false`). Toggle at runtime with `gridApi.setPivotMode(on)` |\n| `pivot-cols` | JSON array of fields whose unique values become columns (`[\"sport\"]` → one column per sport). Multiple fields produce one column per combination, sorted by each field in order |\n| `side-panel` | `true` to render the right-side drag-driven tool panel (default `false`) |\n\n…or drive it at runtime:\n\n```js\nconst api = el.gridApi\napi.setPivotMode(true)\napi.setRowGroupColumns([\"country\"])           // rows\napi.setPivotColumns([\"sport\"])                // columns\napi.setValueColumns([                         // cells (sum of gold per cell)\n  { field: \"gold\", aggFunc: \"sum\" },\n])\napi.addPivotColumn(\"medal\")                   // adds a second pivot dimension\napi.setColumnAggFunc(\"gold\", \"avg\")           // changes the agg func for one value col\n```\n\n**How it renders.** With one value field, headers show the pivot combo only\n(`\"Swimming\"`); with multiple, they include the agg + field\n(`\"Swimming · sum(gold)\"`). Empty intersections render blank (not `0`),\nmatching Excel/Sheets conventions. Filters apply to the underlying leaf rows\nbefore the pivot, so `country = \"USA\"` narrows the pivot to USA-only sports.\n\n**Sortable pivot columns.** Click any pivot column header to sort sibling\ngroup rows by that aggregate (asc → desc → off; shift-click to add to a\nmulti-sort). The synthetic **(All)** totals row stays pinned at the top\nregardless. Sort is preserved across renders and round-trips through\n`persist-key`, so reloads remember which pivot you sorted by.\n\n![Pivot table with country rows × sport columns (Athletics, Cycling, Gymnastics, Swimming); Swimming column header has a descending sort indicator; USA pulled to the top with 24 in Swimming, then Australia 4, China 2, Norway 1, and Brazil + Jamaica with no swimming rows at the bottom; (All) totals row pinned above with 22/7/14/31](docs/images/grid-sortable-pivot.png)\n\n```js\n// You don't normally need this — clicks on the column header do it for you.\n// Programmatic equivalent: find the synthetic field id via\n// gridApi.getPivotResultColumns() then drive setSortModel directly.\nconst swm = api.getPivotResultColumns().find(c =\u003e c.headerName === \"Swimming\")\napi.setSortModel([{ colId: swm.field, sort: \"desc\" }])\n```\n\nSee **[demo 17](demo/17-sortable-pivot.html)** for a focused walk-through.\n\n**The side panel.** Mounts as an `\u003caside data-controller=\"side-panel\"\u003e` inside\n`.sg-grid`. Sections (top → bottom):\n\n- **Pivot mode** — checkbox at the top\n- **Columns** — every real column with a visibility checkbox + small tags\n  (`group` / `pivot` / `sum`) showing where it currently appears\n- **Row Groups** — drop zone for fields used as `row-group-cols`\n- **Values** — drop zone for fields with aggregations. Each chip has a click-to-cycle\n  agg badge (`sum → avg → count → min → max`) and an × to remove\n- **Column Labels** — drop zone for `pivot-cols` (visible only in pivot mode)\n\nFields drag freely between sections; dropping a chip into one section removes it\nfrom the others (a field lives in at most one of {rowGroup, pivot, value}).\nClick the tab icon on the panel's right edge to collapse to just the tab strip.\n\n**Events:** `grid:pivotModeChanged` (`{pivot}`), `grid:columnPivotChanged`\n(`{pivotCols}`) and `grid:columnValueChanged` (`{valueCols}`) fire on the matching\nstate changes. See **[demo 13](demo/13-pivot-side-panel.html)** for the full UX.\n\n## Column header groups (multi-row headers) \u0026 pinned bottom row\n\nStacked column headers and an always-visible totals row, independently\ntoggled. **Column header groups** wrap one or more leaf columns under a\ncommon parent header (`Medals` over Gold / Silver / Bronze); the grid renders\nas many rows as the deepest group nesting requires. In **pivot mode** the\ngrid auto-derives nested headers from each pivot col's `pivotKeys` + the\nvalue field/agg — no extra config needed. The **pinned bottom row** sticks\nto the floor of the body viewport regardless of scroll position and shows\ngrand totals over the currently filtered leaves, using the same `agg-funcs`\ndeclarations as group/pivot aggregations.\n\n![stimulus_grid with three header groups — ATHLETE (Athlete + Age), ORIGIN (Country + Sport), MEDALS (Gold + Silver + Bronze) — stacked above the leaf headers, and a pinned TOTAL row at the bottom showing the grand totals (average age 26.76, gold 162, silver 36, bronze 24) over every filtered row](docs/images/grid-header-groups.png)\n\nDeclare them on the grid element:\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-row-data-url-value=\"/athletes.json\"\n     data-grid-agg-funcs-value='{\"gold\":\"sum\",\"silver\":\"sum\",\"bronze\":\"sum\",\"age\":\"avg\"}'\n     data-grid-pinned-bottom-row-value=\"true\"\n     data-grid-column-groups-value='[\n       {\"headerName\":\"Athlete\",\"children\":[\"athlete\",\"age\"]},\n       {\"headerName\":\"Origin\", \"children\":[\"country\",\"sport\"]},\n       {\"headerName\":\"Medals\", \"children\":[\"gold\",\"silver\",\"bronze\"]}\n     ]'\u003e\n  \u003c!-- …columns… --\u003e\n\u003c/div\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `column-groups` | JSON array of `{headerName, children: [field]}`. Each leaf column appears under at most one group; cols not in any group span all header rows. v1 supports one level of grouping; pivot-derived headers can be arbitrarily deep |\n| `pinned-bottom-row` | `true` renders the sticky bottom totals row (default `false`). Filtered out in pivot mode because the `(All)` totals row already serves that role at the top |\n\n…or drive at runtime:\n\n```js\napi.setColumnGroups([{ headerName:\"Medals\", children:[\"gold\",\"silver\",\"bronze\"] }])\napi.setPinnedBottomRow(true)\n```\n\n**Auto-grouping in pivot mode.** When a pivot would otherwise produce a busy\nsingle-row header, header groups kick in automatically:\n\n- 1 pivot col, 1 value → flat headers (`Swimming`, `Athletics`, …)\n- 1 pivot col, *M* values → 2 rows: pivot value on top, `agg(field)` underneath\n- *N* pivot cols, 1 value → *N* rows: deepest pivot field becomes the leaf label\n- *N* pivot cols, *M* values → *N+1* rows: every pivot field + the value tier\n\nThe two \"Gold\" sub-headers under different parent years don't collapse — runs\nonly merge when the **full path** matches up to the row above. **Events:**\n`grid:columnGroupsChanged` (`{columnGroups}`). See **[demo 14](demo/14-header-groups-pinned-totals.html)** for the user-declared groups + pinned totals.\n\n## Right-click column menu\n\nA one-click shortcut for the most common column operations — pin,\nautosize, group, pivot, aggregate, hide — without opening the side panel\nor wiring API calls. **Right-click any column header** and pick. Works\nout of the box; no setup attribute required.\n\n![stimulus_grid right-click column menu open on the Gold header, showing Pin left/right, Autosize, Group by Gold, Pivot by Gold, Aggregate (sum/avg/count/min/max with \"sum\" marked active), Hide column, Show all columns; the underlying grid is grouped by country with sport / age / gold / silver / bronze and the side panel visible on the right](docs/images/grid-column-menu.png)\n\nItems are emitted only when they make sense for that column + the current\ngrid state, so the menu stays short:\n\n- **Pin left / Pin right / Unpin** — depending on the col's current pin\n- **Autosize this column / Autosize all columns**\n- **Group by {col} / Ungroup {col}**\n- **Pivot by {col} / Remove {col} from pivot** (turns pivot mode on if needed)\n- **Aggregate: sum / avg / count / min / max** (shown for numeric cols or\n  cols already carrying an aggregation; the active agg is marked with `✓`).\n  **Remove aggregation** appears once an agg is set\n- **Hide column / Show all columns**\n\nOutside-click / `Escape` / window scroll / resize all close the menu.\nSynthetic columns (row-number gutter, checkbox column, auto-Group, pivot\nresult) suppress the menu — they're owned by the grid and shouldn't be\npoked through this surface. **Event:** `grid:columnMenuOpened` (`{colId}`)\nfires every time the menu opens so analytics / docs overlays can hook in.\nSee **[demo 15](demo/15-context-menu-persisted-state.html)** for it in\naction.\n\n## Persisted column state\n\nRound-trip the whole grid layout through `localStorage` so user changes\nsurvive a page reload. Set one attribute and the grid handles the rest —\nno app glue, no per-event listeners, no reload boilerplate.\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-row-data-url-value=\"/athletes.json\"\n     data-grid-persist-key-value=\"reports.athletes\"\u003e\n  \u003c!-- …columns… --\u003e\n\u003c/div\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `persist-key` | when non-empty, auto-save/restore the layout under `localStorage[\"sgrid:\" + persistKey]`. Empty (default) disables persistence |\n\nThe snapshot covers everything a user can change:\n\n- column order, widths, pinning, visibility\n- row groups, pivot mode + pivot cols, value aggregations\n- column header groups and the pinned-bottom-row toggle\n- sort, filter and quick filter\n\nWrites are debounced 200 ms and flushed synchronously on `beforeunload`,\nso a `Cmd+R` right after a change doesn't drop state. The grid restores\nonce during initial load and broadcasts a single `grid:columnStateApplied`\nevent when it does, so subscribers (the side panel, the status bar, your\nown listeners) re-render in one shot instead of reacting to every\ngranular change event.\n\nDrive the same flow programmatically — useful for \"save view\" / \"load\nview\" / \"reset layout\" buttons:\n\n```js\nconst snapshot = api.getColumnState()    // JSON-safe object you can POST to your server\napi.applyColumnState(snapshot)           // restore from anywhere — fires grid:columnStateApplied\napi.clearPersistedState()                // wipe the localStorage blob (e.g. a \"reset\" button)\napi.getPersistKey()                      // read back the configured key\n```\n\nSee **[demo 15](demo/15-context-menu-persisted-state.html)** — change\nsort / groups / pivot / values / visibility, reload, watch it stick.\n\n## Master/detail rows\n\nExpand any row to reveal a detail panel beneath it — a header strip, a\nnested `stimulus_grid` of related rows, or arbitrary HTML cloned from a\n`\u003ctemplate\u003e`. The canonical use case is **orders → line items**: the master\nrow shows the order summary, the chevron opens a detail panel with the\nline items grid and a header strip pulled from the master.\n\n![stimulus_grid master/detail — an outer Orders grid with order #1005 expanded to reveal a header strip (status pill, customer, total) above a nested grid of three line items with a pinned bottom-totals row](docs/images/grid-master-detail.png)\n\nEnable it on the grid element and point at a `\u003ctemplate\u003e` for the detail\nshell:\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-master-detail-value=\"true\"\n     data-grid-detail-template-value=\"order-detail-tpl\"\n     data-grid-detail-rows-key-value=\"lineItems\"\n     data-grid-detail-row-height-value=\"280\"\u003e\n  \u003ctable\u003e\n    \u003cthead\u003e\u003ctr\u003e\u003c!-- order columns… --\u003e\u003c/tr\u003e\u003c/thead\u003e\n    \u003ctbody\u003e\u003c/tbody\u003e\n  \u003c/table\u003e\n\u003c/div\u003e\n\n\u003ctemplate id=\"order-detail-tpl\"\u003e\n  \u003cdiv class=\"order-detail\"\u003e\n    \u003cheader class=\"order-detail-head\"\u003e\n      \u003cstrong\u003eOrder #\u003cspan data-detail-bind=\"id\"\u003e\u003c/span\u003e\u003c/strong\u003e\n      \u003cspan class=\"status\"\n            data-detail-bind=\"status\"\n            data-detail-bind-attr=\"data-status:status\"\u003e\u003c/span\u003e\n      \u003cspan class=\"customer\" data-detail-bind=\"customer\"\u003e\u003c/span\u003e\n    \u003c/header\u003e\n    \u003c!-- Inner grid is auto-seeded from master.lineItems (detail-rows-key). --\u003e\n    \u003cdiv data-controller=\"grid\"\n         data-grid-row-height-value=\"28\"\n         data-grid-pinned-bottom-row-value=\"true\"\n         data-grid-agg-funcs-value='{\"qty\":\"sum\",\"lineTotal\":\"sum\"}'\u003e\n      \u003ctable\u003e\n        \u003cthead\u003e\u003ctr\u003e\u003c!-- line-item columns… --\u003e\u003c/tr\u003e\u003c/thead\u003e\n        \u003ctbody\u003e\u003c/tbody\u003e\n      \u003c/table\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/template\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `master-detail` | `true` to enable the expand chevron + detail row pipeline (default `false`) |\n| `detail-template` | id of a `\u003ctemplate\u003e` cloned into each detail panel; supports `[data-detail-bind=\"\u003cfield\u003e\"]` (text), `[data-detail-bind-attr=\"\u003cattr\u003e:\u003cfield\u003e\"]` (attribute), `[data-detail-if=\"\u003cfield\u003e\"]` (drop the node when the field is falsy) |\n| `detail-rows-key` | master-row field holding the array of nested rows; if the template contains a `[data-controller~=\"grid\"]` child, its `data-grid-row-data-value` is seeded from `master[detailRowsKey]` before Stimulus boots it |\n| `detail-row-height` | minimum pixel height for the detail shell (default `240`) |\n\n**Behaviour.** The grid prepends a 32 px pinned-left gutter column with an\nexpand chevron; clicking it toggles the detail panel for that row.\nExpanded state lives in memory on the grid (not in `localStorage` — detail\nexpansion is session-scoped, not layout). Detail rows are display-only:\nthey don't participate in selection, CSV export, range aggregates, or\nkeyboard navigation. Suppressed in pivot / grouped views (the leaves they'd\nhang off have been aggregated away) and incompatible with the uniform-row\nvirtualisation, so the grid switches to non-virtual rendering whenever\nmaster/detail is enabled — best fit is dozens-to-hundreds of master rows.\n\n**Drive it programmatically:**\n\n```js\napi.setMasterDetail(true)\napi.expandDetailRow(orderId)               // emits grid:detailRowExpanded\napi.toggleDetailRow(orderId)               // expand ↔ collapse\napi.collapseAllDetails()                   // close everything\napi.getDetailExpandedRowIds()              // → [1001, 1005, …]\n```\n\n**Events.** `grid:detailRowExpanded` / `grid:detailRowCollapsed`\n(`{rowId, masterRow}`) fire on toggle. `grid:detailRowMounted`\n(`{rowId, masterRow, detailEl, nestedGridApi}`) fires once after the\ntemplate clones into the DOM and the inner grid (if any) has its\n`gridApi` hooked up — handy for adding columns or chrome imperatively.\n\n\u003e All grid events bubble. If you're listening on the *outer* grid and\n\u003e nest another grid inside the detail shell, scope your handler with\n\u003e `if (e.target !== grid) return` so the inner grid's `grid:ready` /\n\u003e `grid:rowDataChanged` don't trigger your outer logic.\n\nSee **[demo 16](demo/16-master-detail.html)** for the full orders ↔\nline-items example with `expand all` / `collapse all` controls and a\nstatus read-out.\n\n## Tree data (self-referential `parent_id`)\n\nRows describe a real hierarchy via `parent_id` (org chart, file tree, BOM,\ncomment thread). The grid flattens the tree into a display list, draws an\nindent + chevron on a configured **tree column**, and routes chevron\nclicks back through the public `gridApi`. Unlike **row grouping** (which\nsynthesises a hierarchy from column values), tree data treats every row\nas a real entity — leaves and branches share the same column layout.\n\n![stimulus_grid in tree mode — an org chart with CEO at the root, two VPs indented one level, engineering managers under VP Engineering, and individual contributors at the deepest level; each row with children has a chevron pointing down; leaves reserve an empty chevron slot so all names line up](docs/images/grid-tree-data.png)\n\n```html\n\u003cdiv data-controller=\"grid\"\n     data-grid-tree-data-value=\"true\"\n     data-grid-tree-parent-field-value=\"parent_id\"\n     data-grid-tree-display-field-value=\"name\"\u003e\n  \u003ctable\u003e\n    \u003cthead\u003e\n      \u003ctr\u003e\n        \u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"name\"\n            data-header-cell-sortable-value=\"true\"\u003eName\u003c/th\u003e\n        \u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"title\"\u003eTitle\u003c/th\u003e\n        \u003cth data-controller=\"header-cell\" data-header-cell-field-value=\"salary\"\n            data-header-cell-type-value=\"number\"\u003eSalary\u003c/th\u003e\n      \u003c/tr\u003e\n    \u003c/thead\u003e\n    \u003ctbody\u003e\u003c/tbody\u003e\n  \u003c/table\u003e\n\u003c/div\u003e\n\n\u003cscript\u003e\n  const grid = document.querySelector(\"[data-controller='grid']\")\n  grid.addEventListener(\"grid:ready\", () =\u003e grid.gridApi.setRowData([\n    { id: 1, parent_id: null, name: \"CEO\",            title: \"CEO\",            salary: 480 },\n    { id: 2, parent_id: 1,    name: \"VP Engineering\", title: \"VP Engineering\", salary: 320 },\n    { id: 3, parent_id: 2,    name: \"Senior Engineer\", title: \"Senior Engineer\", salary: 175 },\n  ]))\n\u003c/script\u003e\n```\n\n| Attribute | Value |\n|---|---|\n| `tree-data` | `true` to flatten `rowData` as a tree (default `false`). Mutually exclusive with row groups + pivot mode |\n| `tree-parent-field` | row field naming the parent row's id (default `\"parent_id\"`). A `null`/missing/self/orphan parent makes the row a root |\n| `tree-display-field` | which column hosts the indent + chevron (default: first non-gutter column) |\n| `tree-default-expanded` | `-1` all expanded · `0` only roots · `N` first-N levels expanded (default `-1`) |\n\n**Behaviour notes.**\n\n- **Filter pulls ancestors in.** When `quickFilter` or a column filter is\n  active, a matching row keeps its **full ancestor chain** visible (so the\n  user always sees the path to the match) and a matching *parent* keeps\n  its **entire subtree** visible (so \"this folder matched\" shows what's\n  inside). All kept rows are force-expanded for the duration of the filter.\n- **Sort works per-sibling-set.** A `sortModel` reorders children within\n  each parent — the tree shape is preserved.\n- **No row mutation.** Per-row tree metadata (level / hasChildren /\n  expanded) lives in a sidecar `treeMeta` Map keyed by row id;\n  `JSON.stringify(row)` won't surface any of the grid's scaffolding.\n- **Cycles + orphans handled.** A `parent_id` referring to a missing /\n  cyclical / self row makes the row a root.\n- **Mutually exclusive** with `row-group-cols` and `pivot-mode` — those\n  assume a flat dataset.\n\n**Events:** `grid:treeRowExpanded` / `grid:treeRowCollapsed` (`{rowId, row}`),\n`grid:treeDataChanged` (`{treeData}`). See **[demo 18](demo/18-tree-data.html)**\nfor a worked org-chart example with expand/collapse, search-with-ancestor-\npreservation, and salary-desc sibling sort.\n\n## Separators \u0026amp; merged cells (quotes, invoices, reports)\n\nDrop **section headings**, **blank spacers**, **ruled dividers**, and\n**summary rows** between data rows; let any single cell **span multiple\ncolumns** via a per-row `__sgSpans` map. Together they cover the structural\nprimitives a real-world quote, invoice, contract, or report layout needs —\nwithout forking the grid into a separate \"document\" component.\n\n![stimulus_grid rendering a tax invoice with PROFESSIONAL SERVICES + HARDWARE section headings, three service line items and two hardware line items, a description-spanning note row about the Raspberry Pi delivery schedule, and a Subtotal / GST 10% / Total due summary block at the foot of the table](docs/images/invoice-separators-merged.png)\n\n### Separator rows\n\nA *separator* is any row in `rowData` carrying `__sgSeparator: true`. The\nshape decides the variant:\n\n```js\n{ __sgSeparator: true }                                      // blank spacer\n{ __sgSeparator: true, variant: 'divider' }                  // thin ruled line\n{ __sgSeparator: true, label: 'Professional services' }      // section heading\n{ __sgSeparator: true, label: 'Subtotal', value: '$17,364' } // summary line\n{ __sgSeparator: true, variant: 'total',                     // emphasised grand-total\n  label: 'Total due', value: '$19,100.40' }\n{ __sgSeparator: true, variant: 'subtle', label: 'Notes' }   // muted heading\n```\n\nVariants: `heading` (default with label), `summary` (default with label+value),\n`total`, `subtle`, `blank`, `divider`. The `variant` field always wins over\nthe auto-pick.\n\n- **Positional anchors.** Separators always render in declared position;\n  sort and filter only reorder / hide the data rows *around* them. Quote\n  sections stay intact when the user clicks a header.\n- **Inert.** Separators are never selected, edited, exported to CSV,\n  counted in aggregates, or stepped over by keyboard navigation.\n- **HTML-first too.** Server-rendered:\n  ```html\n  \u003ctr data-separator=\"heading\" data-label=\"Hardware\"\u003e\u003c/tr\u003e\n  \u003ctr data-separator=\"total\" data-label=\"Total due\" data-value=\"$19,100.40\"\u003e\u003c/tr\u003e\n  ```\n\n### Merged cells\n\nPer-row `__sgSpans`: each key is a field name and each value is how many\nvisible columns the cell should swallow.\n\n```js\n{\n  id: 6,\n  description: '↪ Includes preconfigured Raspbian image + 12 months remote support.',\n  total: 0,\n  // The description cell covers description + qty + unit-price columns.\n  __sgSpans: { description: 3 },\n}\n```\n\nServer-rendered equivalent: just use the standard HTML `colspan` on the `\u003ctd\u003e`\n(or `data-spans=\"N\"`) — the grid picks it up during `_captureInitialMarkup`.\n\n```html\n\u003ctr data-row-id=\"6\"\u003e\n  \u003ctd data-col-id=\"description\" colspan=\"3\"\u003e↪ Includes preconfigured Raspbian image…\u003c/td\u003e\n  \u003ctd data-col-id=\"total\"\u003e$0.00\u003c/td\u003e\n\u003c/tr\u003e\n```\n\nSee **[demo 33](demo/33-invoice-separators-merged.html)** for a worked tax\ninvoice with section headings, a description-spanning note row, and a\nSubtotal / GST 10% / Total due summary block — all on the same grid that\nsorts, edits, exports CSV, and (optionally) takes file drops elsewhere.\n\n## Rails \u0026 Hotwire (`stimulus_grid_rails`)\n\nFor Rails apps, the **[`stimulus_grid_rails`](gem/stimulus_grid_rails)** gem turns\nthe grid into a **server-driven, multi-user editable** grid over Turbo Streams +\nAction Cable — no React, no client-side grid framework, no JS build step. Because\na Rails app knows its schema, the **server** column definition does the work a\ngeneric client grid pushes onto the browser: auth, coercion, validation, editor\nselection, computed-column cascade, and broadcasting.\n\n**Capabilities**\n\n- **Live multi-user editing** — every create/update/destroy broadcasts cell-grained Turbo Stream actions to all connected tabs.\n- **Optimistic cell edits** — a committed cell pulses pending (blue), then the server reconciles (green flash) or reverts (red + error tooltip), with `X-Optimistic-Id` echo-suppression for the originator.\n- **Server-side column registry** — per-column `type`, `editable` (boolean *or* lambda), `editor`/`editor_config`, `validate`, `concurrency`, and `computed`/`depends_on`.\n- **Concurrency \u0026 validation** — version-checked edits (`lock_version` → conflict), server-side validation → revert with errors, computed-column cascade replayed as a bulk stream.\n- **Rows** — create / delete (single + multi-select bulk), tab/newline bulk paste, and undo/redo backed by a server-side audit log (`Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`).\n- **Multi-tenancy \u0026 auth** — tenant-scoped streams (ActsAsTenant), scoped row lookups, and auth inherited from your `parent_controller`.\n- **Scale** — server-side global search, per-column filtering, and a windowed server-side row model for 50–100K+ rows.\n\n**Install** — published on RubyGems as\n[`stimulus_grid_rails`](https://rubygems.org/gems/stimulus_grid_rails). Add it\nwith Bundler:\n\n```bash\nbundle add stimulus_grid_rails\n```\n\n```ruby\n# …or pin it in your Gemfile, then run `bundle install`:\ngem \"stimulus_grid_rails\"\n```\n\nThe engine auto-registers two importmap pins (`stimulus_grid`,\n`stimulus_grid_rails`) and ships the CSS, so no `bin/importmap pin` is needed:\n\n```js\n// app/javascript/application.js\nimport \"@hotwired/turbo-rails\"\nimport { Application } from \"@hotwired/stimulus\"\nimport StimulusGrid from \"stimulus_grid\"\nimport StimulusGridRails from \"stimulus_grid_rails\"\n\nconst application = Application.start()\nStimulusGrid.start(application)        // grid, header-cell, pagination, …\nStimulusGridRails.start(application)   // grid-sync, cell-editor + Turbo Stream actions\n```\n\n```erb\n\u003c%# app/views/layouts/application.html.erb (head) %\u003e\n\u003c%= stylesheet_link_tag \"stimulus_grid\", \"stimulus_grid_rails\" %\u003e\n\u003c%= javascript_importmap_tags %\u003e\n```\n\n```ruby\n# config/routes.rb\nmount ActionCable.server =\u003e \"/cable\"\nmount StimulusGridRails::Engine =\u003e StimulusGridRails.mount_path   # default \"/grids\"\n```\n\nUndo/redo and the audit log are opt-in — install the bundled migration when you\nwant them (everything else works without it):\n\n```bash\nbin/rails stimulus_grid_rails:install:migrations \u0026\u0026 bin/rails db:migrate\n```\n\n**Usage**\n\n```ruby\n# app/grids/athlete_grid.rb — one source of truth for the columns\nclass AthleteGrid \u003c StimulusGridRails::Grid\n  resource :athletes\n  model    Athlete\n  stream_name { |_user| \"athletes\" }\n\n  column :athlete, type: :string,  editable: true, pinned: :left, width: 220\n  column :country, type: :string,  editable: -\u003e(row, user) { user\u0026.admin? }   # per-row/user\n  column :age,     type: :integer, editable: true, concurrency: :version_checked,\n                   validate: -\u003e(v, _r) { \"must be 10–80\" unless (10..80).cover?(v.to_i) }\n  column :total,   type: :integer, computed: true, depends_on: %i[gold silver bronze]\n\n  def compute_total(row) = row.gold.to_i + row.silver.to_i + row.bronze.to_i\nend\n```\n\n```ruby\n# app/models/athlete.rb — make the model broadcast its changes\nclass Athlete \u003c ApplicationRecord\n  include StimulusGridRails::Broadcastable\n  broadcasts_grid AthleteGrid, stream: -\u003e(_a) { \"athletes\" }\n  self.locking_column = :lock_version   # needed for version-checked columns\nend\n```\n\n```erb\n\u003c%# render it anywhere %\u003e\n\u003c%= render partial: \"stimulus_grid_rails/grids/grid\",\n           locals: { grid: AthleteGrid.new(user: current_user),\n                     rows: Athlete.order(:id),\n                     row_selection: \"multiple\", page_size: 25 } %\u003e\n```\n\nDouble-click a cell → edit → Enter commits → optimistic pending → the server\nreconciles or reverts → every other connected tab updates live. A complete\nrunnable app is in [`gem/demo`](gem/demo); full docs in\n[`gem/stimulus_grid_rails/README.md`](gem/stimulus_grid_rails/README.md) and\n[`RAILS.md`](RAILS.md).\n\n## Demos\n\n`npm install \u0026\u0026 npx vite`, then open `http://localhost:5173/demo/` (the\nroot path now redirects there). **295+ demos**, indexed at `demo/index.html`:\n\n- **01–18 — Grid mechanics**: basics, JSON data, filtering, selection,\n  pagination, editing, custom-template renderers, 10k-row virtual scroll,\n  everything-together, live filter, row grouping with aggregation, status\n  bar, pivot mode + side panel, header groups + pinned totals, right-click\n  column menu + persisted state, master/detail (nested grid), sortable\n  pivot columns, and tree data (self-referential `parent_id`).\n- **19–37 — Core renderer library**: the canonical built-ins\n  (email/URL/phone/currency/etc.), date renderers (date / datetime /\n  relative-time / duration), number renderers, boolean, delta, truncate +\n  copyable, image, color swatch, sparkline, heatmap, mask, highlight,\n  multi-line, attachments (Airtable-style), invoice / quote layout\n  (separators + merged cells), Australian addresses with multi-field\n  popover editor, checkbox, switch, and audio attachments with the\n  Howler-powered scrubbing player.\n- **38–99 — Extended renderers**: markdown, JSON, linked-record, coloured\n  tags, time, diff, geo, QR, code, ratings, bullet/donut/histogram/RAG,\n  timeline-steps, mention, expand, units, IP, BSB / ACN / TFN / Medicare,\n  audio / video, reactions, comment-count, ordinal / plural / empty,\n  credit-card, loading-shimmer, plus the editor renderers\n  (select / multiselect / combobox / slider / date-picker / time-picker /\n  date-range / color-picker / textarea), row-action surfaces\n  (action-button / menu / split-button / row-actions / drag-handle /\n  row-number / expand-toggle), avatar-stack / presence / assignee, and\n  identity formats (UUID / git SHA / IBAN / SWIFT / SSN / EIN / VAT / NIN /\n  postal-code / address-us / address-generic).\n- **100–142 — Specialised renderers**: MAC / VIN / ISBN / license-key /\n  barcode, gauge / win-loss / mini-bar / mini-line / trend, countdown /\n  age / fiscal-period / timezone / cron, spinner / error / sync-status /\n  stale / fresh, favicon / domain / social-link / tracking-number /\n  video-link / file / download-link / mime-icon / gallery / waveform,\n  HTML / YAML / XML / autolink / redacted / spoiler / fraction /\n  scientific / radix / percentile, battery / signal-bars / volume.\n- **143–199 — Australian trade compliance, FSM dispatch, vehicle \u0026\n  property**: trade licence cards (white / blue / WWCC / HRWL / COES /\n  COC / QBCC / VBA / gas / asbestos / refrigerant / pool / test-and-tag /\n  insurance currency / GST / ABN / HBCF), job-status / arrival-window /\n  route-stop / travel-time / technician-slot, progress-claim / variation /\n  defect / signature / job-photo, callout-fee / payment-terms / invoice-\n  status / retention / materials-pick, SWMS / JSA / toolbox-talk /\n  PPE-checklist / incident-severity / hazard-rating / site-induction,\n  trade-type / skill-endorsement / subcontractor / crew, rego-plate /\n  rego-status / CTP / service-due / fuel-card / odometer, customer-type /\n  strata / lot-plan / council-LGA / region / suburb-postcode.\n- **200–249 — Communications, headerless / mobile, quote workflow,\n  property \u0026 energy**: email-thread, headerless grid, mobile-optimised\n  card layout, today's-jobs mobile screen, SLA countdown, quote-status,\n  reattendance-counter, job-priority, booking-source, tradie-tags,\n  recurring-service, PC/DLP countdown, inspection-result, punch-list,\n  timesheet-entry, penalty-rate, award-classification, apprentice-\n  supervision, visa work-rights, LSL portable scheme, clock-in geo-stamp,\n  idle-time, stock-level / allocated-available / markup / UOM /\n  supplier-price-rank / special-order-ETA / aged-receivables /\n  payment-method, BAS-period, SOP progress-claim, refund-chargeback,\n  cost-code, authority-limit, insurance-claim, make-safe, excess-\n  collected, loss-adjuster, cause-of-loss, NMI, DBYD, property-type,\n  storeys/level/unit, switchboard-phase, hot-water-system, NCC-class,\n  BAL-rating, heritage-flag, NABERS-stars.\n- **250–282 — Locksmith renderers**: keyway-profile, bitting-code,\n  pin-stack, cylinder-size, AS 4145 grade, EN 1303 grid, MKS hierarchy,\n  restricted-patent, key-authority-signatory, key-holder-register,\n  key-blank-xref, safe cash / attack / fire ratings, combo-change-log,\n  time-lock-window, transponder-chip, cut-style, fob-frequency,\n  all-keys-lost, immobiliser-system, card-credential, ANSI F-code,\n  failsafe / REX / DPS, LOTO-state, MLAA membership, SCEC endorsement,\n  probity-check, insurance-approved, lockout-urgency, rekey-vs-replace,\n  cylinder-condition, cutting-machine.\n- **283–295 — Compliance + sync UX**: WaterMark / RCM, SMS status,\n  call-log, voicemail, portal-last-login, reminder-cadence, group-by-date\n  sticky headers, saved-views chips (localStorage), offline-sync per row,\n  optimistic-save per cell, sync-conflict, row-freshness, quick-add FAB.\n\n## Build\n\n```bash\nnpm run build:lib   # builds dist/stimulus_grid.js (IIFE) + dist/stimulus_grid.esm.js (ESM) + .css\n```\n\n## Tests\n\n```bash\nnpm test                          # JS core: display-list pipeline (Vitest)\ncd gem/demo \u0026\u0026 bin/rails test     # Rails engine: models, controllers, Turbo Streams, audit\n```\n\nBoth run on every push/PR via [GitHub Actions](.github/workflows/ci.yml).\n\nSee [`DESIGN.md`](DESIGN.md) for architecture and the full API reference, and\n[`skills/`](skills) for LLM-oriented usage guides.\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fschappim%2Fstimulus_grid","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fschappim%2Fstimulus_grid","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fschappim%2Fstimulus_grid/lists"}