ng-hub-ui-spreadsheet 22.0.0
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 +571 -0
- package/fesm2022/ng-hub-ui-spreadsheet-signals.mjs +281 -0
- package/fesm2022/ng-hub-ui-spreadsheet-signals.mjs.map +1 -0
- package/fesm2022/ng-hub-ui-spreadsheet.mjs +4598 -0
- package/fesm2022/ng-hub-ui-spreadsheet.mjs.map +1 -0
- package/package.json +67 -0
- package/types/ng-hub-ui-spreadsheet-signals.d.ts +125 -0
- package/types/ng-hub-ui-spreadsheet.d.ts +1958 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hub-env
|
|
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
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
# ng-hub-ui-spreadsheet
|
|
2
|
+
|
|
3
|
+
[EspaΓ±ol](./README.es.md) | **English**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/ng-hub-ui-spreadsheet)
|
|
6
|
+
[](https://angular.dev)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
An editable sheet of cells for Angular 22+ β typed into, moved through with the keyboard, and pasted into from Excel. Rectangular selection, a clipboard that round-trips both ways, frozen panes and a save state per cell, from a single `<hub-spreadsheet>` element. Real grid semantics rather than a table styled to look like one. No paid tier, no feature gate, and nothing outside Angular in its dependencies: the grid primitives live in `ng-hub-ui-utils`, not in `@angular/cdk`.
|
|
10
|
+
|
|
11
|
+
## Documentation and Live Examples
|
|
12
|
+
|
|
13
|
+
This package is part of [Hub UI](https://hubui.dev/en/), a collection of Angular component libraries for standalone apps.
|
|
14
|
+
|
|
15
|
+
- Docs: https://hubui.dev/en/spreadsheet/overview/
|
|
16
|
+
- Live examples: https://hubui.dev/en/spreadsheet/examples/
|
|
17
|
+
- Hub UI: https://hubui.dev/en/
|
|
18
|
+
- Hub UI on GitHub (issues, roadmap and contributing): https://github.com/hub-env/hub-ui
|
|
19
|
+
|
|
20
|
+
## π§© Library Family `ng-hub-ui`
|
|
21
|
+
|
|
22
|
+
This library is part of the **ng-hub-ui** ecosystem:
|
|
23
|
+
|
|
24
|
+
- [**ng-hub-ui-action-sheet**](https://www.npmjs.com/package/ng-hub-ui-action-sheet)
|
|
25
|
+
- [**ng-hub-ui-avatar**](https://www.npmjs.com/package/ng-hub-ui-avatar)
|
|
26
|
+
- [**ng-hub-ui-badges**](https://www.npmjs.com/package/ng-hub-ui-badges)
|
|
27
|
+
- [**ng-hub-ui-board**](https://www.npmjs.com/package/ng-hub-ui-board)
|
|
28
|
+
- [**ng-hub-ui-breadcrumbs**](https://www.npmjs.com/package/ng-hub-ui-breadcrumbs)
|
|
29
|
+
- [**ng-hub-ui-buttons**](https://www.npmjs.com/package/ng-hub-ui-buttons)
|
|
30
|
+
- [**ng-hub-ui-calendar**](https://www.npmjs.com/package/ng-hub-ui-calendar)
|
|
31
|
+
- [**ng-hub-ui-ds**](https://www.npmjs.com/package/ng-hub-ui-ds)
|
|
32
|
+
- [**ng-hub-ui-forms**](https://www.npmjs.com/package/ng-hub-ui-forms)
|
|
33
|
+
- [**ng-hub-ui-history**](https://www.npmjs.com/package/ng-hub-ui-history)
|
|
34
|
+
- [**ng-hub-ui-icons**](https://www.npmjs.com/package/ng-hub-ui-icons)
|
|
35
|
+
- [**ng-hub-ui-loading**](https://www.npmjs.com/package/ng-hub-ui-loading)
|
|
36
|
+
- [**ng-hub-ui-metrics**](https://www.npmjs.com/package/ng-hub-ui-metrics)
|
|
37
|
+
- [**ng-hub-ui-milestones**](https://www.npmjs.com/package/ng-hub-ui-milestones)
|
|
38
|
+
- [**ng-hub-ui-modal**](https://www.npmjs.com/package/ng-hub-ui-modal)
|
|
39
|
+
- [**ng-hub-ui-nav**](https://www.npmjs.com/package/ng-hub-ui-nav)
|
|
40
|
+
- [**ng-hub-ui-paginable**](https://www.npmjs.com/package/ng-hub-ui-paginable)
|
|
41
|
+
- [**ng-hub-ui-panels**](https://www.npmjs.com/package/ng-hub-ui-panels)
|
|
42
|
+
- [**ng-hub-ui-portal**](https://www.npmjs.com/package/ng-hub-ui-portal)
|
|
43
|
+
- [**ng-hub-ui-signature**](https://www.npmjs.com/package/ng-hub-ui-signature)
|
|
44
|
+
- [**ng-hub-ui-skeleton**](https://www.npmjs.com/package/ng-hub-ui-skeleton)
|
|
45
|
+
- [**ng-hub-ui-sortable**](https://www.npmjs.com/package/ng-hub-ui-sortable)
|
|
46
|
+
- [**ng-hub-ui-spreadsheet**](https://www.npmjs.com/package/ng-hub-ui-spreadsheet) β You are here
|
|
47
|
+
- [**ng-hub-ui-stepper**](https://www.npmjs.com/package/ng-hub-ui-stepper)
|
|
48
|
+
- [**ng-hub-ui-toast**](https://www.npmjs.com/package/ng-hub-ui-toast)
|
|
49
|
+
- [**ng-hub-ui-utils**](https://www.npmjs.com/package/ng-hub-ui-utils)
|
|
50
|
+
|
|
51
|
+
## π Table of Contents
|
|
52
|
+
|
|
53
|
+
- [π¦ Description](#-description)
|
|
54
|
+
- [β¨ Features](#-features)
|
|
55
|
+
- [βοΈ Installation](#οΈ-installation)
|
|
56
|
+
- [π Quick Start](#-quick-start)
|
|
57
|
+
- [π Columns](#-columns)
|
|
58
|
+
- [β¨οΈ Keyboard](#οΈ-keyboard)
|
|
59
|
+
- [π The Clipboard](#-the-clipboard)
|
|
60
|
+
- [βοΈ Frozen Panes](#οΈ-frozen-panes)
|
|
61
|
+
- [πΎ Save State](#-save-state)
|
|
62
|
+
- [β Adding and Removing Rows and Columns](#-adding-and-removing-rows-and-columns)
|
|
63
|
+
- [π API Reference](#-api-reference)
|
|
64
|
+
- [Files](#files)
|
|
65
|
+
- [π¨ Styling](#-styling)
|
|
66
|
+
- [βΏ Accessibility](#-accessibility)
|
|
67
|
+
- [π€ Contributing](#-contributing)
|
|
68
|
+
- [π Support & License](#-support--license)
|
|
69
|
+
|
|
70
|
+
## π¦ Description
|
|
71
|
+
|
|
72
|
+
`<hub-spreadsheet>` renders rows and columns you already hold, and reports what the reader did to them. It never writes to your data. You describe each column β what it shows, whether it can be typed into β and handle `commit`, `pasted` and `cleared` as you see fit: write to a store, post to a server, or ignore them.
|
|
73
|
+
|
|
74
|
+
That split is deliberate. It is what lets the same component serve an invoice, a price list and a timesheet without knowing anything about invoices, prices or time.
|
|
75
|
+
|
|
76
|
+
## β¨ Features
|
|
77
|
+
|
|
78
|
+
- **Two identities per column.** A stable alias that everything stored points at, and a visible header you can rewrite whenever you like without breaking a single reference.
|
|
79
|
+
- **Excel's editing keyboard**, down to the details: typing replaces, `F2` keeps, `Enter` commits and drops, `Tab` commits and advances, `Escape` reverts.
|
|
80
|
+
- **Rectangular selection** by shift-arrow, shift-click or dragging.
|
|
81
|
+
- **A clipboard that actually round-trips with Excel.** Both flavours written, both read, with the raw number recovered from the HTML so a figure copied in a locale that writes `1.234,56` arrives as `1234.56`.
|
|
82
|
+
- **Frozen panes**, counted from the edge the way Excel freezes them.
|
|
83
|
+
- **Five save states per cell**, drawn as a bar rather than a badge.
|
|
84
|
+
- **Structured output by alias**: `{ price: 12, units: 3 }`, not a grid of coordinates.
|
|
85
|
+
- **Real grid semantics** β `role="grid"`, row and column indices, `aria-selected`, a roving tab stop β so a screen reader can navigate it.
|
|
86
|
+
- **Input-method safe.** The `Enter` that confirms a Chinese, Japanese or Korean character does not commit the cell.
|
|
87
|
+
- **63 CSS variables** that inherit from `--hub-table-*` before falling back to the design system.
|
|
88
|
+
- **Out to a real `.xlsx` and back**, with figures as figures and dates as dates β and to CSV, with the separator the reader's locale expects. The zip and the XML are written here, so exporting brings no dependency.
|
|
89
|
+
- **No `@angular/cdk`.** The grid primitives are in `ng-hub-ui-utils`.
|
|
90
|
+
|
|
91
|
+
## βοΈ Installation
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm install ng-hub-ui-spreadsheet ng-hub-ui-utils
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`ng-hub-ui-ds` is optional. Without it every token falls back to a literal and the sheet still renders.
|
|
98
|
+
|
|
99
|
+
## π Quick Start
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { Component, signal } from '@angular/core';
|
|
103
|
+
import { HubSpreadsheetColumn, HubSpreadsheetCommit, HubSpreadsheetComponent } from 'ng-hub-ui-spreadsheet';
|
|
104
|
+
|
|
105
|
+
interface Line {
|
|
106
|
+
id: string;
|
|
107
|
+
product: string;
|
|
108
|
+
units: number | null;
|
|
109
|
+
price: number | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Component({
|
|
113
|
+
selector: 'app-order',
|
|
114
|
+
standalone: true,
|
|
115
|
+
imports: [HubSpreadsheetComponent],
|
|
116
|
+
template: `
|
|
117
|
+
<hub-spreadsheet
|
|
118
|
+
[rows]="lines()"
|
|
119
|
+
[columns]="columns"
|
|
120
|
+
[rowKey]="rowKey"
|
|
121
|
+
[frozenColumns]="1"
|
|
122
|
+
(commit)="onCommit($event)"
|
|
123
|
+
/>
|
|
124
|
+
`
|
|
125
|
+
})
|
|
126
|
+
export class OrderComponent {
|
|
127
|
+
protected readonly lines = signal<Line[]>([
|
|
128
|
+
{ id: 'a', product: 'M6 bolt', units: 1200, price: 0.12 },
|
|
129
|
+
{ id: 'b', product: 'M6 washer', units: 3400, price: 0.04 }
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
protected readonly rowKey = (row: Line) => row.id;
|
|
133
|
+
|
|
134
|
+
protected readonly columns: HubSpreadsheetColumn<Line>[] = [
|
|
135
|
+
{ key: 'product', header: 'Product', cell: (row) => ({ value: row.product, editable: true }) },
|
|
136
|
+
{ key: 'units', header: 'Units', kind: 'number', align: 'end', cell: (row) => ({ value: row.units, editable: true }) },
|
|
137
|
+
{
|
|
138
|
+
key: 'price',
|
|
139
|
+
header: 'Unit price',
|
|
140
|
+
kind: 'currency',
|
|
141
|
+
align: 'end',
|
|
142
|
+
cell: (row) => ({ value: row.price, editable: true })
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
key: 'total',
|
|
146
|
+
header: 'Total',
|
|
147
|
+
kind: 'currency',
|
|
148
|
+
align: 'end',
|
|
149
|
+
cell: (row) => ({ value: (row.units ?? 0) * (row.price ?? 0), muted: true })
|
|
150
|
+
}
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
protected onCommit(change: HubSpreadsheetCommit<Line>): void {
|
|
154
|
+
this.lines.update((lines) =>
|
|
155
|
+
lines.map((line) => (line.id === change.row.id ? { ...line, [change.column.key]: change.value } : line))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The `total` column has no `editable`, so it cannot be typed into. Paste a block over it and those values are counted as `skipped` rather than written.
|
|
162
|
+
|
|
163
|
+
## π Columns
|
|
164
|
+
|
|
165
|
+
A column says what it is called and how to get a cell out of a row:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
{
|
|
169
|
+
key: 'price', // stable; what stored state and formulas point at
|
|
170
|
+
header: 'Unit price', // visible; rename freely
|
|
171
|
+
kind: 'currency', // 'text' | 'number' | 'currency'
|
|
172
|
+
align: 'end',
|
|
173
|
+
minWidth: '8rem',
|
|
174
|
+
cell: (row) => ({ value: row.price, editable: true })
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Why two names.** Make the visible name the key and renaming a column means rewriting every reference to it β the mistake Excel made, and the reason a renamed column there breaks formulas across the workbook. Keep them apart and a rename is a rename.
|
|
179
|
+
|
|
180
|
+
Read the sheet back as plain data keyed by alias:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { spreadsheetRecords } from 'ng-hub-ui-spreadsheet';
|
|
184
|
+
|
|
185
|
+
spreadsheetRecords(this.lines(), this.columns);
|
|
186
|
+
// [{ product: 'M6 bolt', units: 1200, price: 0.12, total: 144 }, β¦]
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## β¨οΈ Keyboard
|
|
190
|
+
|
|
191
|
+
| Key | What it does |
|
|
192
|
+
| ----------------------------- | ---------------------------------------------------- |
|
|
193
|
+
| Arrows | Move one cell |
|
|
194
|
+
| Shift + arrows | Extend the selection |
|
|
195
|
+
| `Tab` / `Shift+Tab` | Move sideways, falling onto the next row at the edge |
|
|
196
|
+
| `Home` / `End` | First and last cell of the row |
|
|
197
|
+
| `Ctrl+Home` / `Ctrl+End` | First and last cell of the sheet |
|
|
198
|
+
| `Page Up` / `Page Down` | Move by `pageSize` rows |
|
|
199
|
+
| Any printable character | Replace the cell and start editing |
|
|
200
|
+
| `F2`, `Enter` | Edit, keeping the value |
|
|
201
|
+
| `Alt` + `Down` | Open a cell that holds a list or a date |
|
|
202
|
+
| `Enter` / `Tab` while editing | Commit and move down / right |
|
|
203
|
+
| `Escape` | Revert the edit, then clear the selection |
|
|
204
|
+
| `Delete` / `Backspace` | Empty every editable cell of the selection |
|
|
205
|
+
| `Ctrl+C` / `X` / `V` | Copy, cut, paste |
|
|
206
|
+
| `Ctrl+A` | Select the sheet |
|
|
207
|
+
| `Ctrl+Space` / `Shift+Space` | Select the column / the row |
|
|
208
|
+
|
|
209
|
+
## π The Clipboard
|
|
210
|
+
|
|
211
|
+
Copying writes two flavours: tab-separated text, and HTML carrying each number raw. Pasting reads both and prefers the raw number, which is what makes a figure survive a journey between locales β `1.234,56` copied in Spain arrives as `1234.56` rather than as text.
|
|
212
|
+
|
|
213
|
+
`decimalMark` says which character this reader types decimals with; it is `','` by default and only matters when the source gave no raw value.
|
|
214
|
+
|
|
215
|
+
```html
|
|
216
|
+
<hub-spreadsheet [decimalMark]="'.'" ... />
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Every paste reports what happened:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
protected onPaste(paste: HubSpreadsheetPaste<Line>): void {
|
|
223
|
+
for (const change of paste.cells) { /* write it */ }
|
|
224
|
+
|
|
225
|
+
// paste.skipped β fell outside the sheet or on a read-only cell
|
|
226
|
+
// paste.rejected β did not read as a number in a figure column
|
|
227
|
+
// paste.range β the rectangle it covered
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## βοΈ Frozen Panes
|
|
232
|
+
|
|
233
|
+
```html
|
|
234
|
+
<hub-spreadsheet [frozenColumns]="1" [frozenRows]="2" style="--hub-spreadsheet-max-block-size: 60vh" />
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Counted from the leading edge, as Excel freezes panes. Set a height, or the sheet never scrolls and a frozen header has nothing to stay still against.
|
|
238
|
+
|
|
239
|
+
## πΎ Save State
|
|
240
|
+
|
|
241
|
+
Hand over a map keyed by the row key and the column alias, joined by a tab:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
states = signal<Record<string, HubSpreadsheetCellState>>({
|
|
245
|
+
'a\tprice': 'saving',
|
|
246
|
+
'b\tunits': 'error'
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`pending`, `saving`, `saved`, `error` and `conflict` each paint a bar down the cell's leading edge. `saving` also sets `aria-busy`. `conflict` is striped rather than merely red, so it is told apart from `error` without relying on colour.
|
|
251
|
+
|
|
252
|
+
## β Adding and Removing Rows and Columns
|
|
253
|
+
|
|
254
|
+
The sheet asks; you decide. Nothing is offered until you allow it:
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<hub-spreadsheet
|
|
258
|
+
[structure]="{ insertRows: true, deleteRows: (row, index) => index > 0 }"
|
|
259
|
+
[retiredColumnKeys]="retired()"
|
|
260
|
+
(insertRequested)="onInsert($event)"
|
|
261
|
+
(deleteRequested)="onDelete($event)"
|
|
262
|
+
/>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
An insert request for a column carries a `key` that has never belonged to another column in this sheet β pass every alias you have ever used, including deleted ones, through `retiredColumnKeys`. Reusing a dead alias makes everything that referenced the old column resolve silently to the new one, and the mistake shows up much later as wrong numbers.
|
|
266
|
+
|
|
267
|
+
Before agreeing to a deletion, check what it would orphan:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
import { danglingColumnKeys } from 'ng-hub-ui-spreadsheet';
|
|
271
|
+
|
|
272
|
+
danglingColumnKeys(Object.keys(this.savedState), this.columns); // ['discount']
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## π API Reference
|
|
276
|
+
|
|
277
|
+
### Inputs
|
|
278
|
+
|
|
279
|
+
| Input | Type | Default | Description |
|
|
280
|
+
| -------------------- | ----------------------------------------- | ---------------- | --------------------------------------------------------------------------------- |
|
|
281
|
+
| `rows` | `readonly TRow[]` | β | **Required.** The rows. Never written to. |
|
|
282
|
+
| `columns` | `readonly HubSpreadsheetColumn<TRow>[]` | β | **Required.** What each column shows and allows. |
|
|
283
|
+
| `rowKey` | `(row: TRow) => string` | β | **Required.** A stable name per row. |
|
|
284
|
+
| `states` | `Record<string, HubSpreadsheetCellState>` | `{}` | Save state per cell, keyed `` `${rowKey}\t${columnKey}` ``. |
|
|
285
|
+
| `errors` | `Record<string, string>` | `{}` | A validation message per cell, keyed the same way. Marks the cell, not the sheet. |
|
|
286
|
+
| `decimalMark` | `',' \| '.'` | `','` | How this reader types decimals. |
|
|
287
|
+
| `emptyText` | `string` | `''` | What an empty sheet says. |
|
|
288
|
+
| `pageSize` | `number` | `10` | Rows travelled by `Page Up` and `Page Down`. |
|
|
289
|
+
| `readonly` | `boolean` | `false` | Turns off every editor, whatever the cells say. |
|
|
290
|
+
| `formulas` | `boolean` | `false` | Reads a cell that starts with `=` as a formula. See below. |
|
|
291
|
+
| `editOn` | `'click' \| 'double-click'` | `'double-click'` | What opens the editor with the pointer. Typing opens it either way. |
|
|
292
|
+
| `frozenColumns` | `number` | `0` | How many columns stay pinned to the leading edge. |
|
|
293
|
+
| `frozenRows` | `number` | `0` | How many rows stay pinned below the header. |
|
|
294
|
+
| `virtual` | `boolean` | `false` | Draws only the rows in view. Gives the sheet a height; see below. |
|
|
295
|
+
| `rowHeight` | `number` | `0` | How tall a row is while virtualising. Nought measures the first one drawn. |
|
|
296
|
+
| `spans` | `readonly HubGridSpan[]` | `[]` | Merged blocks, declared by their anchor. |
|
|
297
|
+
| `structure` | `HubSpreadsheetStructureOptions<TRow>` | `{}` | Which structural changes are offered. Everything refused by default. |
|
|
298
|
+
| `retiredColumnKeys` | `readonly string[]` | `[]` | Aliases of deleted columns, so a new one never reuses them. |
|
|
299
|
+
| `contextMenu` | `boolean` | `false` | Offers the structural changes on right-click. |
|
|
300
|
+
| `mergeable` | `boolean` | `false` | Offers putting cells together, and taking them apart, in that menu. |
|
|
301
|
+
| `fillHandle` | `boolean` | `false` | Draws the grip at the corner of the selection. |
|
|
302
|
+
| `canUndo` | `boolean` | `false` | Whether `Ctrl+Z` has anything to ask for. The history belongs to the host. |
|
|
303
|
+
| `canRedo` | `boolean` | `false` | The same for `Ctrl+Shift+Z` and `Ctrl+Y`. |
|
|
304
|
+
| `resizableColumns` | `boolean` | `false` | Lets the reader drag a header's trailing edge. |
|
|
305
|
+
| `reorderableColumns` | `boolean` | `false` | Lets the reader drag a header to move its column. |
|
|
306
|
+
| `columnWidths` | `Record<string, number>` | `{}` | Two-way. Widths keyed by alias, so one survives its column being moved. |
|
|
307
|
+
| `minColumnWidth` | `number` | `48` | The floor a drag cannot go below, in pixels. |
|
|
308
|
+
|
|
309
|
+
### Outputs
|
|
310
|
+
|
|
311
|
+
| Output | Payload | Fires when |
|
|
312
|
+
| ------------------ | ------------------------------- | ----------------------------------------------------------------------- |
|
|
313
|
+
| `commit` | `HubSpreadsheetCommit<TRow>` | A cell took a new value, and it differs from the old one. |
|
|
314
|
+
| `pasted` | `HubSpreadsheetPaste<TRow>` | A block was pasted. |
|
|
315
|
+
| `filled` | `HubSpreadsheetPaste<TRow>` | The fill handle was dragged and released. |
|
|
316
|
+
| `cleared` | `HubSpreadsheetCellRef<TRow>[]` | `Delete` was pressed over a selection. |
|
|
317
|
+
| `selectionChange` | `HubGridRange \| null` | The selected rectangle changed. |
|
|
318
|
+
| `insertRequested` | `HubSpreadsheetInsertRequest` | The reader asked to add rows or columns. |
|
|
319
|
+
| `deleteRequested` | `HubSpreadsheetDeleteRequest` | The reader asked to remove rows or columns. |
|
|
320
|
+
| `undoRequested` | `void` | `Ctrl+Z`. The sheet undoes nothing itself. |
|
|
321
|
+
| `redoRequested` | `void` | `Ctrl+Shift+Z` or `Ctrl+Y`. |
|
|
322
|
+
| `columnMoved` | `{ from, to, key, keys }` | A header was dropped. `keys` arrives already reordered. |
|
|
323
|
+
| `mergeRequested` | `HubSpreadsheetMergeRequest` | The reader asked to put a selection together. Carries what it swallows. |
|
|
324
|
+
| `unmergeRequested` | `readonly HubGridCoords[]` | The reader asked to take apart the blocks their selection touches. |
|
|
325
|
+
|
|
326
|
+
### Helpers
|
|
327
|
+
|
|
328
|
+
| Function | What it gives you |
|
|
329
|
+
| ------------------------------------ | --------------------------------------------------------------------------------------- |
|
|
330
|
+
| `spreadsheetRecords(rows, columns)` | Every row as plain data keyed by alias. |
|
|
331
|
+
| `spreadsheetRecord(row, columns)` | One row, likewise. |
|
|
332
|
+
| `duplicateColumnKeys(columns)` | Aliases declared more than once, which would silently overwrite. |
|
|
333
|
+
| `nextColumnKey(taken, prefix?)` | An alias that has never been used. |
|
|
334
|
+
| `danglingColumnKeys(refs, columns)` | References that no longer resolve to a column. |
|
|
335
|
+
| `parseDecimal(text, mark)` | A number, `null` for empty, `undefined` for unreadable. |
|
|
336
|
+
| `parseClipboardTable(payload)` | A pasted block, with raw numbers where the source declared them. |
|
|
337
|
+
| `serialiseClipboardTable(rows)` | Both clipboard flavours of a block. |
|
|
338
|
+
| `isStructureAllowed(perm, subj, i)` | Whether a permission lets one subject through; a throwing predicate reads as a refusal. |
|
|
339
|
+
| `resolveInsertIndex(ref, side, len)` | Where an insertion lands, clamped to the collection. |
|
|
340
|
+
| `provideHubSpreadsheetControls(a)` | Registers a control adapter, so every list column opens with it. See below. |
|
|
341
|
+
| `mergeRequestFor(spans, range)` | The block a selection would become, and what it swallows; null for one cell. |
|
|
342
|
+
| `applySpanMerge(spans, request)` | The list of blocks after that merge, with the swallowed ones dropped. |
|
|
343
|
+
| `applySpanUnmerge(spans, anchors)` | The list after those blocks are taken apart. |
|
|
344
|
+
| `spansWithin(spans, range)` | The blocks a rectangle touches. |
|
|
345
|
+
| `sheetToXlsx(rows, cols, o?)` | The sheet as a real `.xlsx`, as bytes. |
|
|
346
|
+
| `downloadXlsx(name, bytes)` | Hands the reader that workbook to save. |
|
|
347
|
+
| `xlsxToTable(bytes, o?)` | A workbook's first sheet as text, shared strings and date formats undone. |
|
|
348
|
+
| `xlsxToRecords(bytes, cols, o?)` | The same, keyed by column alias. |
|
|
349
|
+
| `sheetToCsv(rows, cols, o?)` | The sheet as CSV, separator chosen from the decimal mark. |
|
|
350
|
+
| `downloadText(name, text)` | Hands the reader that file, with the mark that makes it UTF-8. |
|
|
351
|
+
| `csvToTable(text, o?)` | A CSV as text, its separator sniffed rather than assumed. |
|
|
352
|
+
| `csvToRecords(text, cols, o?)` | The same, keyed by column alias. |
|
|
353
|
+
| `sheetValues(rows, cols, o?)` | What every cell holds, with the formulas worked out. |
|
|
354
|
+
|
|
355
|
+
## Formulas
|
|
356
|
+
|
|
357
|
+
`formulas` reads a cell whose value starts with `=` as one. A column can be named by the alias it
|
|
358
|
+
was declared with, which is what the aliases were for:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
{ id: 'a', units: 400, price: 0.12, total: '=[units] * [price]' }
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Reorder the columns, rewrite a header, and every formula still means what it said. Excel made the
|
|
365
|
+
other choice, and renaming a column there rewrites every formula in the workbook.
|
|
366
|
+
|
|
367
|
+
Coordinates work too, beside the aliases β `=ROUND(SUM(E1:E4), 2)`. A coordinate says _where_ a
|
|
368
|
+
column was rather than which one it is, so when the sheet changes shape the formulas have to change
|
|
369
|
+
with it: `rewriteRowFormulas()` moves every coordinate in the rows after a column is moved or rows
|
|
370
|
+
and columns are inserted or deleted, and a reference to something deleted becomes `#REF!` rather
|
|
371
|
+
than quietly reading its neighbour. The host calls it in its own handler, because the rows are the
|
|
372
|
+
host's.
|
|
373
|
+
|
|
374
|
+
| Written | Means |
|
|
375
|
+
| --------------------------- | ------------------------------------------------------------ |
|
|
376
|
+
| `[units]` | This row's value in the `units` column. |
|
|
377
|
+
| `B3`, `SUM(A1:B4)` | A coordinate and a range, as a spreadsheet writes them. |
|
|
378
|
+
| `[total:]` | Every **other** row's value in `total` β see the note below. |
|
|
379
|
+
| `+ - * / ^` | Arithmetic; `&` joins text. |
|
|
380
|
+
| `= <> < <= > >=` | Comparison, numbers as numbers and words as words. |
|
|
381
|
+
| `IF(test, then, else)` | Only the branch it takes is worked out. |
|
|
382
|
+
| `SUM AVERAGE MIN MAX COUNT` | Over values and whole columns, gaps left out. |
|
|
383
|
+
| `ROUND ABS LEN CONCAT` | The usual. |
|
|
384
|
+
| `AND OR NOT TRUE FALSE` | Yes and no. |
|
|
385
|
+
|
|
386
|
+
**A whole column leaves out the cell asking for it.** `=SUM([total:])` written in the total row is
|
|
387
|
+
what everybody writes, and taken literally it is a sum that needs its own answer. Reading a
|
|
388
|
+
_different_ column is untouched: a total row that sums the prices sums every price.
|
|
389
|
+
|
|
390
|
+
Everything else that goes round in a circle is caught and shown, not chased. A cell that cannot be
|
|
391
|
+
worked out shows what a spreadsheet shows β `#DIV/0!`, `#NAME?`, `#VALUE!`, `#CYCLE!`, `#SYNTAX!` β
|
|
392
|
+
and is marked. The cell shows the answer; the editor shows the formula, so a mistake is corrected
|
|
393
|
+
rather than retyped; the clipboard carries the formula, which is what the cell holds.
|
|
394
|
+
|
|
395
|
+
**The sheet says what can go in one.** Typing `=` brings up the functions and the columns, narrowed
|
|
396
|
+
as you type, chosen with the arrows and `Enter`; inside brackets only columns are offered. At the
|
|
397
|
+
same time every header shows the alias a formula calls that column by, and clicking one writes it.
|
|
398
|
+
Without that, writing a formula means knowing a name the sheet never showed.
|
|
399
|
+
|
|
400
|
+
**A formula can also be fixed on the column**, declared in code rather than held by the data:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
{ key: 'vat', header: 'VAT 21%', kind: 'currency', formula: '=ROUND([total] * 0.21, 2)', cell: () => ({ value: null }) }
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The same formula in every row, and the cells cannot be typed into at all β replacing one would lose
|
|
407
|
+
the formula for that row alone, which nobody notices until the totals stop adding up.
|
|
408
|
+
|
|
409
|
+
The engine is the library's own: `parseFormula`, `evaluateFormula` and `evaluateSheet` are exported
|
|
410
|
+
for a host that wants to work a sheet out without drawing it.
|
|
411
|
+
|
|
412
|
+
## A sheet that is long
|
|
413
|
+
|
|
414
|
+
`virtual` draws what the viewport covers on both axes and reserves the size of the rest, so ten
|
|
415
|
+
thousand rows by fifty columns cost a couple of hundred cells rather than half a million.
|
|
416
|
+
|
|
417
|
+
```html
|
|
418
|
+
<hub-spreadsheet [rows]="rows()" [columns]="columns" [rowKey]="rowKey" [virtual]="true" />
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Three things are worth knowing before turning it on.
|
|
422
|
+
|
|
423
|
+
- **The rows out of view are not in the document.** The browser's own find and printing reach only
|
|
424
|
+
what is drawn. For a sheet meant to be printed, leave it off.
|
|
425
|
+
- **The rows have to be of one height.** It is measured off the first row drawn, which is right
|
|
426
|
+
whenever they are alike. Declare `rowHeight` for a sheet built while hidden β a row that has
|
|
427
|
+
never been laid out measures nought, and a window worked out from nought is the whole sheet.
|
|
428
|
+
- **The columns are windowed from their measured widths.** The first render draws them all, since
|
|
429
|
+
there is nothing to measure otherwise; one width that cannot be measured falls back to drawing
|
|
430
|
+
them all for good.
|
|
431
|
+
- **The sheet becomes the thing that scrolls.** Left to grow it is as tall as its content, the page
|
|
432
|
+
scrolls instead, and there is nothing to virtualise; so turning it on sets a height of `24rem`.
|
|
433
|
+
Set `--hub-spreadsheet-max-block-size` to choose your own.
|
|
434
|
+
|
|
435
|
+
Frozen rows stay in the document wherever the reader has scrolled, and a merged block reaching into
|
|
436
|
+
the window brings its anchor with it β a block is drawn by its anchor, and one left outside the
|
|
437
|
+
window would not be drawn at all.
|
|
438
|
+
|
|
439
|
+
## A richer editor in a cell
|
|
440
|
+
|
|
441
|
+
A list column opens with a native `<select>`: nothing to install, and the behaviour a phone
|
|
442
|
+
already knows. Two ways to put something else there.
|
|
443
|
+
|
|
444
|
+
**Cells of your own, at rest.** `hubSpreadsheetCell` draws what a column shows while it is not
|
|
445
|
+
being edited β a badge, an avatar, a row of actions. The clipboard still carries the value rather
|
|
446
|
+
than the drawing.
|
|
447
|
+
|
|
448
|
+
```html
|
|
449
|
+
<ng-template hubSpreadsheetCell="state" let-value>
|
|
450
|
+
<hub-badge variant="soft" [color]="colourOf(value)">{{ labelOf(value) }}</hub-badge>
|
|
451
|
+
</ng-template>
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
**One column, one template.** `hubSpreadsheetEditor` names the column by its alias and hands the
|
|
455
|
+
template the value, a `commit` / `cancel` pair and `seed` β the character the reader opened the
|
|
456
|
+
cell with, which belongs in a picker's search box.
|
|
457
|
+
|
|
458
|
+
```html
|
|
459
|
+
<hub-spreadsheet [rows]="rows()" [columns]="columns" [rowKey]="rowKey">
|
|
460
|
+
<ng-template hubSpreadsheetEditor="assignee" let-value let-seed="seed" let-commit="commit">
|
|
461
|
+
<hub-select
|
|
462
|
+
[items]="people"
|
|
463
|
+
autoOpen
|
|
464
|
+
[initialSearchTerm]="seed ?? null"
|
|
465
|
+
[ngModel]="value"
|
|
466
|
+
(ngModelChange)="commit($event)"
|
|
467
|
+
/>
|
|
468
|
+
</ng-template>
|
|
469
|
+
</hub-spreadsheet>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**Every list column, one provider.** Register an adapter and they all upgrade, with no template
|
|
473
|
+
written anywhere:
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
import { provideHubSpreadsheetControls } from 'ng-hub-ui-spreadsheet';
|
|
477
|
+
import { hubFormControlAdapter } from 'ng-hub-ui-forms';
|
|
478
|
+
|
|
479
|
+
providers: [provideHubSpreadsheetControls(hubFormControlAdapter)];
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
The adapter is declared by this library and satisfied structurally, so nothing here imports the
|
|
483
|
+
package that provides it β the same bargain `ng-hub-ui-paginable` strikes for its table controls.
|
|
484
|
+
Register none and the native list stays. A column that declares its own template still wins: a
|
|
485
|
+
rule for every column loses to one written for this one.
|
|
486
|
+
|
|
487
|
+
## Files
|
|
488
|
+
|
|
489
|
+
Out to a workbook, or to CSV, and back in again.
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
import { sheetToXlsx, downloadXlsx, sheetToCsv, downloadText, xlsxToRecords, csvToRecords } from 'ng-hub-ui-spreadsheet';
|
|
493
|
+
|
|
494
|
+
downloadXlsx('order-lines.xlsx', sheetToXlsx(this.rows(), this.columns, { sheetName: 'Order lines' }));
|
|
495
|
+
downloadText('order-lines.csv', sheetToCsv(this.rows(), this.columns, { decimalMark: ',' }));
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The `.xlsx` keeps what a CSV loses: a figure is a figure and a date is a date, whatever the machine
|
|
499
|
+
that opens it thinks the decimal mark is. The CSV is for whoever wants to read it in something
|
|
500
|
+
older, and it chooses its separator from that mark β where the comma is the decimal mark a file
|
|
501
|
+
written with commas opens as a single column of text, so it writes semicolons instead.
|
|
502
|
+
|
|
503
|
+
Both write **what the cell shows**: a list column exports the label somebody chose, and a formula
|
|
504
|
+
exports the number it came to, because a column of `=[units] * [price]` is of no use to whoever
|
|
505
|
+
opens the file. `values: 'stored'` is the other way round, for the file that is meant to come back.
|
|
506
|
+
|
|
507
|
+
Coming in, both readers return the file's rows keyed by column alias:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
511
|
+
|
|
512
|
+
// A workbook is a zip, and a zip starts with PK. Read rather than taken from the file's name.
|
|
513
|
+
const records =
|
|
514
|
+
bytes[0] === 0x50 && bytes[1] === 0x4b
|
|
515
|
+
? await xlsxToRecords(bytes, this.columns)
|
|
516
|
+
: csvToRecords(new TextDecoder().decode(bytes), this.columns);
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
The headings are matched to the columns by header first and alias second, both ignoring case and
|
|
520
|
+
surrounding space, so a file whose columns were reordered still lands in the right place and a
|
|
521
|
+
heading nothing recognises is left alone rather than written into whatever column came next. What
|
|
522
|
+
comes back is text; `parseForColumn(text, column, decimalMark)` turns it into a value, or returns
|
|
523
|
+
`undefined` for something the column cannot hold.
|
|
524
|
+
|
|
525
|
+
**Neither direction brings a dependency.** The zip is written here, with its entries stored rather
|
|
526
|
+
than deflated β which the format has always allowed and every reader accepts, at the cost of a file
|
|
527
|
+
a few times larger than Excel's. Reading uses the decompressor the platform already has
|
|
528
|
+
(`DecompressionStream`, in every browser since 2023), since a real workbook is deflated. What is not
|
|
529
|
+
there: several sheets, column widths, and editing a workbook that already exists.
|
|
530
|
+
|
|
531
|
+
## π¨ Styling
|
|
532
|
+
|
|
533
|
+
The component ships its own styles. Nothing to import.
|
|
534
|
+
|
|
535
|
+
Theme it by setting tokens on any ancestor:
|
|
536
|
+
|
|
537
|
+
```scss
|
|
538
|
+
:root {
|
|
539
|
+
--hub-spreadsheet-cursor-color: #6f42c1;
|
|
540
|
+
--hub-spreadsheet-max-block-size: 60vh;
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Each value falls through `--hub-table-*` before `--hub-sys-*`, so a project that has themed its tables with `ng-hub-ui-paginable` gets its sheets dressed to match without setting anything.
|
|
545
|
+
|
|
546
|
+
Full catalogue: [`docs/css-variables-reference.md`](./docs/css-variables-reference.md).
|
|
547
|
+
|
|
548
|
+
## βΏ Accessibility
|
|
549
|
+
|
|
550
|
+
The sheet is a real `role="grid"`: cells carry `aria-colindex` and their rows `aria-rowindex`, `aria-selected` follows the range, `aria-readonly` marks what cannot be typed into, `aria-invalid` marks an editor holding something unreadable, and `aria-busy` marks a cell being saved. Focus roves: one cell is in the tab order, the arrows do the rest.
|
|
551
|
+
|
|
552
|
+
One thing worth knowing about the implementation. Browsers dispatch `paste` only to editable elements, so the paste shortcut hands focus to an off-screen field for the length of the event and the cell takes it straight back. That is what keeps the clipboard working in Firefox and Safari, not only in Chrome.
|
|
553
|
+
|
|
554
|
+
## π€ Contributing
|
|
555
|
+
|
|
556
|
+
```bash
|
|
557
|
+
git clone https://github.com/hub-env/hub-ui.git
|
|
558
|
+
cd hub-ui
|
|
559
|
+
npm install
|
|
560
|
+
npm run build:libs
|
|
561
|
+
ng test spreadsheet
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/). Issues, roadmap and discussion live in [hub-env/hub-ui](https://github.com/hub-env/hub-ui).
|
|
565
|
+
|
|
566
|
+
## π Support & License
|
|
567
|
+
|
|
568
|
+
- Issues: https://github.com/hub-env/hub-ui/issues
|
|
569
|
+
- Docs: https://hubui.dev/en/spreadsheet/overview/
|
|
570
|
+
|
|
571
|
+
MIT Β© Carlos Morcillo FernΓ‘ndez. See [LICENSE](./LICENSE).
|