ngx-bs-grid 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -2
- package/fesm2022/ngx-bs-grid.mjs +6514 -0
- package/fesm2022/ngx-bs-grid.mjs.map +1 -0
- package/package.json +73 -3
- package/styles/bs-grid-dark.css +21 -0
- package/types/ngx-bs-grid.d.ts +1825 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BlockSurvey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,291 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ngx-bs-grid
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/ngx-bs-grid)
|
|
4
|
+
[](https://www.npmjs.com/package/ngx-bs-grid)
|
|
5
|
+
[](https://github.com/BlockSurvey/bs-grid/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/BlockSurvey/bs-grid/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
**[Live demo](https://blocksurvey.github.io/bs-grid/)** · **[GitHub](https://github.com/BlockSurvey/bs-grid)** · **[Changelog](https://github.com/BlockSurvey/bs-grid/blob/main/CHANGELOG.md)** · **[Report an issue](https://github.com/BlockSurvey/bs-grid/issues)**
|
|
9
|
+
|
|
10
|
+
A lightweight Angular data grid with **zero runtime dependencies**. It needs nothing beyond Angular and RxJS.
|
|
11
|
+
|
|
12
|
+
Built at [BlockSurvey](https://blocksurvey.io) to replace AG Grid on production screens rendering tens of thousands of survey responses. The `@Input`/`@Output` and `GridApi` surface deliberately mirrors AG Grid's, so migrating an existing grid is mostly a rename.
|
|
13
|
+
|
|
14
|
+
MIT licensed. No license key, no watermark, no enterprise tier.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Row and column virtualization — only the visible window is in the DOM
|
|
19
|
+
- Column pinning (left/right), resize, reorder, hide/show, auto-size, flex sizing
|
|
20
|
+
- Sorting, per-column filters, quick filter, external filter hook
|
|
21
|
+
- Row selection (single/multiple, checkbox) and **range/cell selection** with copy, paste, fill-down, bulk edit and delete, plus edge auto-scroll while dragging
|
|
22
|
+
- Paste from Excel / Google Sheets (tab-separated, quoted multi-line cells supported)
|
|
23
|
+
- Row grouping with `sum` / `avg` / `count` / `min` / `max` or custom aggregation
|
|
24
|
+
- Cell renderers and cell editors as regular Angular components (`agInit` / `refresh` contract), plus built-in text, number, date and select editors
|
|
25
|
+
- Sidebar tool panels (columns, filters) and a status bar with row counts and a live Sum / Average / Min / Max / Count of the selected cells
|
|
26
|
+
- CSV export and real `.xlsx` Excel export (multi-sheet, frozen panes), built in with no zip library
|
|
27
|
+
- Pagination with AG Grid's panel, page-size dropdown, auto page size and pagination API
|
|
28
|
+
- Keyboard navigation and ARIA roles
|
|
29
|
+
- SSR-safe — every browser API is guarded with `isPlatformBrowser`
|
|
30
|
+
- Themed entirely with CSS custom properties
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install ngx-bs-grid
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Peer deps: Angular 20, 21 or 22 (`@angular/core`, `@angular/common`, `@angular/forms`) and `rxjs` ^7.8.
|
|
39
|
+
|
|
40
|
+
| Angular | Status |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| 21 | Tested: production build, SSR prerender and hydration |
|
|
43
|
+
| 20 | Tested: SSR render |
|
|
44
|
+
| 22 | Allowed by the peer range, not tested yet |
|
|
45
|
+
|
|
46
|
+
## Migrating from AG Grid
|
|
47
|
+
|
|
48
|
+
Most screens move over by changing the imports and the tag. Column definitions, events and `GridApi` calls keep their AG Grid names.
|
|
49
|
+
|
|
50
|
+
| AG Grid | bs-grid |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `<ag-grid-angular>` | `<bs-grid>` |
|
|
53
|
+
| `AgGridAngular` / `AgGridModule` | `BsGridComponent` (standalone) / `BsGridModule` |
|
|
54
|
+
| `ColDef`, `GridApi`, `GridReadyEvent` from `ag-grid-community` | the same names from `ngx-bs-grid` |
|
|
55
|
+
| `ICellRendererAngularComp`, `ICellEditorAngularComp` from `ag-grid-angular` | the same names from `ngx-bs-grid` |
|
|
56
|
+
| `agNumberColumnFilter`, `agDateColumnFilter`, `agSetColumnFilter`, `agSelectCellEditor`, `agNumberCellEditor`, … | the same string keys |
|
|
57
|
+
|
|
58
|
+
Check [Not yet supported](#not-yet-supported) before you migrate a screen that uses Enterprise-only row models.
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
Standalone component:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { Component } from '@angular/core';
|
|
66
|
+
import { BsGridComponent, ColDef, GridApi, GridReadyEvent } from 'ngx-bs-grid';
|
|
67
|
+
|
|
68
|
+
@Component({
|
|
69
|
+
selector: 'app-people',
|
|
70
|
+
imports: [BsGridComponent],
|
|
71
|
+
template: `
|
|
72
|
+
<div style="height: 500px">
|
|
73
|
+
<bs-grid
|
|
74
|
+
[rowData]="rows"
|
|
75
|
+
[columnDefs]="cols"
|
|
76
|
+
[defaultColDef]="{ sortable: true, filter: true, resizable: true }"
|
|
77
|
+
[rowSelection]="'multiple'"
|
|
78
|
+
[enableRangeSelection]="true"
|
|
79
|
+
(gridReady)="onReady($event)"
|
|
80
|
+
(cellValueChanged)="onEdit($event)">
|
|
81
|
+
</bs-grid>
|
|
82
|
+
</div>
|
|
83
|
+
`,
|
|
84
|
+
})
|
|
85
|
+
export class PeopleComponent {
|
|
86
|
+
private api!: GridApi;
|
|
87
|
+
|
|
88
|
+
cols: ColDef[] = [
|
|
89
|
+
{ field: 'name', headerName: 'Name', pinned: 'left', width: 180 },
|
|
90
|
+
{ field: 'email', headerName: 'Email', flex: 1 },
|
|
91
|
+
{ field: 'score', headerName: 'Score', editable: true, width: 120 },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
rows = [
|
|
95
|
+
{ name: 'Ada Lovelace', email: 'ada@example.com', score: 98 },
|
|
96
|
+
{ name: 'Alan Turing', email: 'alan@example.com', score: 95 },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
onReady(e: GridReadyEvent) { this.api = e.api; this.api.sizeColumnsToFit(); }
|
|
100
|
+
onEdit(e: any) { console.log(e.oldValue, '→', e.newValue); }
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
NgModule app:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { BsGridModule } from 'ngx-bs-grid';
|
|
108
|
+
|
|
109
|
+
@NgModule({ imports: [BsGridModule] })
|
|
110
|
+
export class FeatureModule {}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Sizing — read this first
|
|
114
|
+
|
|
115
|
+
The grid fills its parent. **Give it a bounded box** or the page grows instead of the grid scrolling internally:
|
|
116
|
+
|
|
117
|
+
```scss
|
|
118
|
+
.grid-wrapper { height: 500px; } // or flex: 1 1 0%; min-height: 0;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Inside flex layouts: every row-flex ancestor needs `min-width: 0`, every column-flex ancestor needs `flex: 1 1 0%; min-height: 0`. Bootstrap `.col-*` wrappers need `h-100`.
|
|
122
|
+
|
|
123
|
+
## Column definitions
|
|
124
|
+
|
|
125
|
+
`ColDef` fields:
|
|
126
|
+
|
|
127
|
+
`colId`, `field`, `headerName`, `headerTooltip`, `width`, `minWidth`, `maxWidth`, `flex`, `resizable`, `suppressSizeToFit`, `suppressAutoSize`, `pinned`, `lockPinned`, `lockPosition`, `suppressMovable`, `hide`, `sortable`, `sort`, `sortIndex`, `sortingOrder`, `comparator`, `filter`, `filterParams`, `floatingFilter`, `filterValueGetter`, `valueGetter`, `valueFormatter`, `valueParser`, `valueSetter`, `cellRenderer`, `cellRendererParams`, `cellRendererSelector`, `cellEditor`, `cellEditorParams`, `cellEditorPopup`, `cellEditorSelector`, `editable`, `cellStyle`, `cellClass`, `cellClassRules`, `tooltipField`, `tooltipValueGetter`, `checkboxSelection`, `headerCheckboxSelection`, `headerCheckboxSelectionFilteredOnly`, `autoHeight`, `wrapText`, `cellDataType`.
|
|
128
|
+
|
|
129
|
+
## Cell renderer
|
|
130
|
+
|
|
131
|
+
Any Angular component implementing `ICellRendererAngularComp`:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { Component } from '@angular/core';
|
|
135
|
+
import { ICellRendererAngularComp, BsCellParams } from 'ngx-bs-grid';
|
|
136
|
+
|
|
137
|
+
@Component({ template: `<span class="badge">{{ value }}</span>` })
|
|
138
|
+
export class BadgeRenderer implements ICellRendererAngularComp {
|
|
139
|
+
value = '';
|
|
140
|
+
agInit(params: BsCellParams) { this.value = params.value; }
|
|
141
|
+
refresh(params: BsCellParams) { this.value = params.value; return true; }
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
{ field: 'status', cellRenderer: BadgeRenderer }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Returning `false` from `refresh()` recreates the component; `true` keeps it (the fast path).
|
|
150
|
+
|
|
151
|
+
## GridApi
|
|
152
|
+
|
|
153
|
+
Handed to you in `(gridReady)`. Selected methods:
|
|
154
|
+
|
|
155
|
+
| Area | Methods |
|
|
156
|
+
|---|---|
|
|
157
|
+
| Columns | `getColumnState`, `applyColumnState`, `resetColumnState`, `setColumnDefs`, `setColumnVisible`, `setColumnsVisible`, `sizeColumnsToFit`, `autoSizeColumns`, `autoSizeAllColumns` |
|
|
158
|
+
| Filtering | `getFilterModel`, `setFilterModel`, `setQuickFilter`, `isAnyFilterPresent`, `onFilterChanged` |
|
|
159
|
+
| Selection | `getSelectedNodes`, `getSelectedRows`, `selectAll`, `deselectAll`, `setNodesSelected` |
|
|
160
|
+
| Ranges | `getCellRanges`, `addCellRange`, `clearCellSelection` |
|
|
161
|
+
| Rows | `getRowNode`, `forEachNode`, `forEachNodeAfterFilterAndSort`, `getDisplayedRowCount`, `getDisplayedRowAtIndex`, `applyTransaction`, `refreshCells`, `redrawRows` |
|
|
162
|
+
| Viewport | `ensureIndexVisible`, `ensureColumnVisible`, `setFocusedCell`, `getFocusedCell` |
|
|
163
|
+
| Pagination | `paginationGetCurrentPage`, `paginationGetTotalPages`, `paginationGetPageSize`, `paginationGetRowCount`, `paginationGoToPage`, `paginationGoToNextPage`, `paginationGoToPreviousPage`, `paginationGoToFirstPage`, `paginationGoToLastPage`, `paginationSetPageSize`, `paginationIsLastPageFound` |
|
|
164
|
+
| Export | `exportDataAsCsv`, `getDataAsCsv`, `exportDataAsExcel`, `getDataAsExcel`, `exportMultipleSheetsAsExcel`, `getMultipleSheetsAsExcel`, `getSheetDataForExcel` |
|
|
165
|
+
| Options | `setGridOption`, `getGridOption` |
|
|
166
|
+
|
|
167
|
+
## Events
|
|
168
|
+
|
|
169
|
+
`gridReady`, `firstDataRendered`, `selectionChanged`, `rowSelected`, `rowClicked`, `rowDoubleClicked`, `cellClicked`, `cellDoubleClicked`, `cellValueChanged`, `cellEditingStarted`, `cellContextMenu`, `cellFocused`, `cellSelectionChanged`, `cellSelectionDeleteStart`, `cellSelectionDeleteEnd`, `pasteStart`, `pasteEnd`, `paginationChanged`, `filterChanged`, `sortChanged`, `columnPinned`, `columnMoved`, `columnResized`, `columnVisible`.
|
|
170
|
+
|
|
171
|
+
## Editing
|
|
172
|
+
|
|
173
|
+
Editing works like AG Grid: after a value changes (typed, pasted, filled down or set with `rowNode.setDataValue`), group totals are recalculated but rows are **not** re-sorted or re-filtered, so the row stays where the user is looking. The next sort, filter or data change puts it in place. `setDataValue` works on any row node, including ones from `forEachNode` or events. Custom editors receive Enter / Escape / Tab from the grid; return `true` from `colDef.suppressKeyboardEvent` to keep a key inside your editor.
|
|
174
|
+
|
|
175
|
+
## Clipboard
|
|
176
|
+
|
|
177
|
+
- **Copy** (`Ctrl/Cmd+C`) writes the selected range as tab-separated text, so it pastes straight into a spreadsheet. Shape each value with `[processCellForClipboard]`.
|
|
178
|
+
- **Paste** (`Ctrl/Cmd+V`) needs `[enableRangeSelection]="true"`. A block is written from the top-left of the selection (or the focused cell); a single value fills every cell of a multi-cell selection. Only `editable` cells change. Each value passes through `[processCellFromClipboard]`, then the column's `valueParser`, and fires `cellValueChanged`. The pasted block is selected afterwards.
|
|
179
|
+
|
|
180
|
+
## Pagination
|
|
181
|
+
|
|
182
|
+
```html
|
|
183
|
+
<bs-grid [rowData]="rows" [columnDefs]="cols"
|
|
184
|
+
[pagination]="true"
|
|
185
|
+
[paginationPageSize]="50"
|
|
186
|
+
[paginationPageSizeSelector]="[25, 50, 100]"
|
|
187
|
+
(paginationChanged)="onPage($event)">
|
|
188
|
+
</bs-grid>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Input | Default | Notes |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `pagination` | `false` | Turns paging on. |
|
|
194
|
+
| `paginationPageSize` | `100` | Rows per page. |
|
|
195
|
+
| `paginationPageSizeSelector` | `true` | Page-size dropdown. `true` offers 20 / 50 / 100, an array sets the sizes, `false` hides it. The current size is always listed. |
|
|
196
|
+
| `paginationAutoPageSize` | `false` | Fits exactly as many rows as the grid's height holds, and hides the dropdown. |
|
|
197
|
+
| `suppressPaginationPanel` | `false` | Hides the panel so you can build your own controls on the API. |
|
|
198
|
+
|
|
199
|
+
It behaves like AG Grid's client-side pagination:
|
|
200
|
+
- Page numbers in the API are zero-based. Row indexes stay global (row 45 is row 45 on any page).
|
|
201
|
+
- Sorting, filtering and data updates keep the current page, clamped to the new page count. Turning pagination off returns to page 1.
|
|
202
|
+
- Changing the page size keeps the page number, clamped.
|
|
203
|
+
- Keyboard navigation, range selection and paste stay inside the current page. ↑ on the page's first row moves to the header, and Tab past the page's last cell leaves the grid.
|
|
204
|
+
- `ensureIndexVisible(i)` jumps to the row's page only when the panel is suppressed. With the panel shown, the user's page choice is left alone.
|
|
205
|
+
- Select-all in the header selects every row, not just the page.
|
|
206
|
+
|
|
207
|
+
## Responsive behaviour
|
|
208
|
+
|
|
209
|
+
The grid fills whatever box you give it and reflows when that box changes size (it watches its own size, not the window):
|
|
210
|
+
- Rows and columns are virtualized, so a phone renders only what fits.
|
|
211
|
+
- **Pinned columns that don't fit are unpinned automatically**, as in AG Grid v31+. When left + right pinned columns would leave less than 50 px of scrollable center, columns are unpinned (right side first, then left, alternating, skipping `lockPinned`) until it fits. They are not re-pinned when the grid grows again; re-apply your saved column state if you want them back. Customise or turn this off with `[processUnpinnedColumns]` (return `[]` to keep every pin).
|
|
212
|
+
- Dragging a pinned column wider stops 50 px before the center would disappear.
|
|
213
|
+
- The status bar and pagination panel wrap onto extra lines on narrow screens.
|
|
214
|
+
- Header menus, the context menu and filter popups stay inside the grid.
|
|
215
|
+
- `paginationAutoPageSize` recomputes the page size whenever the grid's height changes.
|
|
216
|
+
- Give `flex` columns a `minWidth` (for example `{ field: 'name', flex: 1, minWidth: 160 }`). On a narrow screen a flex column shrinks to its `minWidth` (default 40 px) and the grid scrolls sideways, the same as AG Grid.
|
|
217
|
+
|
|
218
|
+
## Status bar
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
statusBar = { statusPanels: [
|
|
222
|
+
{ statusPanel: 'agTotalRowCountComponent' },
|
|
223
|
+
{ statusPanel: 'agAggregationComponent' }, // Sum / Average / Min / Max / Count of selected cells
|
|
224
|
+
] };
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Keyboard
|
|
228
|
+
|
|
229
|
+
| Keys | Action |
|
|
230
|
+
|---|---|
|
|
231
|
+
| Arrows | move focused cell |
|
|
232
|
+
| Shift + Arrows | extend range selection |
|
|
233
|
+
| Tab / Shift+Tab | next / previous cell |
|
|
234
|
+
| Enter, F2 | start editing |
|
|
235
|
+
| Escape | cancel edit |
|
|
236
|
+
| Home / End, PageUp / PageDown | jump within the viewport |
|
|
237
|
+
| Ctrl/Cmd + C | copy selected range |
|
|
238
|
+
| Ctrl/Cmd + V | paste into the selection |
|
|
239
|
+
| Ctrl/Cmd + D | fill down |
|
|
240
|
+
| Enter (while editing) | save and keep focus on the cell (`enterNavigatesVerticallyAfterEdit` moves focus down) |
|
|
241
|
+
| Escape (while editing) | cancel and keep focus on the cell |
|
|
242
|
+
| Ctrl/Cmd + Enter (while editing) | apply the value to every cell in the selection |
|
|
243
|
+
| Space | select / deselect the focused row |
|
|
244
|
+
| Delete | clear selected range |
|
|
245
|
+
|
|
246
|
+
## Theming
|
|
247
|
+
|
|
248
|
+
Override any token on the `bs-grid` element:
|
|
249
|
+
|
|
250
|
+
```css
|
|
251
|
+
bs-grid {
|
|
252
|
+
--bs-grid-border: #e5e7eb;
|
|
253
|
+
--bs-grid-border-strong: #d1d5db;
|
|
254
|
+
--bs-grid-header-bg: #f9fafb;
|
|
255
|
+
--bs-grid-header-text: #111827;
|
|
256
|
+
--bs-grid-text: #374151;
|
|
257
|
+
--bs-grid-row-hover: #f3f4f6;
|
|
258
|
+
--bs-grid-selected: #eef2ff;
|
|
259
|
+
--bs-grid-pinned-bg: #fcfcfd;
|
|
260
|
+
--bs-grid-accent: #4f46e5;
|
|
261
|
+
--bs-grid-accent-strong: #4338ca;
|
|
262
|
+
--bs-grid-accent-soft: #eef2ff;
|
|
263
|
+
--bs-grid-font: 13px/1.4 system-ui, sans-serif;
|
|
264
|
+
--bs-grid-bg: #ffffff; /* rows, panels, menus, editors */
|
|
265
|
+
--bs-grid-text-muted: #7f8c8d; /* secondary chrome text */
|
|
266
|
+
--bs-grid-row-active: #f2f6fd; /* last-clicked row */
|
|
267
|
+
--bs-grid-group-bg: #f6f8fa; /* row-group header rows */
|
|
268
|
+
--bs-grid-control-border: #a0a8ae; /* checkbox outlines */
|
|
269
|
+
--bs-grid-overlay-bg: rgba(255, 255, 255, 0.7); /* loading scrim */
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A dark theme ships with the package:
|
|
274
|
+
|
|
275
|
+
```json
|
|
276
|
+
"styles": ["node_modules/ngx-bs-grid/styles/bs-grid-dark.css"]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
then put `class="bs-grid-dark"` on any ancestor.
|
|
280
|
+
|
|
281
|
+
## Server-side rendering
|
|
282
|
+
|
|
283
|
+
Works with zoneless and zone-based Angular SSR with no configuration. On the server the grid renders its shell (header row, row slots, status bar); cell content is filled in on the client once the viewport has a real width, since column virtualization needs it. Your own cell renderers must guard their own browser API access.
|
|
284
|
+
|
|
285
|
+
## Not yet supported
|
|
286
|
+
|
|
287
|
+
Compared with AG Grid Enterprise: the server-side and infinite row models, pivot charts / integrated charts, master-detail rows, tree data, and undo/redo. Issues and PRs welcome.
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
MIT © BlockSurvey
|