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.
@@ -0,0 +1,4598 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import * as i0 from '@angular/core';
3
+ import { input, inject, TemplateRef, Directive, InjectionToken, ViewContainerRef, afterNextRender, ElementRef, Injector, booleanAttribute, contentChildren, model, output, signal, computed, viewChild, effect, untracked, afterRenderEffect, ChangeDetectionStrategy, Component, makeEnvironmentProviders } from '@angular/core';
4
+ import { buildSpanMap, gridWindow, growWindowToSpans, clampGridRange, gridRangeBetween, coversRange, isCovered, spanAt, isWithinGridRange, gridCellTabIndex, moveGridFocus, resolveGridIntent, resolveGridEdge, anchorOf, gridRangeCells, TranslatePipe, HUB_TRANSLATION_PREFIX } from 'ng-hub-ui-utils';
5
+
6
+ /**
7
+ * Reading a sheet back as plain data, keyed by column alias.
8
+ *
9
+ * The point of the aliases: the owner works with `{ price: 12, units: 3 }` instead of mapping a
10
+ * grid of coordinates back onto its own fields by position, which breaks the moment a column
11
+ * moves.
12
+ *
13
+ * These read the value a column reports, never the text it decides to display, so a cell showing
14
+ * «120 ud.» still yields the number 120.
15
+ */
16
+ /** One row as plain data. */
17
+ function spreadsheetRecord(row, columns) {
18
+ const record = {};
19
+ for (const column of columns) {
20
+ record[column.key] = column.cell(row).value;
21
+ }
22
+ return record;
23
+ }
24
+ /** Every row as plain data, in the order the sheet holds them. */
25
+ function spreadsheetRecords(rows, columns) {
26
+ return rows.map((row) => spreadsheetRecord(row, columns));
27
+ }
28
+ /**
29
+ * The aliases declared more than once, each named a single time.
30
+ *
31
+ * A repeated alias is not a cosmetic problem: the later column silently overwrites the earlier
32
+ * one in every structured read, and once formulas arrive the reference becomes ambiguous. The
33
+ * sheet reports this rather than guessing which column was meant.
34
+ */
35
+ function duplicateColumnKeys(columns) {
36
+ const seen = new Set();
37
+ const repeated = new Set();
38
+ for (const column of columns) {
39
+ if (seen.has(column.key)) {
40
+ repeated.add(column.key);
41
+ }
42
+ seen.add(column.key);
43
+ }
44
+ return [...repeated];
45
+ }
46
+
47
+ /**
48
+ * Delimited text, read and written.
49
+ *
50
+ * The clipboard speaks it with tabs and a CSV file with commas — or with semicolons, wherever the
51
+ * comma is already busy being a decimal mark. Everything else about them is the same, so they
52
+ * share this.
53
+ */
54
+ /**
55
+ * Reads a table out of delimited text.
56
+ *
57
+ * One state machine for the clipboard and for CSV, because they are the same format with a
58
+ * different separator and the awkward parts are identical: a quoted cell holding the separator, a
59
+ * doubled quote standing for one, a line ending that may be one character or two, and a stray
60
+ * quote in the middle of a cell that is content rather than punctuation — `12" de pantalla`.
61
+ *
62
+ * @param input - The text to read.
63
+ * @param delimiter - What separates the cells of a row.
64
+ * @returns The rows, each as a list of cells, exactly as they were written.
65
+ */
66
+ function parseDelimited(input, delimiter = '\t') {
67
+ if (input === '') {
68
+ return [];
69
+ }
70
+ const rows = [];
71
+ let row = [];
72
+ let cell = '';
73
+ let quoted = false;
74
+ let atCellStart = true;
75
+ for (let index = 0; index < input.length; index++) {
76
+ const char = input[index];
77
+ if (quoted) {
78
+ if (char === '"') {
79
+ if (input[index + 1] === '"') {
80
+ cell += '"';
81
+ index++;
82
+ }
83
+ else {
84
+ quoted = false;
85
+ }
86
+ }
87
+ else {
88
+ cell += char;
89
+ }
90
+ continue;
91
+ }
92
+ if (char === '"' && atCellStart) {
93
+ quoted = true;
94
+ atCellStart = false;
95
+ continue;
96
+ }
97
+ atCellStart = false;
98
+ if (char === delimiter) {
99
+ row.push(cell);
100
+ cell = '';
101
+ atCellStart = true;
102
+ continue;
103
+ }
104
+ if (char === '\n' || char === '\r') {
105
+ // Swallow the LF of a CRLF so a Windows paste does not gain a blank row per line.
106
+ if (char === '\r' && input[index + 1] === '\n') {
107
+ index++;
108
+ }
109
+ row.push(cell);
110
+ rows.push(row);
111
+ row = [];
112
+ cell = '';
113
+ atCellStart = true;
114
+ continue;
115
+ }
116
+ cell += char;
117
+ }
118
+ // A trailing newline closed the last row already; anything left is a real final row.
119
+ if (cell !== '' || row.length) {
120
+ row.push(cell);
121
+ rows.push(row);
122
+ }
123
+ return rows;
124
+ }
125
+ /**
126
+ * Writes a table as delimited text.
127
+ *
128
+ * A cell is quoted only when it has to be: one that holds the separator, a quote, or a line
129
+ * ending. Quoting everything is also correct and is what many exporters do, but it makes a file
130
+ * that is unpleasant to read and twice the size for nothing.
131
+ *
132
+ * @param rows - The rows to write.
133
+ * @param delimiter - What to put between the cells.
134
+ * @param newline - What ends a line. CRLF is what a spreadsheet on Windows expects.
135
+ */
136
+ function serialiseDelimited(rows, delimiter = '\t', newline = '\n') {
137
+ const needsQuotes = (cell) => cell.includes(delimiter) || cell.includes('"') || /[\r\n]/.test(cell);
138
+ return rows
139
+ .map((row) => row.map((cell) => (needsQuotes(cell) ? `"${cell.replace(/"/g, '""')}"` : cell)).join(delimiter))
140
+ .join(newline);
141
+ }
142
+
143
+ /**
144
+ * Reads a number out of text written in the reader's convention.
145
+ *
146
+ * Deliberately three-valued: a number, `null` for an empty cell — which is an absence and not a
147
+ * zero — and `undefined` for text that is not a number at all, so a caller can refuse a paste
148
+ * instead of silently writing NaN.
149
+ *
150
+ * @param text The text as typed or as pasted.
151
+ * @param decimalMark Which character separates the decimals for this reader.
152
+ */
153
+ function parseDecimal(text, decimalMark) {
154
+ // Excel pads figures with non-breaking spaces, which trim() does not touch.
155
+ const trimmed = text.replace(/[\s ]/g, '');
156
+ if (trimmed === '') {
157
+ return null;
158
+ }
159
+ const thousands = decimalMark === ',' ? '.' : ',';
160
+ const bare = trimmed.split(thousands).join('').replace(/%$/, '').replace(decimalMark, '.');
161
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(bare)) {
162
+ return undefined;
163
+ }
164
+ const value = Number(bare);
165
+ return Number.isFinite(value) ? value : undefined;
166
+ }
167
+ /**
168
+ * Parses a pasted block into a table of cells.
169
+ *
170
+ * The plain text decides the shape, because it is the only flavour guaranteed to be there and to
171
+ * be complete. The HTML, when present and parseable, is read alongside it purely to recover raw
172
+ * numbers.
173
+ */
174
+ function parseClipboardTable(payload) {
175
+ const rows = parseDelimited(payload.text ?? '');
176
+ if (!rows.length) {
177
+ return [];
178
+ }
179
+ const raw = payload.html ? rawValuesFromHtml(payload.html) : [];
180
+ return rows.map((row, rowIndex) => row.map((text, colIndex) => {
181
+ const value = raw[rowIndex]?.[colIndex];
182
+ return value === undefined ? { text } : { text, raw: value };
183
+ }));
184
+ }
185
+ /**
186
+ * Writes a block of values as both flavours.
187
+ *
188
+ * Numbers go into the plain text with a dot and no grouping, which every spreadsheet reads
189
+ * whatever its reader has configured, and into the HTML as `x:num`, which is what lets another
190
+ * instance of this component — or Excel — recover the exact value rather than re-parsing a
191
+ * formatted string.
192
+ */
193
+ function serialiseClipboardTable(rows) {
194
+ const text = rows.map((row) => row.map(toDelimitedCell).join('\t')).join('\n');
195
+ const html = '<table>' + rows.map((row) => '<tr>' + row.map(toHtmlCell).join('') + '</tr>').join('') + '</table>';
196
+ return { text, html };
197
+ }
198
+ /** One cell as tab-separated text, quoted only when it would otherwise break the shape. */
199
+ function toDelimitedCell(value) {
200
+ if (value === null) {
201
+ return '';
202
+ }
203
+ if (typeof value === 'number') {
204
+ return String(value);
205
+ }
206
+ return /[\t\n\r"]/.test(value) ? `"${value.split('"').join('""')}"` : value;
207
+ }
208
+ /** One cell as HTML, with the raw number attached when there is one. */
209
+ function toHtmlCell(value) {
210
+ if (value === null) {
211
+ return '<td></td>';
212
+ }
213
+ if (typeof value === 'number') {
214
+ return `<td x:num="${value}">${value}</td>`;
215
+ }
216
+ return `<td>${escapeHtml(value)}</td>`;
217
+ }
218
+ function escapeHtml(value) {
219
+ return value.split('&').join('&amp;').split('<').join('&lt;').split('>').join('&gt;');
220
+ }
221
+ /**
222
+ * Splits tab-separated text into rows and cells, honouring quoting.
223
+ *
224
+ * A hand-rolled state machine rather than a pair of splits, because a cell may legitimately hold
225
+ * a tab or a newline when it is quoted, and splitting first destroys exactly that data. A stray
226
+ * quote in the middle of a cell — `12" de pantalla` — is content, not a delimiter, so quoting
227
+ * only counts at the start of a cell.
228
+ */
229
+ /**
230
+ * Pulls the raw numbers out of the HTML flavour, laid out to match the table.
231
+ *
232
+ * Parsed with `DOMParser` and read through `textContent` and attributes. The clipboard is
233
+ * attacker-controlled input, so nothing here is ever assigned to `innerHTML`: pasting a crafted
234
+ * cell into a page is a direct route to running somebody else's script.
235
+ */
236
+ function rawValuesFromHtml(html) {
237
+ if (typeof DOMParser === 'undefined') {
238
+ return [];
239
+ }
240
+ // Office prefixes the fragment with a byte-offset header that is not markup.
241
+ const markup = html.slice(Math.max(0, html.indexOf('<')));
242
+ const table = new DOMParser().parseFromString(markup, 'text/html').querySelector('table');
243
+ if (!table) {
244
+ return [];
245
+ }
246
+ return [...table.querySelectorAll('tr')].map((tr) => [...tr.querySelectorAll('td, th')].map((cell) => rawValueOf(cell)));
247
+ }
248
+ /** The declared number of one cell, from whichever dialect the source speaks. */
249
+ function rawValueOf(cell) {
250
+ const excel = cell.getAttribute('x:num');
251
+ if (excel !== null && excel !== '') {
252
+ const value = Number(excel);
253
+ return Number.isFinite(value) ? value : undefined;
254
+ }
255
+ const sheets = cell.getAttribute('data-sheets-value');
256
+ if (sheets) {
257
+ try {
258
+ // Google encodes the type in key "1" and the number in key "3"; 3 means number.
259
+ const parsed = JSON.parse(sheets);
260
+ if (parsed['1'] === 3 && typeof parsed['3'] === 'number') {
261
+ return parsed['3'];
262
+ }
263
+ }
264
+ catch {
265
+ // A dialect this does not know. The displayed text still carries the cell.
266
+ }
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ /** Matches an alias this module allocated, capturing its number. */
272
+ const GENERATED = /^(.+)-(\d+)$/;
273
+ /**
274
+ * Whether a permission lets this particular subject through.
275
+ *
276
+ * Silence means no. A sheet that gains the ability to delete rows because an option was left out
277
+ * is a worse failure than one that refuses until told otherwise.
278
+ *
279
+ * A predicate that throws is treated as a refusal: the owner's guard is broken, and acting on a
280
+ * broken guard is how data gets deleted that should not have been.
281
+ */
282
+ function isStructureAllowed(permission, subject, index) {
283
+ if (permission === undefined) {
284
+ return false;
285
+ }
286
+ if (typeof permission === 'boolean') {
287
+ return permission;
288
+ }
289
+ try {
290
+ return permission(subject, index);
291
+ }
292
+ catch {
293
+ return false;
294
+ }
295
+ }
296
+ /**
297
+ * Where an insertion actually lands.
298
+ *
299
+ * @param reference The row or column the reader pointed at.
300
+ * @param side Whether the new one goes before it or after it.
301
+ * @param length How many there are now.
302
+ * @returns An index between 0 and `length`, inclusive, since appending at the end is legal.
303
+ */
304
+ function resolveInsertIndex(reference, side, length) {
305
+ const clamped = Math.max(0, Math.min(length, Math.trunc(reference)));
306
+ const index = side === 'after' ? clamped + 1 : clamped;
307
+ return Math.max(0, Math.min(length, index));
308
+ }
309
+ /**
310
+ * An alias for a new column that has never belonged to another one.
311
+ *
312
+ * Pass every alias the sheet has ever handed out, the live ones and the retired ones together.
313
+ * That is the whole point: an alias is an identity, so reusing a deleted column's name makes
314
+ * everything that referenced the old column silently resolve to the new one, and the mistake
315
+ * surfaces much later as wrong numbers rather than as an error.
316
+ *
317
+ * The same rule the project already applies to task codes, for the same reason.
318
+ *
319
+ * @param taken Every alias ever used, whether the column still exists or not.
320
+ * @param prefix The family to number within; `col` when not given.
321
+ */
322
+ function nextColumnKey(taken, prefix = 'col') {
323
+ let highest = 0;
324
+ for (const key of taken) {
325
+ const match = GENERATED.exec(key);
326
+ if (match && match[1] === prefix) {
327
+ highest = Math.max(highest, Number(match[2]));
328
+ }
329
+ }
330
+ return `${prefix}-${highest + 1}`;
331
+ }
332
+ /**
333
+ * The references that no longer resolve to a column.
334
+ *
335
+ * Give it whatever points at columns — the keys of a saved-state map, the aliases a formula
336
+ * mentions — and it reports which of them have nothing behind them any more. Each one named
337
+ * once, in the order first seen, so a message to the reader lists them the way they were found.
338
+ */
339
+ function danglingColumnKeys(referenced, columns) {
340
+ const live = new Set(columns.map((column) => column.key));
341
+ const broken = [];
342
+ const seen = new Set();
343
+ for (const key of referenced) {
344
+ if (!live.has(key) && !seen.has(key)) {
345
+ seen.add(key);
346
+ broken.push(key);
347
+ }
348
+ }
349
+ return broken;
350
+ }
351
+
352
+ /**
353
+ * Turning a stored value into what a cell shows, and turning what was typed back into a value.
354
+ *
355
+ * One place for both directions, because they have to agree: a cell that shows `On hold` must
356
+ * accept `On hold` back, or pasting a column onto itself loses data.
357
+ */
358
+ /** Written forms of yes and no, in the languages the library ships labels for. */
359
+ const AFFIRMATIVE = new Set(['1', 'true', 'yes', 'y', 'sí', 'si', 'oui', 'ja', 'да', 'はい', '是', 'نعم']);
360
+ const NEGATIVE = new Set(['0', 'false', 'no', 'n', 'non', 'nein', 'нет', 'いいえ', '否', 'لا']);
361
+ /** The option a stored value belongs to, or undefined when it belongs to none of them. */
362
+ function optionFor(value, column) {
363
+ return column.options?.find((option) => option.value === value);
364
+ }
365
+ /**
366
+ * What the cell shows for a value.
367
+ *
368
+ * A `select` value whose option has been removed is shown as the stored value rather than as
369
+ * nothing: blanking it would read as data loss, and the reader would have no way to tell what the
370
+ * row used to say.
371
+ */
372
+ function displayValue(value, column) {
373
+ if (value === null || value === '') {
374
+ return '';
375
+ }
376
+ switch (column.kind) {
377
+ case 'select':
378
+ return optionFor(value, column)?.label ?? String(value);
379
+ case 'boolean':
380
+ // A mark rather than the word: a column of them reads as a column, and it takes no
381
+ // width worth arguing about.
382
+ return isTruthy(value) ? '✓' : '';
383
+ default:
384
+ return String(value);
385
+ }
386
+ }
387
+ /**
388
+ * What a typed or pasted string is worth in a column.
389
+ *
390
+ * Three answers, as everywhere else here: the value, `null` for an empty cell, and `undefined`
391
+ * for something this column cannot hold — which lets the caller refuse rather than store rubbish.
392
+ */
393
+ function parseForColumn(text, column, decimalMark) {
394
+ const trimmed = text.trim();
395
+ switch (column.kind) {
396
+ case 'number':
397
+ case 'currency':
398
+ return parseDecimal(text, decimalMark);
399
+ case 'select': {
400
+ if (trimmed === '') {
401
+ return null;
402
+ }
403
+ // The stored value first, then the label — a paste from a spreadsheet carries what was
404
+ // displayed, and a column pasted onto itself has to survive the round trip.
405
+ const byValue = column.options?.find((option) => String(option.value) === trimmed);
406
+ const byLabel = column.options?.find((option) => option.label === trimmed);
407
+ const match = byValue ?? byLabel;
408
+ // Nothing on the list means nothing this column may hold. Refusing is the whole point
409
+ // of declaring a closed list in the first place.
410
+ return match ? match.value : undefined;
411
+ }
412
+ case 'boolean': {
413
+ if (trimmed === '') {
414
+ return null;
415
+ }
416
+ const lowered = trimmed.toLowerCase();
417
+ if (AFFIRMATIVE.has(lowered)) {
418
+ return 1;
419
+ }
420
+ if (NEGATIVE.has(lowered)) {
421
+ return 0;
422
+ }
423
+ return undefined;
424
+ }
425
+ case 'date':
426
+ // Kept as the text it was written in. Turning it into a Date and back is what shifts a
427
+ // date by a day for anyone east or west of whoever wrote the code.
428
+ return trimmed === '' ? null : trimmed;
429
+ default:
430
+ return trimmed === '' ? null : text;
431
+ }
432
+ }
433
+ /** Whether a stored value counts as yes. */
434
+ function isTruthy(value) {
435
+ if (typeof value === 'number') {
436
+ return value !== 0;
437
+ }
438
+ return typeof value === 'string' && AFFIRMATIVE.has(value.toLowerCase());
439
+ }
440
+
441
+ /**
442
+ * Replaces the editor of one column with a template of your own.
443
+ *
444
+ * The sheet ships editors for the kinds it knows — a field for text and figures, a list for a
445
+ * column with options, a date field, a checkbox — and this is how a column gets something else:
446
+ * a `hub-select` with a search box, a picker of your own, anything that can report a value.
447
+ *
448
+ * ```html
449
+ * <hub-spreadsheet [rows]="rows()" [columns]="columns" [rowKey]="rowKey">
450
+ * <ng-template hubSpreadsheetEditor="supplier" let-value let-ctx="commit">
451
+ * <hub-select [options]="suppliers()" [ngModel]="value" (ngModelChange)="ctx($event)" />
452
+ * </ng-template>
453
+ * </hub-spreadsheet>
454
+ * ```
455
+ *
456
+ * The template is responsible for calling `commit` or `cancel`. Until it does, the editor stays
457
+ * open — which is what lets a picker take as many clicks as it needs without the sheet deciding
458
+ * the reader has finished.
459
+ */
460
+ class HubSpreadsheetEditorDirective {
461
+ /** The alias of the column this editor belongs to. */
462
+ column = input.required({ ...(ngDevMode ? { debugName: "column" } : /* istanbul ignore next */ {}), alias: 'hubSpreadsheetEditor' });
463
+ /**
464
+ * The same rows the sheet is given, only so the compiler knows what a row is here.
465
+ *
466
+ * Nothing reads it. A template is compiled on its own, with no way of knowing which sheet will
467
+ * mount it, so `let-row` arrives as `unknown` and every use of it needs a cast. Handing the
468
+ * rows over is how Angular's own structural directives solve this, and it costs one binding.
469
+ */
470
+ rows = input([], { ...(ngDevMode ? { debugName: "rows" } : /* istanbul ignore next */ {}), alias: 'hubSpreadsheetEditorRows' });
471
+ template = inject(TemplateRef);
472
+ /** Lets Angular narrow `let-` bindings inside the template to the real context type. */
473
+ static ngTemplateContextGuard(_directive, _context) {
474
+ return true;
475
+ }
476
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetEditorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
477
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: HubSpreadsheetEditorDirective, isStandalone: true, selector: "[hubSpreadsheetEditor]", inputs: { column: { classPropertyName: "column", publicName: "hubSpreadsheetEditor", isSignal: true, isRequired: true, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "hubSpreadsheetEditorRows", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
478
+ }
479
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetEditorDirective, decorators: [{
480
+ type: Directive,
481
+ args: [{
482
+ selector: '[hubSpreadsheetEditor]',
483
+ standalone: true
484
+ }]
485
+ }], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubSpreadsheetEditor", required: true }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubSpreadsheetEditorRows", required: false }] }] } });
486
+
487
+ /**
488
+ * Draws one column's cells yourself: a badge for a state, an avatar beside a name, a row of
489
+ * actions.
490
+ *
491
+ * The sibling of `hubSpreadsheetEditor`, for the other half of a cell's life. That one replaces
492
+ * what appears when the cell is opened; this one replaces what the cell shows while it is not.
493
+ *
494
+ * ```html
495
+ * <hub-spreadsheet [rows]="rows()" [columns]="columns" [rowKey]="rowKey">
496
+ * <ng-template hubSpreadsheetCell="status" let-value>
497
+ * <hub-badge [variant]="value === 'done' ? 'success' : 'warning'">{{ value }}</hub-badge>
498
+ * </ng-template>
499
+ * </hub-spreadsheet>
500
+ * ```
501
+ *
502
+ * Two things stay the sheet's whatever the template draws. The clipboard carries the cell's value,
503
+ * not its decoration, so a copy round-trips as data rather than as a picture of it. And the cell
504
+ * is still the thing the keyboard is on: the template is drawn inside it, not instead of it.
505
+ *
506
+ * A template with something clickable in it joins the tab order of the page, which the grid's own
507
+ * roving tab stop does not manage — put the action behind `hubSpreadsheetEditor` instead when the
508
+ * cell should be entered before it can be used.
509
+ */
510
+ class HubSpreadsheetCellDirective {
511
+ /** The alias of the column these cells belong to. */
512
+ column = input.required({ ...(ngDevMode ? { debugName: "column" } : /* istanbul ignore next */ {}), alias: 'hubSpreadsheetCell' });
513
+ /**
514
+ * The same rows the sheet is given, only so the compiler knows what a row is here.
515
+ *
516
+ * Nothing reads it. A template is compiled on its own, with no way of knowing which sheet will
517
+ * mount it, so `let-row` arrives as `unknown` and every use of it needs a cast. Handing the
518
+ * rows over is how Angular's own structural directives solve this, and it costs one binding:
519
+ *
520
+ * ```html
521
+ * <ng-template hubSpreadsheetCell="actions" [hubSpreadsheetCellRows]="orders()" let-row="row">
522
+ * ```
523
+ */
524
+ rows = input([], { ...(ngDevMode ? { debugName: "rows" } : /* istanbul ignore next */ {}), alias: 'hubSpreadsheetCellRows' });
525
+ template = inject(TemplateRef);
526
+ /** Lets Angular narrow `let-` bindings inside the template to the real context type. */
527
+ static ngTemplateContextGuard(_directive, _context) {
528
+ return true;
529
+ }
530
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetCellDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
531
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: HubSpreadsheetCellDirective, isStandalone: true, selector: "[hubSpreadsheetCell]", inputs: { column: { classPropertyName: "column", publicName: "hubSpreadsheetCell", isSignal: true, isRequired: true, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "hubSpreadsheetCellRows", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
532
+ }
533
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetCellDirective, decorators: [{
534
+ type: Directive,
535
+ args: [{
536
+ selector: '[hubSpreadsheetCell]',
537
+ standalone: true
538
+ }]
539
+ }], propDecorators: { column: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubSpreadsheetCell", required: true }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubSpreadsheetCellRows", required: false }] }] } });
540
+
541
+ /**
542
+ * Resizing and reordering columns, as arithmetic on their own.
543
+ *
544
+ * Kept apart from the component so the fiddly parts — a floor that a drag cannot go under, an
545
+ * index that shifts as a column leaves its old place — are settled by tests rather than by
546
+ * pulling at a header and hoping.
547
+ */
548
+ /**
549
+ * The width a drag arrives at, or null when there is no honest answer.
550
+ *
551
+ * Null rather than a guess: if the starting width could not be measured — missing, or zero
552
+ * because nothing had been laid out yet — any number this returns
553
+ * is invented, and an invented width either collapses the column to its floor or makes it jump.
554
+ * Saying so lets the caller leave the column exactly as it is, which is what the reader expects
555
+ * from a drag that could not be interpreted.
556
+ *
557
+ * @param start The width the column had when the drag began.
558
+ * @param delta How far the pointer has travelled since.
559
+ * @param min The narrowest the column may become.
560
+ * @returns A whole number of pixels never below `min`, or null when the drag cannot be read.
561
+ */
562
+ function resolveColumnWidth(start, delta, min) {
563
+ // Zero counts as unmeasurable, not as a width: no column is nought pixels wide, so a zero
564
+ // means the element had not been laid out when the drag began. Accepting it would make the
565
+ // column jump to wherever the pointer happened to be.
566
+ if (!Number.isFinite(start) || start <= 0) {
567
+ return null;
568
+ }
569
+ const travelled = Number.isFinite(delta) ? delta : 0;
570
+ // Whole pixels: half a pixel of column leaves a hairline of the one behind showing through.
571
+ return Math.max(min, Math.round(start + travelled));
572
+ }
573
+ /**
574
+ * A list with one item moved to another position.
575
+ *
576
+ * `to` is read against the list as it stands before the move, which is what a reader means when
577
+ * they drop a column "here". Taking the item out first would shift every later index by one and
578
+ * make a rightward move land one place short — the off-by-one every reorder implementation meets
579
+ * exactly once.
580
+ *
581
+ * @returns A new array; the one passed in is left alone.
582
+ */
583
+ function moveColumn(items, from, to) {
584
+ const next = [...items];
585
+ if (from < 0 || from >= next.length) {
586
+ return next;
587
+ }
588
+ const target = Math.max(0, Math.min(next.length - 1, to));
589
+ if (target === from) {
590
+ return next;
591
+ }
592
+ const [moved] = next.splice(from, 1);
593
+ next.splice(target, 0, moved);
594
+ return next;
595
+ }
596
+
597
+ /** Text ending in digits: `INV-007`, `Week 3`. The trailing digits are what gets counted. */
598
+ const TRAILING_NUMBER = /^(.*?)(\d+)(\D*)$/;
599
+ /**
600
+ * Reads a source run and says how to continue it.
601
+ *
602
+ * A single value still counts as a run: dragging one number down produces 1, 2, 3, which is what
603
+ * every spreadsheet does and what people expect.
604
+ */
605
+ function detectSeries(values) {
606
+ const repeat = { kind: 'repeat', values };
607
+ if (!values.length || values.some((value) => value === null || value === '')) {
608
+ return repeat;
609
+ }
610
+ if (values.every((value) => typeof value === 'number')) {
611
+ const numbers = values;
612
+ const step = numbers.length === 1 ? 1 : numbers[1] - numbers[0];
613
+ // Every gap has to match, or it is not a run the reader meant.
614
+ for (let index = 2; index < numbers.length; index++) {
615
+ if (round(numbers[index] - numbers[index - 1]) !== round(step)) {
616
+ return repeat;
617
+ }
618
+ }
619
+ return { kind: 'number', start: numbers[numbers.length - 1], step: round(step) };
620
+ }
621
+ if (values.every((value) => typeof value === 'string')) {
622
+ return textSeries(values) ?? repeat;
623
+ }
624
+ return repeat;
625
+ }
626
+ /**
627
+ * The values a drag of `count` cells should write.
628
+ *
629
+ * @param source What the reader had selected when they took the handle.
630
+ * @param count How many cells the drag covers.
631
+ */
632
+ function fillValues(source, count) {
633
+ if (count <= 0) {
634
+ return [];
635
+ }
636
+ if (!source.length) {
637
+ return Array.from({ length: count }, () => null);
638
+ }
639
+ const series = detectSeries(source);
640
+ const out = [];
641
+ for (let index = 0; index < count; index++) {
642
+ switch (series.kind) {
643
+ case 'number':
644
+ out.push(round(series.start + series.step * (index + 1)));
645
+ break;
646
+ case 'text-number': {
647
+ const next = series.start + series.step * (index + 1);
648
+ out.push(`${series.prefix}${String(Math.abs(next)).padStart(series.pad, '0')}${series.suffix}`);
649
+ break;
650
+ }
651
+ case 'repeat':
652
+ out.push(series.values[index % series.values.length] ?? null);
653
+ break;
654
+ }
655
+ }
656
+ return out;
657
+ }
658
+ /** A run of text ending in numbers, or null when the pieces do not line up. */
659
+ function textSeries(values) {
660
+ const parts = values.map((value) => TRAILING_NUMBER.exec(value));
661
+ if (parts.some((part) => !part)) {
662
+ return null;
663
+ }
664
+ const matches = parts;
665
+ const prefix = matches[0][1];
666
+ const suffix = matches[0][3];
667
+ // Only a run that shares its wording is a run. `INV-1` and `REC-2` are two names, not a series.
668
+ if (matches.some((match) => match[1] !== prefix || match[3] !== suffix)) {
669
+ return null;
670
+ }
671
+ const numbers = matches.map((match) => Number(match[2]));
672
+ const step = numbers.length === 1 ? 1 : numbers[1] - numbers[0];
673
+ for (let index = 2; index < numbers.length; index++) {
674
+ if (numbers[index] - numbers[index - 1] !== step) {
675
+ return null;
676
+ }
677
+ }
678
+ return {
679
+ kind: 'text-number',
680
+ prefix,
681
+ suffix,
682
+ start: numbers[numbers.length - 1],
683
+ step,
684
+ // The written width is kept, so a run of INV-008 does not become INV-10.
685
+ pad: matches[0][2].length
686
+ };
687
+ }
688
+ /**
689
+ * Rounds away the drift of adding decimals repeatedly.
690
+ *
691
+ * `0.1 + 0.1 + 0.1` is not `0.3` in binary floating point, and a column of prices that reads
692
+ * `0.30000000000000004` is a bug report waiting to happen.
693
+ */
694
+ function round(value) {
695
+ return Number(value.toFixed(10));
696
+ }
697
+
698
+ /**
699
+ * Working out where frozen rows and columns sit.
700
+ *
701
+ * `position: sticky` pins an element at a distance from the edge, but it cannot work that
702
+ * distance out for itself: the second frozen column has to sit exactly as far in as the first
703
+ * one is wide, and CSS has no way to add up its siblings. So the widths are measured once after
704
+ * a render and turned into offsets here.
705
+ */
706
+ /**
707
+ * The distance from the leading edge for each of the first `count` tracks.
708
+ *
709
+ * @param sizes The measured width or height of every track, in order.
710
+ * @param count How many of them are frozen.
711
+ * @returns One offset per frozen track; the first is always zero.
712
+ */
713
+ function frozenOffsets(sizes, count) {
714
+ const frozen = Math.max(0, Math.min(Math.trunc(count) || 0, sizes.length));
715
+ const offsets = [];
716
+ let running = 0;
717
+ for (let index = 0; index < frozen; index++) {
718
+ offsets.push(running);
719
+ const size = sizes[index];
720
+ // A track that could not be measured contributes nothing rather than turning every
721
+ // offset after it into NaN, which would unpin the whole pane.
722
+ running += Number.isFinite(size) ? Math.round(size) : 0;
723
+ }
724
+ return offsets;
725
+ }
726
+ /**
727
+ * Whether a track is the last frozen one.
728
+ *
729
+ * That one carries the divider: a frozen pane needs a visible seam, or content scrolling
730
+ * underneath it reads as content that belongs to it.
731
+ */
732
+ function isFrozenEdge(index, count) {
733
+ return count > 0 && index === count - 1;
734
+ }
735
+
736
+ /**
737
+ * Opens the list of a native select, or the calendar of a date field, as its cell opens.
738
+ *
739
+ * Without it the reader clicks a cell, gets an editor, and has to click a second time to see what
740
+ * the column allows — two gestures for one decision.
741
+ *
742
+ * `showPicker` needs the click that opened the cell still to count as a gesture, and it is recent
743
+ * enough that some browsers do not have it. Either way a refusal leaves the editor focused and the
744
+ * list closed, which is where it stood before, so nothing has to be undone.
745
+ *
746
+ * Only for a list and a date. On a text field the same method opens the browser's own autofill
747
+ * suggestions, which is not what the cell asked for.
748
+ *
749
+ * @returns whether the picker was asked to open.
750
+ */
751
+ function revealPicker(element) {
752
+ const picker = element;
753
+ if (typeof picker?.showPicker !== 'function') {
754
+ return false;
755
+ }
756
+ try {
757
+ picker.showPicker();
758
+ return true;
759
+ }
760
+ catch {
761
+ return false;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Running a parsed formula against the sheet around it.
767
+ *
768
+ * The rules are a spreadsheet's rather than a programming language's, which is what a reader
769
+ * expects: an empty cell is nought in arithmetic and an empty string in text, `TRUE` is one, and
770
+ * dividing by nought is an error rather than infinity.
771
+ */
772
+ /** Whether what came back is a failure. */
773
+ function failed$1(value) {
774
+ return (typeof value === 'object' && value !== null && 'ok' in value && value.ok === false);
775
+ }
776
+ /**
777
+ * A value as a number.
778
+ *
779
+ * An empty cell counts as nought, as it does in a spreadsheet — a column of figures with gaps adds
780
+ * up rather than refusing. Text that is not a number is an error and not a silent nought: `"abc" *
781
+ * 2` is a mistake worth seeing.
782
+ */
783
+ function asNumber(value) {
784
+ if (value === null || value === '') {
785
+ return 0;
786
+ }
787
+ if (typeof value === 'number') {
788
+ return Number.isFinite(value) ? value : { ok: false, code: 'type' };
789
+ }
790
+ if (typeof value === 'boolean') {
791
+ return value ? 1 : 0;
792
+ }
793
+ const parsed = Number(String(value).replace(',', '.'));
794
+ return Number.isFinite(parsed) ? parsed : { ok: false, code: 'type', at: String(value) };
795
+ }
796
+ /** A value as text, where an empty cell is an empty string rather than the word "null". */
797
+ function asText(value) {
798
+ if (value === null) {
799
+ return '';
800
+ }
801
+ if (typeof value === 'boolean') {
802
+ return value ? 'TRUE' : 'FALSE';
803
+ }
804
+ return String(value);
805
+ }
806
+ /** Whether a value counts as yes. */
807
+ function asBoolean(value) {
808
+ if (typeof value === 'boolean') {
809
+ return value;
810
+ }
811
+ if (typeof value === 'number') {
812
+ return value !== 0;
813
+ }
814
+ return value !== null && value !== '' && value.toUpperCase() !== 'FALSE';
815
+ }
816
+ /** Every number in a list of values and lists, with the empties left out. */
817
+ function numbersOf(values) {
818
+ const numbers = [];
819
+ for (const value of values) {
820
+ if (Array.isArray(value)) {
821
+ const nested = numbersOf(value);
822
+ if (failed$1(nested)) {
823
+ return nested;
824
+ }
825
+ numbers.push(...nested);
826
+ continue;
827
+ }
828
+ const single = value;
829
+ // A gap in a column is not a nought to average against: SUM of three figures and two
830
+ // blanks is the sum of three figures, and AVERAGE of them divides by three.
831
+ if (single === null || single === '') {
832
+ continue;
833
+ }
834
+ const number = asNumber(single);
835
+ if (failed$1(number)) {
836
+ return number;
837
+ }
838
+ numbers.push(number);
839
+ }
840
+ return numbers;
841
+ }
842
+ /**
843
+ * Runs a formula.
844
+ *
845
+ * @param formula - What {@link parseFormula} gave back.
846
+ * @param context - How to reach the sheet: this row's cells, and whole columns.
847
+ * @returns The value, or what went wrong.
848
+ */
849
+ function evaluateFormula(formula, context) {
850
+ function walk(node) {
851
+ switch (node.kind) {
852
+ case 'number':
853
+ case 'text':
854
+ case 'boolean':
855
+ return node.value;
856
+ case 'cell': {
857
+ const value = context.cell(node.alias);
858
+ // Undefined is "no such column", which is a mistake; null is an empty cell, which
859
+ // is not. Collapsing the two is how a typo in an alias comes out as nought.
860
+ return value === undefined ? { ok: false, code: 'name', at: node.alias } : value;
861
+ }
862
+ case 'column': {
863
+ const values = context.column(node.alias);
864
+ return values === undefined ? { ok: false, code: 'name', at: node.alias } : values;
865
+ }
866
+ case 'at': {
867
+ const value = context.at?.(node.row, node.col);
868
+ // A place outside the sheet is a mistake, not an empty cell: a formula that points
869
+ // off the edge should say so rather than quietly read nothing.
870
+ return value === undefined ? { ok: false, code: 'name', at: 'ref' } : value;
871
+ }
872
+ case 'area': {
873
+ const values = context.area?.(node.top, node.left, node.bottom, node.right);
874
+ return values === undefined ? { ok: false, code: 'name', at: 'ref' } : values;
875
+ }
876
+ case 'unary': {
877
+ const operand = walk(node.operand);
878
+ if (failed$1(operand)) {
879
+ return operand;
880
+ }
881
+ const number = asNumber(operand);
882
+ if (failed$1(number)) {
883
+ return number;
884
+ }
885
+ return node.operator === '-' ? -number : number;
886
+ }
887
+ case 'binary':
888
+ return binary(node);
889
+ case 'call':
890
+ return call(node);
891
+ }
892
+ }
893
+ function binary(node) {
894
+ const left = walk(node.left);
895
+ if (failed$1(left)) {
896
+ return left;
897
+ }
898
+ const right = walk(node.right);
899
+ if (failed$1(right)) {
900
+ return right;
901
+ }
902
+ const a = left;
903
+ const b = right;
904
+ if (node.operator === '&') {
905
+ return asText(a) + asText(b);
906
+ }
907
+ if (['=', '<>', '<', '<=', '>', '>='].includes(node.operator)) {
908
+ return compare(node.operator, a, b);
909
+ }
910
+ const x = asNumber(a);
911
+ if (failed$1(x)) {
912
+ return x;
913
+ }
914
+ const y = asNumber(b);
915
+ if (failed$1(y)) {
916
+ return y;
917
+ }
918
+ switch (node.operator) {
919
+ case '+':
920
+ return x + y;
921
+ case '-':
922
+ return x - y;
923
+ case '*':
924
+ return x * y;
925
+ case '/':
926
+ return y === 0 ? { ok: false, code: 'divide-by-zero' } : x / y;
927
+ case '^':
928
+ return x ** y;
929
+ }
930
+ return { ok: false, code: 'syntax', at: node.operator };
931
+ }
932
+ /** Two values compared the way a reader means it: numbers as numbers, text as text. */
933
+ function compare(operator, a, b) {
934
+ const bothNumbers = typeof a !== 'string' && typeof b !== 'string';
935
+ const x = bothNumbers ? asNumber(a) : asText(a);
936
+ const y = bothNumbers ? asNumber(b) : asText(b);
937
+ switch (operator) {
938
+ case '=':
939
+ return x === y;
940
+ case '<>':
941
+ return x !== y;
942
+ case '<':
943
+ return x < y;
944
+ case '<=':
945
+ return x <= y;
946
+ case '>':
947
+ return x > y;
948
+ default:
949
+ return x >= y;
950
+ }
951
+ }
952
+ function call(node) {
953
+ const args = [];
954
+ // IF decides which branch to run, so its arguments are not all worked out up front: a
955
+ // division by nought on the branch that is not taken is not an error the reader made.
956
+ if (node.name === 'IF') {
957
+ if (node.args.length < 2 || node.args.length > 3) {
958
+ return { ok: false, code: 'type', at: 'IF' };
959
+ }
960
+ const condition = walk(node.args[0]);
961
+ if (failed$1(condition)) {
962
+ return condition;
963
+ }
964
+ if (asBoolean(condition)) {
965
+ return walk(node.args[1]);
966
+ }
967
+ return node.args[2] ? walk(node.args[2]) : false;
968
+ }
969
+ for (const argument of node.args) {
970
+ const value = walk(argument);
971
+ if (failed$1(value)) {
972
+ return value;
973
+ }
974
+ args.push(value);
975
+ }
976
+ return apply(node.name, args);
977
+ }
978
+ function apply(name, args) {
979
+ switch (name) {
980
+ case 'SUM':
981
+ case 'AVERAGE':
982
+ case 'AVG':
983
+ case 'MIN':
984
+ case 'MAX': {
985
+ const numbers = numbersOf(args);
986
+ if (failed$1(numbers)) {
987
+ return numbers;
988
+ }
989
+ if (name === 'SUM') {
990
+ return numbers.reduce((total, value) => total + value, 0);
991
+ }
992
+ if (!numbers.length) {
993
+ // Nothing to average, and nothing to take the least or the most of.
994
+ return name === 'MIN' || name === 'MAX' ? 0 : { ok: false, code: 'divide-by-zero' };
995
+ }
996
+ if (name === 'MIN') {
997
+ return Math.min(...numbers);
998
+ }
999
+ if (name === 'MAX') {
1000
+ return Math.max(...numbers);
1001
+ }
1002
+ return numbers.reduce((total, value) => total + value, 0) / numbers.length;
1003
+ }
1004
+ case 'COUNT': {
1005
+ const numbers = numbersOf(args);
1006
+ return failed$1(numbers) ? 0 : numbers.length;
1007
+ }
1008
+ case 'ROUND': {
1009
+ const value = asNumber(args[0]);
1010
+ if (failed$1(value)) {
1011
+ return value;
1012
+ }
1013
+ const places = args.length > 1 ? asNumber(args[1]) : 0;
1014
+ if (failed$1(places)) {
1015
+ return places;
1016
+ }
1017
+ const factor = 10 ** places;
1018
+ return Math.round(value * factor) / factor;
1019
+ }
1020
+ case 'ABS': {
1021
+ const value = asNumber(args[0]);
1022
+ return failed$1(value) ? value : Math.abs(value);
1023
+ }
1024
+ case 'AND':
1025
+ return args.every((value) => asBoolean(value));
1026
+ case 'OR':
1027
+ return args.some((value) => asBoolean(value));
1028
+ case 'NOT':
1029
+ return !asBoolean(args[0]);
1030
+ case 'CONCAT':
1031
+ return args.map((value) => asText(value)).join('');
1032
+ case 'LEN':
1033
+ return asText(args[0]).length;
1034
+ default:
1035
+ return { ok: false, code: 'name', at: name };
1036
+ }
1037
+ }
1038
+ const result = walk(formula.node);
1039
+ if (failed$1(result)) {
1040
+ return result;
1041
+ }
1042
+ // A whole column where a single value was wanted: SUM() takes one, but a cell cannot show one.
1043
+ if (Array.isArray(result)) {
1044
+ return { ok: false, code: 'type' };
1045
+ }
1046
+ return { ok: true, value: result };
1047
+ }
1048
+
1049
+ /**
1050
+ * Coordinates: the other way to point at a cell, and what has to happen to them afterwards.
1051
+ *
1052
+ * An alias says which column it means and goes on meaning it whatever happens to the sheet. A
1053
+ * coordinate says *where* the column was when the formula was written, which is only the same
1054
+ * thing until somebody moves it. That is not a reason to refuse coordinates — a spreadsheet
1055
+ * without `B2` is not a spreadsheet — it is a reason to move them with the sheet, which is what
1056
+ * every spreadsheet does and what these functions are for.
1057
+ */
1058
+ /** The letters a column is known by: A, B … Z, AA, AB … */
1059
+ function columnLabel(index) {
1060
+ if (index < 0) {
1061
+ return '';
1062
+ }
1063
+ let label = '';
1064
+ let rest = index;
1065
+ for (;;) {
1066
+ label = String.fromCharCode(65 + (rest % 26)) + label;
1067
+ rest = Math.floor(rest / 26) - 1;
1068
+ if (rest < 0) {
1069
+ return label;
1070
+ }
1071
+ }
1072
+ }
1073
+ /** Which column a run of letters means, or -1 when it means none. */
1074
+ function columnIndexOf(label) {
1075
+ if (!/^[A-Za-z]+$/.test(label)) {
1076
+ return -1;
1077
+ }
1078
+ let index = 0;
1079
+ for (const character of label.toUpperCase()) {
1080
+ index = index * 26 + (character.charCodeAt(0) - 64);
1081
+ }
1082
+ return index - 1;
1083
+ }
1084
+ /** Reads `B3` as a place, or nothing when it is not one. */
1085
+ function parseCoordinate(text) {
1086
+ const match = /^([A-Za-z]+)([0-9]+)$/.exec(text);
1087
+ if (!match) {
1088
+ return null;
1089
+ }
1090
+ const col = columnIndexOf(match[1]);
1091
+ const row = Number(match[2]) - 1;
1092
+ return col < 0 || row < 0 ? null : { row, col };
1093
+ }
1094
+ /** Writes a place back as `B3`, which is what a formula shows. */
1095
+ function formatCoordinate(coordinate) {
1096
+ return `${columnLabel(coordinate.col)}${coordinate.row + 1}`;
1097
+ }
1098
+ /** What a column moving does to a column index. */
1099
+ function afterColumnMove(col, from, to) {
1100
+ if (col === from) {
1101
+ return to;
1102
+ }
1103
+ // Everything between the two places shuffles by one, in whichever direction the column went.
1104
+ if (from < to) {
1105
+ return col > from && col <= to ? col - 1 : col;
1106
+ }
1107
+ return col >= to && col < from ? col + 1 : col;
1108
+ }
1109
+ /** What inserting or deleting tracks does to an index along that axis. */
1110
+ function afterInsertOrDelete(index, at, count) {
1111
+ if (count > 0) {
1112
+ return index >= at ? index + count : index;
1113
+ }
1114
+ const removed = -count;
1115
+ if (index >= at && index < at + removed) {
1116
+ return 'gone';
1117
+ }
1118
+ return index >= at + removed ? index - removed : index;
1119
+ }
1120
+ /** Where a coordinate ends up after a change, or that it no longer points at anything. */
1121
+ function moveCoordinate(coordinate, change) {
1122
+ if (change.kind === 'move-column') {
1123
+ return { row: coordinate.row, col: afterColumnMove(coordinate.col, change.from, change.to) };
1124
+ }
1125
+ if (change.kind === 'columns') {
1126
+ const col = afterInsertOrDelete(coordinate.col, change.at, change.count);
1127
+ return col === 'gone' ? 'gone' : { row: coordinate.row, col };
1128
+ }
1129
+ const row = afterInsertOrDelete(coordinate.row, change.at, change.count);
1130
+ return row === 'gone' ? 'gone' : { row, col: coordinate.col };
1131
+ }
1132
+ /**
1133
+ * Rewrites the coordinates in a formula so they point at the same data as before.
1134
+ *
1135
+ * Text, not a parsed tree, and deliberately: what a reader typed is what is stored, so what comes
1136
+ * back has to be something they would recognise as their own formula with a letter changed. A
1137
+ * reference to something that has been deleted becomes `#REF!`, which is what a spreadsheet does
1138
+ * and is honest — the alternative is a formula that quietly reads its neighbour.
1139
+ *
1140
+ * @param formula - The text of the formula, `=` and all.
1141
+ * @param change - What happened to the sheet.
1142
+ * @returns The formula with its coordinates moved.
1143
+ */
1144
+ function rewriteCoordinates(formula, change) {
1145
+ if (typeof formula !== 'string' || !formula.trimStart().startsWith('=')) {
1146
+ return formula;
1147
+ }
1148
+ // Left alone inside quotes and inside brackets: `"A1"` is a word somebody wrote, and `[price]`
1149
+ // is an alias that happens to look like letters.
1150
+ let result = '';
1151
+ let index = 0;
1152
+ let quoted = false;
1153
+ let bracketed = false;
1154
+ while (index < formula.length) {
1155
+ const character = formula[index];
1156
+ if (quoted) {
1157
+ result += character;
1158
+ quoted = character !== '"' || formula[index + 1] === '"';
1159
+ index++;
1160
+ continue;
1161
+ }
1162
+ if (character === '"') {
1163
+ quoted = true;
1164
+ result += character;
1165
+ index++;
1166
+ continue;
1167
+ }
1168
+ if (character === '[') {
1169
+ bracketed = true;
1170
+ }
1171
+ if (character === ']') {
1172
+ bracketed = false;
1173
+ }
1174
+ if (!bracketed) {
1175
+ const match = /^[A-Za-z]+[0-9]+/.exec(formula.slice(index));
1176
+ const coordinate = match && parseCoordinate(match[0]);
1177
+ if (coordinate) {
1178
+ const moved = moveCoordinate(coordinate, change);
1179
+ result += moved === 'gone' ? '#REF!' : formatCoordinate(moved);
1180
+ index += match[0].length;
1181
+ continue;
1182
+ }
1183
+ }
1184
+ result += character;
1185
+ index++;
1186
+ }
1187
+ return result;
1188
+ }
1189
+ /**
1190
+ * The rows again, with every formula in them moved to match the sheet's new shape.
1191
+ *
1192
+ * The sheet reports what the reader asked for and writes nothing, as it does with everything else,
1193
+ * so this is the line the host writes in its own handler:
1194
+ *
1195
+ * ```ts
1196
+ * protected onColumnMoved(move: { from: number; to: number; keys: string[] }): void {
1197
+ * this.order.set(move.keys);
1198
+ * this.rows.update((rows) => rewriteRowFormulas(rows, { kind: 'move-column', from: move.from, to: move.to }));
1199
+ * }
1200
+ * ```
1201
+ *
1202
+ * Every own property that holds a formula is rewritten, which covers the usual case of formulas
1203
+ * living in the row's own fields. A host that keeps them somewhere else — a map beside the data, a
1204
+ * column that declares its own — walks that itself with {@link rewriteCoordinates}.
1205
+ *
1206
+ * @param rows - The rows as they are.
1207
+ * @param change - What happened to the sheet.
1208
+ * @returns New rows; the ones with nothing to rewrite are the very same objects, so a sheet that
1209
+ * tracks by identity redraws only what moved.
1210
+ */
1211
+ function rewriteRowFormulas(rows, change) {
1212
+ return rows.map((row) => {
1213
+ let changed = false;
1214
+ const next = { ...row };
1215
+ for (const [key, value] of Object.entries(next)) {
1216
+ if (typeof value !== 'string') {
1217
+ continue;
1218
+ }
1219
+ const rewritten = rewriteCoordinates(value, change);
1220
+ if (rewritten !== value) {
1221
+ next[key] = rewritten;
1222
+ changed = true;
1223
+ }
1224
+ }
1225
+ return (changed ? next : row);
1226
+ });
1227
+ }
1228
+
1229
+ /**
1230
+ * Turning the text of a formula into something that can be run.
1231
+ *
1232
+ * Hand-written rather than generated, and deliberately: a grammar this small is a page of code,
1233
+ * and a parser generator would be a dependency, a build step and a licence to check for the sake
1234
+ * of it. What it costs is that the precedence is written out by hand — which is also what makes it
1235
+ * readable.
1236
+ *
1237
+ * Precedence, loosest first, as in a spreadsheet:
1238
+ *
1239
+ * comparison = <> < <= > >=
1240
+ * concatenation &
1241
+ * additive + -
1242
+ * multiplicative * /
1243
+ * power ^
1244
+ * unary - +
1245
+ * primary number, text, TRUE/FALSE, [alias], [alias:], NAME(…), ( … )
1246
+ */
1247
+ /** Whether a stored value is a formula rather than a value. */
1248
+ function isFormula(value) {
1249
+ return typeof value === 'string' && value.trimStart().startsWith('=');
1250
+ }
1251
+ const SYMBOLS = ['<>', '<=', '>=', '+', '-', '*', '/', '^', '&', '=', '<', '>', '(', ')', ',', ';', ':'];
1252
+ /** Splits the text into the pieces the parser walks, or says where it gave up. */
1253
+ function tokenise(source) {
1254
+ const tokens = [];
1255
+ let index = 0;
1256
+ while (index < source.length) {
1257
+ const char = source[index];
1258
+ if (/\s/.test(char)) {
1259
+ index++;
1260
+ continue;
1261
+ }
1262
+ // A reference: [alias] for this row, [alias:] for the whole column.
1263
+ if (char === '[') {
1264
+ const end = source.indexOf(']', index);
1265
+ if (end < 0) {
1266
+ return { ok: false, code: 'syntax', at: '[' };
1267
+ }
1268
+ const inside = source.slice(index + 1, end);
1269
+ const whole = inside.endsWith(':');
1270
+ const alias = (whole ? inside.slice(0, -1) : inside).trim();
1271
+ if (!alias) {
1272
+ return { ok: false, code: 'syntax', at: '[]' };
1273
+ }
1274
+ tokens.push({ kind: 'reference', alias, whole });
1275
+ index = end + 1;
1276
+ continue;
1277
+ }
1278
+ // A quoted string. Two quotes in a row are one quote, as in a spreadsheet.
1279
+ if (char === '"') {
1280
+ let text = '';
1281
+ index++;
1282
+ for (;;) {
1283
+ if (index >= source.length) {
1284
+ return { ok: false, code: 'syntax', at: '"' };
1285
+ }
1286
+ if (source[index] === '"') {
1287
+ if (source[index + 1] === '"') {
1288
+ text += '"';
1289
+ index += 2;
1290
+ continue;
1291
+ }
1292
+ index++;
1293
+ break;
1294
+ }
1295
+ text += source[index];
1296
+ index++;
1297
+ }
1298
+ tokens.push({ kind: 'text', value: text });
1299
+ continue;
1300
+ }
1301
+ if (/[0-9.]/.test(char)) {
1302
+ const match = /^[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/.exec(source.slice(index));
1303
+ if (!match) {
1304
+ return { ok: false, code: 'syntax', at: char };
1305
+ }
1306
+ tokens.push({ kind: 'number', value: Number(match[0]) });
1307
+ index += match[0].length;
1308
+ continue;
1309
+ }
1310
+ if (/[A-Za-z_]/.test(char)) {
1311
+ const match = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(source.slice(index));
1312
+ tokens.push({ kind: 'name', value: match[0] });
1313
+ index += match[0].length;
1314
+ continue;
1315
+ }
1316
+ const symbol = SYMBOLS.find((candidate) => source.startsWith(candidate, index));
1317
+ if (!symbol) {
1318
+ return { ok: false, code: 'syntax', at: char };
1319
+ }
1320
+ tokens.push({ kind: 'symbol', value: symbol });
1321
+ index += symbol.length;
1322
+ }
1323
+ return tokens;
1324
+ }
1325
+ /** Whether what came back is a failure rather than what was asked for. */
1326
+ function failed(value) {
1327
+ return typeof value === 'object' && value !== null && 'ok' in value && value.ok === false;
1328
+ }
1329
+ /**
1330
+ * Reads the text of a formula, with or without its leading `=`.
1331
+ *
1332
+ * @returns The parsed formula, or the first thing that did not make sense.
1333
+ */
1334
+ function parseFormula(source) {
1335
+ const text = source.trimStart().startsWith('=') ? source.trimStart().slice(1) : source;
1336
+ const tokens = tokenise(text);
1337
+ if (failed(tokens)) {
1338
+ return tokens;
1339
+ }
1340
+ let at = 0;
1341
+ const peek = () => tokens[at];
1342
+ const eat = (value) => {
1343
+ const token = peek();
1344
+ if (token?.kind === 'symbol' && token.value === value) {
1345
+ at++;
1346
+ return true;
1347
+ }
1348
+ return false;
1349
+ };
1350
+ const cells = new Set();
1351
+ const columns = new Set();
1352
+ let coordinates = false;
1353
+ function parseExpression() {
1354
+ return parseComparison();
1355
+ }
1356
+ function parseComparison() {
1357
+ let left = parseConcat();
1358
+ if (failed(left)) {
1359
+ return left;
1360
+ }
1361
+ for (;;) {
1362
+ const token = peek();
1363
+ const operator = token?.kind === 'symbol' ? token.value : '';
1364
+ if (!['=', '<>', '<', '<=', '>', '>='].includes(operator)) {
1365
+ return left;
1366
+ }
1367
+ at++;
1368
+ const right = parseConcat();
1369
+ if (failed(right)) {
1370
+ return right;
1371
+ }
1372
+ left = { kind: 'binary', operator: operator, left, right };
1373
+ }
1374
+ }
1375
+ function parseConcat() {
1376
+ let left = parseAdditive();
1377
+ if (failed(left)) {
1378
+ return left;
1379
+ }
1380
+ while (eat('&')) {
1381
+ const right = parseAdditive();
1382
+ if (failed(right)) {
1383
+ return right;
1384
+ }
1385
+ left = { kind: 'binary', operator: '&', left, right };
1386
+ }
1387
+ return left;
1388
+ }
1389
+ function parseAdditive() {
1390
+ let left = parseMultiplicative();
1391
+ if (failed(left)) {
1392
+ return left;
1393
+ }
1394
+ for (;;) {
1395
+ const token = peek();
1396
+ const operator = token?.kind === 'symbol' ? token.value : '';
1397
+ if (operator !== '+' && operator !== '-') {
1398
+ return left;
1399
+ }
1400
+ at++;
1401
+ const right = parseMultiplicative();
1402
+ if (failed(right)) {
1403
+ return right;
1404
+ }
1405
+ left = { kind: 'binary', operator, left, right };
1406
+ }
1407
+ }
1408
+ function parseMultiplicative() {
1409
+ let left = parsePower();
1410
+ if (failed(left)) {
1411
+ return left;
1412
+ }
1413
+ for (;;) {
1414
+ const token = peek();
1415
+ const operator = token?.kind === 'symbol' ? token.value : '';
1416
+ if (operator !== '*' && operator !== '/') {
1417
+ return left;
1418
+ }
1419
+ at++;
1420
+ const right = parsePower();
1421
+ if (failed(right)) {
1422
+ return right;
1423
+ }
1424
+ left = { kind: 'binary', operator, left, right };
1425
+ }
1426
+ }
1427
+ function parsePower() {
1428
+ const left = parseUnary();
1429
+ if (failed(left)) {
1430
+ return left;
1431
+ }
1432
+ if (!eat('^')) {
1433
+ return left;
1434
+ }
1435
+ // Right to left, as everywhere else: 2^3^2 is 2^(3^2).
1436
+ const right = parsePower();
1437
+ if (failed(right)) {
1438
+ return right;
1439
+ }
1440
+ return { kind: 'binary', operator: '^', left, right };
1441
+ }
1442
+ function parseUnary() {
1443
+ const token = peek();
1444
+ if (token?.kind === 'symbol' && (token.value === '-' || token.value === '+')) {
1445
+ at++;
1446
+ const operand = parseUnary();
1447
+ if (failed(operand)) {
1448
+ return operand;
1449
+ }
1450
+ return { kind: 'unary', operator: token.value, operand };
1451
+ }
1452
+ return parsePrimary();
1453
+ }
1454
+ function parsePrimary() {
1455
+ const token = peek();
1456
+ if (!token) {
1457
+ return { ok: false, code: 'syntax', at: 'end' };
1458
+ }
1459
+ if (token.kind === 'number') {
1460
+ at++;
1461
+ return { kind: 'number', value: token.value };
1462
+ }
1463
+ if (token.kind === 'text') {
1464
+ at++;
1465
+ return { kind: 'text', value: token.value };
1466
+ }
1467
+ if (token.kind === 'reference') {
1468
+ at++;
1469
+ if (token.whole) {
1470
+ columns.add(token.alias);
1471
+ return { kind: 'column', alias: token.alias };
1472
+ }
1473
+ cells.add(token.alias);
1474
+ return { kind: 'cell', alias: token.alias };
1475
+ }
1476
+ if (token.kind === 'name') {
1477
+ at++;
1478
+ const upper = token.value.toUpperCase();
1479
+ if (upper === 'TRUE' || upper === 'FALSE') {
1480
+ return { kind: 'boolean', value: upper === 'TRUE' };
1481
+ }
1482
+ // A place: `B3`, or `B3:D7` for a rectangle of them. Written the way a spreadsheet
1483
+ // writes it, because that is what somebody arriving from one will type.
1484
+ const place = parseCoordinate(token.value);
1485
+ if (place) {
1486
+ coordinates = true;
1487
+ if (!eat(':')) {
1488
+ return { kind: 'at', row: place.row, col: place.col };
1489
+ }
1490
+ const next = peek();
1491
+ const far = next?.kind === 'name' ? parseCoordinate(next.value) : null;
1492
+ if (!far) {
1493
+ return { ok: false, code: 'syntax', at: ':' };
1494
+ }
1495
+ at++;
1496
+ return {
1497
+ kind: 'area',
1498
+ top: Math.min(place.row, far.row),
1499
+ left: Math.min(place.col, far.col),
1500
+ bottom: Math.max(place.row, far.row),
1501
+ right: Math.max(place.col, far.col)
1502
+ };
1503
+ }
1504
+ if (!eat('(')) {
1505
+ // A bare word is a name nothing answers to — the reader probably meant a column,
1506
+ // which is written in brackets.
1507
+ return { ok: false, code: 'name', at: token.value };
1508
+ }
1509
+ const args = [];
1510
+ if (!eat(')')) {
1511
+ for (;;) {
1512
+ const argument = parseExpression();
1513
+ if (failed(argument)) {
1514
+ return argument;
1515
+ }
1516
+ args.push(argument);
1517
+ // A comma or a semicolon: the same separator wears both costumes depending on
1518
+ // where the reader learnt to write formulas.
1519
+ if (eat(',') || eat(';')) {
1520
+ continue;
1521
+ }
1522
+ if (eat(')')) {
1523
+ break;
1524
+ }
1525
+ return { ok: false, code: 'syntax', at: ')' };
1526
+ }
1527
+ }
1528
+ return { kind: 'call', name: upper, args };
1529
+ }
1530
+ if (token.value === '(') {
1531
+ at++;
1532
+ const inner = parseExpression();
1533
+ if (failed(inner)) {
1534
+ return inner;
1535
+ }
1536
+ if (!eat(')')) {
1537
+ return { ok: false, code: 'syntax', at: ')' };
1538
+ }
1539
+ return inner;
1540
+ }
1541
+ return { ok: false, code: 'syntax', at: token.value };
1542
+ }
1543
+ const node = parseExpression();
1544
+ if (failed(node)) {
1545
+ return node;
1546
+ }
1547
+ if (at < tokens.length) {
1548
+ const rest = tokens[at];
1549
+ return { ok: false, code: 'syntax', at: rest.kind === 'symbol' || rest.kind === 'name' ? String(rest.value) : 'end' };
1550
+ }
1551
+ return { node, cells: [...cells], columns: [...columns], coordinates };
1552
+ }
1553
+
1554
+ const where = (row, alias) => `${row}\t${alias}`;
1555
+ /**
1556
+ * Runs every formula in a sheet, in whatever order the dependencies demand.
1557
+ *
1558
+ * Worked out lazily and remembered: a cell is run the first time something needs it, and a column
1559
+ * read by twenty rows is not run twenty times. The walk carries the cells it is in the middle of,
1560
+ * which is how a circle is caught the moment it closes rather than by counting to a thousand.
1561
+ *
1562
+ * @returns Every cell of the sheet, ready to be shown.
1563
+ */
1564
+ function evaluateSheet(input) {
1565
+ const { rows, columns, raw } = input;
1566
+ const byAlias = new Map(columns.map((column) => [column.key, column]));
1567
+ const done = new Map();
1568
+ const parsed = new Map();
1569
+ const running = new Set();
1570
+ /** The formula of a cell, parsed once however many cells hold the same text. */
1571
+ function formulaOf(text) {
1572
+ let result = parsed.get(text);
1573
+ if (!result) {
1574
+ result = parseFormula(text);
1575
+ parsed.set(text, result);
1576
+ }
1577
+ return result;
1578
+ }
1579
+ function valueOf(rowIndex, alias) {
1580
+ const key = where(rowIndex, alias);
1581
+ const already = done.get(key);
1582
+ if (already) {
1583
+ return already;
1584
+ }
1585
+ const column = byAlias.get(alias);
1586
+ if (!column || rowIndex < 0 || rowIndex >= rows.length) {
1587
+ return { value: null, failure: { ok: false, code: 'name', at: alias } };
1588
+ }
1589
+ // A column that carries its own formula answers with it, whatever the row happens to hold.
1590
+ // The reader cannot type over one, so what is underneath is not a value that was replaced
1591
+ // — it is a value that was never asked for.
1592
+ const source = column.formula ?? raw(rowIndex, column);
1593
+ if (!isFormula(source)) {
1594
+ const plain = { value: source };
1595
+ done.set(key, plain);
1596
+ return plain;
1597
+ }
1598
+ // Already on the stack: the cell needs itself. Remembered as a failure so the other cells
1599
+ // that read it fail the same way instead of starting the walk again.
1600
+ if (running.has(key)) {
1601
+ const circular = { value: null, failure: { ok: false, code: 'cycle', at: alias } };
1602
+ done.set(key, circular);
1603
+ return circular;
1604
+ }
1605
+ const formula = formulaOf(source);
1606
+ if ('ok' in formula) {
1607
+ const broken = { value: null, failure: formula };
1608
+ done.set(key, broken);
1609
+ return broken;
1610
+ }
1611
+ running.add(key);
1612
+ const outcome = evaluateFormula(formula, {
1613
+ cell: (name) => (byAlias.has(name) ? valueOf(rowIndex, name).value : undefined),
1614
+ // A whole column leaves out the cell that is asking for it, and only when the cell is
1615
+ // in that very column. `=SUM([total:])` written in the total row is what everybody
1616
+ // writes and what everybody means; taken at its word it is a sum that needs its own
1617
+ // answer, and the reader is shown `#CYCLE!` for asking the obvious thing. Reading a
1618
+ // different column is untouched: a total row summing the prices sums every price.
1619
+ column: (name) => byAlias.has(name)
1620
+ ? rows
1621
+ .map((_, index) => name === alias && index === rowIndex ? null : valueOf(index, name).value)
1622
+ .filter((_, index) => !(name === alias && index === rowIndex))
1623
+ : undefined,
1624
+ // A coordinate is the same walk under another name, so a circle drawn with `B3` is
1625
+ // caught exactly as one drawn with an alias.
1626
+ at: (row, col) => {
1627
+ const target = columns[col];
1628
+ return target && row >= 0 && row < rows.length
1629
+ ? valueOf(row, target.key).value
1630
+ : undefined;
1631
+ },
1632
+ area: (top, left, bottom, right) => {
1633
+ if (left < 0 || right >= columns.length || top < 0 || bottom >= rows.length) {
1634
+ return undefined;
1635
+ }
1636
+ const values = [];
1637
+ for (let row = top; row <= bottom; row++) {
1638
+ for (let col = left; col <= right; col++) {
1639
+ // The cell asking is left out of its own rectangle, for the same reason a
1640
+ // whole column leaves it out: a total inside the range it totals.
1641
+ if (row === rowIndex && columns[col]?.key === alias) {
1642
+ continue;
1643
+ }
1644
+ values.push(valueOf(row, columns[col].key).value);
1645
+ }
1646
+ }
1647
+ return values;
1648
+ }
1649
+ });
1650
+ running.delete(key);
1651
+ // A cell that turned out to be part of a circle answers with the circle, not with the
1652
+ // half-answer the walk happened to reach before it noticed.
1653
+ const settled = done.get(key);
1654
+ if (settled?.failure?.code === 'cycle') {
1655
+ return settled;
1656
+ }
1657
+ const result = outcome.ok
1658
+ ? { value: outcome.value }
1659
+ : { value: null, failure: outcome };
1660
+ done.set(key, result);
1661
+ return result;
1662
+ }
1663
+ return rows.map((_, rowIndex) => {
1664
+ const line = {};
1665
+ for (const column of columns) {
1666
+ line[column.key] = valueOf(rowIndex, column.key);
1667
+ }
1668
+ return line;
1669
+ });
1670
+ }
1671
+
1672
+ /**
1673
+ * What a cell shows for a formula that could not be worked out.
1674
+ *
1675
+ * The spreadsheet spellings rather than sentences: they fit in a cell, they are the same in every
1676
+ * language, and anybody who has used a spreadsheet already knows what `#DIV/0!` means. They also
1677
+ * travel — a file exported with `#DIV/0!` in a cell says the same thing to whoever opens it in
1678
+ * Excel as it did here.
1679
+ */
1680
+ const FORMULA_ERROR_TEXT = {
1681
+ syntax: '#SYNTAX!',
1682
+ name: '#NAME?',
1683
+ 'divide-by-zero': '#DIV/0!',
1684
+ cycle: '#CYCLE!',
1685
+ type: '#VALUE!'
1686
+ };
1687
+ /** Every spelling, for telling an error apart from something a reader typed. */
1688
+ const FORMULA_ERRORS = Object.values(FORMULA_ERROR_TEXT);
1689
+ /** What to show for a failure. */
1690
+ function formulaErrorText(failure) {
1691
+ return FORMULA_ERROR_TEXT[failure.code];
1692
+ }
1693
+
1694
+ /**
1695
+ * What to offer a reader who is in the middle of writing a formula.
1696
+ *
1697
+ * A formula names columns by an alias that is nowhere on screen — the header shows a title meant
1698
+ * for people, not the key the formula needs — so writing one from memory means knowing something
1699
+ * the sheet never showed. That is the actual problem: not that the language is hard, but that the
1700
+ * words are hidden.
1701
+ *
1702
+ * Kept as arithmetic over strings, with no DOM and no component, so the awkward parts — where the
1703
+ * word being typed starts, what counts as one — can be held still by a test.
1704
+ */
1705
+ /** The functions the engine knows, with a word on each. */
1706
+ const HUB_FORMULA_FUNCTIONS = [
1707
+ { name: 'SUM', hint: 'Adds numbers and whole columns' },
1708
+ { name: 'AVERAGE', hint: 'The mean of what is there, gaps left out' },
1709
+ { name: 'MIN', hint: 'The least of them' },
1710
+ { name: 'MAX', hint: 'The most of them' },
1711
+ { name: 'COUNT', hint: 'How many are numbers' },
1712
+ { name: 'ROUND', hint: 'To a given number of places' },
1713
+ { name: 'ABS', hint: 'Without its sign' },
1714
+ { name: 'IF', hint: 'One answer or the other' },
1715
+ { name: 'AND', hint: 'True when all of them are' },
1716
+ { name: 'OR', hint: 'True when any of them is' },
1717
+ { name: 'NOT', hint: 'The opposite' },
1718
+ { name: 'CONCAT', hint: 'Joins text together' },
1719
+ { name: 'LEN', hint: 'How long a piece of text is' }
1720
+ ];
1721
+ /**
1722
+ * The word the caret is in the middle of.
1723
+ *
1724
+ * Inside brackets it is a column being named — `[pri` — and everywhere else it is a function, or
1725
+ * the beginning of one. Everything before the last operator, bracket or space is settled and is
1726
+ * left alone.
1727
+ */
1728
+ function formulaFragment(draft, caret = draft.length) {
1729
+ const upTo = draft.slice(0, caret);
1730
+ const opened = upTo.lastIndexOf('[');
1731
+ const closed = upTo.lastIndexOf(']');
1732
+ if (opened > closed) {
1733
+ return { start: opened + 1, text: upTo.slice(opened + 1), inBrackets: true };
1734
+ }
1735
+ const match = /[A-Za-z_][A-Za-z0-9_]*$/.exec(upTo);
1736
+ return match ? { start: match.index, text: match[0], inBrackets: false } : { start: caret, text: '', inBrackets: false };
1737
+ }
1738
+ /**
1739
+ * What to offer for a draft, in the order it should be shown.
1740
+ *
1741
+ * Columns first while inside brackets, functions first outside them, because that is what the
1742
+ * reader is asking for by having typed the bracket at all. An empty fragment offers everything:
1743
+ * somebody who has just typed `=` is exactly the person who needs the list most.
1744
+ *
1745
+ * @param draft - The formula as it stands, `=` and all.
1746
+ * @param vocabulary - The aliases the sheet has.
1747
+ * @param caret - Where the caret is; the end of the draft when not given.
1748
+ */
1749
+ function suggestFormula(draft, vocabulary, caret = draft.length) {
1750
+ if (!draft.trimStart().startsWith('=')) {
1751
+ return [];
1752
+ }
1753
+ const fragment = formulaFragment(draft, caret);
1754
+ const needle = fragment.text.toLocaleLowerCase();
1755
+ const matches = (candidate) => !needle || candidate.toLocaleLowerCase().startsWith(needle);
1756
+ const columns = vocabulary.aliases
1757
+ .filter((alias) => matches(alias))
1758
+ .map((alias) => ({
1759
+ label: alias,
1760
+ kind: 'column',
1761
+ // Inside brackets the brackets are already there; outside, they come with it.
1762
+ insert: fragment.inBrackets ? alias : `[${alias}]`,
1763
+ hint: vocabulary.titles?.[alias]
1764
+ }));
1765
+ const functions = fragment.inBrackets
1766
+ ? []
1767
+ : HUB_FORMULA_FUNCTIONS.filter((entry) => matches(entry.name)).map((entry) => ({
1768
+ label: entry.name,
1769
+ kind: 'function',
1770
+ insert: `${entry.name}(`,
1771
+ hint: entry.hint
1772
+ }));
1773
+ return fragment.inBrackets ? columns : [...functions, ...columns];
1774
+ }
1775
+ /**
1776
+ * The draft with a suggestion put in, and where the caret should end up.
1777
+ *
1778
+ * The caret matters more than it looks: dropped at the end of `SUM(` it carries straight on into
1779
+ * the brackets, and dropped after `[price]` it is ready for the operator. Leaving it where it was
1780
+ * makes the reader move it by hand every single time.
1781
+ */
1782
+ function applySuggestion(draft, suggestion, caret = draft.length) {
1783
+ const fragment = formulaFragment(draft, caret);
1784
+ const before = draft.slice(0, fragment.start);
1785
+ const after = draft.slice(caret);
1786
+ // A closing bracket the reader has already typed is not doubled.
1787
+ const trailing = fragment.inBrackets && after.startsWith(']') ? after.slice(1) : after;
1788
+ const insert = fragment.inBrackets ? `${suggestion.insert}]` : suggestion.insert;
1789
+ return { draft: before + insert + trailing, caret: before.length + insert.length };
1790
+ }
1791
+
1792
+ /**
1793
+ * The optional adapter a sheet renders its list cells through.
1794
+ *
1795
+ * Injected with `{ optional: true }`: absent means "draw the native list", which is what the
1796
+ * library does on its own. Register it with {@link provideHubSpreadsheetControls}.
1797
+ */
1798
+ const HUB_SPREADSHEET_CONTROLS = new InjectionToken('HUB_SPREADSHEET_CONTROLS');
1799
+
1800
+ /**
1801
+ * Renders a cell's editor through the optional {@link HUB_SPREADSHEET_CONTROLS} adapter.
1802
+ *
1803
+ * Placed on an `<ng-container>` in the branch that only runs when an adapter is registered; the
1804
+ * native list stays in the branch beside it as the answer that needs nothing installed. The
1805
+ * control is created as a sibling of the anchor and torn down with it.
1806
+ *
1807
+ * The config is read once, when the editor opens. A cell editor lives for one decision — it opens,
1808
+ * the reader chooses, it closes — so there is nothing to keep in sync afterwards, and the value
1809
+ * arriving from elsewhere mid-edit is the case the sheet already refuses.
1810
+ */
1811
+ class HubSpreadsheetControlDirective {
1812
+ /** What to build. */
1813
+ config = input.required({ ...(ngDevMode ? { debugName: "config" } : /* istanbul ignore next */ {}), alias: 'hubSpreadsheetControl' });
1814
+ vcr = inject(ViewContainerRef);
1815
+ adapter = inject(HUB_SPREADSHEET_CONTROLS, { optional: true });
1816
+ handle = null;
1817
+ constructor() {
1818
+ // After the cell has been laid out, so a control that measures itself against its host —
1819
+ // a panel deciding whether to open upwards — reads a box that is already the cell's.
1820
+ afterNextRender(() => {
1821
+ this.handle = this.adapter?.create(this.vcr, this.config()) ?? null;
1822
+ });
1823
+ }
1824
+ ngOnDestroy() {
1825
+ this.handle?.destroy();
1826
+ this.handle = null;
1827
+ }
1828
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetControlDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1829
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: HubSpreadsheetControlDirective, isStandalone: true, selector: "[hubSpreadsheetControl]", inputs: { config: { classPropertyName: "config", publicName: "hubSpreadsheetControl", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
1830
+ }
1831
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetControlDirective, decorators: [{
1832
+ type: Directive,
1833
+ args: [{
1834
+ selector: '[hubSpreadsheetControl]'
1835
+ }]
1836
+ }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubSpreadsheetControl", required: true }] }] } });
1837
+
1838
+ /** Whether two coordinates are the same cell. */
1839
+ function sameCell(a, b) {
1840
+ return a.row === b.row && a.col === b.col;
1841
+ }
1842
+ /** The rectangle a block occupies. */
1843
+ function boundsOf(span) {
1844
+ return {
1845
+ top: span.row,
1846
+ left: span.col,
1847
+ bottom: span.row + span.rowSpan - 1,
1848
+ right: span.col + span.colSpan - 1
1849
+ };
1850
+ }
1851
+ /** Whether two rectangles share at least one cell. */
1852
+ function overlaps(a, b) {
1853
+ return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom;
1854
+ }
1855
+ /** The blocks that share a cell with a rectangle, in the order they were declared. */
1856
+ function spansWithin(spans, range) {
1857
+ return spans.filter((span) => overlaps(boundsOf(span), range));
1858
+ }
1859
+ /**
1860
+ * The block a selection would become, and the blocks it swallows.
1861
+ *
1862
+ * `null` for a selection of one cell that is not already a block: there is nothing to merge, and
1863
+ * an entry that does nothing should not be offered.
1864
+ */
1865
+ function mergeRequestFor(spans, range) {
1866
+ const rowSpan = range.bottom - range.top + 1;
1867
+ const colSpan = range.right - range.left + 1;
1868
+ if (rowSpan < 1 || colSpan < 1 || (rowSpan === 1 && colSpan === 1)) {
1869
+ return null;
1870
+ }
1871
+ return {
1872
+ span: { row: range.top, col: range.left, rowSpan, colSpan },
1873
+ replaced: spansWithin(spans, range).map((span) => ({ row: span.row, col: span.col }))
1874
+ };
1875
+ }
1876
+ /**
1877
+ * The list of blocks after a merge.
1878
+ *
1879
+ * The swallowed ones are dropped rather than left behind: two blocks claiming the same cell is a
1880
+ * sheet that draws one of them and loses the other, and which one depends on the order they happen
1881
+ * to be declared in.
1882
+ */
1883
+ function applySpanMerge(spans, request) {
1884
+ const kept = spans.filter((span) => !request.replaced.some((anchor) => sameCell(anchor, span)));
1885
+ return [...kept, request.span];
1886
+ }
1887
+ /** The list of blocks after the ones anchored at `anchors` are taken apart. */
1888
+ function applySpanUnmerge(spans, anchors) {
1889
+ return spans.filter((span) => !anchors.some((anchor) => sameCell(anchor, span)));
1890
+ }
1891
+
1892
+ /**
1893
+ * The height a row is assumed to have on the very first frame of a virtualised sheet, in pixels.
1894
+ *
1895
+ * Only ever used once: there is nothing to measure until something is drawn, and what is drawn
1896
+ * depends on the height. A rough number breaks that circle, and the measurement that follows
1897
+ * replaces it on the next frame. Being wrong costs a few rows too many or too few for one frame;
1898
+ * waiting for certainty costs drawing every row there is.
1899
+ */
1900
+ const FIRST_PASS_ROW_HEIGHT = 32;
1901
+ /**
1902
+ * An editable sheet of cells: typed into, moved through with the keyboard, and pasted into from
1903
+ * a spreadsheet.
1904
+ *
1905
+ * It knows nothing about what the cells mean. The owner describes each column, keeps the save
1906
+ * state it cares about, and does the writing when the sheet reports a change. The sheet never
1907
+ * mutates the rows it is given, which is what lets the same component serve an invoice, a price
1908
+ * list and a timesheet without learning about any of them.
1909
+ *
1910
+ * Focus lives on the cells themselves, with one tab stop that travels — the arrangement screen
1911
+ * readers expect from a grid. Pasting is the exception: a browser only dispatches `paste` to an
1912
+ * editable element, so the paste shortcut hands focus to an off-screen field for the length of
1913
+ * the event and takes it straight back.
1914
+ */
1915
+ class HubSpreadsheetComponent {
1916
+ #host = inject(ElementRef);
1917
+ #injector = inject(Injector);
1918
+ /** The rows, as the owner holds them. Never written to. */
1919
+ rows = input.required(/* @ts-ignore */
1920
+ ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
1921
+ /** What each column shows and allows. */
1922
+ columns = input.required(/* @ts-ignore */
1923
+ ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
1924
+ /** A stable name per row, so a save state follows its row across a reload. */
1925
+ rowKey = input.required(/* @ts-ignore */
1926
+ ...(ngDevMode ? [{ debugName: "rowKey" }] : /* istanbul ignore next */ []));
1927
+ /** The save state of each cell, keyed by `rowKey` and column alias joined by a tab. */
1928
+ states = input({}, /* @ts-ignore */
1929
+ ...(ngDevMode ? [{ debugName: "states" }] : /* istanbul ignore next */ []));
1930
+ /**
1931
+ * A validation message per cell, keyed the same way as {@link states}.
1932
+ *
1933
+ * Owned by the host, because validation belongs to whoever knows what the data means. Keeping
1934
+ * it a plain map rather than tying it to one forms API is what lets the same sheet be driven
1935
+ * by Signal Forms, by reactive forms, or by nothing at all.
1936
+ */
1937
+ errors = input({}, /* @ts-ignore */
1938
+ ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
1939
+ /** The character this reader types decimals with. */
1940
+ decimalMark = input(',', /* @ts-ignore */
1941
+ ...(ngDevMode ? [{ debugName: "decimalMark" }] : /* istanbul ignore next */ []));
1942
+ /** What an empty sheet says. */
1943
+ emptyText = input('', /* @ts-ignore */
1944
+ ...(ngDevMode ? [{ debugName: "emptyText" }] : /* istanbul ignore next */ []));
1945
+ /** How many rows Page Up and Page Down travel. */
1946
+ pageSize = input(10, /* @ts-ignore */
1947
+ ...(ngDevMode ? [{ debugName: "pageSize" }] : /* istanbul ignore next */ []));
1948
+ /** Turns off every editor, whatever the individual cells say. */
1949
+ readonly = input(false, { ...(ngDevMode ? { debugName: "readonly" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1950
+ /**
1951
+ * How many columns stay pinned against the leading edge while the sheet scrolls sideways.
1952
+ *
1953
+ * Counted from the start, the way a spreadsheet freezes panes, rather than marked one by one:
1954
+ * a set of frozen columns with a gap in it cannot be laid out, so the shape of the input rules
1955
+ * the mistake out instead of validating against it.
1956
+ */
1957
+ frozenColumns = input(0, /* @ts-ignore */
1958
+ ...(ngDevMode ? [{ debugName: "frozenColumns" }] : /* istanbul ignore next */ []));
1959
+ /** How many rows stay pinned below the header while the sheet scrolls down. */
1960
+ frozenRows = input(0, /* @ts-ignore */
1961
+ ...(ngDevMode ? [{ debugName: "frozenRows" }] : /* istanbul ignore next */ []));
1962
+ /**
1963
+ * Cells merged into blocks, each named by its top-left corner.
1964
+ *
1965
+ * Owned by the host, which is why they come in as an input and why the reader's merging goes
1966
+ * back out as a request rather than being done here — see {@link mergeable}. A declared block
1967
+ * is treated as one cell everywhere it matters: the cursor lands on the block rather than
1968
+ * inside it, a selection that would clip one grows to contain it, and the covered cells are
1969
+ * not drawn at all.
1970
+ */
1971
+ spans = input([], /* @ts-ignore */
1972
+ ...(ngDevMode ? [{ debugName: "spans" }] : /* istanbul ignore next */ []));
1973
+ /** Which structural changes the reader may ask for. Everything is refused by default. */
1974
+ structure = input({}, /* @ts-ignore */
1975
+ ...(ngDevMode ? [{ debugName: "structure" }] : /* istanbul ignore next */ []));
1976
+ /** Editors supplied by the host, one per column alias. */
1977
+ customEditors = contentChildren(HubSpreadsheetEditorDirective, /* @ts-ignore */
1978
+ ...(ngDevMode ? [{ debugName: "customEditors" }] : /* istanbul ignore next */ []));
1979
+ /** Cell templates supplied by the host, for what a column shows while it is not being edited. */
1980
+ customCells = contentChildren(HubSpreadsheetCellDirective, /* @ts-ignore */
1981
+ ...(ngDevMode ? [{ debugName: "customCells" }] : /* istanbul ignore next */ []));
1982
+ /** Aliases retired by earlier deletions, so a new column never reuses one. */
1983
+ retiredColumnKeys = input([], /* @ts-ignore */
1984
+ ...(ngDevMode ? [{ debugName: "retiredColumnKeys" }] : /* istanbul ignore next */ []));
1985
+ /** Whether the sheet offers the fill handle at the corner of the selection. */
1986
+ fillHandle = input(false, { ...(ngDevMode ? { debugName: "fillHandle" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1987
+ /** Whether undo is available; the sheet only asks, the owner keeps the history. */
1988
+ canUndo = input(false, { ...(ngDevMode ? { debugName: "canUndo" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1989
+ /** Whether redo is available. */
1990
+ canRedo = input(false, { ...(ngDevMode ? { debugName: "canRedo" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1991
+ /** Whether a reader may drag a column's trailing edge to widen or narrow it. */
1992
+ resizableColumns = input(false, { ...(ngDevMode ? { debugName: "resizableColumns" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1993
+ /** Whether a reader may drag a header to put the column somewhere else. */
1994
+ reorderableColumns = input(false, { ...(ngDevMode ? { debugName: "reorderableColumns" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1995
+ /**
1996
+ * The width of each column in pixels, keyed by alias.
1997
+ *
1998
+ * Two-way, and view state rather than data: the sheet writes it as the reader drags, and the
1999
+ * owner may persist it so a layout survives a reload. A column with no entry keeps whatever
2000
+ * width its content asks for.
2001
+ */
2002
+ columnWidths = model({}, /* @ts-ignore */
2003
+ ...(ngDevMode ? [{ debugName: "columnWidths" }] : /* istanbul ignore next */ []));
2004
+ /** The narrowest a column may be dragged. */
2005
+ minColumnWidth = input(48, /* @ts-ignore */
2006
+ ...(ngDevMode ? [{ debugName: "minColumnWidth" }] : /* istanbul ignore next */ []));
2007
+ /**
2008
+ * Whether a right-click offers the menu of structural changes.
2009
+ *
2010
+ * Off unless asked for, and each entry still has to pass `structure`. A menu that lists what
2011
+ * the reader is not allowed to do is worse than no menu.
2012
+ */
2013
+ contextMenu = input(false, { ...(ngDevMode ? { debugName: "contextMenu" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2014
+ /**
2015
+ * Whether the reader may put cells together and take them apart again.
2016
+ *
2017
+ * Off unless asked for, like every other structural change: a sheet whose shape is part of the
2018
+ * document it belongs to should not be reshaped by a stray right-click. The blocks stay the
2019
+ * host's — the sheet reports what was asked for through {@link mergeRequested} and
2020
+ * {@link unmergeRequested} and writes nothing itself. Offered through the context menu, so it
2021
+ * needs {@link contextMenu} to be reachable with the pointer.
2022
+ */
2023
+ mergeable = input(false, { ...(ngDevMode ? { debugName: "mergeable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2024
+ /**
2025
+ * Whether a cell whose value starts with `=` is a formula.
2026
+ *
2027
+ * Off unless asked for, and not as caution for its own sake: a column of text may perfectly
2028
+ * well hold `=1+1` as the thing somebody wrote, and a sheet that decides to do arithmetic on
2029
+ * it has lost the reader's data.
2030
+ *
2031
+ * Formulas read columns by their alias — `=[price] * [units]`, `=SUM([total:])` — never by a
2032
+ * coordinate, which is what the aliases were for: move a column, rename its header, and every
2033
+ * formula still means what it said.
2034
+ *
2035
+ * The cell shows the answer; the editor shows the formula, so it can be corrected rather than
2036
+ * retyped; and the clipboard carries what the cell holds, which is the formula.
2037
+ */
2038
+ formulas = input(false, { ...(ngDevMode ? { debugName: "formulas" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2039
+ /**
2040
+ * Whether to draw only the rows the reader can see.
2041
+ *
2042
+ * Off unless asked for. A sheet of a few hundred rows is better off whole — every row is in the
2043
+ * document, so the browser's own find works, printing works, and nothing depends on a
2044
+ * measurement. Past a few thousand it stops being a choice: the first paint arrives late and
2045
+ * every keystroke pays for cells nobody is looking at.
2046
+ *
2047
+ * What it costs: the rows out of view are not in the document, so `Ctrl+F` and printing only
2048
+ * reach what is drawn, and the rows have to be of one height — see {@link rowHeight}.
2049
+ */
2050
+ virtual = input(false, { ...(ngDevMode ? { debugName: "virtual" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2051
+ /**
2052
+ * How tall a row is, in pixels, when {@link virtual} is on.
2053
+ *
2054
+ * Nought means "measure the first one drawn", which is right whenever the rows are alike. Say
2055
+ * it outright for a sheet whose first row is not typical, or one that is built while hidden —
2056
+ * a tab that is not open measures every row as nought, and a window worked out from nought is
2057
+ * the whole sheet.
2058
+ */
2059
+ rowHeight = input(0, /* @ts-ignore */
2060
+ ...(ngDevMode ? [{ debugName: "rowHeight" }] : /* istanbul ignore next */ []));
2061
+ /**
2062
+ * What opens the editor with the pointer.
2063
+ *
2064
+ * `double-click` by default, the spreadsheet convention: a click picks the cell and leaves its
2065
+ * value where it is. Selecting is the gesture a reader makes most — to copy, to look, to drag
2066
+ * out a range — and turning every one of them into an open field puts the value at the mercy
2067
+ * of the next keystroke. Typing is still the short way in: with the cell picked, the first
2068
+ * character opens the editor and lands in it, so nothing is lost by not opening on the click.
2069
+ *
2070
+ * `click` opens on the first one, for a sheet that exists to be typed into and where the two
2071
+ * gestures per cell are the tax.
2072
+ *
2073
+ * Either way the editor opens on release and only when the pointer did not travel, so
2074
+ * dragging out a range still works — and Shift-clicking still extends rather than edits.
2075
+ */
2076
+ editOn = input('double-click', /* @ts-ignore */
2077
+ ...(ngDevMode ? [{ debugName: "editOn" }] : /* istanbul ignore next */ []));
2078
+ /** A cell took a new value. */
2079
+ commit = output();
2080
+ /** A block was pasted. */
2081
+ pasted = output();
2082
+ /** Delete was pressed over these cells, the editable ones of the selection. */
2083
+ cleared = output();
2084
+ /** The selected rectangle changed; null when nothing is selected. */
2085
+ selectionChange = output();
2086
+ /** The reader asked to add rows or columns. */
2087
+ insertRequested = output();
2088
+ /** The reader asked to remove rows or columns. */
2089
+ deleteRequested = output();
2090
+ /**
2091
+ * A drag of the fill handle finished.
2092
+ *
2093
+ * Reported as one event for the whole gesture, so an owner keeping a history can make it one
2094
+ * undo step. Filling cell by cell and undoing cell by cell is experienced as a bug.
2095
+ */
2096
+ filled = output();
2097
+ /** The reader asked to undo. The owner holds the history and decides what that means. */
2098
+ undoRequested = output();
2099
+ /** The reader asked to redo. */
2100
+ redoRequested = output();
2101
+ /**
2102
+ * The reader dropped a column somewhere else.
2103
+ *
2104
+ * Reported rather than applied: the column list belongs to the owner, as the rows do. The
2105
+ * payload carries the reordered aliases so acting on it is a single assignment.
2106
+ */
2107
+ columnMoved = output();
2108
+ /**
2109
+ * The reader asked to put a selection together into one block.
2110
+ *
2111
+ * The payload carries the block to create and the anchors of the blocks it swallows, because a
2112
+ * host that merely appends the new one ends up with two claiming the same cell — and which of
2113
+ * them is drawn depends on the order they were declared in. `applySpanMerge()` does the whole
2114
+ * thing in a line.
2115
+ */
2116
+ mergeRequested = output();
2117
+ /** The reader asked to take apart the blocks their selection touches, named by their anchors. */
2118
+ unmergeRequested = output();
2119
+ /** The cell the cursor is on. */
2120
+ active = signal(null, /* @ts-ignore */
2121
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
2122
+ /** The other corner of the selection; null when only the active cell is selected. */
2123
+ anchor = signal(null, /* @ts-ignore */
2124
+ ...(ngDevMode ? [{ debugName: "anchor" }] : /* istanbul ignore next */ []));
2125
+ editing = signal(false, /* @ts-ignore */
2126
+ ...(ngDevMode ? [{ debugName: "editing" }] : /* istanbul ignore next */ []));
2127
+ draft = signal('', /* @ts-ignore */
2128
+ ...(ngDevMode ? [{ debugName: "draft" }] : /* istanbul ignore next */ []));
2129
+ /**
2130
+ * The control adapter, when the project registered one.
2131
+ *
2132
+ * Optional on purpose: without it the sheet draws its own native list, which is what makes the
2133
+ * library installable on its own.
2134
+ */
2135
+ #controls = inject(HUB_SPREADSHEET_CONTROLS, { optional: true });
2136
+ /**
2137
+ * The character that opened the cell, when typing opened it.
2138
+ *
2139
+ * Kept beside the draft rather than inside it because a supplied editor needs it as it was
2140
+ * typed, while the draft has already been turned into whatever the column stores.
2141
+ */
2142
+ seed = signal(undefined, /* @ts-ignore */
2143
+ ...(ngDevMode ? [{ debugName: "seed" }] : /* istanbul ignore next */ []));
2144
+ /** The editor holds text that does not read as a value, so it stays open and marked. */
2145
+ invalid = signal(false, /* @ts-ignore */
2146
+ ...(ngDevMode ? [{ debugName: "invalid" }] : /* istanbul ignore next */ []));
2147
+ dragging = false;
2148
+ /** Whether the pointer visited another cell during this press, which makes it a drag. */
2149
+ dragMoved = false;
2150
+ /** The selection a fill drag started from; null when no fill is in progress. */
2151
+ fillSource = signal(null, /* @ts-ignore */
2152
+ ...(ngDevMode ? [{ debugName: "fillSource" }] : /* istanbul ignore next */ []));
2153
+ /** What the fill would cover if released now. */
2154
+ fillTarget = signal(null, /* @ts-ignore */
2155
+ ...(ngDevMode ? [{ debugName: "fillTarget" }] : /* istanbul ignore next */ []));
2156
+ /** The column being widened, with where the pointer started and how wide it then was. */
2157
+ resizing = null;
2158
+ /** The column being dragged to another place. */
2159
+ movingColumn = signal(null, /* @ts-ignore */
2160
+ ...(ngDevMode ? [{ debugName: "movingColumn" }] : /* istanbul ignore next */ []));
2161
+ /** Where it would land if dropped now. */
2162
+ moveTargetColumn = signal(null, /* @ts-ignore */
2163
+ ...(ngDevMode ? [{ debugName: "moveTargetColumn" }] : /* istanbul ignore next */ []));
2164
+ /** Where the structural menu sits, in pixels from the sheet's own corner; null when closed. */
2165
+ menuAt = signal(null, /* @ts-ignore */
2166
+ ...(ngDevMode ? [{ debugName: "menuAt" }] : /* istanbul ignore next */ []));
2167
+ /** Where each frozen column sits, in pixels from the leading edge. */
2168
+ columnOffsets = signal([], /* @ts-ignore */
2169
+ ...(ngDevMode ? [{ debugName: "columnOffsets" }] : /* istanbul ignore next */ []));
2170
+ /** Where each frozen row sits, in pixels from the top of the scroll area. */
2171
+ rowOffsets = signal([], /* @ts-ignore */
2172
+ ...(ngDevMode ? [{ debugName: "rowOffsets" }] : /* istanbul ignore next */ []));
2173
+ /** The merges, indexed once per change rather than scanned on every keystroke. */
2174
+ spanMap = computed(() => buildSpanMap(this.spans()), /* @ts-ignore */
2175
+ ...(ngDevMode ? [{ debugName: "spanMap" }] : /* istanbul ignore next */ []));
2176
+ bounds = computed(() => ({
2177
+ rows: this.rows().length,
2178
+ cols: this.columns().length
2179
+ }), /* @ts-ignore */
2180
+ ...(ngDevMode ? [{ debugName: "bounds" }] : /* istanbul ignore next */ []));
2181
+ /**
2182
+ * What the viewport has told us about itself: how far it has scrolled and how tall it is.
2183
+ *
2184
+ * Nought until the first measurement, which is why {@link gridWindow} draws a first screenful
2185
+ * from nothing rather than drawing nothing: the rows it puts in are what the measurement then
2186
+ * measures.
2187
+ */
2188
+ viewport = viewChild('viewport', /* @ts-ignore */
2189
+ ...(ngDevMode ? [{ debugName: "viewport" }] : /* istanbul ignore next */ []));
2190
+ scrollTop = signal(0, /* @ts-ignore */
2191
+ ...(ngDevMode ? [{ debugName: "scrollTop" }] : /* istanbul ignore next */ []));
2192
+ viewportHeight = signal(0, /* @ts-ignore */
2193
+ ...(ngDevMode ? [{ debugName: "viewportHeight" }] : /* istanbul ignore next */ []));
2194
+ /** The height of a row, measured off the first one drawn unless the host declared it. */
2195
+ measuredRowHeight = signal(0, /* @ts-ignore */
2196
+ ...(ngDevMode ? [{ debugName: "measuredRowHeight" }] : /* istanbul ignore next */ []));
2197
+ /** How far the viewport has travelled sideways, and how much of it is on screen. */
2198
+ scrollLeft = signal(0, /* @ts-ignore */
2199
+ ...(ngDevMode ? [{ debugName: "scrollLeft" }] : /* istanbul ignore next */ []));
2200
+ viewportWidth = signal(0, /* @ts-ignore */
2201
+ ...(ngDevMode ? [{ debugName: "viewportWidth" }] : /* istanbul ignore next */ []));
2202
+ /** The width of every column, by index, as last measured. */
2203
+ measuredColumnWidths = signal([], /* @ts-ignore */
2204
+ ...(ngDevMode ? [{ debugName: "measuredColumnWidths" }] : /* istanbul ignore next */ []));
2205
+ /** The rows to draw: the frozen ones, then the window, or all of them when not virtualising. */
2206
+ rowWindow = computed(() => {
2207
+ const total = this.rows().length;
2208
+ const frozen = Math.min(this.frozenRows(), total);
2209
+ if (!this.virtual()) {
2210
+ return { start: frozen, end: total - 1, before: 0, after: 0 };
2211
+ }
2212
+ // Nothing measured and nothing declared, which is only ever the first frame: a rough height
2213
+ // rather than the whole sheet. Drawing it whole once is exactly the cost virtualising is
2214
+ // here to avoid — ten thousand rows took a minute and a half of it — and the measurement
2215
+ // that follows lands on the real height before anybody sees the difference.
2216
+ const size = this.rowHeight() || this.measuredRowHeight() || FIRST_PASS_ROW_HEIGHT;
2217
+ const window = gridWindow({
2218
+ offset: this.scrollTop(),
2219
+ viewport: this.viewportHeight(),
2220
+ sizes: size,
2221
+ count: total,
2222
+ pinned: frozen
2223
+ });
2224
+ return growWindowToSpans(window, this.spans(), 'row', size, { count: total, pinned: frozen });
2225
+ }, /* @ts-ignore */
2226
+ ...(ngDevMode ? [{ debugName: "rowWindow" }] : /* istanbul ignore next */ []));
2227
+ /**
2228
+ * The columns to draw, the same way as the rows.
2229
+ *
2230
+ * Their widths are measured rather than declared, so the first render draws them all — there
2231
+ * is nothing to measure otherwise — and every render after that windows them with what the
2232
+ * first one learnt. A column the sheet has never drawn has no width, and one unknown width is
2233
+ * enough to fall back to drawing the lot: a window is only as honest as the sizes it is built
2234
+ * from.
2235
+ */
2236
+ columnWindow = computed(() => {
2237
+ const total = this.columns().length;
2238
+ const frozen = Math.min(this.frozenColumns(), total);
2239
+ const everything = { start: frozen, end: total - 1, before: 0, after: 0 };
2240
+ if (!this.virtual()) {
2241
+ return everything;
2242
+ }
2243
+ const widths = this.measuredColumnWidths();
2244
+ if (widths.length !== total || widths.some((width) => !(width > 0))) {
2245
+ return everything;
2246
+ }
2247
+ const window = gridWindow({
2248
+ offset: this.scrollLeft(),
2249
+ viewport: this.viewportWidth(),
2250
+ sizes: widths,
2251
+ pinned: frozen
2252
+ });
2253
+ return growWindowToSpans(window, this.spans(), 'col', widths, { pinned: frozen });
2254
+ }, /* @ts-ignore */
2255
+ ...(ngDevMode ? [{ debugName: "columnWindow" }] : /* istanbul ignore next */ []));
2256
+ /** The column indices in the document, frozen ones first. */
2257
+ drawnColumns = computed(() => {
2258
+ const total = this.columns().length;
2259
+ const frozen = Math.min(this.frozenColumns(), total);
2260
+ const window = this.columnWindow();
2261
+ const drawn = [];
2262
+ for (let index = 0; index < frozen; index++) {
2263
+ drawn.push(index);
2264
+ }
2265
+ for (let index = window.start; index <= window.end; index++) {
2266
+ drawn.push(index);
2267
+ }
2268
+ return drawn;
2269
+ }, /* @ts-ignore */
2270
+ ...(ngDevMode ? [{ debugName: "drawnColumns" }] : /* istanbul ignore next */ []));
2271
+ /**
2272
+ * How many cells a row of the sheet actually holds, spacers included.
2273
+ *
2274
+ * What a spacer row has to span. `bounds().cols` would be wrong whenever the columns are
2275
+ * windowed — it counts the columns the sheet has, not the ones in the document — and a colspan
2276
+ * that overshoots stretches the table wider than its own header.
2277
+ */
2278
+ drawnCellCount = computed(() => {
2279
+ const window = this.columnWindow();
2280
+ return this.drawnColumns().length + (window.before > 0 ? 1 : 0) + (window.after > 0 ? 1 : 0);
2281
+ }, /* @ts-ignore */
2282
+ ...(ngDevMode ? [{ debugName: "drawnCellCount" }] : /* istanbul ignore next */ []));
2283
+ /** The row indices in the document, frozen ones first. */
2284
+ drawnRows = computed(() => {
2285
+ const total = this.rows().length;
2286
+ const frozen = Math.min(this.frozenRows(), total);
2287
+ const window = this.rowWindow();
2288
+ const drawn = [];
2289
+ for (let index = 0; index < frozen; index++) {
2290
+ drawn.push(index);
2291
+ }
2292
+ for (let index = window.start; index <= window.end; index++) {
2293
+ drawn.push(index);
2294
+ }
2295
+ return drawn;
2296
+ }, /* @ts-ignore */
2297
+ ...(ngDevMode ? [{ debugName: "drawnRows" }] : /* istanbul ignore next */ []));
2298
+ /** Every cell, worked out once per change of rows or columns rather than on every paint. */
2299
+ grid = computed(() => {
2300
+ const columns = this.columns();
2301
+ const cells = this.rows().map((row) => columns.map((column) => column.cell(row)));
2302
+ if (!this.formulas()) {
2303
+ return cells;
2304
+ }
2305
+ const places = new Map(columns.map((column, index) => [column.key, index]));
2306
+ const sheet = evaluateSheet({
2307
+ rows: this.rows(),
2308
+ columns,
2309
+ raw: (rowIndex, column) => cells[rowIndex]?.[places.get(column.key) ?? -1]?.value ?? null
2310
+ });
2311
+ return cells.map((line, rowIndex) => line.map((cell, colIndex) => {
2312
+ const column = columns[colIndex];
2313
+ // Either the row holds a formula, or the column carries one for every row.
2314
+ if (!isFormula(cell.value) && !column.formula) {
2315
+ return cell;
2316
+ }
2317
+ const result = sheet[rowIndex]?.[column.key];
2318
+ // The value stays the formula: it is what the editor opens on and what the
2319
+ // clipboard carries. What changes is what the cell shows.
2320
+ return {
2321
+ ...cell,
2322
+ text: result?.failure ? formulaErrorText(result.failure) : displayValue(result?.value ?? null, column)
2323
+ };
2324
+ }));
2325
+ }, /* @ts-ignore */
2326
+ ...(ngDevMode ? [{ debugName: "grid" }] : /* istanbul ignore next */ []));
2327
+ /** Which suggestion the keyboard is on, or -1 when the reader has not moved into the list. */
2328
+ suggestionAt = signal(-1, /* @ts-ignore */
2329
+ ...(ngDevMode ? [{ debugName: "suggestionAt" }] : /* istanbul ignore next */ []));
2330
+ /**
2331
+ * What to offer while a formula is being written, or nothing when one is not.
2332
+ *
2333
+ * A formula names columns by an alias the sheet never shows — the header carries a title meant
2334
+ * for people — so without this, writing one means knowing something that is not on screen.
2335
+ */
2336
+ suggestions = computed(() => {
2337
+ if (!this.formulas() || !this.editing()) {
2338
+ return [];
2339
+ }
2340
+ const columns = this.columns();
2341
+ return suggestFormula(this.draft(), {
2342
+ aliases: columns.map((column) => column.key),
2343
+ titles: Object.fromEntries(columns.map((column) => [column.key, column.header]))
2344
+ });
2345
+ }, /* @ts-ignore */
2346
+ ...(ngDevMode ? [{ debugName: "suggestions" }] : /* istanbul ignore next */ []));
2347
+ /** Whether the sheet is showing the list of what can go into a formula. */
2348
+ suggesting = computed(() => this.suggestions().length > 0, /* @ts-ignore */
2349
+ ...(ngDevMode ? [{ debugName: "suggesting" }] : /* istanbul ignore next */ []));
2350
+ /**
2351
+ * The one the keyboard is on, which Enter and Tab put in.
2352
+ *
2353
+ * The first is taken as chosen the moment there is a word being typed, so `=SU` and Tab
2354
+ * finishes it — which is what completing means, and what a reader who has seen a spreadsheet
2355
+ * expects. Nothing is chosen when there is no word: a bare `=`, or a formula that has just
2356
+ * closed a bracket, is a list to look at rather than a completion to accept, and Enter there
2357
+ * means commit.
2358
+ */
2359
+ suggestionIndex = computed(() => {
2360
+ const moved = this.suggestionAt();
2361
+ if (moved >= 0 && moved < this.suggestions().length) {
2362
+ return moved;
2363
+ }
2364
+ return formulaFragment(this.draft()).text && this.suggestions().length ? 0 : -1;
2365
+ }, /* @ts-ignore */
2366
+ ...(ngDevMode ? [{ debugName: "suggestionIndex" }] : /* istanbul ignore next */ []));
2367
+ /** Whether a formula is being written, which is when the aliases are worth showing. */
2368
+ writingFormula = computed(() => this.formulas() && this.editing() && isFormula(this.draft()), /* @ts-ignore */
2369
+ ...(ngDevMode ? [{ debugName: "writingFormula" }] : /* istanbul ignore next */ []));
2370
+ /** The letters a column answers to in a formula: A, B … Z, AA. */
2371
+ columnLetter(index) {
2372
+ return columnLabel(index);
2373
+ }
2374
+ /** Puts a suggestion into the draft and leaves the caret where the next thing is typed. */
2375
+ useSuggestion(suggestion) {
2376
+ const editor = this.#host.nativeElement.querySelector('input.hub-spreadsheet__editor');
2377
+ const caret = editor?.selectionStart ?? this.draft().length;
2378
+ const applied = applySuggestion(this.draft(), suggestion, caret);
2379
+ this.draft.set(applied.draft);
2380
+ this.suggestionAt.set(-1);
2381
+ if (editor) {
2382
+ editor.value = applied.draft;
2383
+ editor.focus();
2384
+ editor.setSelectionRange(applied.caret, applied.caret);
2385
+ }
2386
+ }
2387
+ /** Whether a cell is showing a formula that could not be worked out. */
2388
+ hasFormulaError(row, col) {
2389
+ const cell = this.grid()[row]?.[col];
2390
+ const fromColumn = !!this.columns()[col]?.formula;
2391
+ return !!cell && (isFormula(cell.value) || fromColumn) && FORMULA_ERRORS.includes(cell.text ?? '');
2392
+ }
2393
+ keys = computed(() => {
2394
+ const keyOf = this.rowKey();
2395
+ return this.rows().map((row) => keyOf(row));
2396
+ }, /* @ts-ignore */
2397
+ ...(ngDevMode ? [{ debugName: "keys" }] : /* istanbul ignore next */ []));
2398
+ range = computed(() => {
2399
+ const active = this.active();
2400
+ if (!active) {
2401
+ return null;
2402
+ }
2403
+ const base = clampGridRange(gridRangeBetween(this.anchor() ?? active, active), this.bounds());
2404
+ // A range that clips a merge cannot be copied, filled or cleared coherently, so it grows
2405
+ // until every block it touches is inside it — which is what Excel does too.
2406
+ return base ? clampGridRange(coversRange(this.spanMap(), base), this.bounds()) : null;
2407
+ }, /* @ts-ignore */
2408
+ ...(ngDevMode ? [{ debugName: "range" }] : /* istanbul ignore next */ []));
2409
+ /**
2410
+ * Keeps the cursor where it was when the rows are replaced by a reload after a save, and drops
2411
+ * it only once it points at nothing. Typing a value and pressing Enter has to land on the cell
2412
+ * below even when the saved figures come back as fresh objects.
2413
+ */
2414
+ reconcile = effect(() => {
2415
+ const { rows, cols } = this.bounds();
2416
+ untracked(() => {
2417
+ const active = this.active();
2418
+ if (active && (active.row >= rows || active.col >= cols)) {
2419
+ this.active.set(null);
2420
+ this.anchor.set(null);
2421
+ this.editing.set(false);
2422
+ }
2423
+ });
2424
+ }, /* @ts-ignore */
2425
+ ...(ngDevMode ? [{ debugName: "reconcile" }] : /* istanbul ignore next */ []));
2426
+ /**
2427
+ * Keeps the viewport's measurements up to date while virtualising.
2428
+ *
2429
+ * In the read phase, like the frozen offsets: reading layout while the browser is writing one
2430
+ * forces a reflow in the middle of it, which is how a grid loses frames exactly when it is
2431
+ * being scrolled.
2432
+ *
2433
+ * It settles rather than loops. Measuring feeds the window, the window changes what is drawn,
2434
+ * and the next pass measures the same numbers — a signal set to the value it already holds
2435
+ * tells nobody, so the third pass does not happen. It reads `drawnRows` so that a sheet whose
2436
+ * data arrives later is measured when its first row finally exists, which is the common case
2437
+ * and the one a single measurement at birth gets wrong.
2438
+ */
2439
+ measureViewport = afterRenderEffect({
2440
+ read: () => {
2441
+ if (!this.virtual()) {
2442
+ return;
2443
+ }
2444
+ this.drawnRows();
2445
+ this.onViewportScroll();
2446
+ }
2447
+ });
2448
+ /**
2449
+ * The width of every column, read off the headers by the index they carry.
2450
+ *
2451
+ * By index and not by position, because a windowed header holds the frozen columns and then
2452
+ * the ones in view — so the third header in the document is rarely the third column. A width
2453
+ * that is not on screen keeps whatever was measured last, which is what makes windowing the
2454
+ * columns possible at all: they are measured once, while the sheet is still drawing them all.
2455
+ */
2456
+ readColumnWidths() {
2457
+ const total = this.columns().length;
2458
+ const known = [...this.measuredColumnWidths()];
2459
+ known.length = total;
2460
+ for (const header of this.#host.nativeElement.querySelectorAll('.hub-spreadsheet__header')) {
2461
+ const index = Number(header.getAttribute('data-header'));
2462
+ const width = header.getBoundingClientRect().width;
2463
+ if (Number.isInteger(index) && index >= 0 && index < total && width > 0) {
2464
+ known[index] = width;
2465
+ }
2466
+ }
2467
+ const measured = Array.from(known, (width) => width ?? 0);
2468
+ const previous = this.measuredColumnWidths();
2469
+ // Only when something actually moved. A fresh array is a fresh reference, and a reference
2470
+ // set after every render is a render after every render: the measurement feeds the window,
2471
+ // the window is drawn, and the drawing measures again for ever.
2472
+ if (measured.length !== previous.length || measured.some((width, index) => width !== previous[index])) {
2473
+ this.measuredColumnWidths.set(measured);
2474
+ }
2475
+ return measured;
2476
+ }
2477
+ /**
2478
+ * Measures the frozen tracks after every render and turns the sizes into offsets.
2479
+ *
2480
+ * Reading layout has to happen in the read phase or it forces a synchronous reflow in the
2481
+ * middle of writing one, which is the classic way a grid loses frames while scrolling.
2482
+ */
2483
+ measureFrozen = afterRenderEffect({
2484
+ read: () => {
2485
+ const columns = this.frozenColumns();
2486
+ const rows = this.frozenRows();
2487
+ const widths = this.readColumnWidths();
2488
+ if (columns <= 0 && rows <= 0) {
2489
+ this.columnOffsets.set([]);
2490
+ this.rowOffsets.set([]);
2491
+ return;
2492
+ }
2493
+ const host = this.#host.nativeElement;
2494
+ const bodyRows = [...host.querySelectorAll('.hub-spreadsheet__row')];
2495
+ this.columnOffsets.set(frozenOffsets(widths, columns));
2496
+ // Frozen rows start below the header, which is itself pinned at the top.
2497
+ const headerHeight = host.querySelector('.hub-spreadsheet__header')?.getBoundingClientRect().height ?? 0;
2498
+ this.rowOffsets.set(frozenOffsets(bodyRows.map((row) => row.getBoundingClientRect().height), rows).map((offset) => offset + Math.round(headerHeight)));
2499
+ }
2500
+ });
2501
+ /**
2502
+ * Whether a column is pinned.
2503
+ *
2504
+ * Answered from the input alone, never from the measurement. A cell is taken out of the flow
2505
+ * because the caller asked for it; the measured offset only decides where it lands. Gating
2506
+ * the two on the same signal would leave the pane unpinned for the frame before the first
2507
+ * measurement, and permanently unpinned anywhere the render hooks do not run.
2508
+ */
2509
+ isFrozenColumn(col) {
2510
+ return col < Math.min(this.frozenColumns(), this.bounds().cols);
2511
+ }
2512
+ /** Whether a row is pinned. Same reasoning as {@link isFrozenColumn}. */
2513
+ isFrozenRow(row) {
2514
+ return row < Math.min(this.frozenRows(), this.bounds().rows);
2515
+ }
2516
+ /** Where a frozen column sits, once measured; null until then, and the stylesheet pins it at zero. */
2517
+ columnOffsetOf(col) {
2518
+ return this.isFrozenColumn(col) ? (this.columnOffsets()[col] ?? null) : null;
2519
+ }
2520
+ /** Where a frozen row sits, once measured. */
2521
+ rowOffsetOf(row) {
2522
+ return this.isFrozenRow(row) ? (this.rowOffsets()[row] ?? null) : null;
2523
+ }
2524
+ isColumnEdge(col) {
2525
+ return isFrozenEdge(col, Math.min(this.frozenColumns(), this.bounds().cols));
2526
+ }
2527
+ isRowEdge(row) {
2528
+ return isFrozenEdge(row, Math.min(this.frozenRows(), this.bounds().rows));
2529
+ }
2530
+ /** Whether a cell is swallowed by a merge, and therefore not rendered. */
2531
+ isHidden(row, col) {
2532
+ return isCovered(this.spanMap(), { row, col });
2533
+ }
2534
+ /** How many rows a cell occupies; null leaves the attribute off. */
2535
+ rowSpanOf(row, col) {
2536
+ const span = spanAt(this.spanMap(), { row, col });
2537
+ return span && span.rowSpan > 1 ? span.rowSpan : null;
2538
+ }
2539
+ /** How many columns a cell occupies; null leaves the attribute off. */
2540
+ colSpanOf(row, col) {
2541
+ const span = spanAt(this.spanMap(), { row, col });
2542
+ return span && span.colSpan > 1 ? span.colSpan : null;
2543
+ }
2544
+ /**
2545
+ * Whether a cell reaches the trailing edge of the sheet, counting the columns it spans.
2546
+ *
2547
+ * Asked rather than guessed with `:last-child`. Once cells can be swallowed by a merge, the
2548
+ * last one drawn in a row is not the one in the last column — in a row where the rightmost
2549
+ * block is covered it is the cell before it, and that is the one that would lose its border.
2550
+ */
2551
+ reachesEnd(row, col) {
2552
+ const span = spanAt(this.spanMap(), { row, col });
2553
+ return col + (span?.colSpan ?? 1) - 1 >= this.bounds().cols - 1;
2554
+ }
2555
+ isActive(row, col) {
2556
+ const active = this.active();
2557
+ return !!active && active.row === row && active.col === col;
2558
+ }
2559
+ isSelected(row, col) {
2560
+ return isWithinGridRange({ row, col }, this.range());
2561
+ }
2562
+ tabIndexOf(row, col) {
2563
+ return gridCellTabIndex({ row, col }, this.active(), 'roving');
2564
+ }
2565
+ stateOf(row, col) {
2566
+ return this.states()[this.cellKey(row, col)];
2567
+ }
2568
+ /** The validation message the host attached to a cell, if any. */
2569
+ errorOf(row, col) {
2570
+ return this.errors()[this.cellKey(row, col)];
2571
+ }
2572
+ /** How a cell is addressed in the `states` and `errors` maps. */
2573
+ cellKey(row, col) {
2574
+ return `${this.keys()[row]}\t${this.columns()[col].key}`;
2575
+ }
2576
+ /**
2577
+ * What a cell shows: its own text when it gives one, otherwise whatever its column makes of
2578
+ * the value — an option's label, a mark for a yes, the value itself for everything else.
2579
+ */
2580
+ displayOf(cell, column) {
2581
+ if (cell.text !== undefined && cell.text !== null) {
2582
+ return cell.text;
2583
+ }
2584
+ return displayValue(cell.value, column);
2585
+ }
2586
+ /**
2587
+ * What a hosted control should be for this cell, or null when there is nothing to host.
2588
+ *
2589
+ * Null covers three cases and they are all deliberate: no adapter registered, a column that is
2590
+ * not a list, and a column whose host supplied its own editor — which is read first and wins,
2591
+ * because a template written for one column is a more specific answer than a rule for all of
2592
+ * them.
2593
+ */
2594
+ hostedControl(row, col) {
2595
+ const column = this.columns()[col];
2596
+ if (!this.#controls || this.editorFor(col) || !(column.kind === 'select' || column.options?.length)) {
2597
+ return null;
2598
+ }
2599
+ return {
2600
+ kind: 'select',
2601
+ value: this.grid()[row][col].value,
2602
+ options: column.options?.map((option) => ({ ...option })),
2603
+ label: column.header,
2604
+ labelType: 'visually-hidden',
2605
+ ariaLabel: column.header,
2606
+ placeholder: column.placeholder,
2607
+ // A cell is a search box's whole reason for being wired, it empties with `Delete`, and
2608
+ // the gesture that opened it has been spent by the time it exists.
2609
+ searchable: true,
2610
+ clearable: false,
2611
+ autoOpen: true,
2612
+ searchTerm: this.seed(),
2613
+ onValueChange: (value) => this.commitFromEditor(value)
2614
+ };
2615
+ }
2616
+ /**
2617
+ * Which answer a list shows while it is open.
2618
+ *
2619
+ * Read off the draft rather than off the cell, so a list opened by typing shows the answer that
2620
+ * keystroke reached. The draft holds the stored value until a keystroke moves it, so an
2621
+ * ordinary open still shows what the cell holds.
2622
+ */
2623
+ isChosen(option, column) {
2624
+ return parseForColumn(this.draft(), column, this.decimalMark()) === option.value;
2625
+ }
2626
+ /** Whether a boolean cell is on, which is what draws its checkbox. */
2627
+ isOn(cell) {
2628
+ return isTruthy(cell.value);
2629
+ }
2630
+ /**
2631
+ * What a cell should show to say it opens onto something, or null when it opens onto a field.
2632
+ *
2633
+ * Only drawn on the cell the reader is on. A caret down every row of a column would read as
2634
+ * content and make the sheet look like a form, and it is only ever the current cell that is
2635
+ * about to be opened.
2636
+ */
2637
+ affordanceOf(column, col) {
2638
+ if (column.kind === 'date') {
2639
+ return 'date';
2640
+ }
2641
+ // A column with a supplied editor counts as a list: that hook exists for pickers, and the
2642
+ // sheet cannot see inside the template to know which kind it is.
2643
+ return column.kind === 'select' || column.options?.length || this.editorFor(col) ? 'list' : null;
2644
+ }
2645
+ /** The template this column's editor should use, if the host supplied one. */
2646
+ editorFor(col) {
2647
+ const key = this.columns()[col]?.key;
2648
+ return this.customEditors().find((editor) => editor.column() === key);
2649
+ }
2650
+ /** The template this column draws its cells with, if the host supplied one. */
2651
+ cellTemplateFor(col) {
2652
+ const key = this.columns()[col]?.key;
2653
+ return this.customCells().find((template) => template.column() === key);
2654
+ }
2655
+ /** The context a cell template is rendered with. */
2656
+ cellContext(row, col) {
2657
+ const cell = this.grid()[row][col];
2658
+ return {
2659
+ $implicit: cell.value,
2660
+ cell,
2661
+ row: this.rows()[row],
2662
+ column: this.columns()[col],
2663
+ active: this.isActive(row, col)
2664
+ };
2665
+ }
2666
+ /** The context a custom editor template is rendered with. */
2667
+ editorContext(row, col) {
2668
+ const cell = this.grid()[row][col];
2669
+ return {
2670
+ $implicit: cell.value,
2671
+ seed: this.seed(),
2672
+ cell,
2673
+ row: this.rows()[row],
2674
+ column: this.columns()[col],
2675
+ commit: (value) => this.commitFromEditor(value),
2676
+ cancel: () => this.cancelEdit()
2677
+ };
2678
+ }
2679
+ /** Accepts a value a custom editor or a built-in list reported, and moves on as Enter would. */
2680
+ commitFromEditor(value) {
2681
+ const active = this.active();
2682
+ if (!active) {
2683
+ return;
2684
+ }
2685
+ const column = this.columns()[active.col];
2686
+ const cell = this.grid()[active.row][active.col];
2687
+ this.editing.set(false);
2688
+ this.invalid.set(false);
2689
+ if (value !== cell.value) {
2690
+ this.commit.emit({ coords: active, row: this.rows()[active.row], column, cell, value });
2691
+ }
2692
+ this.moveTo(moveGridFocus(active, { row: 1, col: 0 }, this.bounds(), { horizontal: 'continuous' }), false);
2693
+ }
2694
+ /**
2695
+ * Turns a boolean cell over without opening an editor.
2696
+ *
2697
+ * A checkbox that needs opening, ticking and confirming is three gestures for one bit. The
2698
+ * keyboard reaches it the same way, since a printable character on such a column toggles too.
2699
+ */
2700
+ toggleBoolean(row, col) {
2701
+ if (!this.canEdit(row, col)) {
2702
+ return;
2703
+ }
2704
+ const cell = this.grid()[row][col];
2705
+ const column = this.columns()[col];
2706
+ this.commit.emit({
2707
+ coords: { row, col },
2708
+ row: this.rows()[row],
2709
+ column,
2710
+ cell,
2711
+ value: isTruthy(cell.value) ? 0 : 1
2712
+ });
2713
+ }
2714
+ canEdit(row, col) {
2715
+ // A column with a formula of its own has nothing to type into: the cell shows what the
2716
+ // column works out, and letting a reader replace it would lose the formula for that row
2717
+ // alone, which is the sort of thing nobody notices until the totals stop adding up.
2718
+ if (this.formulas() && this.columns()[col]?.formula) {
2719
+ return false;
2720
+ }
2721
+ return !this.readonly() && !!this.grid()[row]?.[col]?.editable;
2722
+ }
2723
+ onKeydown(event) {
2724
+ const active = this.active();
2725
+ if (!active) {
2726
+ return;
2727
+ }
2728
+ if (this.editing()) {
2729
+ this.catchUp(event);
2730
+ return;
2731
+ }
2732
+ const intent = resolveGridIntent(event, {
2733
+ pageSize: this.pageSize(),
2734
+ editable: !this.readonly()
2735
+ });
2736
+ if (!intent) {
2737
+ return;
2738
+ }
2739
+ // Copy and cut ride the browser's own events, which carry the clipboard. Paste needs the
2740
+ // focus moved to something editable first, or the event is never dispatched at all.
2741
+ if (intent.kind === 'copy' || intent.kind === 'cut') {
2742
+ return;
2743
+ }
2744
+ if (intent.kind === 'paste') {
2745
+ this.focusSink();
2746
+ return;
2747
+ }
2748
+ event.preventDefault();
2749
+ switch (intent.kind) {
2750
+ case 'move':
2751
+ this.moveTo(moveGridFocus(active, intent.delta, this.bounds(), { horizontal: 'continuous' }), intent.extend);
2752
+ break;
2753
+ case 'edge':
2754
+ this.moveTo(resolveGridEdge(active, intent.edge, this.bounds()), intent.extend);
2755
+ break;
2756
+ case 'edit':
2757
+ this.startEdit(intent.seed);
2758
+ break;
2759
+ case 'clear':
2760
+ this.announceCleared();
2761
+ break;
2762
+ case 'cancel':
2763
+ this.anchor.set(null);
2764
+ this.announceSelection();
2765
+ break;
2766
+ case 'select-all':
2767
+ this.selectRange({ top: 0, bottom: this.bounds().rows - 1, left: 0, right: this.bounds().cols - 1 });
2768
+ break;
2769
+ case 'select-row':
2770
+ this.selectRange({ top: active.row, bottom: active.row, left: 0, right: this.bounds().cols - 1 });
2771
+ break;
2772
+ case 'select-col':
2773
+ this.selectRange({ top: 0, bottom: this.bounds().rows - 1, left: active.col, right: active.col });
2774
+ break;
2775
+ case 'undo':
2776
+ if (this.canUndo()) {
2777
+ this.undoRequested.emit();
2778
+ }
2779
+ break;
2780
+ case 'redo':
2781
+ if (this.canRedo()) {
2782
+ this.redoRequested.emit();
2783
+ }
2784
+ break;
2785
+ }
2786
+ }
2787
+ onMouseDown(event, row, col) {
2788
+ if (event.button !== 0 || (this.editing() && this.isActive(row, col))) {
2789
+ return;
2790
+ }
2791
+ if (this.editing()) {
2792
+ this.finishEdit(null);
2793
+ }
2794
+ this.dragging = true;
2795
+ this.dragMoved = false;
2796
+ this.moveTo({ row, col }, event.shiftKey);
2797
+ }
2798
+ /**
2799
+ * Opens the editor on release, not on press.
2800
+ *
2801
+ * Pressing would settle the question too early: a press that turns into a drag is a range
2802
+ * selection, and one that does not is a request to type. Waiting for the release is what lets
2803
+ * both gestures start the same way.
2804
+ */
2805
+ onMouseUp(event, row, col) {
2806
+ if (event.button !== 0 ||
2807
+ event.shiftKey ||
2808
+ this.dragMoved ||
2809
+ this.editing() ||
2810
+ this.editOn() !== 'click' ||
2811
+ !this.isActive(row, col)) {
2812
+ return;
2813
+ }
2814
+ this.startEdit(undefined);
2815
+ }
2816
+ onMouseEnter(row, col) {
2817
+ if (this.dragging && !this.isActive(row, col)) {
2818
+ this.dragMoved = true;
2819
+ }
2820
+ if (this.fillSource()) {
2821
+ this.trackFill(row, col);
2822
+ return;
2823
+ }
2824
+ if (this.dragging && !this.isActive(row, col)) {
2825
+ this.moveTo({ row, col }, true);
2826
+ }
2827
+ }
2828
+ /** The width a column has been dragged to, or null while it sizes itself to its content. */
2829
+ widthOf(col) {
2830
+ return this.columnWidths()[this.columns()[col].key] ?? null;
2831
+ }
2832
+ onResizeStart(event, col) {
2833
+ if (event.button !== 0) {
2834
+ return;
2835
+ }
2836
+ // The grip lives inside the header; without this the header would also start a reorder.
2837
+ event.preventDefault();
2838
+ event.stopPropagation();
2839
+ const header = this.#host.nativeElement.querySelector(`[data-header="${col}"]`);
2840
+ this.resizing = {
2841
+ col,
2842
+ startX: event.clientX,
2843
+ startWidth: header?.getBoundingClientRect().width ?? Number.NaN
2844
+ };
2845
+ }
2846
+ onHeaderMouseDown(event, col) {
2847
+ if (event.button !== 0 || !this.reorderableColumns()) {
2848
+ return;
2849
+ }
2850
+ event.preventDefault();
2851
+ this.movingColumn.set(col);
2852
+ this.moveTargetColumn.set(col);
2853
+ }
2854
+ onHeaderMouseEnter(col) {
2855
+ if (this.movingColumn() !== null) {
2856
+ this.moveTargetColumn.set(col);
2857
+ }
2858
+ }
2859
+ /** Runs for every pointer move while something is being dragged, and only then. */
2860
+ onPointerMove(event) {
2861
+ const resizing = this.resizing;
2862
+ if (!resizing) {
2863
+ return;
2864
+ }
2865
+ const width = resolveColumnWidth(resizing.startWidth, event.clientX - resizing.startX, this.minColumnWidth());
2866
+ if (width === null) {
2867
+ return;
2868
+ }
2869
+ const key = this.columns()[resizing.col]?.key;
2870
+ if (key) {
2871
+ this.columnWidths.update((widths) => ({ ...widths, [key]: width }));
2872
+ }
2873
+ }
2874
+ endDrag() {
2875
+ this.dragging = false;
2876
+ this.resizing = null;
2877
+ this.commitColumnMove();
2878
+ if (this.fillSource()) {
2879
+ this.commitFill();
2880
+ }
2881
+ }
2882
+ onContextMenu(event, row, col) {
2883
+ if (!this.contextMenu()) {
2884
+ return;
2885
+ }
2886
+ // A right-click on a cell outside the selection moves the cursor there first, as a
2887
+ // spreadsheet does: acting on cells the reader cannot see selected is how rows get
2888
+ // deleted that should not have been.
2889
+ if (!this.isSelected(row, col)) {
2890
+ this.moveTo({ row, col }, false);
2891
+ }
2892
+ if (!this.menuEntries().length) {
2893
+ return;
2894
+ }
2895
+ event.preventDefault();
2896
+ const host = this.#host.nativeElement.getBoundingClientRect();
2897
+ this.menuAt.set({ x: event.clientX - host.left, y: event.clientY - host.top });
2898
+ }
2899
+ closeMenu() {
2900
+ this.menuAt.set(null);
2901
+ }
2902
+ /**
2903
+ * What the menu offers here, already filtered by what is allowed.
2904
+ *
2905
+ * Built rather than rendered-and-hidden so the menu is never opened empty, and so no entry
2906
+ * appears that would do nothing when clicked.
2907
+ */
2908
+ menuEntries = computed(() => {
2909
+ const range = this.range();
2910
+ if (!range || this.readonly()) {
2911
+ return [];
2912
+ }
2913
+ const rows = this.rows();
2914
+ const columns = this.columns();
2915
+ const structure = this.structure();
2916
+ const entries = [];
2917
+ const rowCount = range.bottom - range.top + 1;
2918
+ const colCount = range.right - range.left + 1;
2919
+ if (isStructureAllowed(structure.insertRows, rows[range.top] ?? null, range.top)) {
2920
+ entries.push({ id: 'row-before', label: 'INSERT_ROW_ABOVE', count: 1 });
2921
+ entries.push({ id: 'row-after', label: 'INSERT_ROW_BELOW', count: 1 });
2922
+ }
2923
+ if (this.allRows(range, (row, index) => isStructureAllowed(structure.deleteRows, row, index))) {
2924
+ entries.push({ id: 'row-delete', label: 'DELETE_ROWS', count: rowCount });
2925
+ }
2926
+ if (isStructureAllowed(structure.insertColumns, columns[range.left] ?? null, range.left)) {
2927
+ entries.push({ id: 'col-before', label: 'INSERT_COLUMN_BEFORE', count: 1 });
2928
+ entries.push({ id: 'col-after', label: 'INSERT_COLUMN_AFTER', count: 1 });
2929
+ }
2930
+ if (this.allColumns(range, (column, index) => isStructureAllowed(structure.deleteColumns, column, index))) {
2931
+ entries.push({ id: 'col-delete', label: 'DELETE_COLUMNS', count: colCount });
2932
+ }
2933
+ if (this.mergeable()) {
2934
+ // The selection has already grown to contain whole blocks, so what it asks for is one
2935
+ // rectangle and the arithmetic has nothing left to guess.
2936
+ if (mergeRequestFor(this.spans(), range)) {
2937
+ entries.push({ id: 'merge', label: 'MERGE_CELLS', count: rowCount * colCount });
2938
+ }
2939
+ const touched = spansWithin(this.spans(), range);
2940
+ if (touched.length) {
2941
+ entries.push({ id: 'unmerge', label: 'UNMERGE_CELLS', count: touched.length });
2942
+ }
2943
+ }
2944
+ return entries;
2945
+ }, /* @ts-ignore */
2946
+ ...(ngDevMode ? [{ debugName: "menuEntries" }] : /* istanbul ignore next */ []));
2947
+ runMenuEntry(id) {
2948
+ this.closeMenu();
2949
+ switch (id) {
2950
+ case 'row-before':
2951
+ this.requestInsertRow('before');
2952
+ break;
2953
+ case 'row-after':
2954
+ this.requestInsertRow('after');
2955
+ break;
2956
+ case 'row-delete':
2957
+ this.requestDeleteRows();
2958
+ break;
2959
+ case 'col-before':
2960
+ this.requestInsertColumn('before');
2961
+ break;
2962
+ case 'col-after':
2963
+ this.requestInsertColumn('after');
2964
+ break;
2965
+ case 'col-delete':
2966
+ this.requestDeleteColumns();
2967
+ break;
2968
+ case 'merge':
2969
+ this.requestMerge();
2970
+ break;
2971
+ case 'unmerge':
2972
+ this.requestUnmerge();
2973
+ break;
2974
+ }
2975
+ }
2976
+ /** Reports the block the selection would become, with what it swallows. */
2977
+ requestMerge() {
2978
+ const range = this.range();
2979
+ const request = range && mergeRequestFor(this.spans(), range);
2980
+ if (request) {
2981
+ this.mergeRequested.emit(request);
2982
+ }
2983
+ }
2984
+ /** Reports the blocks the selection touches, so the host can take them apart. */
2985
+ requestUnmerge() {
2986
+ const range = this.range();
2987
+ if (!range) {
2988
+ return;
2989
+ }
2990
+ const anchors = spansWithin(this.spans(), range).map((span) => ({ row: span.row, col: span.col }));
2991
+ if (anchors.length) {
2992
+ this.unmergeRequested.emit(anchors);
2993
+ }
2994
+ }
2995
+ allRows(range, allowed) {
2996
+ const rows = this.rows();
2997
+ for (let index = range.top; index <= range.bottom; index++) {
2998
+ if (!allowed(rows[index], index)) {
2999
+ return false;
3000
+ }
3001
+ }
3002
+ return true;
3003
+ }
3004
+ allColumns(range, allowed) {
3005
+ const columns = this.columns();
3006
+ for (let index = range.left; index <= range.right; index++) {
3007
+ if (!allowed(columns[index], index)) {
3008
+ return false;
3009
+ }
3010
+ }
3011
+ return true;
3012
+ }
3013
+ /**
3014
+ * Reads the viewport after it has scrolled, or been resized, or drawn its first rows.
3015
+ *
3016
+ * Signals rather than a stored geometry, so the window is a computed like everything else and
3017
+ * nothing has to remember to recompute it. The row height is measured from the first row in
3018
+ * the document, which is also the only row that is certainly there.
3019
+ */
3020
+ onViewportScroll() {
3021
+ const viewport = this.viewport()?.nativeElement;
3022
+ if (!viewport) {
3023
+ return;
3024
+ }
3025
+ this.scrollTop.set(viewport.scrollTop);
3026
+ this.viewportHeight.set(viewport.clientHeight);
3027
+ this.scrollLeft.set(viewport.scrollLeft);
3028
+ this.viewportWidth.set(viewport.clientWidth);
3029
+ if (!this.rowHeight()) {
3030
+ const row = viewport.querySelector('.hub-spreadsheet__row');
3031
+ const height = row?.getBoundingClientRect().height ?? 0;
3032
+ // A row of nought is a sheet that has not been laid out — a closed tab, a hidden
3033
+ // panel. Keeping the last good measurement is better than throwing the window away.
3034
+ if (height > 0) {
3035
+ this.measuredRowHeight.set(height);
3036
+ }
3037
+ }
3038
+ }
3039
+ /** Reports where a dragged column was dropped, if it moved at all. */
3040
+ commitColumnMove() {
3041
+ const from = this.movingColumn();
3042
+ const to = this.moveTargetColumn();
3043
+ this.movingColumn.set(null);
3044
+ this.moveTargetColumn.set(null);
3045
+ if (from === null || to === null || from === to) {
3046
+ return;
3047
+ }
3048
+ const keys = this.columns().map((column) => column.key);
3049
+ this.columnMoved.emit({ from, to, key: keys[from], keys: moveColumn(keys, from, to) });
3050
+ }
3051
+ /** Whether a header is the one being dragged, or the place it would land. */
3052
+ isMovingColumn(col) {
3053
+ return this.movingColumn() === col;
3054
+ }
3055
+ isMoveTarget(col) {
3056
+ return this.movingColumn() !== null && this.moveTargetColumn() === col && this.movingColumn() !== col;
3057
+ }
3058
+ /** Whether the fill handle belongs on this cell: the far corner of the selection. */
3059
+ hasFillHandle(row, col) {
3060
+ const range = this.range();
3061
+ return this.fillHandle() && !this.readonly() && !!range && row === range.bottom && col === range.right;
3062
+ }
3063
+ /** Whether a cell is inside the area a released fill would write to. */
3064
+ isFillPreview(row, col) {
3065
+ const target = this.fillTarget();
3066
+ return !!target && isWithinGridRange({ row, col }, target) && !isWithinGridRange({ row, col }, this.range());
3067
+ }
3068
+ onFillHandleDown(event) {
3069
+ if (event.button !== 0) {
3070
+ return;
3071
+ }
3072
+ // Stops the cell underneath from treating this as the start of a new selection.
3073
+ event.preventDefault();
3074
+ event.stopPropagation();
3075
+ const range = this.range();
3076
+ if (range) {
3077
+ this.fillSource.set(range);
3078
+ this.fillTarget.set(range);
3079
+ }
3080
+ }
3081
+ /** Grows the preview towards wherever the pointer went, on one axis only. */
3082
+ trackFill(row, col) {
3083
+ const source = this.fillSource();
3084
+ if (!source) {
3085
+ return;
3086
+ }
3087
+ const down = Math.max(0, row - source.bottom);
3088
+ const right = Math.max(0, col - source.right);
3089
+ // One axis at a time, as a spreadsheet does: a diagonal drag would have to invent a rule
3090
+ // for the corner it opens up, and any rule there surprises somebody.
3091
+ this.fillTarget.set(down >= right
3092
+ ? { ...source, bottom: Math.max(source.bottom, row) }
3093
+ : { ...source, right: Math.max(source.right, col) });
3094
+ }
3095
+ /** Works out what the finished drag writes, reports it, and leaves the selection covering it. */
3096
+ commitFill() {
3097
+ const source = this.fillSource();
3098
+ const target = this.fillTarget();
3099
+ this.fillSource.set(null);
3100
+ this.fillTarget.set(null);
3101
+ if (!source || !target) {
3102
+ return;
3103
+ }
3104
+ const grid = this.grid();
3105
+ const rows = this.rows();
3106
+ const columns = this.columns();
3107
+ const cells = [];
3108
+ let skipped = 0;
3109
+ const down = target.bottom - source.bottom;
3110
+ const across = target.right - source.right;
3111
+ const write = (row, col, value) => {
3112
+ const cell = grid[row]?.[col];
3113
+ if (!cell?.editable) {
3114
+ skipped++;
3115
+ return;
3116
+ }
3117
+ if (value !== cell.value) {
3118
+ cells.push({ coords: { row, col }, row: rows[row], column: columns[col], cell, value });
3119
+ }
3120
+ };
3121
+ if (down > 0) {
3122
+ for (let col = source.left; col <= source.right; col++) {
3123
+ const seed = [];
3124
+ for (let row = source.top; row <= source.bottom; row++) {
3125
+ seed.push(grid[row][col].value);
3126
+ }
3127
+ fillValues(seed, down).forEach((value, offset) => write(source.bottom + 1 + offset, col, value));
3128
+ }
3129
+ }
3130
+ else if (across > 0) {
3131
+ for (let row = source.top; row <= source.bottom; row++) {
3132
+ const seed = [];
3133
+ for (let col = source.left; col <= source.right; col++) {
3134
+ seed.push(grid[row][col].value);
3135
+ }
3136
+ fillValues(seed, across).forEach((value, offset) => write(row, source.right + 1 + offset, value));
3137
+ }
3138
+ }
3139
+ else {
3140
+ return;
3141
+ }
3142
+ this.anchor.set({ row: target.top, col: target.left });
3143
+ this.active.set({ row: target.bottom, col: target.right });
3144
+ this.filled.emit({ cells, range: target, skipped, rejected: 0 });
3145
+ this.announceSelection();
3146
+ }
3147
+ /**
3148
+ * Opens the cell from its own mark, on the first click.
3149
+ *
3150
+ * The mark exists to be pressed, so it does not wait for a second click the way the rest of the
3151
+ * cell does — which is what a spreadsheet does with the list of a cell that has one. The press
3152
+ * is swallowed rather than let through: the cell underneath would otherwise start a range drag,
3153
+ * and the release would land on an editor that was not there when the press began.
3154
+ */
3155
+ onAffordanceDown(event, row, col) {
3156
+ if (event.button !== 0) {
3157
+ return;
3158
+ }
3159
+ event.preventDefault();
3160
+ event.stopPropagation();
3161
+ this.moveTo({ row, col }, false);
3162
+ this.startEdit(undefined);
3163
+ }
3164
+ onDoubleClick() {
3165
+ this.startEdit(undefined);
3166
+ }
3167
+ onCopy(event, cut) {
3168
+ const range = this.range();
3169
+ if (!range || this.editing() || !event.clipboardData) {
3170
+ return;
3171
+ }
3172
+ const grid = this.grid();
3173
+ const values = [];
3174
+ for (let row = range.top; row <= range.bottom; row++) {
3175
+ const line = [];
3176
+ for (let col = range.left; col <= range.right; col++) {
3177
+ line.push(grid[row][col].value);
3178
+ }
3179
+ values.push(line);
3180
+ }
3181
+ const { text, html } = serialiseClipboardTable(values);
3182
+ event.clipboardData.setData('text/plain', text);
3183
+ event.clipboardData.setData('text/html', html);
3184
+ event.preventDefault();
3185
+ if (cut) {
3186
+ this.announceCleared();
3187
+ }
3188
+ }
3189
+ onPaste(event) {
3190
+ const active = this.active();
3191
+ if (!active || this.editing() || !event.clipboardData) {
3192
+ return;
3193
+ }
3194
+ event.preventDefault();
3195
+ const block = parseClipboardTable({
3196
+ text: event.clipboardData.getData('text/plain'),
3197
+ html: event.clipboardData.getData('text/html')
3198
+ });
3199
+ this.focusActive();
3200
+ if (!block.length) {
3201
+ return;
3202
+ }
3203
+ this.applyPaste(active, block);
3204
+ }
3205
+ /** Asks the owner to add a row beside the cursor, if it allows it. */
3206
+ requestInsertRow(side) {
3207
+ const active = this.active();
3208
+ const rows = this.rows();
3209
+ const subject = active ? (rows[active.row] ?? null) : null;
3210
+ if (!isStructureAllowed(this.structure().insertRows, subject, active?.row ?? 0)) {
3211
+ return;
3212
+ }
3213
+ this.insertRequested.emit({
3214
+ axis: 'row',
3215
+ at: resolveInsertIndex(active?.row ?? rows.length, side, rows.length),
3216
+ count: 1
3217
+ });
3218
+ }
3219
+ /** Asks the owner to remove the rows the selection covers, if it allows it. */
3220
+ requestDeleteRows() {
3221
+ const range = this.range();
3222
+ const rows = this.rows();
3223
+ if (!range) {
3224
+ return;
3225
+ }
3226
+ for (let row = range.top; row <= range.bottom; row++) {
3227
+ if (!isStructureAllowed(this.structure().deleteRows, rows[row], row)) {
3228
+ return;
3229
+ }
3230
+ }
3231
+ this.deleteRequested.emit({ axis: 'row', at: range.top, count: range.bottom - range.top + 1 });
3232
+ }
3233
+ /** Asks the owner to add a column, handing over an alias that has never been used here. */
3234
+ requestInsertColumn(side) {
3235
+ const active = this.active();
3236
+ const columns = this.columns();
3237
+ const subject = active ? (columns[active.col] ?? null) : null;
3238
+ if (!isStructureAllowed(this.structure().insertColumns, subject, active?.col ?? 0)) {
3239
+ return;
3240
+ }
3241
+ this.insertRequested.emit({
3242
+ axis: 'column',
3243
+ at: resolveInsertIndex(active?.col ?? columns.length, side, columns.length),
3244
+ count: 1,
3245
+ key: nextColumnKey([...columns.map((column) => column.key), ...this.retiredColumnKeys()])
3246
+ });
3247
+ }
3248
+ /** Asks the owner to remove the columns the selection covers, naming the aliases at stake. */
3249
+ requestDeleteColumns() {
3250
+ const range = this.range();
3251
+ const columns = this.columns();
3252
+ if (!range) {
3253
+ return;
3254
+ }
3255
+ for (let col = range.left; col <= range.right; col++) {
3256
+ if (!isStructureAllowed(this.structure().deleteColumns, columns[col], col)) {
3257
+ return;
3258
+ }
3259
+ }
3260
+ this.deleteRequested.emit({
3261
+ axis: 'column',
3262
+ at: range.left,
3263
+ count: range.right - range.left + 1,
3264
+ keys: columns.slice(range.left, range.right + 1).map((column) => column.key)
3265
+ });
3266
+ }
3267
+ onDraftInput(event) {
3268
+ // A keystroke changes what is being offered, so whatever was highlighted is no longer the
3269
+ // thing the reader was looking at.
3270
+ this.suggestionAt.set(-1);
3271
+ this.draft.set(event.target.value);
3272
+ this.invalid.set(false);
3273
+ }
3274
+ onEditorKeydown(event) {
3275
+ // A composition is in flight: the Enter that picks a candidate is not the Enter that
3276
+ // commits the cell.
3277
+ if (event.isComposing || event.keyCode === 229) {
3278
+ return;
3279
+ }
3280
+ if (this.onSuggestionKeydown(event)) {
3281
+ return;
3282
+ }
3283
+ switch (event.key) {
3284
+ // Stopped as well as prevented, all three of them. The editor sits inside the grid, so
3285
+ // the same keystroke reaches the grid's own handler a moment later — and there it
3286
+ // reads as a fresh Enter on the cell below, which opened an editor the reader never
3287
+ // asked for on every single commit.
3288
+ case 'Enter':
3289
+ event.preventDefault();
3290
+ event.stopPropagation();
3291
+ this.finishEdit({ row: event.shiftKey ? -1 : 1, col: 0 });
3292
+ break;
3293
+ case 'Tab':
3294
+ event.preventDefault();
3295
+ event.stopPropagation();
3296
+ this.finishEdit({ row: 0, col: event.shiftKey ? -1 : 1 });
3297
+ break;
3298
+ case 'Escape':
3299
+ event.preventDefault();
3300
+ event.stopPropagation();
3301
+ this.cancelEdit();
3302
+ break;
3303
+ }
3304
+ }
3305
+ /**
3306
+ * The list of suggestions takes the arrows, and takes Enter only once the reader is in it.
3307
+ *
3308
+ * Enter is the awkward one: it both accepts a suggestion and commits a cell, and which of the
3309
+ * two it means depends on whether the reader has moved into the list. Moving into it is
3310
+ * deliberate — an arrow key — so somebody who types a whole formula and presses Enter commits
3311
+ * it, which is what they meant.
3312
+ *
3313
+ * @returns Whether the key was the list's, and the editor should leave it alone.
3314
+ */
3315
+ onSuggestionKeydown(event) {
3316
+ const suggestions = this.suggestions();
3317
+ if (!suggestions.length) {
3318
+ return false;
3319
+ }
3320
+ const index = this.suggestionIndex();
3321
+ switch (event.key) {
3322
+ case 'ArrowDown':
3323
+ event.preventDefault();
3324
+ event.stopPropagation();
3325
+ this.suggestionAt.set((index + 1) % suggestions.length);
3326
+ return true;
3327
+ case 'ArrowUp':
3328
+ event.preventDefault();
3329
+ event.stopPropagation();
3330
+ this.suggestionAt.set(index <= 0 ? suggestions.length - 1 : index - 1);
3331
+ return true;
3332
+ case 'Tab':
3333
+ case 'Enter':
3334
+ if (index < 0) {
3335
+ return false;
3336
+ }
3337
+ event.preventDefault();
3338
+ event.stopPropagation();
3339
+ this.useSuggestion(suggestions[index]);
3340
+ return true;
3341
+ case 'Escape':
3342
+ // Out of the list, not out of the cell: a reader dismissing a list of suggestions
3343
+ // has not asked to throw away what they were writing.
3344
+ if (index >= 0) {
3345
+ event.preventDefault();
3346
+ event.stopPropagation();
3347
+ this.suggestionAt.set(-1);
3348
+ return true;
3349
+ }
3350
+ return false;
3351
+ }
3352
+ return false;
3353
+ }
3354
+ /** Leaving the editor keeps what was typed, as clicking away does in a spreadsheet. */
3355
+ onEditorBlur() {
3356
+ if (this.editing() && !this.finishEdit(null)) {
3357
+ this.cancelEdit();
3358
+ }
3359
+ }
3360
+ trackRow = (index) => this.keys()[index] ?? String(index);
3361
+ trackColumn = (_index, column) => column.key;
3362
+ /** Writes a pasted block into the sheet and reports what fitted and what did not. */
3363
+ applyPaste(at, block) {
3364
+ const rows = this.rows();
3365
+ const columns = this.columns();
3366
+ const grid = this.grid();
3367
+ const cells = [];
3368
+ let skipped = 0;
3369
+ let rejected = 0;
3370
+ block.forEach((line, rowOffset) => line.forEach((incoming, colOffset) => {
3371
+ const row = at.row + rowOffset;
3372
+ const col = at.col + colOffset;
3373
+ const cell = grid[row]?.[col];
3374
+ if (!cell?.editable || this.readonly()) {
3375
+ skipped++;
3376
+ return;
3377
+ }
3378
+ const column = columns[col];
3379
+ const value = this.read(incoming, column);
3380
+ if (value === undefined) {
3381
+ rejected++;
3382
+ return;
3383
+ }
3384
+ if (value !== cell.value) {
3385
+ cells.push({ coords: { row, col }, row: rows[row], column, cell, value });
3386
+ }
3387
+ }));
3388
+ const height = block.length;
3389
+ const width = Math.max(...block.map((line) => line.length));
3390
+ const range = clampGridRange({ top: at.row, bottom: at.row + height - 1, left: at.col, right: at.col + width - 1 }, this.bounds());
3391
+ this.anchor.set(at);
3392
+ this.active.set({
3393
+ row: Math.min(this.bounds().rows - 1, at.row + height - 1),
3394
+ col: Math.min(this.bounds().cols - 1, at.col + width - 1)
3395
+ });
3396
+ this.pasted.emit({
3397
+ cells,
3398
+ range: range ?? { top: at.row, bottom: at.row, left: at.col, right: at.col },
3399
+ skipped,
3400
+ rejected
3401
+ });
3402
+ this.announceSelection();
3403
+ }
3404
+ /**
3405
+ * What a pasted cell is worth in this column.
3406
+ *
3407
+ * The raw number from the HTML flavour wins where there is one, because it does not depend on
3408
+ * how the source happened to format it. Only when it is absent is the displayed text parsed,
3409
+ * and then in this reader's own convention.
3410
+ */
3411
+ read(incoming, column) {
3412
+ const kind = column.kind ?? 'text';
3413
+ // A formula is kept as what it is, whatever the column holds. Read as a figure it is not
3414
+ // one — `=2*4` is not a number — and the cell refused the very thing it exists to accept:
3415
+ // a column of currency is exactly where somebody writes a formula.
3416
+ if (this.formulas() && isFormula(incoming.text)) {
3417
+ return incoming.text;
3418
+ }
3419
+ // The raw number from the HTML flavour wins for a figure column, because it does not
3420
+ // depend on how the source happened to format it. For every other kind the text is what
3421
+ // carries the meaning.
3422
+ if (incoming.raw !== undefined && (kind === 'number' || kind === 'currency')) {
3423
+ return incoming.raw;
3424
+ }
3425
+ return parseForColumn(incoming.text, column, this.decimalMark());
3426
+ }
3427
+ startEdit(seed) {
3428
+ const active = this.active();
3429
+ if (!active || !this.canEdit(active.row, active.col)) {
3430
+ return;
3431
+ }
3432
+ // A yes/no column has nothing to type into. Opening a field over it would show the stored
3433
+ // 0 or 1 as text and ask the reader to edit a number that is not what the column means.
3434
+ if (this.columns()[active.col].kind === 'boolean') {
3435
+ this.toggleBoolean(active.row, active.col);
3436
+ return;
3437
+ }
3438
+ const cell = this.grid()[active.row][active.col];
3439
+ const column = this.columns()[active.col];
3440
+ const kind = column.kind ?? 'text';
3441
+ // The character that opened the cell is the reader's first keystroke, and it has to land
3442
+ // somewhere. In a list it picks the first answer that starts with it, as a native list
3443
+ // does when you type at it; in a date field it lands nowhere, because a letter is not a
3444
+ // date and keeping it would commit rubbish on the way out.
3445
+ const opener = kind === 'select' ? this.optionStartingWith(column, seed) : undefined;
3446
+ this.anchor.set(null);
3447
+ this.seed.set(seed);
3448
+ this.draft.set(this.openingText(cell, kind, seed, opener));
3449
+ this.invalid.set(false);
3450
+ this.editing.set(true);
3451
+ afterNextRender(() => {
3452
+ const editor = this.#host.nativeElement.querySelector('.hub-spreadsheet__editor');
3453
+ if (!editor) {
3454
+ // A supplied editor draws whatever it likes, so the most the sheet can do is put
3455
+ // the keyboard inside it and let it take over from there.
3456
+ this.focusSuppliedEditor();
3457
+ return;
3458
+ }
3459
+ editor.focus();
3460
+ // Opened with Enter or F2 the value is kept and selected; opened by typing, the caret
3461
+ // sits after the character that opened it. Only a text field has a selection to
3462
+ // place — a list has no such method and a date field refuses.
3463
+ if (seed === undefined && editor instanceof HTMLInputElement && editor.type === 'text') {
3464
+ editor.select();
3465
+ }
3466
+ // One gesture for one decision: the list and the calendar come up with the cell
3467
+ // instead of waiting for a second click. Not when the cell was opened by typing
3468
+ // into it, though — a calendar over a half-written date takes the keystrokes that
3469
+ // were meant to finish it.
3470
+ if (seed === undefined && (kind === 'select' || kind === 'date')) {
3471
+ revealPicker(editor);
3472
+ }
3473
+ }, { injector: this.#injector });
3474
+ }
3475
+ /** Puts the keyboard in the first thing a supplied editor offers it. */
3476
+ focusSuppliedEditor() {
3477
+ this.#host.nativeElement
3478
+ .querySelector('.hub-spreadsheet__custom-editor :is(input, select, textarea, button, [tabindex]:not([tabindex="-1"]))')
3479
+ ?.focus();
3480
+ }
3481
+ /**
3482
+ * Keeps the characters typed between opening a cell and the editor getting the focus.
3483
+ *
3484
+ * The field does not exist until the view has been drawn, so the focus can only move to it on
3485
+ * the render after the keystroke that opened the cell. Anybody typing quickly — `8`, `0`, `0`
3486
+ * inside one frame — has the first character open the cell and the rest arrive here, at the
3487
+ * grid, with nowhere to go. They used to be dropped: the cell opened on `8` and committed `8`.
3488
+ *
3489
+ * So they are added to the draft, which is what the field shows the moment it appears.
3490
+ */
3491
+ catchUp(event) {
3492
+ if (event.isComposing || event.keyCode === 229 || event.ctrlKey || event.metaKey || event.altKey) {
3493
+ return;
3494
+ }
3495
+ if (event.key.length !== 1) {
3496
+ return;
3497
+ }
3498
+ const editor = this.#host.nativeElement.querySelector('.hub-spreadsheet__editor');
3499
+ // Only for a key that did not come from the field itself. Once the field has the focus its
3500
+ // keystrokes bubble up here too, and adding those to the draft would type everything
3501
+ // twice. Read off the event rather than off `document.activeElement`, which is the same
3502
+ // question asked of something that lies about it in tests.
3503
+ if (editor && event.target === editor) {
3504
+ return;
3505
+ }
3506
+ event.preventDefault();
3507
+ this.draft.update((draft) => draft + event.key);
3508
+ if (editor instanceof HTMLInputElement) {
3509
+ editor.value = this.draft();
3510
+ }
3511
+ }
3512
+ /**
3513
+ * Reads what was typed, reports it if it changed, then moves by `step`.
3514
+ *
3515
+ * @returns false when the text is not a value for this column, leaving the editor open and
3516
+ * marked rather than throwing away what the reader wrote.
3517
+ */
3518
+ finishEdit(step) {
3519
+ const active = this.active();
3520
+ if (!active) {
3521
+ return true;
3522
+ }
3523
+ const column = this.columns()[active.col];
3524
+ const cell = this.grid()[active.row][active.col];
3525
+ const value = this.read({ text: this.draft() }, column);
3526
+ if (value === undefined) {
3527
+ this.invalid.set(true);
3528
+ return false;
3529
+ }
3530
+ this.editing.set(false);
3531
+ this.invalid.set(false);
3532
+ if (value !== cell.value) {
3533
+ this.commit.emit({ coords: active, row: this.rows()[active.row], column, cell, value });
3534
+ }
3535
+ if (step) {
3536
+ this.moveTo(moveGridFocus(active, step, this.bounds(), { horizontal: 'continuous' }), false);
3537
+ }
3538
+ else {
3539
+ this.focusActive();
3540
+ }
3541
+ return true;
3542
+ }
3543
+ cancelEdit() {
3544
+ this.editing.set(false);
3545
+ this.invalid.set(false);
3546
+ this.focusActive();
3547
+ }
3548
+ /**
3549
+ * The first answer of a list that starts with what was typed, skipping the ones a reader is
3550
+ * not allowed to choose. Matched on the label, because that is what they were reading.
3551
+ */
3552
+ optionStartingWith(column, seed) {
3553
+ if (seed === undefined || seed === '') {
3554
+ return undefined;
3555
+ }
3556
+ const start = seed.toLocaleLowerCase();
3557
+ return column.options?.find((option) => !option.disabled && option.label.toLocaleLowerCase().startsWith(start));
3558
+ }
3559
+ /** What the editor starts with, which is not always what was typed. */
3560
+ openingText(cell, kind, seed, opener) {
3561
+ if (seed === undefined) {
3562
+ return this.editableTextOf(cell);
3563
+ }
3564
+ if (kind === 'select') {
3565
+ // The label, which is what a list is read back from; an unmatched character leaves the
3566
+ // cell as it was rather than emptying it.
3567
+ return opener ? opener.label : this.editableTextOf(cell);
3568
+ }
3569
+ // A date field cannot hold half a date, and a letter left in the draft would be committed
3570
+ // as one the moment the reader clicked away.
3571
+ return kind === 'date' ? '' : seed;
3572
+ }
3573
+ /** A value as it goes into the editor: numbers in this reader's own convention. */
3574
+ editableTextOf(cell) {
3575
+ if (cell.value === null) {
3576
+ return '';
3577
+ }
3578
+ return typeof cell.value === 'number' ? String(cell.value).replace('.', this.decimalMark()) : String(cell.value);
3579
+ }
3580
+ moveTo(rawTarget, extend) {
3581
+ // Arithmetic runs on plain coordinates; landing on a covered cell then resolves to the
3582
+ // block that owns it, so the cursor is never on something that is not drawn.
3583
+ const target = anchorOf(this.spanMap(), rawTarget);
3584
+ if (extend) {
3585
+ this.anchor.set(this.anchor() ?? this.active());
3586
+ }
3587
+ else {
3588
+ this.anchor.set(null);
3589
+ }
3590
+ this.active.set(target);
3591
+ this.announceSelection();
3592
+ this.focusActive();
3593
+ }
3594
+ selectRange(range) {
3595
+ this.anchor.set({ row: range.top, col: range.left });
3596
+ this.active.set({ row: range.bottom, col: range.right });
3597
+ this.announceSelection();
3598
+ this.focusActive();
3599
+ }
3600
+ focusActive() {
3601
+ // While virtualising, the cell the cursor has just moved to may not be in the document at
3602
+ // all — that is the whole point of virtualising — so the scroller is moved first and the
3603
+ // focus follows on the render that puts the row there.
3604
+ this.scrollRowIntoView();
3605
+ afterNextRender(() => {
3606
+ const active = this.active();
3607
+ if (active) {
3608
+ this.#host.nativeElement
3609
+ .querySelector(`[data-cell="${active.row}-${active.col}"]`)
3610
+ ?.focus({ preventScroll: false });
3611
+ }
3612
+ }, { injector: this.#injector });
3613
+ }
3614
+ /**
3615
+ * Brings the row the cursor is on inside the viewport, when the sheet is the thing that
3616
+ * scrolls and the row might not be drawn.
3617
+ *
3618
+ * Only the arithmetic the browser cannot do for us: `scrollIntoView` needs an element, and a
3619
+ * row that is not in the document has none. The offset is worked out from the row height,
3620
+ * which virtualising already depends on, and the scroller is nudged by the least that brings
3621
+ * the row in — jumping it to the middle would move the sheet under a reader who only pressed
3622
+ * the down arrow.
3623
+ */
3624
+ scrollRowIntoView() {
3625
+ const active = this.active();
3626
+ const viewport = this.viewport()?.nativeElement;
3627
+ const size = this.rowHeight() || this.measuredRowHeight();
3628
+ if (!active || !viewport || !this.virtual() || size <= 0) {
3629
+ return;
3630
+ }
3631
+ const frozen = Math.min(this.frozenRows(), this.rows().length);
3632
+ // A frozen row is pinned, so it is on screen wherever the reader has got to — but landing
3633
+ // on one means going back to the top of the sheet, and leaving the view where it was makes
3634
+ // the cursor look lost.
3635
+ if (active.row < frozen) {
3636
+ viewport.scrollTop = 0;
3637
+ this.scrollColumnIntoView(viewport, active.col);
3638
+ return;
3639
+ }
3640
+ const top = (active.row - frozen) * size;
3641
+ const bottom = top + size;
3642
+ const visibleTop = viewport.scrollTop;
3643
+ const visibleBottom = visibleTop + viewport.clientHeight;
3644
+ if (top < visibleTop) {
3645
+ viewport.scrollTop = top;
3646
+ }
3647
+ else if (bottom > visibleBottom) {
3648
+ viewport.scrollTop = bottom - viewport.clientHeight;
3649
+ }
3650
+ this.scrollColumnIntoView(viewport, active.col);
3651
+ }
3652
+ /**
3653
+ * The same for the column the cursor is on, which may be out of the document sideways.
3654
+ *
3655
+ * Measured widths rather than a single size: columns are rarely alike, and adding up the ones
3656
+ * before the cursor is the only way to know where it starts. Nothing to do when a width is
3657
+ * missing, which is also when the sheet is drawing every column anyway.
3658
+ */
3659
+ scrollColumnIntoView(viewport, col) {
3660
+ const widths = this.measuredColumnWidths();
3661
+ const frozen = Math.min(this.frozenColumns(), this.columns().length);
3662
+ if (widths.length !== this.columns().length || widths.some((width) => !(width > 0))) {
3663
+ return;
3664
+ }
3665
+ // Same for a frozen column: pinned, so visible, but `Home` means the beginning of the row
3666
+ // and the sheet has to come back with the cursor.
3667
+ if (col < frozen) {
3668
+ viewport.scrollLeft = 0;
3669
+ return;
3670
+ }
3671
+ let start = 0;
3672
+ for (let index = frozen; index < col; index++) {
3673
+ start += widths[index];
3674
+ }
3675
+ const end = start + widths[col];
3676
+ const visibleStart = viewport.scrollLeft;
3677
+ const visibleEnd = visibleStart + viewport.clientWidth;
3678
+ if (start < visibleStart) {
3679
+ viewport.scrollLeft = start;
3680
+ }
3681
+ else if (end > visibleEnd) {
3682
+ viewport.scrollLeft = end - viewport.clientWidth;
3683
+ }
3684
+ }
3685
+ /** Hands focus to the off-screen field so the browser will dispatch a paste at all. */
3686
+ focusSink() {
3687
+ this.#host.nativeElement.querySelector('.hub-spreadsheet__sink')?.focus({
3688
+ preventScroll: true
3689
+ });
3690
+ }
3691
+ refAt(coords) {
3692
+ return {
3693
+ coords,
3694
+ row: this.rows()[coords.row],
3695
+ column: this.columns()[coords.col],
3696
+ cell: this.grid()[coords.row][coords.col]
3697
+ };
3698
+ }
3699
+ announceSelection() {
3700
+ this.selectionChange.emit(this.range());
3701
+ }
3702
+ announceCleared() {
3703
+ const range = this.range();
3704
+ if (!range || this.readonly()) {
3705
+ return;
3706
+ }
3707
+ const cells = gridRangeCells(range)
3708
+ .filter(({ row, col }) => this.canEdit(row, col) && this.grid()[row][col].value !== null)
3709
+ .map((coords) => this.refAt(coords));
3710
+ if (cells.length) {
3711
+ this.cleared.emit(cells);
3712
+ }
3713
+ }
3714
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3715
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: HubSpreadsheetComponent, isStandalone: true, selector: "hub-spreadsheet", inputs: { rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: true, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: true, transformFunction: null }, rowKey: { classPropertyName: "rowKey", publicName: "rowKey", isSignal: true, isRequired: true, transformFunction: null }, states: { classPropertyName: "states", publicName: "states", isSignal: true, isRequired: false, transformFunction: null }, errors: { classPropertyName: "errors", publicName: "errors", isSignal: true, isRequired: false, transformFunction: null }, decimalMark: { classPropertyName: "decimalMark", publicName: "decimalMark", isSignal: true, isRequired: false, transformFunction: null }, emptyText: { classPropertyName: "emptyText", publicName: "emptyText", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, frozenColumns: { classPropertyName: "frozenColumns", publicName: "frozenColumns", isSignal: true, isRequired: false, transformFunction: null }, frozenRows: { classPropertyName: "frozenRows", publicName: "frozenRows", isSignal: true, isRequired: false, transformFunction: null }, spans: { classPropertyName: "spans", publicName: "spans", isSignal: true, isRequired: false, transformFunction: null }, structure: { classPropertyName: "structure", publicName: "structure", isSignal: true, isRequired: false, transformFunction: null }, retiredColumnKeys: { classPropertyName: "retiredColumnKeys", publicName: "retiredColumnKeys", isSignal: true, isRequired: false, transformFunction: null }, fillHandle: { classPropertyName: "fillHandle", publicName: "fillHandle", isSignal: true, isRequired: false, transformFunction: null }, canUndo: { classPropertyName: "canUndo", publicName: "canUndo", isSignal: true, isRequired: false, transformFunction: null }, canRedo: { classPropertyName: "canRedo", publicName: "canRedo", isSignal: true, isRequired: false, transformFunction: null }, resizableColumns: { classPropertyName: "resizableColumns", publicName: "resizableColumns", isSignal: true, isRequired: false, transformFunction: null }, reorderableColumns: { classPropertyName: "reorderableColumns", publicName: "reorderableColumns", isSignal: true, isRequired: false, transformFunction: null }, columnWidths: { classPropertyName: "columnWidths", publicName: "columnWidths", isSignal: true, isRequired: false, transformFunction: null }, minColumnWidth: { classPropertyName: "minColumnWidth", publicName: "minColumnWidth", isSignal: true, isRequired: false, transformFunction: null }, contextMenu: { classPropertyName: "contextMenu", publicName: "contextMenu", isSignal: true, isRequired: false, transformFunction: null }, mergeable: { classPropertyName: "mergeable", publicName: "mergeable", isSignal: true, isRequired: false, transformFunction: null }, formulas: { classPropertyName: "formulas", publicName: "formulas", isSignal: true, isRequired: false, transformFunction: null }, virtual: { classPropertyName: "virtual", publicName: "virtual", isSignal: true, isRequired: false, transformFunction: null }, rowHeight: { classPropertyName: "rowHeight", publicName: "rowHeight", isSignal: true, isRequired: false, transformFunction: null }, editOn: { classPropertyName: "editOn", publicName: "editOn", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { columnWidths: "columnWidthsChange", commit: "commit", pasted: "pasted", cleared: "cleared", selectionChange: "selectionChange", insertRequested: "insertRequested", deleteRequested: "deleteRequested", filled: "filled", undoRequested: "undoRequested", redoRequested: "redoRequested", columnMoved: "columnMoved", mergeRequested: "mergeRequested", unmergeRequested: "unmergeRequested" }, host: { attributes: { "ngSkipHydration": "true" }, listeners: { "document:mouseup": "endDrag()", "document:mousemove": "onPointerMove($event)", "document:mousedown": "closeMenu()", "document:keydown.escape": "closeMenu()" }, properties: { "class.hub-spreadsheet--readonly": "readonly()", "class.hub-spreadsheet--editing": "editing()", "class.hub-spreadsheet--virtual": "virtual()" }, classAttribute: "hub-spreadsheet" }, providers: [{ provide: HUB_TRANSLATION_PREFIX, useValue: 'HUBUI.SPREADSHEET' }], queries: [{ propertyName: "customEditors", predicate: HubSpreadsheetEditorDirective, isSignal: true }, { propertyName: "customCells", predicate: HubSpreadsheetCellDirective, isSignal: true }], viewQueries: [{ propertyName: "viewport", first: true, predicate: ["viewport"], descendants: true, isSignal: true }], ngImport: i0, template: "<!--\n\tThe off-screen field exists for one reason: a browser only dispatches `paste` to something\n\teditable. The paste shortcut parks the focus here for the length of the event and the cell\n\ttakes it straight back, so the reader never notices and the grid keeps its own focus model.\n-->\n<textarea class=\"hub-spreadsheet__sink\" tabindex=\"-1\" aria-hidden=\"true\" readonly (paste)=\"onPaste($event)\"></textarea>\n\n<div class=\"hub-spreadsheet__viewport\" #viewport (scroll)=\"onViewportScroll()\">\n\t<table\n\t\tclass=\"hub-spreadsheet__table\"\n\t\trole=\"grid\"\n\t\t[attr.aria-rowcount]=\"bounds().rows\"\n\t\t[attr.aria-colcount]=\"bounds().cols\"\n\t\t[attr.aria-readonly]=\"readonly() || null\"\n\t\t(keydown)=\"onKeydown($event)\"\n\t\t(copy)=\"onCopy($event, false)\"\n\t\t(cut)=\"onCopy($event, true)\"\n\t>\n\t\t<thead class=\"hub-spreadsheet__head\">\n\t\t\t<tr role=\"row\">\n\t\t\t\t<!-- Holds the width of the columns that are not drawn, so the sheet is as wide as it\n\t\t\t\tsays it is and the ones that are drawn land where they belong. -->\n\t\t\t\t@if (columnWindow().before > 0) {\n\t\t\t\t\t<!-- A minimum, not a width: in a table that shares its space out, a width is a\n\t\t\t\t\tsuggestion the algorithm is free to ignore, and an empty cell asked for three\n\t\t\t\t\tthousand pixels came out two wide. -->\n\t\t\t\t\t<th\n\t\t\t\t\t\tclass=\"hub-spreadsheet__spacer\"\n\t\t\t\t\t\trole=\"presentation\"\n\t\t\t\t\t\t[style.min-inline-size.px]=\"columnWindow().before\"\n\t\t\t\t\t\t[style.inline-size.px]=\"columnWindow().before\"\n\t\t\t\t\t></th>\n\t\t\t\t}\n\n\t\t\t\t@for (columnIndex of drawnColumns(); track trackColumn(columnIndex, columns()[columnIndex])) {\n\t\t\t\t\t@let column = columns()[columnIndex];\n\t\t\t\t\t<th\n\t\t\t\t\t\tclass=\"hub-spreadsheet__header\"\n\t\t\t\t\t\trole=\"columnheader\"\n\t\t\t\t\t\tscope=\"col\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--frozen]=\"isFrozenColumn(columnIndex)\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--frozen-edge]=\"isColumnEdge(columnIndex)\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--end]=\"column.align === 'end'\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--center]=\"column.align === 'center'\"\n\t\t\t\t\t\t[style.inset-inline-start.px]=\"columnOffsetOf(columnIndex)\"\n\t\t\t\t\t\t[style.min-width]=\"column.minWidth\"\n\t\t\t\t\t\t[style.width.px]=\"widthOf(columnIndex)\"\n\t\t\t\t\t\t[attr.data-header]=\"columnIndex\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--end-of-sheet]=\"columnIndex === bounds().cols - 1\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--moving]=\"isMovingColumn(columnIndex)\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--move-target]=\"isMoveTarget(columnIndex)\"\n\t\t\t\t\t\t(mousedown)=\"onHeaderMouseDown($event, columnIndex)\"\n\t\t\t\t\t\t(mouseenter)=\"onHeaderMouseEnter(columnIndex)\"\n\t\t\t\t\t\t[attr.aria-colindex]=\"columnIndex + 1\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span [class.hub-spreadsheet__header-text--hidden]=\"column.headerHidden\">\n\t\t\t\t\t\t\t{{ column.header }}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<!-- The name a formula calls this column, which is not the name on the header.\n\t\t\t\t\tShown only while one is being written, and clickable, because the alternative is\n\t\t\t\t\tknowing it by heart. -->\n\t\t\t\t\t\t@if (writingFormula()) {\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__alias\"\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\ttabindex=\"-1\"\n\t\t\t\t\t\t\t\t(mousedown)=\"\n\t\t\t\t\t\t\t\t\t$event.preventDefault();\n\t\t\t\t\t\t\t\t\tuseSuggestion({ label: column.key, kind: 'column', insert: '[' + column.key + ']' })\n\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__alias-letter\">{{ columnLetter(columnIndex) }}</span>\n\t\t\t\t\t\t\t\t{{ column.key }}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t@if (column.subheader) {\n\t\t\t\t\t\t\t<small class=\"hub-spreadsheet__subheader\">{{ column.subheader }}</small>\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t@if (resizableColumns()) {\n\t\t\t\t\t\t\t<!-- Sits inside the header's trailing edge. Hidden from assistive technology:\n\t\t\t\t\t\t\ta width is a matter of taste, and a screen reader has no use for a drag\n\t\t\t\t\t\t\ttarget it cannot aim at. -->\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__resize-grip\"\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t(mousedown)=\"onResizeStart($event, columnIndex)\"\n\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t}\n\t\t\t\t\t</th>\n\t\t\t\t}\n\n\t\t\t\t@if (columnWindow().after > 0) {\n\t\t\t\t\t<th\n\t\t\t\t\t\tclass=\"hub-spreadsheet__spacer\"\n\t\t\t\t\t\trole=\"presentation\"\n\t\t\t\t\t\t[style.min-inline-size.px]=\"columnWindow().after\"\n\t\t\t\t\t\t[style.inline-size.px]=\"columnWindow().after\"\n\t\t\t\t\t></th>\n\t\t\t\t}\n\t\t\t</tr>\n\t\t</thead>\n\n\t\t<tbody class=\"hub-spreadsheet__body\">\n\t\t\t<!-- The rows above the window are not in the document; this holds their height, so the\n\t\t\tscrollbar still says how long the sheet is and the rows that are drawn land where they\n\t\t\tbelong. Hidden from assistive technology, which reads `aria-rowindex` instead. -->\n\t\t\t@if (rowWindow().before > 0) {\n\t\t\t\t<tr class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\">\n\t\t\t\t\t<td [attr.colspan]=\"drawnCellCount()\" [style.block-size.px]=\"rowWindow().before\"></td>\n\t\t\t\t</tr>\n\t\t\t}\n\n\t\t\t@for (rowIndex of drawnRows(); track trackRow(rowIndex)) {\n\t\t\t\t<tr\n\t\t\t\t\tclass=\"hub-spreadsheet__row\"\n\t\t\t\t\trole=\"row\"\n\t\t\t\t\t[class.hub-spreadsheet__row--frozen]=\"isFrozenRow(rowIndex)\"\n\t\t\t\t\t[class.hub-spreadsheet__row--frozen-edge]=\"isRowEdge(rowIndex)\"\n\t\t\t\t\t[attr.aria-rowindex]=\"rowIndex + 1\"\n\t\t\t\t>\n\t\t\t\t\t@if (columnWindow().before > 0) {\n\t\t\t\t\t\t<td class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\"></td>\n\t\t\t\t\t}\n\n\t\t\t\t\t@for (colIndex of drawnColumns(); track trackColumn(colIndex, columns()[colIndex])) {\n\t\t\t\t\t\t@let column = columns()[colIndex];\n\t\t\t\t\t\t<!-- A cell swallowed by a merge is not rendered at all: the anchor's rowspan and\n\t\t\t\t\t\tcolspan already occupy its place, and drawing it would push the row out of shape. -->\n\t\t\t\t\t\t@if (!isHidden(rowIndex, colIndex)) {\n\t\t\t\t\t\t\t@let cell = grid()[rowIndex][colIndex];\n\t\t\t\t\t\t\t@let state = stateOf(rowIndex, colIndex);\n\t\t\t\t\t\t\t@let error = errorOf(rowIndex, colIndex);\n\t\t\t\t\t\t\t@let editable = canEdit(rowIndex, colIndex);\n\t\t\t\t\t\t\t<!-- Worked out once per cell, and only for the one that is open: the config a\n\t\t\t\t\t\tregistered control adapter is asked for, or null when there is nothing to host. -->\n\t\t\t\t\t\t\t@let hosted = editing() && isActive(rowIndex, colIndex) ? hostedControl(rowIndex, colIndex) : null;\n\n\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__cell\"\n\t\t\t\t\t\t\t\trole=\"gridcell\"\n\t\t\t\t\t\t\t\t[attr.data-cell]=\"rowIndex + '-' + colIndex\"\n\t\t\t\t\t\t\t\t[attr.tabindex]=\"tabIndexOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-colindex]=\"colIndex + 1\"\n\t\t\t\t\t\t\t\t[attr.rowspan]=\"rowSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.colspan]=\"colSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-rowspan]=\"rowSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-colspan]=\"colSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-selected]=\"isSelected(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-readonly]=\"!editable || null\"\n\t\t\t\t\t\t\t\t[attr.aria-invalid]=\"(isActive(rowIndex, colIndex) && invalid()) || error ? true : null\"\n\t\t\t\t\t\t\t\t[attr.aria-busy]=\"state === 'saving' || null\"\n\t\t\t\t\t\t\t\t[attr.title]=\"error ?? cell.title\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--active]=\"isActive(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--editing]=\"editing() && isActive(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--selected]=\"isSelected(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--end-of-sheet]=\"reachesEnd(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--filling]=\"isFillPreview(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--readonly]=\"!editable\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--muted]=\"cell.muted\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--end]=\"column.align === 'end'\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--center]=\"column.align === 'center'\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-col]=\"isFrozenColumn(colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-row]=\"isFrozenRow(rowIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-col-edge]=\"isColumnEdge(colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-row-edge]=\"isRowEdge(rowIndex)\"\n\t\t\t\t\t\t\t\t[style.inset-inline-start.px]=\"columnOffsetOf(colIndex)\"\n\t\t\t\t\t\t\t\t[style.inset-block-start.px]=\"rowOffsetOf(rowIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--invalid]=\"\n\t\t\t\t\t\t\t\t\t(isActive(rowIndex, colIndex) && invalid()) ||\n\t\t\t\t\t\t\t\t\t!!error ||\n\t\t\t\t\t\t\t\t\thasFormulaError(rowIndex, colIndex)\n\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\t[attr.data-state]=\"state\"\n\t\t\t\t\t\t\t\t(mousedown)=\"onMouseDown($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t(mouseenter)=\"onMouseEnter(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t(mouseup)=\"onMouseUp($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t(dblclick)=\"onDoubleClick()\"\n\t\t\t\t\t\t\t\t(contextmenu)=\"onContextMenu($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t@if (editing() && isActive(rowIndex, colIndex)) {\n\t\t\t\t\t\t\t\t\t@if (editorFor(colIndex); as custom) {\n\t\t\t\t\t\t\t\t\t\t<!-- The host supplied an editor for this column. It reports a value through\n\t\t\t\t\t\t\t\t\t`commit`, or walks away through `cancel`; until it does one of the two the\n\t\t\t\t\t\t\t\t\teditor stays open, so a picker may take as many clicks as it needs. -->\n\t\t\t\t\t\t\t\t\t\t<div class=\"hub-spreadsheet__custom-editor\">\n\t\t\t\t\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"custom.template\"\n\t\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"editorContext(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t} @else if (hosted) {\n\t\t\t\t\t\t\t\t\t\t<!-- A control adapter is registered and this column is a list, so the cell\n\t\t\t\t\t\t\t\t\topens with whatever the project wired instead of the native list. The\n\t\t\t\t\t\t\t\t\twrapper is the supplied editor's, because from the sheet's side this is\n\t\t\t\t\t\t\t\t\tone: something it did not build, filling the cell. -->\n\t\t\t\t\t\t\t\t\t\t<div class=\"hub-spreadsheet__custom-editor\">\n\t\t\t\t\t\t\t\t\t\t\t<ng-container [hubSpreadsheetControl]=\"hosted\" />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t} @else if (column.kind === 'select') {\n\t\t\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__editor hub-spreadsheet__editor--select\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"column.header\"\n\t\t\t\t\t\t\t\t\t\t\t(change)=\"commitFromEditor($any($event.target).value || null)\"\n\t\t\t\t\t\t\t\t\t\t\t(keydown)=\"onEditorKeydown($event)\"\n\t\t\t\t\t\t\t\t\t\t\t(blur)=\"onEditorBlur()\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<option value=\"\" [selected]=\"cell.value === null\"></option>\n\t\t\t\t\t\t\t\t\t\t\t@for (option of column.options ?? []; track option.value) {\n\t\t\t\t\t\t\t\t\t\t\t\t<option\n\t\t\t\t\t\t\t\t\t\t\t\t\t[value]=\"option.value\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t[selected]=\"isChosen(option, column)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t[disabled]=\"option.disabled && option.value !== cell.value\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{{ option.label }}\n\t\t\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t\t@if (suggesting()) {\n\t\t\t\t\t\t\t\t\t\t\t<!-- What can go into the formula, because the aliases it needs are nowhere\n\t\t\t\t\t\t\t\t\t\telse on screen. Hidden from assistive technology: the field owns the\n\t\t\t\t\t\t\t\t\t\tconversation, and a list that announced itself over every keystroke\n\t\t\t\t\t\t\t\t\t\twould talk across it. -->\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"hub-spreadsheet__suggestions\" role=\"presentation\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t\t\t\t@for (suggestion of suggestions(); track suggestion.label; let index = $index) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__suggestion\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttabindex=\"-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t[class.hub-spreadsheet__suggestion--on]=\"index === suggestionIndex()\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t(mousedown)=\"$event.preventDefault(); useSuggestion(suggestion)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__suggestion-name\">{{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsuggestion.label\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t@if (suggestion.hint) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__suggestion-hint\">{{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsuggestion.hint\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__editor\"\n\t\t\t\t\t\t\t\t\t\t\t[type]=\"column.kind === 'date' ? 'date' : 'text'\"\n\t\t\t\t\t\t\t\t\t\t\t[value]=\"draft()\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"column.header\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-invalid]=\"invalid() || null\"\n\t\t\t\t\t\t\t\t\t\t\t(input)=\"onDraftInput($event)\"\n\t\t\t\t\t\t\t\t\t\t\t(keydown)=\"onEditorKeydown($event)\"\n\t\t\t\t\t\t\t\t\t\t\t(blur)=\"onEditorBlur()\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t@if (column.kind === 'boolean') {\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__check\"\n\t\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\t\t[checked]=\"isOn(cell)\"\n\t\t\t\t\t\t\t\t\t\t\t[disabled]=\"!editable\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"column.header\"\n\t\t\t\t\t\t\t\t\t\t\t(change)=\"toggleBoolean(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t\t(click)=\"$event.stopPropagation()\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t} @else if (cellTemplateFor(colIndex); as own) {\n\t\t\t\t\t\t\t\t\t\t<!-- The host draws this column's cells. The value still travels to the\n\t\t\t\t\t\t\t\t\tclipboard rather than the drawing, and the cell is still what the\n\t\t\t\t\t\t\t\t\tkeyboard is on: the template is inside it, not instead of it. -->\n\t\t\t\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"own.template\"\n\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"cellContext(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t\t<!-- The row's number, so a coordinate can be read off the sheet instead of\n\t\t\t\t\t\t\t\t\tcounted. Only while a formula is being written, and only in the first\n\t\t\t\t\t\t\t\t\tcolumn drawn, which is where a spreadsheet puts it. -->\n\t\t\t\t\t\t\t\t\t\t@if (writingFormula() && colIndex === drawnColumns()[0]) {\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__rownum\" aria-hidden=\"true\">{{ rowIndex + 1 }}</span>\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__value\">{{ displayOf(cell, column) }}</span>\n\n\t\t\t\t\t\t\t\t\t\t@if (column.placeholder && !displayOf(cell, column)) {\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__placeholder\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t\t\t\t{{ column.placeholder }}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t<!-- Says the cell opens onto a list or a calendar rather than a field, and\n\t\t\t\t\t\t\t\t\topens it on one click \u2014 as a spreadsheet drops the list of a cell that\n\t\t\t\t\t\t\t\t\thas one. Shown on the cell the reader is on and on the one under the\n\t\t\t\t\t\t\t\t\tpointer. Hidden from assistive technology: the keyboard opens the same\n\t\t\t\t\t\t\t\t\tcell with Alt and the down arrow, and reads the editor itself. -->\n\t\t\t\t\t\t\t\t\t\t@if (editable && affordanceOf(column, colIndex); as affordance) {\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__affordance\"\n\t\t\t\t\t\t\t\t\t\t\t\t[class.hub-spreadsheet__affordance--date]=\"affordance === 'date'\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t\t(mousedown)=\"onAffordanceDown($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t@if (cell.secondary) {\n\t\t\t\t\t\t\t\t\t\t<small class=\"hub-spreadsheet__secondary\">{{ cell.secondary }}</small>\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t@if (hasFillHandle(rowIndex, colIndex)) {\n\t\t\t\t\t\t\t\t\t\t<!-- Drawn inside the corner cell so it travels with the selection. Hidden from\n\t\t\t\t\t\t\t\t\tassistive technology: filling is available from the keyboard through the\n\t\t\t\t\t\t\t\t\tclipboard, and a screen reader has no use for a drag target. -->\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__fill-handle\"\n\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t(mousedown)=\"onFillHandleDown($event)\"\n\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t@if (columnWindow().after > 0) {\n\t\t\t\t\t\t<td class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\"></td>\n\t\t\t\t\t}\n\t\t\t\t</tr>\n\t\t\t} @empty {\n\t\t\t\t<tr class=\"hub-spreadsheet__row hub-spreadsheet__row--empty\" role=\"row\">\n\t\t\t\t\t<td class=\"hub-spreadsheet__empty\" role=\"gridcell\" [attr.colspan]=\"bounds().cols || 1\">\n\t\t\t\t\t\t{{ emptyText() }}\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t}\n\t\t\t@if (rowWindow().after > 0) {\n\t\t\t\t<tr class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\">\n\t\t\t\t\t<td [attr.colspan]=\"drawnCellCount()\" [style.block-size.px]=\"rowWindow().after\"></td>\n\t\t\t\t</tr>\n\t\t\t}\n\t\t</tbody>\n\t</table>\n</div>\n\n@if (menuAt(); as at) {\n\t<!-- Positioned against the host, which is the sheet's own corner, so the menu travels with a\n\tscrolled page and never has to be repositioned on scroll. -->\n\t<div\n\t\tclass=\"hub-spreadsheet__menu\"\n\t\trole=\"menu\"\n\t\t[attr.aria-label]=\"'ROW_ACTIONS' | translate\"\n\t\t[style.inset-inline-start.px]=\"at.x\"\n\t\t[style.inset-block-start.px]=\"at.y\"\n\t\t(mousedown)=\"$event.stopPropagation()\"\n\t>\n\t\t@for (entry of menuEntries(); track entry.id) {\n\t\t\t<button class=\"hub-spreadsheet__menu-item\" type=\"button\" role=\"menuitem\" (click)=\"runMenuEntry(entry.id)\">\n\t\t\t\t{{ entry.label | translate: { count: entry.count } }}\n\t\t\t</button>\n\t\t}\n\t</div>\n}\n", styles: ["@charset \"UTF-8\";:host{position:relative;--hub-spreadsheet-bg: var(--hub-table-bg, var(--hub-sys-color-surface, #fff));--hub-spreadsheet-color: var(--hub-table-color, var(--hub-sys-color-text, #212529));--hub-spreadsheet-border-color: var(--hub-table-border-color, var(--hub-sys-border-color-default, #dee2e6));--hub-spreadsheet-border-width: var(--hub-table-border-width, 1px);--hub-spreadsheet-border-radius: var(--hub-table-border-radius, var(--hub-sys-radius-lg, .5rem));--hub-spreadsheet-cell-padding-x: var(--hub-table-cell-padding-x, .6rem);--hub-spreadsheet-cell-padding-y: var(--hub-table-cell-padding-y, .4rem);--hub-spreadsheet-cell-line-height: 1.5em;--hub-spreadsheet-header-bg: var(--hub-table-container-bg, var(--hub-sys-color-surface-subtle, #f8f9fa));--hub-spreadsheet-header-color: var(--hub-sys-text-muted, #6a737b);--hub-spreadsheet-header-font-size: .8125rem;--hub-spreadsheet-header-font-weight: 600;--hub-spreadsheet-cursor-color: var(--hub-sys-color-primary, #0d6efd);--hub-spreadsheet-cursor-width: 2px;--hub-spreadsheet-selection-bg: color-mix(in srgb, var(--hub-spreadsheet-cursor-color) 12%, transparent);--hub-spreadsheet-readonly-bg: color-mix(in srgb, var(--hub-spreadsheet-header-bg) 55%, var(--hub-spreadsheet-bg));--hub-spreadsheet-affordance-color: var(--hub-select-arrow-color, var(--hub-sys-text-muted, #6a737b));--hub-spreadsheet-affordance-size: var(--hub-select-arrow-size, 5px);--hub-spreadsheet-affordance-gap: var(--hub-select-arrow-gap, .5rem);--hub-spreadsheet-affordance-field-border: var(--hub-input-border-width, 1px);--hub-spreadsheet-placeholder-color: var(--hub-sys-text-muted, #6a737b);--hub-spreadsheet-placeholder-opacity: .65;--hub-spreadsheet-placeholder-transition: var(--hub-sys-transition-fast, all .15s ease-in-out);--hub-spreadsheet-readonly-color: var(--hub-sys-text-muted, #6a737b);--hub-spreadsheet-editor-bg: var(--hub-spreadsheet-bg);--hub-spreadsheet-editor-color: var(--hub-spreadsheet-color);--hub-spreadsheet-editor-field-bg: transparent;--hub-spreadsheet-editor-field-border-color: transparent;--hub-spreadsheet-editor-field-border-radius: 0;--hub-spreadsheet-editor-field-focus-shadow: none;--hub-spreadsheet-state-pending-color: var(--hub-sys-color-warning, #cc8a00);--hub-spreadsheet-state-saving-color: var(--hub-sys-color-info, #0a7ea4);--hub-spreadsheet-state-saved-color: var(--hub-sys-color-success, #1a7f37);--hub-spreadsheet-state-error-color: var(--hub-sys-color-danger, #b3261e);--hub-spreadsheet-state-conflict-color: var(--hub-sys-color-danger, #b3261e);--hub-spreadsheet-state-bar-width: 3px;--hub-spreadsheet-suggestions-bg: var(--hub-spreadsheet-menu-bg);--hub-spreadsheet-suggestions-border-color: var(--hub-spreadsheet-menu-border-color);--hub-spreadsheet-suggestion-on-bg: var(--hub-spreadsheet-selection-bg);--hub-spreadsheet-alias-bg: color-mix(in srgb, var(--hub-spreadsheet-cursor-color) 12%, transparent);--hub-spreadsheet-alias-color: var(--hub-spreadsheet-cursor-color);--hub-spreadsheet-menu-bg: var(--hub-sys-color-surface, #fff);--hub-spreadsheet-menu-color: var(--hub-spreadsheet-color);--hub-spreadsheet-menu-border-color: var(--hub-spreadsheet-border-color);--hub-spreadsheet-menu-radius: var(--hub-sys-radius-sm, .25rem);--hub-spreadsheet-menu-shadow: 0 6px 20px rgb(0 0 0 / 18%);--hub-spreadsheet-menu-item-padding-x: .85rem;--hub-spreadsheet-menu-item-padding-y: .4rem;--hub-spreadsheet-menu-item-bg-hover: var(--hub-spreadsheet-selection-bg);--hub-spreadsheet-resize-grip-width: 5px;--hub-spreadsheet-resize-grip-color: var(--hub-spreadsheet-cursor-color);--hub-spreadsheet-moving-opacity: .5;--hub-spreadsheet-fill-handle-size: 7px;--hub-spreadsheet-fill-handle-color: var(--hub-spreadsheet-cursor-color);--hub-spreadsheet-fill-preview-bg: color-mix(in srgb, var(--hub-spreadsheet-cursor-color) 6%, transparent);--hub-spreadsheet-invalid-color: var(--hub-sys-color-danger, #b3261e);--hub-spreadsheet-invalid-border-width: 1px;--hub-spreadsheet-invalid-bg: color-mix(in srgb, var(--hub-spreadsheet-invalid-color) 10%, transparent);--hub-spreadsheet-max-block-size: var(--hub-table-container-max-block-size, none);--hub-spreadsheet-frozen-divider-color: var(--hub-spreadsheet-border-color, var(--hub-sys-border-color-default, #dee2e6));--hub-spreadsheet-frozen-divider-width: 2px;display:block;color:var(--hub-spreadsheet-color)}.hub-spreadsheet__sink{position:absolute;inline-size:1px;block-size:1px;padding:0;border:0;opacity:0;pointer-events:none;inset-block-start:0;inset-inline-start:0}:host(.hub-spreadsheet--virtual){--hub-spreadsheet-max-block-size: var(--hub-table-container-max-block-size, 24rem)}.hub-spreadsheet__viewport{position:relative;overflow:auto;max-block-size:var(--hub-spreadsheet-max-block-size);border:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-border-color);border-radius:var(--hub-spreadsheet-border-radius);background:var(--hub-spreadsheet-bg)}.hub-spreadsheet__table{inline-size:100%;border-collapse:separate;border-spacing:0;font-variant-numeric:tabular-nums}.hub-spreadsheet__header,.hub-spreadsheet__cell{--hub-spreadsheet-cell-edge-inline: var(--hub-spreadsheet-border-width);--hub-spreadsheet-cell-edge-block: var(--hub-spreadsheet-border-width);padding:var(--hub-spreadsheet-cell-padding-y) var(--hub-spreadsheet-cell-padding-x);border-inline-end:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-border-color);border-block-end:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-border-color);background:var(--hub-spreadsheet-bg);white-space:nowrap;text-align:start}.hub-spreadsheet__header--end-of-sheet,.hub-spreadsheet__cell--end-of-sheet{--hub-spreadsheet-cell-edge-inline: 0px;border-inline-end:0}.hub-spreadsheet__header{position:sticky;isolation:isolate;inset-block-start:0;z-index:2;background:var(--hub-spreadsheet-header-bg);color:var(--hub-spreadsheet-header-color);font-size:var(--hub-spreadsheet-header-font-size);font-weight:var(--hub-spreadsheet-header-font-weight)}.hub-spreadsheet__header--moving{opacity:var(--hub-spreadsheet-moving-opacity)}.hub-spreadsheet__header--move-target{outline:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);outline-offset:calc(var(--hub-spreadsheet-cursor-width) * -1)}.hub-spreadsheet__header--frozen{inset-inline-start:0;z-index:4}.hub-spreadsheet__header--frozen-edge{border-inline-end:var(--hub-spreadsheet-frozen-divider-width) solid var(--hub-spreadsheet-frozen-divider-color)}.hub-spreadsheet__header--end{text-align:end}.hub-spreadsheet__header--center{text-align:center}.hub-spreadsheet__header-text--hidden{position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.hub-spreadsheet__subheader{display:block;font-weight:400;opacity:.8}.hub-spreadsheet__cell{position:relative;cursor:cell;-webkit-user-select:none;user-select:none}.hub-spreadsheet__cell--end{text-align:end}.hub-spreadsheet__cell--center{text-align:center}.hub-spreadsheet__cell--frozen-col,.hub-spreadsheet__cell--frozen-row{position:sticky;z-index:1}.hub-spreadsheet__cell--frozen-col{inset-inline-start:0}.hub-spreadsheet__cell--frozen-col.hub-spreadsheet__cell--frozen-row{z-index:2}.hub-spreadsheet__cell--frozen-col-edge{--hub-spreadsheet-cell-edge-inline: var(--hub-spreadsheet-frozen-divider-width);border-inline-end:var(--hub-spreadsheet-frozen-divider-width) solid var(--hub-spreadsheet-frozen-divider-color)}.hub-spreadsheet__cell--frozen-row-edge{--hub-spreadsheet-cell-edge-block: var(--hub-spreadsheet-frozen-divider-width);border-block-end:var(--hub-spreadsheet-frozen-divider-width) solid var(--hub-spreadsheet-frozen-divider-color)}.hub-spreadsheet__cell--filling{background-image:linear-gradient(var(--hub-spreadsheet-fill-preview-bg),var(--hub-spreadsheet-fill-preview-bg))}.hub-spreadsheet__cell--selected{background-image:linear-gradient(var(--hub-spreadsheet-selection-bg),var(--hub-spreadsheet-selection-bg))}.hub-spreadsheet__cell--readonly{background-color:var(--hub-spreadsheet-readonly-bg);color:var(--hub-spreadsheet-readonly-color);cursor:default}.hub-spreadsheet__cell--muted{color:var(--hub-spreadsheet-readonly-color)}.hub-spreadsheet__cell--invalid{background-image:linear-gradient(var(--hub-spreadsheet-invalid-bg),var(--hub-spreadsheet-invalid-bg));color:var(--hub-spreadsheet-invalid-color);box-shadow:inset 0 0 0 var(--hub-spreadsheet-invalid-border-width) var(--hub-spreadsheet-invalid-color)}.hub-spreadsheet__cell--active{outline:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);outline-offset:calc(var(--hub-spreadsheet-cursor-width) * -1);z-index:2}.hub-spreadsheet__cell--editing{block-size:calc(var(--hub-spreadsheet-cell-line-height) + var(--hub-spreadsheet-cell-padding-y) * 2 + var(--hub-spreadsheet-cell-edge-block))}.hub-spreadsheet__cell--active.hub-spreadsheet__cell--frozen-col,.hub-spreadsheet__cell--active.hub-spreadsheet__cell--frozen-row{z-index:3}.hub-spreadsheet__cell[data-state]:before{content:\"\";position:absolute;inset-block:0;inset-inline-start:0;inline-size:var(--hub-spreadsheet-state-bar-width)}.hub-spreadsheet__cell[data-state=pending]:before{background:var(--hub-spreadsheet-state-pending-color)}.hub-spreadsheet__cell[data-state=saving]:before{background:var(--hub-spreadsheet-state-saving-color)}.hub-spreadsheet__cell[data-state=saved]:before{background:var(--hub-spreadsheet-state-saved-color)}.hub-spreadsheet__cell[data-state=error]:before{background:var(--hub-spreadsheet-state-error-color)}.hub-spreadsheet__cell[data-state=conflict]:before{background:repeating-linear-gradient(45deg,var(--hub-spreadsheet-state-conflict-color) 0 3px,transparent 3px 6px)}.hub-spreadsheet__editor--select{padding-inline:calc(var(--hub-spreadsheet-cell-padding-x) - 2px);appearance:auto}.hub-spreadsheet__custom-editor{position:absolute;inset-block-start:0;inset-inline-start:0;inset-block-end:calc(var(--hub-spreadsheet-cell-edge-block) * -1);inset-inline-end:calc(var(--hub-spreadsheet-cell-edge-inline) * -1);display:grid;background:var(--hub-spreadsheet-editor-bg);outline:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);outline-offset:calc(var(--hub-spreadsheet-cursor-width) * -1);z-index:3;--hub-input-bg: var(--hub-spreadsheet-editor-field-bg);--hub-input-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-input-border-radius: var(--hub-spreadsheet-editor-field-border-radius);--hub-input-focus-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-input-focus-box-shadow: var(--hub-spreadsheet-editor-field-focus-shadow);--hub-input-padding-x: var(--hub-spreadsheet-cell-padding-x);--hub-select-bg: var(--hub-spreadsheet-editor-field-bg);--hub-select-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-select-border-radius: var(--hub-spreadsheet-editor-field-border-radius);--hub-select-focus-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-select-focus-box-shadow: var(--hub-spreadsheet-editor-field-focus-shadow);--hub-select-padding-x: var(--hub-spreadsheet-cell-padding-x)}.hub-spreadsheet__spacer>td{padding:0;border:0;background:var(--hub-spreadsheet-bg)}.hub-spreadsheet__affordance{position:absolute;inset-block:0;inset-inline-end:calc(var(--hub-spreadsheet-affordance-field-border) - var(--hub-spreadsheet-cell-edge-inline));display:flex;align-items:center;padding-inline:var(--hub-spreadsheet-affordance-gap);cursor:pointer;opacity:0}.hub-spreadsheet__cell:hover>.hub-spreadsheet__affordance,.hub-spreadsheet__cell--active>.hub-spreadsheet__affordance{opacity:1}.hub-spreadsheet__affordance:after{content:\"\";inline-size:0;block-size:0;border-style:solid;border-width:var(--hub-spreadsheet-affordance-size) var(--hub-spreadsheet-affordance-size) calc(var(--hub-spreadsheet-affordance-size) / 2);border-color:var(--hub-spreadsheet-affordance-color) transparent transparent}.hub-spreadsheet__affordance--date:after{inline-size:calc(var(--hub-spreadsheet-affordance-size) * 2);block-size:calc(var(--hub-spreadsheet-affordance-size) * 1.8);border:1px solid var(--hub-spreadsheet-affordance-color);border-block-start-width:3px;border-radius:2px}.hub-spreadsheet__placeholder{position:absolute;inset-block:0;inset-inline:var(--hub-spreadsheet-cell-padding-x);display:flex;align-items:center;color:var(--hub-spreadsheet-placeholder-color);opacity:0;pointer-events:none;transition:var(--hub-spreadsheet-placeholder-transition)}.hub-spreadsheet__cell:hover>.hub-spreadsheet__placeholder{opacity:var(--hub-spreadsheet-placeholder-opacity)}.hub-spreadsheet__check{inline-size:1em;block-size:1em;accent-color:var(--hub-spreadsheet-cursor-color);cursor:pointer}.hub-spreadsheet__check:disabled{cursor:default}.hub-spreadsheet__resize-grip{position:absolute;inset-block:0;inset-inline-end:0;inline-size:var(--hub-spreadsheet-resize-grip-width);cursor:col-resize;background:transparent;z-index:5}.hub-spreadsheet__resize-grip:hover,.hub-spreadsheet__resize-grip:active{background:var(--hub-spreadsheet-resize-grip-color)}.hub-spreadsheet__fill-handle{position:absolute;inset-block-end:0;inset-inline-end:0;inline-size:var(--hub-spreadsheet-fill-handle-size);block-size:var(--hub-spreadsheet-fill-handle-size);background:var(--hub-spreadsheet-fill-handle-color);cursor:crosshair;z-index:4}.hub-spreadsheet__secondary{display:block;opacity:.75}.hub-spreadsheet__editor{position:absolute;inset-block-start:0;inset-inline-start:0;inset-block-end:calc(var(--hub-spreadsheet-cell-edge-block) * -1);inset-inline-end:calc(var(--hub-spreadsheet-cell-edge-inline) * -1);padding:var(--hub-spreadsheet-cell-padding-y) var(--hub-spreadsheet-cell-padding-x);border:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);border-radius:0;background:var(--hub-spreadsheet-editor-bg);color:var(--hub-spreadsheet-editor-color);font:inherit;font-variant-numeric:tabular-nums;text-align:inherit;outline:none;z-index:2}.hub-spreadsheet__editor[aria-invalid=true]{border-color:var(--hub-spreadsheet-invalid-color)}.hub-spreadsheet__empty{padding-block:calc(var(--hub-spreadsheet-cell-padding-y) * 4);color:var(--hub-spreadsheet-readonly-color);text-align:center;cursor:default}.hub-spreadsheet__menu{position:absolute;z-index:10;min-inline-size:12rem;padding-block:.25rem;border:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-menu-border-color);border-radius:var(--hub-spreadsheet-menu-radius);background:var(--hub-spreadsheet-menu-bg);color:var(--hub-spreadsheet-menu-color);box-shadow:var(--hub-spreadsheet-menu-shadow)}.hub-spreadsheet__menu-item{display:block;inline-size:100%;padding:var(--hub-spreadsheet-menu-item-padding-y) var(--hub-spreadsheet-menu-item-padding-x);border:0;background:transparent;color:inherit;font:inherit;text-align:start;white-space:nowrap;cursor:pointer}.hub-spreadsheet__menu-item:hover,.hub-spreadsheet__menu-item:focus-visible{background:var(--hub-spreadsheet-menu-item-bg-hover)}.hub-spreadsheet__suggestions{position:absolute;inset-block-start:100%;inset-inline-start:0;z-index:12;display:flex;flex-direction:column;min-inline-size:14rem;max-block-size:14rem;overflow-y:auto;border:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-suggestions-border-color);border-radius:var(--hub-spreadsheet-border-radius);background:var(--hub-spreadsheet-suggestions-bg);box-shadow:var(--hub-spreadsheet-menu-shadow)}.hub-spreadsheet__suggestion{display:flex;gap:.5rem;align-items:baseline;justify-content:space-between;padding:var(--hub-spreadsheet-cell-padding-y) var(--hub-spreadsheet-cell-padding-x);border:0;background:transparent;color:inherit;font:inherit;text-align:start;cursor:pointer}.hub-spreadsheet__suggestion:hover,.hub-spreadsheet__suggestion--on{background:var(--hub-spreadsheet-suggestion-on-bg)}.hub-spreadsheet__suggestion-name{font-variant-numeric:tabular-nums}.hub-spreadsheet__suggestion-hint{color:var(--hub-spreadsheet-readonly-color);font-size:var(--hub-spreadsheet-header-font-size)}.hub-spreadsheet__alias{margin-inline-start:.4rem;padding:0 .3rem;border:0;border-radius:.25rem;background:var(--hub-spreadsheet-alias-bg);color:var(--hub-spreadsheet-alias-color);font:inherit;font-size:var(--hub-spreadsheet-header-font-size);font-weight:400;cursor:pointer}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: HubSpreadsheetControlDirective, selector: "[hubSpreadsheetControl]", inputs: ["hubSpreadsheetControl"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3716
+ }
3717
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubSpreadsheetComponent, decorators: [{
3718
+ type: Component,
3719
+ args: [{ selector: 'hub-spreadsheet', standalone: true, imports: [NgTemplateOutlet, TranslatePipe, HubSpreadsheetControlDirective], providers: [{ provide: HUB_TRANSLATION_PREFIX, useValue: 'HUBUI.SPREADSHEET' }], changeDetection: ChangeDetectionStrategy.OnPush, host: {
3720
+ /*
3721
+ * The sheet draws itself from what it measures, so what a server writes and what a browser
3722
+ * then wants are two different documents — and hydration's job is to insist they are one.
3723
+ *
3724
+ * A virtualised sheet measures a viewport that does not exist on a server and draws every
3725
+ * row; the browser measures a real one and draws fifteen. A column that draws its own cells
3726
+ * needs its template, which a content query has not resolved on the first server pass. Both
3727
+ * come out as NG0500, and neither is a mistake to be fixed — it is what this component is.
3728
+ *
3729
+ * So the markup is re-created rather than adopted. The server's copy still ships in the
3730
+ * page, which is what a crawler reads; the browser simply builds its own.
3731
+ */
3732
+ ngSkipHydration: 'true',
3733
+ class: 'hub-spreadsheet',
3734
+ '[class.hub-spreadsheet--readonly]': 'readonly()',
3735
+ '[class.hub-spreadsheet--editing]': 'editing()',
3736
+ '[class.hub-spreadsheet--virtual]': 'virtual()',
3737
+ '(document:mouseup)': 'endDrag()',
3738
+ '(document:mousemove)': 'onPointerMove($event)',
3739
+ '(document:mousedown)': 'closeMenu()',
3740
+ '(document:keydown.escape)': 'closeMenu()'
3741
+ }, template: "<!--\n\tThe off-screen field exists for one reason: a browser only dispatches `paste` to something\n\teditable. The paste shortcut parks the focus here for the length of the event and the cell\n\ttakes it straight back, so the reader never notices and the grid keeps its own focus model.\n-->\n<textarea class=\"hub-spreadsheet__sink\" tabindex=\"-1\" aria-hidden=\"true\" readonly (paste)=\"onPaste($event)\"></textarea>\n\n<div class=\"hub-spreadsheet__viewport\" #viewport (scroll)=\"onViewportScroll()\">\n\t<table\n\t\tclass=\"hub-spreadsheet__table\"\n\t\trole=\"grid\"\n\t\t[attr.aria-rowcount]=\"bounds().rows\"\n\t\t[attr.aria-colcount]=\"bounds().cols\"\n\t\t[attr.aria-readonly]=\"readonly() || null\"\n\t\t(keydown)=\"onKeydown($event)\"\n\t\t(copy)=\"onCopy($event, false)\"\n\t\t(cut)=\"onCopy($event, true)\"\n\t>\n\t\t<thead class=\"hub-spreadsheet__head\">\n\t\t\t<tr role=\"row\">\n\t\t\t\t<!-- Holds the width of the columns that are not drawn, so the sheet is as wide as it\n\t\t\t\tsays it is and the ones that are drawn land where they belong. -->\n\t\t\t\t@if (columnWindow().before > 0) {\n\t\t\t\t\t<!-- A minimum, not a width: in a table that shares its space out, a width is a\n\t\t\t\t\tsuggestion the algorithm is free to ignore, and an empty cell asked for three\n\t\t\t\t\tthousand pixels came out two wide. -->\n\t\t\t\t\t<th\n\t\t\t\t\t\tclass=\"hub-spreadsheet__spacer\"\n\t\t\t\t\t\trole=\"presentation\"\n\t\t\t\t\t\t[style.min-inline-size.px]=\"columnWindow().before\"\n\t\t\t\t\t\t[style.inline-size.px]=\"columnWindow().before\"\n\t\t\t\t\t></th>\n\t\t\t\t}\n\n\t\t\t\t@for (columnIndex of drawnColumns(); track trackColumn(columnIndex, columns()[columnIndex])) {\n\t\t\t\t\t@let column = columns()[columnIndex];\n\t\t\t\t\t<th\n\t\t\t\t\t\tclass=\"hub-spreadsheet__header\"\n\t\t\t\t\t\trole=\"columnheader\"\n\t\t\t\t\t\tscope=\"col\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--frozen]=\"isFrozenColumn(columnIndex)\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--frozen-edge]=\"isColumnEdge(columnIndex)\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--end]=\"column.align === 'end'\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--center]=\"column.align === 'center'\"\n\t\t\t\t\t\t[style.inset-inline-start.px]=\"columnOffsetOf(columnIndex)\"\n\t\t\t\t\t\t[style.min-width]=\"column.minWidth\"\n\t\t\t\t\t\t[style.width.px]=\"widthOf(columnIndex)\"\n\t\t\t\t\t\t[attr.data-header]=\"columnIndex\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--end-of-sheet]=\"columnIndex === bounds().cols - 1\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--moving]=\"isMovingColumn(columnIndex)\"\n\t\t\t\t\t\t[class.hub-spreadsheet__header--move-target]=\"isMoveTarget(columnIndex)\"\n\t\t\t\t\t\t(mousedown)=\"onHeaderMouseDown($event, columnIndex)\"\n\t\t\t\t\t\t(mouseenter)=\"onHeaderMouseEnter(columnIndex)\"\n\t\t\t\t\t\t[attr.aria-colindex]=\"columnIndex + 1\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span [class.hub-spreadsheet__header-text--hidden]=\"column.headerHidden\">\n\t\t\t\t\t\t\t{{ column.header }}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<!-- The name a formula calls this column, which is not the name on the header.\n\t\t\t\t\tShown only while one is being written, and clickable, because the alternative is\n\t\t\t\t\tknowing it by heart. -->\n\t\t\t\t\t\t@if (writingFormula()) {\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__alias\"\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\ttabindex=\"-1\"\n\t\t\t\t\t\t\t\t(mousedown)=\"\n\t\t\t\t\t\t\t\t\t$event.preventDefault();\n\t\t\t\t\t\t\t\t\tuseSuggestion({ label: column.key, kind: 'column', insert: '[' + column.key + ']' })\n\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__alias-letter\">{{ columnLetter(columnIndex) }}</span>\n\t\t\t\t\t\t\t\t{{ column.key }}\n\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t@if (column.subheader) {\n\t\t\t\t\t\t\t<small class=\"hub-spreadsheet__subheader\">{{ column.subheader }}</small>\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t@if (resizableColumns()) {\n\t\t\t\t\t\t\t<!-- Sits inside the header's trailing edge. Hidden from assistive technology:\n\t\t\t\t\t\t\ta width is a matter of taste, and a screen reader has no use for a drag\n\t\t\t\t\t\t\ttarget it cannot aim at. -->\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__resize-grip\"\n\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t(mousedown)=\"onResizeStart($event, columnIndex)\"\n\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t}\n\t\t\t\t\t</th>\n\t\t\t\t}\n\n\t\t\t\t@if (columnWindow().after > 0) {\n\t\t\t\t\t<th\n\t\t\t\t\t\tclass=\"hub-spreadsheet__spacer\"\n\t\t\t\t\t\trole=\"presentation\"\n\t\t\t\t\t\t[style.min-inline-size.px]=\"columnWindow().after\"\n\t\t\t\t\t\t[style.inline-size.px]=\"columnWindow().after\"\n\t\t\t\t\t></th>\n\t\t\t\t}\n\t\t\t</tr>\n\t\t</thead>\n\n\t\t<tbody class=\"hub-spreadsheet__body\">\n\t\t\t<!-- The rows above the window are not in the document; this holds their height, so the\n\t\t\tscrollbar still says how long the sheet is and the rows that are drawn land where they\n\t\t\tbelong. Hidden from assistive technology, which reads `aria-rowindex` instead. -->\n\t\t\t@if (rowWindow().before > 0) {\n\t\t\t\t<tr class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\">\n\t\t\t\t\t<td [attr.colspan]=\"drawnCellCount()\" [style.block-size.px]=\"rowWindow().before\"></td>\n\t\t\t\t</tr>\n\t\t\t}\n\n\t\t\t@for (rowIndex of drawnRows(); track trackRow(rowIndex)) {\n\t\t\t\t<tr\n\t\t\t\t\tclass=\"hub-spreadsheet__row\"\n\t\t\t\t\trole=\"row\"\n\t\t\t\t\t[class.hub-spreadsheet__row--frozen]=\"isFrozenRow(rowIndex)\"\n\t\t\t\t\t[class.hub-spreadsheet__row--frozen-edge]=\"isRowEdge(rowIndex)\"\n\t\t\t\t\t[attr.aria-rowindex]=\"rowIndex + 1\"\n\t\t\t\t>\n\t\t\t\t\t@if (columnWindow().before > 0) {\n\t\t\t\t\t\t<td class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\"></td>\n\t\t\t\t\t}\n\n\t\t\t\t\t@for (colIndex of drawnColumns(); track trackColumn(colIndex, columns()[colIndex])) {\n\t\t\t\t\t\t@let column = columns()[colIndex];\n\t\t\t\t\t\t<!-- A cell swallowed by a merge is not rendered at all: the anchor's rowspan and\n\t\t\t\t\t\tcolspan already occupy its place, and drawing it would push the row out of shape. -->\n\t\t\t\t\t\t@if (!isHidden(rowIndex, colIndex)) {\n\t\t\t\t\t\t\t@let cell = grid()[rowIndex][colIndex];\n\t\t\t\t\t\t\t@let state = stateOf(rowIndex, colIndex);\n\t\t\t\t\t\t\t@let error = errorOf(rowIndex, colIndex);\n\t\t\t\t\t\t\t@let editable = canEdit(rowIndex, colIndex);\n\t\t\t\t\t\t\t<!-- Worked out once per cell, and only for the one that is open: the config a\n\t\t\t\t\t\tregistered control adapter is asked for, or null when there is nothing to host. -->\n\t\t\t\t\t\t\t@let hosted = editing() && isActive(rowIndex, colIndex) ? hostedControl(rowIndex, colIndex) : null;\n\n\t\t\t\t\t\t\t<td\n\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__cell\"\n\t\t\t\t\t\t\t\trole=\"gridcell\"\n\t\t\t\t\t\t\t\t[attr.data-cell]=\"rowIndex + '-' + colIndex\"\n\t\t\t\t\t\t\t\t[attr.tabindex]=\"tabIndexOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-colindex]=\"colIndex + 1\"\n\t\t\t\t\t\t\t\t[attr.rowspan]=\"rowSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.colspan]=\"colSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-rowspan]=\"rowSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-colspan]=\"colSpanOf(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-selected]=\"isSelected(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[attr.aria-readonly]=\"!editable || null\"\n\t\t\t\t\t\t\t\t[attr.aria-invalid]=\"(isActive(rowIndex, colIndex) && invalid()) || error ? true : null\"\n\t\t\t\t\t\t\t\t[attr.aria-busy]=\"state === 'saving' || null\"\n\t\t\t\t\t\t\t\t[attr.title]=\"error ?? cell.title\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--active]=\"isActive(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--editing]=\"editing() && isActive(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--selected]=\"isSelected(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--end-of-sheet]=\"reachesEnd(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--filling]=\"isFillPreview(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--readonly]=\"!editable\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--muted]=\"cell.muted\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--end]=\"column.align === 'end'\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--center]=\"column.align === 'center'\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-col]=\"isFrozenColumn(colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-row]=\"isFrozenRow(rowIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-col-edge]=\"isColumnEdge(colIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--frozen-row-edge]=\"isRowEdge(rowIndex)\"\n\t\t\t\t\t\t\t\t[style.inset-inline-start.px]=\"columnOffsetOf(colIndex)\"\n\t\t\t\t\t\t\t\t[style.inset-block-start.px]=\"rowOffsetOf(rowIndex)\"\n\t\t\t\t\t\t\t\t[class.hub-spreadsheet__cell--invalid]=\"\n\t\t\t\t\t\t\t\t\t(isActive(rowIndex, colIndex) && invalid()) ||\n\t\t\t\t\t\t\t\t\t!!error ||\n\t\t\t\t\t\t\t\t\thasFormulaError(rowIndex, colIndex)\n\t\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t\t\t[attr.data-state]=\"state\"\n\t\t\t\t\t\t\t\t(mousedown)=\"onMouseDown($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t(mouseenter)=\"onMouseEnter(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t(mouseup)=\"onMouseUp($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t(dblclick)=\"onDoubleClick()\"\n\t\t\t\t\t\t\t\t(contextmenu)=\"onContextMenu($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t@if (editing() && isActive(rowIndex, colIndex)) {\n\t\t\t\t\t\t\t\t\t@if (editorFor(colIndex); as custom) {\n\t\t\t\t\t\t\t\t\t\t<!-- The host supplied an editor for this column. It reports a value through\n\t\t\t\t\t\t\t\t\t`commit`, or walks away through `cancel`; until it does one of the two the\n\t\t\t\t\t\t\t\t\teditor stays open, so a picker may take as many clicks as it needs. -->\n\t\t\t\t\t\t\t\t\t\t<div class=\"hub-spreadsheet__custom-editor\">\n\t\t\t\t\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"custom.template\"\n\t\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"editorContext(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t} @else if (hosted) {\n\t\t\t\t\t\t\t\t\t\t<!-- A control adapter is registered and this column is a list, so the cell\n\t\t\t\t\t\t\t\t\topens with whatever the project wired instead of the native list. The\n\t\t\t\t\t\t\t\t\twrapper is the supplied editor's, because from the sheet's side this is\n\t\t\t\t\t\t\t\t\tone: something it did not build, filling the cell. -->\n\t\t\t\t\t\t\t\t\t\t<div class=\"hub-spreadsheet__custom-editor\">\n\t\t\t\t\t\t\t\t\t\t\t<ng-container [hubSpreadsheetControl]=\"hosted\" />\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t} @else if (column.kind === 'select') {\n\t\t\t\t\t\t\t\t\t\t<select\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__editor hub-spreadsheet__editor--select\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"column.header\"\n\t\t\t\t\t\t\t\t\t\t\t(change)=\"commitFromEditor($any($event.target).value || null)\"\n\t\t\t\t\t\t\t\t\t\t\t(keydown)=\"onEditorKeydown($event)\"\n\t\t\t\t\t\t\t\t\t\t\t(blur)=\"onEditorBlur()\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<option value=\"\" [selected]=\"cell.value === null\"></option>\n\t\t\t\t\t\t\t\t\t\t\t@for (option of column.options ?? []; track option.value) {\n\t\t\t\t\t\t\t\t\t\t\t\t<option\n\t\t\t\t\t\t\t\t\t\t\t\t\t[value]=\"option.value\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t[selected]=\"isChosen(option, column)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t[disabled]=\"option.disabled && option.value !== cell.value\"\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{{ option.label }}\n\t\t\t\t\t\t\t\t\t\t\t\t</option>\n\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t</select>\n\t\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t\t@if (suggesting()) {\n\t\t\t\t\t\t\t\t\t\t\t<!-- What can go into the formula, because the aliases it needs are nowhere\n\t\t\t\t\t\t\t\t\t\telse on screen. Hidden from assistive technology: the field owns the\n\t\t\t\t\t\t\t\t\t\tconversation, and a list that announced itself over every keystroke\n\t\t\t\t\t\t\t\t\t\twould talk across it. -->\n\t\t\t\t\t\t\t\t\t\t\t<div class=\"hub-spreadsheet__suggestions\" role=\"presentation\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t\t\t\t@for (suggestion of suggestions(); track suggestion.label; let index = $index) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__suggestion\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttabindex=\"-1\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t[class.hub-spreadsheet__suggestion--on]=\"index === suggestionIndex()\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t(mousedown)=\"$event.preventDefault(); useSuggestion(suggestion)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__suggestion-name\">{{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsuggestion.label\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t@if (suggestion.hint) {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__suggestion-hint\">{{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tsuggestion.hint\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__editor\"\n\t\t\t\t\t\t\t\t\t\t\t[type]=\"column.kind === 'date' ? 'date' : 'text'\"\n\t\t\t\t\t\t\t\t\t\t\t[value]=\"draft()\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"column.header\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-invalid]=\"invalid() || null\"\n\t\t\t\t\t\t\t\t\t\t\t(input)=\"onDraftInput($event)\"\n\t\t\t\t\t\t\t\t\t\t\t(keydown)=\"onEditorKeydown($event)\"\n\t\t\t\t\t\t\t\t\t\t\t(blur)=\"onEditorBlur()\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t@if (column.kind === 'boolean') {\n\t\t\t\t\t\t\t\t\t\t<input\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__check\"\n\t\t\t\t\t\t\t\t\t\t\ttype=\"checkbox\"\n\t\t\t\t\t\t\t\t\t\t\t[checked]=\"isOn(cell)\"\n\t\t\t\t\t\t\t\t\t\t\t[disabled]=\"!editable\"\n\t\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"column.header\"\n\t\t\t\t\t\t\t\t\t\t\t(change)=\"toggleBoolean(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t\t(click)=\"$event.stopPropagation()\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t} @else if (cellTemplateFor(colIndex); as own) {\n\t\t\t\t\t\t\t\t\t\t<!-- The host draws this column's cells. The value still travels to the\n\t\t\t\t\t\t\t\t\tclipboard rather than the drawing, and the cell is still what the\n\t\t\t\t\t\t\t\t\tkeyboard is on: the template is inside it, not instead of it. -->\n\t\t\t\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"own.template\"\n\t\t\t\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"cellContext(rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t\t<!-- The row's number, so a coordinate can be read off the sheet instead of\n\t\t\t\t\t\t\t\t\tcounted. Only while a formula is being written, and only in the first\n\t\t\t\t\t\t\t\t\tcolumn drawn, which is where a spreadsheet puts it. -->\n\t\t\t\t\t\t\t\t\t\t@if (writingFormula() && colIndex === drawnColumns()[0]) {\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__rownum\" aria-hidden=\"true\">{{ rowIndex + 1 }}</span>\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__value\">{{ displayOf(cell, column) }}</span>\n\n\t\t\t\t\t\t\t\t\t\t@if (column.placeholder && !displayOf(cell, column)) {\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"hub-spreadsheet__placeholder\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t\t\t\t{{ column.placeholder }}\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t\t<!-- Says the cell opens onto a list or a calendar rather than a field, and\n\t\t\t\t\t\t\t\t\topens it on one click \u2014 as a spreadsheet drops the list of a cell that\n\t\t\t\t\t\t\t\t\thas one. Shown on the cell the reader is on and on the one under the\n\t\t\t\t\t\t\t\t\tpointer. Hidden from assistive technology: the keyboard opens the same\n\t\t\t\t\t\t\t\t\tcell with Alt and the down arrow, and reads the editor itself. -->\n\t\t\t\t\t\t\t\t\t\t@if (editable && affordanceOf(column, colIndex); as affordance) {\n\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__affordance\"\n\t\t\t\t\t\t\t\t\t\t\t\t[class.hub-spreadsheet__affordance--date]=\"affordance === 'date'\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t\t(mousedown)=\"onAffordanceDown($event, rowIndex, colIndex)\"\n\t\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t@if (cell.secondary) {\n\t\t\t\t\t\t\t\t\t\t<small class=\"hub-spreadsheet__secondary\">{{ cell.secondary }}</small>\n\t\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t\t@if (hasFillHandle(rowIndex, colIndex)) {\n\t\t\t\t\t\t\t\t\t\t<!-- Drawn inside the corner cell so it travels with the selection. Hidden from\n\t\t\t\t\t\t\t\t\tassistive technology: filling is available from the keyboard through the\n\t\t\t\t\t\t\t\t\tclipboard, and a screen reader has no use for a drag target. -->\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-spreadsheet__fill-handle\"\n\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t\t(mousedown)=\"onFillHandleDown($event)\"\n\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</td>\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t@if (columnWindow().after > 0) {\n\t\t\t\t\t\t<td class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\"></td>\n\t\t\t\t\t}\n\t\t\t\t</tr>\n\t\t\t} @empty {\n\t\t\t\t<tr class=\"hub-spreadsheet__row hub-spreadsheet__row--empty\" role=\"row\">\n\t\t\t\t\t<td class=\"hub-spreadsheet__empty\" role=\"gridcell\" [attr.colspan]=\"bounds().cols || 1\">\n\t\t\t\t\t\t{{ emptyText() }}\n\t\t\t\t\t</td>\n\t\t\t\t</tr>\n\t\t\t}\n\t\t\t@if (rowWindow().after > 0) {\n\t\t\t\t<tr class=\"hub-spreadsheet__spacer\" role=\"presentation\" aria-hidden=\"true\">\n\t\t\t\t\t<td [attr.colspan]=\"drawnCellCount()\" [style.block-size.px]=\"rowWindow().after\"></td>\n\t\t\t\t</tr>\n\t\t\t}\n\t\t</tbody>\n\t</table>\n</div>\n\n@if (menuAt(); as at) {\n\t<!-- Positioned against the host, which is the sheet's own corner, so the menu travels with a\n\tscrolled page and never has to be repositioned on scroll. -->\n\t<div\n\t\tclass=\"hub-spreadsheet__menu\"\n\t\trole=\"menu\"\n\t\t[attr.aria-label]=\"'ROW_ACTIONS' | translate\"\n\t\t[style.inset-inline-start.px]=\"at.x\"\n\t\t[style.inset-block-start.px]=\"at.y\"\n\t\t(mousedown)=\"$event.stopPropagation()\"\n\t>\n\t\t@for (entry of menuEntries(); track entry.id) {\n\t\t\t<button class=\"hub-spreadsheet__menu-item\" type=\"button\" role=\"menuitem\" (click)=\"runMenuEntry(entry.id)\">\n\t\t\t\t{{ entry.label | translate: { count: entry.count } }}\n\t\t\t</button>\n\t\t}\n\t</div>\n}\n", styles: ["@charset \"UTF-8\";:host{position:relative;--hub-spreadsheet-bg: var(--hub-table-bg, var(--hub-sys-color-surface, #fff));--hub-spreadsheet-color: var(--hub-table-color, var(--hub-sys-color-text, #212529));--hub-spreadsheet-border-color: var(--hub-table-border-color, var(--hub-sys-border-color-default, #dee2e6));--hub-spreadsheet-border-width: var(--hub-table-border-width, 1px);--hub-spreadsheet-border-radius: var(--hub-table-border-radius, var(--hub-sys-radius-lg, .5rem));--hub-spreadsheet-cell-padding-x: var(--hub-table-cell-padding-x, .6rem);--hub-spreadsheet-cell-padding-y: var(--hub-table-cell-padding-y, .4rem);--hub-spreadsheet-cell-line-height: 1.5em;--hub-spreadsheet-header-bg: var(--hub-table-container-bg, var(--hub-sys-color-surface-subtle, #f8f9fa));--hub-spreadsheet-header-color: var(--hub-sys-text-muted, #6a737b);--hub-spreadsheet-header-font-size: .8125rem;--hub-spreadsheet-header-font-weight: 600;--hub-spreadsheet-cursor-color: var(--hub-sys-color-primary, #0d6efd);--hub-spreadsheet-cursor-width: 2px;--hub-spreadsheet-selection-bg: color-mix(in srgb, var(--hub-spreadsheet-cursor-color) 12%, transparent);--hub-spreadsheet-readonly-bg: color-mix(in srgb, var(--hub-spreadsheet-header-bg) 55%, var(--hub-spreadsheet-bg));--hub-spreadsheet-affordance-color: var(--hub-select-arrow-color, var(--hub-sys-text-muted, #6a737b));--hub-spreadsheet-affordance-size: var(--hub-select-arrow-size, 5px);--hub-spreadsheet-affordance-gap: var(--hub-select-arrow-gap, .5rem);--hub-spreadsheet-affordance-field-border: var(--hub-input-border-width, 1px);--hub-spreadsheet-placeholder-color: var(--hub-sys-text-muted, #6a737b);--hub-spreadsheet-placeholder-opacity: .65;--hub-spreadsheet-placeholder-transition: var(--hub-sys-transition-fast, all .15s ease-in-out);--hub-spreadsheet-readonly-color: var(--hub-sys-text-muted, #6a737b);--hub-spreadsheet-editor-bg: var(--hub-spreadsheet-bg);--hub-spreadsheet-editor-color: var(--hub-spreadsheet-color);--hub-spreadsheet-editor-field-bg: transparent;--hub-spreadsheet-editor-field-border-color: transparent;--hub-spreadsheet-editor-field-border-radius: 0;--hub-spreadsheet-editor-field-focus-shadow: none;--hub-spreadsheet-state-pending-color: var(--hub-sys-color-warning, #cc8a00);--hub-spreadsheet-state-saving-color: var(--hub-sys-color-info, #0a7ea4);--hub-spreadsheet-state-saved-color: var(--hub-sys-color-success, #1a7f37);--hub-spreadsheet-state-error-color: var(--hub-sys-color-danger, #b3261e);--hub-spreadsheet-state-conflict-color: var(--hub-sys-color-danger, #b3261e);--hub-spreadsheet-state-bar-width: 3px;--hub-spreadsheet-suggestions-bg: var(--hub-spreadsheet-menu-bg);--hub-spreadsheet-suggestions-border-color: var(--hub-spreadsheet-menu-border-color);--hub-spreadsheet-suggestion-on-bg: var(--hub-spreadsheet-selection-bg);--hub-spreadsheet-alias-bg: color-mix(in srgb, var(--hub-spreadsheet-cursor-color) 12%, transparent);--hub-spreadsheet-alias-color: var(--hub-spreadsheet-cursor-color);--hub-spreadsheet-menu-bg: var(--hub-sys-color-surface, #fff);--hub-spreadsheet-menu-color: var(--hub-spreadsheet-color);--hub-spreadsheet-menu-border-color: var(--hub-spreadsheet-border-color);--hub-spreadsheet-menu-radius: var(--hub-sys-radius-sm, .25rem);--hub-spreadsheet-menu-shadow: 0 6px 20px rgb(0 0 0 / 18%);--hub-spreadsheet-menu-item-padding-x: .85rem;--hub-spreadsheet-menu-item-padding-y: .4rem;--hub-spreadsheet-menu-item-bg-hover: var(--hub-spreadsheet-selection-bg);--hub-spreadsheet-resize-grip-width: 5px;--hub-spreadsheet-resize-grip-color: var(--hub-spreadsheet-cursor-color);--hub-spreadsheet-moving-opacity: .5;--hub-spreadsheet-fill-handle-size: 7px;--hub-spreadsheet-fill-handle-color: var(--hub-spreadsheet-cursor-color);--hub-spreadsheet-fill-preview-bg: color-mix(in srgb, var(--hub-spreadsheet-cursor-color) 6%, transparent);--hub-spreadsheet-invalid-color: var(--hub-sys-color-danger, #b3261e);--hub-spreadsheet-invalid-border-width: 1px;--hub-spreadsheet-invalid-bg: color-mix(in srgb, var(--hub-spreadsheet-invalid-color) 10%, transparent);--hub-spreadsheet-max-block-size: var(--hub-table-container-max-block-size, none);--hub-spreadsheet-frozen-divider-color: var(--hub-spreadsheet-border-color, var(--hub-sys-border-color-default, #dee2e6));--hub-spreadsheet-frozen-divider-width: 2px;display:block;color:var(--hub-spreadsheet-color)}.hub-spreadsheet__sink{position:absolute;inline-size:1px;block-size:1px;padding:0;border:0;opacity:0;pointer-events:none;inset-block-start:0;inset-inline-start:0}:host(.hub-spreadsheet--virtual){--hub-spreadsheet-max-block-size: var(--hub-table-container-max-block-size, 24rem)}.hub-spreadsheet__viewport{position:relative;overflow:auto;max-block-size:var(--hub-spreadsheet-max-block-size);border:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-border-color);border-radius:var(--hub-spreadsheet-border-radius);background:var(--hub-spreadsheet-bg)}.hub-spreadsheet__table{inline-size:100%;border-collapse:separate;border-spacing:0;font-variant-numeric:tabular-nums}.hub-spreadsheet__header,.hub-spreadsheet__cell{--hub-spreadsheet-cell-edge-inline: var(--hub-spreadsheet-border-width);--hub-spreadsheet-cell-edge-block: var(--hub-spreadsheet-border-width);padding:var(--hub-spreadsheet-cell-padding-y) var(--hub-spreadsheet-cell-padding-x);border-inline-end:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-border-color);border-block-end:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-border-color);background:var(--hub-spreadsheet-bg);white-space:nowrap;text-align:start}.hub-spreadsheet__header--end-of-sheet,.hub-spreadsheet__cell--end-of-sheet{--hub-spreadsheet-cell-edge-inline: 0px;border-inline-end:0}.hub-spreadsheet__header{position:sticky;isolation:isolate;inset-block-start:0;z-index:2;background:var(--hub-spreadsheet-header-bg);color:var(--hub-spreadsheet-header-color);font-size:var(--hub-spreadsheet-header-font-size);font-weight:var(--hub-spreadsheet-header-font-weight)}.hub-spreadsheet__header--moving{opacity:var(--hub-spreadsheet-moving-opacity)}.hub-spreadsheet__header--move-target{outline:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);outline-offset:calc(var(--hub-spreadsheet-cursor-width) * -1)}.hub-spreadsheet__header--frozen{inset-inline-start:0;z-index:4}.hub-spreadsheet__header--frozen-edge{border-inline-end:var(--hub-spreadsheet-frozen-divider-width) solid var(--hub-spreadsheet-frozen-divider-color)}.hub-spreadsheet__header--end{text-align:end}.hub-spreadsheet__header--center{text-align:center}.hub-spreadsheet__header-text--hidden{position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.hub-spreadsheet__subheader{display:block;font-weight:400;opacity:.8}.hub-spreadsheet__cell{position:relative;cursor:cell;-webkit-user-select:none;user-select:none}.hub-spreadsheet__cell--end{text-align:end}.hub-spreadsheet__cell--center{text-align:center}.hub-spreadsheet__cell--frozen-col,.hub-spreadsheet__cell--frozen-row{position:sticky;z-index:1}.hub-spreadsheet__cell--frozen-col{inset-inline-start:0}.hub-spreadsheet__cell--frozen-col.hub-spreadsheet__cell--frozen-row{z-index:2}.hub-spreadsheet__cell--frozen-col-edge{--hub-spreadsheet-cell-edge-inline: var(--hub-spreadsheet-frozen-divider-width);border-inline-end:var(--hub-spreadsheet-frozen-divider-width) solid var(--hub-spreadsheet-frozen-divider-color)}.hub-spreadsheet__cell--frozen-row-edge{--hub-spreadsheet-cell-edge-block: var(--hub-spreadsheet-frozen-divider-width);border-block-end:var(--hub-spreadsheet-frozen-divider-width) solid var(--hub-spreadsheet-frozen-divider-color)}.hub-spreadsheet__cell--filling{background-image:linear-gradient(var(--hub-spreadsheet-fill-preview-bg),var(--hub-spreadsheet-fill-preview-bg))}.hub-spreadsheet__cell--selected{background-image:linear-gradient(var(--hub-spreadsheet-selection-bg),var(--hub-spreadsheet-selection-bg))}.hub-spreadsheet__cell--readonly{background-color:var(--hub-spreadsheet-readonly-bg);color:var(--hub-spreadsheet-readonly-color);cursor:default}.hub-spreadsheet__cell--muted{color:var(--hub-spreadsheet-readonly-color)}.hub-spreadsheet__cell--invalid{background-image:linear-gradient(var(--hub-spreadsheet-invalid-bg),var(--hub-spreadsheet-invalid-bg));color:var(--hub-spreadsheet-invalid-color);box-shadow:inset 0 0 0 var(--hub-spreadsheet-invalid-border-width) var(--hub-spreadsheet-invalid-color)}.hub-spreadsheet__cell--active{outline:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);outline-offset:calc(var(--hub-spreadsheet-cursor-width) * -1);z-index:2}.hub-spreadsheet__cell--editing{block-size:calc(var(--hub-spreadsheet-cell-line-height) + var(--hub-spreadsheet-cell-padding-y) * 2 + var(--hub-spreadsheet-cell-edge-block))}.hub-spreadsheet__cell--active.hub-spreadsheet__cell--frozen-col,.hub-spreadsheet__cell--active.hub-spreadsheet__cell--frozen-row{z-index:3}.hub-spreadsheet__cell[data-state]:before{content:\"\";position:absolute;inset-block:0;inset-inline-start:0;inline-size:var(--hub-spreadsheet-state-bar-width)}.hub-spreadsheet__cell[data-state=pending]:before{background:var(--hub-spreadsheet-state-pending-color)}.hub-spreadsheet__cell[data-state=saving]:before{background:var(--hub-spreadsheet-state-saving-color)}.hub-spreadsheet__cell[data-state=saved]:before{background:var(--hub-spreadsheet-state-saved-color)}.hub-spreadsheet__cell[data-state=error]:before{background:var(--hub-spreadsheet-state-error-color)}.hub-spreadsheet__cell[data-state=conflict]:before{background:repeating-linear-gradient(45deg,var(--hub-spreadsheet-state-conflict-color) 0 3px,transparent 3px 6px)}.hub-spreadsheet__editor--select{padding-inline:calc(var(--hub-spreadsheet-cell-padding-x) - 2px);appearance:auto}.hub-spreadsheet__custom-editor{position:absolute;inset-block-start:0;inset-inline-start:0;inset-block-end:calc(var(--hub-spreadsheet-cell-edge-block) * -1);inset-inline-end:calc(var(--hub-spreadsheet-cell-edge-inline) * -1);display:grid;background:var(--hub-spreadsheet-editor-bg);outline:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);outline-offset:calc(var(--hub-spreadsheet-cursor-width) * -1);z-index:3;--hub-input-bg: var(--hub-spreadsheet-editor-field-bg);--hub-input-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-input-border-radius: var(--hub-spreadsheet-editor-field-border-radius);--hub-input-focus-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-input-focus-box-shadow: var(--hub-spreadsheet-editor-field-focus-shadow);--hub-input-padding-x: var(--hub-spreadsheet-cell-padding-x);--hub-select-bg: var(--hub-spreadsheet-editor-field-bg);--hub-select-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-select-border-radius: var(--hub-spreadsheet-editor-field-border-radius);--hub-select-focus-border-color: var(--hub-spreadsheet-editor-field-border-color);--hub-select-focus-box-shadow: var(--hub-spreadsheet-editor-field-focus-shadow);--hub-select-padding-x: var(--hub-spreadsheet-cell-padding-x)}.hub-spreadsheet__spacer>td{padding:0;border:0;background:var(--hub-spreadsheet-bg)}.hub-spreadsheet__affordance{position:absolute;inset-block:0;inset-inline-end:calc(var(--hub-spreadsheet-affordance-field-border) - var(--hub-spreadsheet-cell-edge-inline));display:flex;align-items:center;padding-inline:var(--hub-spreadsheet-affordance-gap);cursor:pointer;opacity:0}.hub-spreadsheet__cell:hover>.hub-spreadsheet__affordance,.hub-spreadsheet__cell--active>.hub-spreadsheet__affordance{opacity:1}.hub-spreadsheet__affordance:after{content:\"\";inline-size:0;block-size:0;border-style:solid;border-width:var(--hub-spreadsheet-affordance-size) var(--hub-spreadsheet-affordance-size) calc(var(--hub-spreadsheet-affordance-size) / 2);border-color:var(--hub-spreadsheet-affordance-color) transparent transparent}.hub-spreadsheet__affordance--date:after{inline-size:calc(var(--hub-spreadsheet-affordance-size) * 2);block-size:calc(var(--hub-spreadsheet-affordance-size) * 1.8);border:1px solid var(--hub-spreadsheet-affordance-color);border-block-start-width:3px;border-radius:2px}.hub-spreadsheet__placeholder{position:absolute;inset-block:0;inset-inline:var(--hub-spreadsheet-cell-padding-x);display:flex;align-items:center;color:var(--hub-spreadsheet-placeholder-color);opacity:0;pointer-events:none;transition:var(--hub-spreadsheet-placeholder-transition)}.hub-spreadsheet__cell:hover>.hub-spreadsheet__placeholder{opacity:var(--hub-spreadsheet-placeholder-opacity)}.hub-spreadsheet__check{inline-size:1em;block-size:1em;accent-color:var(--hub-spreadsheet-cursor-color);cursor:pointer}.hub-spreadsheet__check:disabled{cursor:default}.hub-spreadsheet__resize-grip{position:absolute;inset-block:0;inset-inline-end:0;inline-size:var(--hub-spreadsheet-resize-grip-width);cursor:col-resize;background:transparent;z-index:5}.hub-spreadsheet__resize-grip:hover,.hub-spreadsheet__resize-grip:active{background:var(--hub-spreadsheet-resize-grip-color)}.hub-spreadsheet__fill-handle{position:absolute;inset-block-end:0;inset-inline-end:0;inline-size:var(--hub-spreadsheet-fill-handle-size);block-size:var(--hub-spreadsheet-fill-handle-size);background:var(--hub-spreadsheet-fill-handle-color);cursor:crosshair;z-index:4}.hub-spreadsheet__secondary{display:block;opacity:.75}.hub-spreadsheet__editor{position:absolute;inset-block-start:0;inset-inline-start:0;inset-block-end:calc(var(--hub-spreadsheet-cell-edge-block) * -1);inset-inline-end:calc(var(--hub-spreadsheet-cell-edge-inline) * -1);padding:var(--hub-spreadsheet-cell-padding-y) var(--hub-spreadsheet-cell-padding-x);border:var(--hub-spreadsheet-cursor-width) solid var(--hub-spreadsheet-cursor-color);border-radius:0;background:var(--hub-spreadsheet-editor-bg);color:var(--hub-spreadsheet-editor-color);font:inherit;font-variant-numeric:tabular-nums;text-align:inherit;outline:none;z-index:2}.hub-spreadsheet__editor[aria-invalid=true]{border-color:var(--hub-spreadsheet-invalid-color)}.hub-spreadsheet__empty{padding-block:calc(var(--hub-spreadsheet-cell-padding-y) * 4);color:var(--hub-spreadsheet-readonly-color);text-align:center;cursor:default}.hub-spreadsheet__menu{position:absolute;z-index:10;min-inline-size:12rem;padding-block:.25rem;border:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-menu-border-color);border-radius:var(--hub-spreadsheet-menu-radius);background:var(--hub-spreadsheet-menu-bg);color:var(--hub-spreadsheet-menu-color);box-shadow:var(--hub-spreadsheet-menu-shadow)}.hub-spreadsheet__menu-item{display:block;inline-size:100%;padding:var(--hub-spreadsheet-menu-item-padding-y) var(--hub-spreadsheet-menu-item-padding-x);border:0;background:transparent;color:inherit;font:inherit;text-align:start;white-space:nowrap;cursor:pointer}.hub-spreadsheet__menu-item:hover,.hub-spreadsheet__menu-item:focus-visible{background:var(--hub-spreadsheet-menu-item-bg-hover)}.hub-spreadsheet__suggestions{position:absolute;inset-block-start:100%;inset-inline-start:0;z-index:12;display:flex;flex-direction:column;min-inline-size:14rem;max-block-size:14rem;overflow-y:auto;border:var(--hub-spreadsheet-border-width) solid var(--hub-spreadsheet-suggestions-border-color);border-radius:var(--hub-spreadsheet-border-radius);background:var(--hub-spreadsheet-suggestions-bg);box-shadow:var(--hub-spreadsheet-menu-shadow)}.hub-spreadsheet__suggestion{display:flex;gap:.5rem;align-items:baseline;justify-content:space-between;padding:var(--hub-spreadsheet-cell-padding-y) var(--hub-spreadsheet-cell-padding-x);border:0;background:transparent;color:inherit;font:inherit;text-align:start;cursor:pointer}.hub-spreadsheet__suggestion:hover,.hub-spreadsheet__suggestion--on{background:var(--hub-spreadsheet-suggestion-on-bg)}.hub-spreadsheet__suggestion-name{font-variant-numeric:tabular-nums}.hub-spreadsheet__suggestion-hint{color:var(--hub-spreadsheet-readonly-color);font-size:var(--hub-spreadsheet-header-font-size)}.hub-spreadsheet__alias{margin-inline-start:.4rem;padding:0 .3rem;border:0;border-radius:.25rem;background:var(--hub-spreadsheet-alias-bg);color:var(--hub-spreadsheet-alias-color);font:inherit;font-size:var(--hub-spreadsheet-header-font-size);font-weight:400;cursor:pointer}\n"] }]
3742
+ }], propDecorators: { rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: true }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: true }] }], rowKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowKey", required: true }] }], states: [{ type: i0.Input, args: [{ isSignal: true, alias: "states", required: false }] }], errors: [{ type: i0.Input, args: [{ isSignal: true, alias: "errors", required: false }] }], decimalMark: [{ type: i0.Input, args: [{ isSignal: true, alias: "decimalMark", required: false }] }], emptyText: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyText", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], frozenColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "frozenColumns", required: false }] }], frozenRows: [{ type: i0.Input, args: [{ isSignal: true, alias: "frozenRows", required: false }] }], spans: [{ type: i0.Input, args: [{ isSignal: true, alias: "spans", required: false }] }], structure: [{ type: i0.Input, args: [{ isSignal: true, alias: "structure", required: false }] }], customEditors: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => HubSpreadsheetEditorDirective), { isSignal: true }] }], customCells: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => HubSpreadsheetCellDirective), { isSignal: true }] }], retiredColumnKeys: [{ type: i0.Input, args: [{ isSignal: true, alias: "retiredColumnKeys", required: false }] }], fillHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "fillHandle", required: false }] }], canUndo: [{ type: i0.Input, args: [{ isSignal: true, alias: "canUndo", required: false }] }], canRedo: [{ type: i0.Input, args: [{ isSignal: true, alias: "canRedo", required: false }] }], resizableColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "resizableColumns", required: false }] }], reorderableColumns: [{ type: i0.Input, args: [{ isSignal: true, alias: "reorderableColumns", required: false }] }], columnWidths: [{ type: i0.Input, args: [{ isSignal: true, alias: "columnWidths", required: false }] }, { type: i0.Output, args: ["columnWidthsChange"] }], minColumnWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minColumnWidth", required: false }] }], contextMenu: [{ type: i0.Input, args: [{ isSignal: true, alias: "contextMenu", required: false }] }], mergeable: [{ type: i0.Input, args: [{ isSignal: true, alias: "mergeable", required: false }] }], formulas: [{ type: i0.Input, args: [{ isSignal: true, alias: "formulas", required: false }] }], virtual: [{ type: i0.Input, args: [{ isSignal: true, alias: "virtual", required: false }] }], rowHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowHeight", required: false }] }], editOn: [{ type: i0.Input, args: [{ isSignal: true, alias: "editOn", required: false }] }], commit: [{ type: i0.Output, args: ["commit"] }], pasted: [{ type: i0.Output, args: ["pasted"] }], cleared: [{ type: i0.Output, args: ["cleared"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], insertRequested: [{ type: i0.Output, args: ["insertRequested"] }], deleteRequested: [{ type: i0.Output, args: ["deleteRequested"] }], filled: [{ type: i0.Output, args: ["filled"] }], undoRequested: [{ type: i0.Output, args: ["undoRequested"] }], redoRequested: [{ type: i0.Output, args: ["redoRequested"] }], columnMoved: [{ type: i0.Output, args: ["columnMoved"] }], mergeRequested: [{ type: i0.Output, args: ["mergeRequested"] }], unmergeRequested: [{ type: i0.Output, args: ["unmergeRequested"] }], viewport: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }] } });
3743
+
3744
+ /**
3745
+ * Handing the reader a file to save.
3746
+ *
3747
+ * Six lines that every host writes and two things they all get wrong: the object URL nobody
3748
+ * releases, and the anchor left in the document.
3749
+ *
3750
+ * @param filename - What to call it.
3751
+ * @param blob - What goes in it.
3752
+ */
3753
+ function downloadBlob(filename, blob) {
3754
+ if (typeof document === 'undefined') {
3755
+ return;
3756
+ }
3757
+ const url = URL.createObjectURL(blob);
3758
+ const anchor = document.createElement('a');
3759
+ anchor.href = url;
3760
+ anchor.download = filename;
3761
+ anchor.rel = 'noopener';
3762
+ document.body.appendChild(anchor);
3763
+ anchor.click();
3764
+ anchor.remove();
3765
+ URL.revokeObjectURL(url);
3766
+ }
3767
+
3768
+ /**
3769
+ * What every cell of a sheet holds, with the formulas run if asked.
3770
+ *
3771
+ * Shared by the exporters because both need the same answer and neither should be the one deciding
3772
+ * it: what a cell shows is the sheet's business, and a second copy of that decision inside the CSV
3773
+ * writer would drift from the component the first time a formula grows a new case.
3774
+ *
3775
+ * A formula that cannot be worked out comes out as its spreadsheet spelling — `#DIV/0!` and the
3776
+ * rest — which is what Excel writes in the same situation and what the reader of the file expects
3777
+ * to see.
3778
+ */
3779
+ function sheetValues(rows, columns, options = {}) {
3780
+ const cells = rows.map((row) => columns.map((column) => column.cell(row).value));
3781
+ const anyFormula = columns.some((column) => column.formula) || cells.some((line) => line.some((value) => isFormula(value)));
3782
+ if (!options.formulas || !anyFormula) {
3783
+ return cells;
3784
+ }
3785
+ const places = new Map(columns.map((column, index) => [column.key, index]));
3786
+ const sheet = evaluateSheet({
3787
+ rows,
3788
+ columns,
3789
+ raw: (rowIndex, column) => cells[rowIndex]?.[places.get(column.key) ?? -1] ?? null
3790
+ });
3791
+ return cells.map((line, rowIndex) => line.map((value, colIndex) => {
3792
+ const column = columns[colIndex];
3793
+ if (!isFormula(value) && !column.formula) {
3794
+ return value;
3795
+ }
3796
+ const result = sheet[rowIndex]?.[column.key];
3797
+ return result?.failure ? formulaErrorText(result.failure) : (result?.value ?? null);
3798
+ }));
3799
+ }
3800
+
3801
+ /**
3802
+ * Turns a file's rows into records keyed by column alias, matching its headers to the sheet.
3803
+ *
3804
+ * Matched on the header first and the alias second, both ignoring case and surrounding space: a
3805
+ * file that came from this sheet carries the headers, and one written by hand often carries the
3806
+ * aliases. A column the file has nothing for is left out rather than filled with an empty string,
3807
+ * so a partial file updates what it names and leaves the rest alone.
3808
+ *
3809
+ * Shared by every importer, because the matching is the part that has to behave the same whatever
3810
+ * the file was: somebody who fixes a CSV by renaming a heading expects the same file to work when
3811
+ * they save it as a workbook instead.
3812
+ *
3813
+ * @returns One record per row of the file, keyed by column alias.
3814
+ */
3815
+ function tableToRecords(table, columns) {
3816
+ const normalise = (value) => value.trim().toLocaleLowerCase();
3817
+ const places = new Map();
3818
+ if (table.headers) {
3819
+ table.headers.forEach((heading, index) => {
3820
+ const match = columns.find((column) => normalise(column.header) === normalise(heading) || normalise(column.key) === normalise(heading));
3821
+ if (match) {
3822
+ places.set(index, match.key);
3823
+ }
3824
+ });
3825
+ }
3826
+ else {
3827
+ columns.forEach((column, index) => places.set(index, column.key));
3828
+ }
3829
+ return table.rows.map((row) => {
3830
+ const record = {};
3831
+ for (const [index, alias] of places) {
3832
+ if (row[index] !== undefined) {
3833
+ record[alias] = row[index];
3834
+ }
3835
+ }
3836
+ return record;
3837
+ });
3838
+ }
3839
+
3840
+ /** The separator to use when nobody said: a comma cannot separate what it also punctuates. */
3841
+ function delimiterFor(options) {
3842
+ return options.delimiter ?? (options.decimalMark === ',' ? ';' : ',');
3843
+ }
3844
+ /** A value as it goes into the file. */
3845
+ function textOf(value, column, options) {
3846
+ if (value === null) {
3847
+ return '';
3848
+ }
3849
+ if (options.values === 'stored') {
3850
+ return String(value);
3851
+ }
3852
+ const shown = displayValue(value, column);
3853
+ // A figure is written the way this reader writes figures, or a spreadsheet opening the file
3854
+ // reads 1.234,56 as text and quietly leaves the column out of every sum.
3855
+ return typeof value === 'number' && options.decimalMark === ',' ? shown.replace('.', ',') : shown;
3856
+ }
3857
+ /**
3858
+ * Writes a sheet as CSV.
3859
+ *
3860
+ * @param rows - The rows, as the sheet has them.
3861
+ * @param columns - The columns, which decide the order and the headers.
3862
+ * @param options - How to write it.
3863
+ * @returns The file's contents.
3864
+ */
3865
+ function sheetToCsv(rows, columns, options = {}) {
3866
+ const delimiter = delimiterFor(options);
3867
+ const lines = [];
3868
+ if (options.headers !== false) {
3869
+ lines.push(columns.map((column) => column.header));
3870
+ }
3871
+ const values = sheetValues(rows, columns, { formulas: options.values !== 'stored' && options.formulas !== false });
3872
+ for (const line of values) {
3873
+ lines.push(line.map((value, index) => textOf(value, columns[index], options)));
3874
+ }
3875
+ return serialiseDelimited(lines, delimiter, options.newline ?? '\r\n');
3876
+ }
3877
+ /**
3878
+ * Which separator a file is written with.
3879
+ *
3880
+ * Counted on the first line rather than guessed from the file's name: an exporter's idea of CSV
3881
+ * and the reader's locale often disagree, and the line that holds the headers is the one that
3882
+ * shows it most clearly.
3883
+ */
3884
+ function sniffDelimiter(text) {
3885
+ const firstLine = text.split(/\r?\n/, 1)[0] ?? '';
3886
+ const counts = [
3887
+ [',', (firstLine.match(/,/g) ?? []).length],
3888
+ [';', (firstLine.match(/;/g) ?? []).length],
3889
+ ['\t', (firstLine.match(/\t/g) ?? []).length]
3890
+ ];
3891
+ return counts.sort((a, b) => b[1] - a[1])[0][1] > 0 ? counts[0][0] : ',';
3892
+ }
3893
+ /**
3894
+ * Reads a CSV file.
3895
+ *
3896
+ * @param text - The file's contents.
3897
+ * @param options - How to read it; the separator is sniffed when not given.
3898
+ */
3899
+ function csvToTable(text, options = {}) {
3900
+ const rows = parseDelimited(text, options.delimiter ?? sniffDelimiter(text));
3901
+ if (options.headers === false || !rows.length) {
3902
+ return { headers: null, rows };
3903
+ }
3904
+ return { headers: rows[0], rows: rows.slice(1) };
3905
+ }
3906
+ /**
3907
+ * Reads a CSV file into rows keyed by column alias, matching its headers to the sheet's columns.
3908
+ *
3909
+ * @returns One record per row of the file, keyed by column alias.
3910
+ */
3911
+ function csvToRecords(text, columns, options = {}) {
3912
+ return tableToRecords(csvToTable(text, options), columns);
3913
+ }
3914
+ /**
3915
+ * Hands the reader a CSV file to save.
3916
+ *
3917
+ * @param filename - What to call it.
3918
+ * @param text - What goes in it.
3919
+ * @param type - Its media type.
3920
+ */
3921
+ function downloadText(filename, text, type = 'text/csv;charset=utf-8') {
3922
+ // The mark is what tells a spreadsheet the file is UTF-8. Without it an accented word opens as
3923
+ // two characters of nonsense, and the reader blames whoever exported it.
3924
+ const contents = type.startsWith('text/csv') ? '\ufeff' + text : text;
3925
+ downloadBlob(filename, new Blob([contents], { type }));
3926
+ }
3927
+
3928
+ /**
3929
+ * A zip file, written by hand.
3930
+ *
3931
+ * An xlsx is a zip of XML, and this is the part of that sentence that usually drags a library in.
3932
+ * It does not have to: the entries are **stored** rather than deflated, which the format has
3933
+ * allowed since the beginning and every reader supports, and what is left is a checksum and three
3934
+ * fixed-shape records. A spreadsheet of a few thousand cells makes a file a couple of times bigger
3935
+ * than a compressed one would be — against a dependency of some hundreds of kilobytes that every
3936
+ * consumer of the library would pay for whether they export anything or not.
3937
+ */
3938
+ /** The table CRC-32 needs, worked out once. */
3939
+ const CRC_TABLE = (() => {
3940
+ const table = new Uint32Array(256);
3941
+ for (let index = 0; index < 256; index++) {
3942
+ let value = index;
3943
+ for (let bit = 0; bit < 8; bit++) {
3944
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
3945
+ }
3946
+ table[index] = value >>> 0;
3947
+ }
3948
+ return table;
3949
+ })();
3950
+ /** The checksum a zip entry carries, which a reader uses to know the bytes arrived whole. */
3951
+ function crc32(bytes) {
3952
+ let crc = 0xffffffff;
3953
+ for (const byte of bytes) {
3954
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
3955
+ }
3956
+ return (crc ^ 0xffffffff) >>> 0;
3957
+ }
3958
+ /** Writes little-endian numbers into a buffer, which is all a zip header is. */
3959
+ function writer(size) {
3960
+ const bytes = new Uint8Array(size);
3961
+ let at = 0;
3962
+ return {
3963
+ bytes,
3964
+ u16(value) {
3965
+ bytes[at++] = value & 0xff;
3966
+ bytes[at++] = (value >>> 8) & 0xff;
3967
+ },
3968
+ u32(value) {
3969
+ bytes[at++] = value & 0xff;
3970
+ bytes[at++] = (value >>> 8) & 0xff;
3971
+ bytes[at++] = (value >>> 16) & 0xff;
3972
+ bytes[at++] = (value >>> 24) & 0xff;
3973
+ },
3974
+ raw(source) {
3975
+ bytes.set(source, at);
3976
+ at += source.length;
3977
+ }
3978
+ };
3979
+ }
3980
+ /**
3981
+ * Packs a set of files into a zip archive.
3982
+ *
3983
+ * Everything is stored rather than compressed, and dated to a fixed moment: a file that changes
3984
+ * every time it is written for no reason other than the clock is a file that cannot be compared
3985
+ * with the one from yesterday.
3986
+ *
3987
+ * @param entries - What goes in it.
3988
+ * @returns The bytes of the archive.
3989
+ */
3990
+ function zip(entries) {
3991
+ const encoder = new TextEncoder();
3992
+ const parts = [];
3993
+ const directory = [];
3994
+ let offset = 0;
3995
+ // 1 January 1980, which is the earliest a zip can say and the only honest answer when the
3996
+ // contents did not come from a file with a date of its own.
3997
+ const time = 0;
3998
+ const date = 33;
3999
+ for (const entry of entries) {
4000
+ const name = encoder.encode(entry.path);
4001
+ const data = typeof entry.data === 'string' ? encoder.encode(entry.data) : entry.data;
4002
+ const checksum = crc32(data);
4003
+ const local = writer(30 + name.length);
4004
+ local.u32(0x04034b50);
4005
+ local.u16(20);
4006
+ local.u16(0);
4007
+ local.u16(0);
4008
+ local.u16(time);
4009
+ local.u16(date);
4010
+ local.u32(checksum);
4011
+ local.u32(data.length);
4012
+ local.u32(data.length);
4013
+ local.u16(name.length);
4014
+ local.u16(0);
4015
+ local.raw(name);
4016
+ parts.push(local.bytes, data);
4017
+ const central = writer(46 + name.length);
4018
+ central.u32(0x02014b50);
4019
+ central.u16(20);
4020
+ central.u16(20);
4021
+ central.u16(0);
4022
+ central.u16(0);
4023
+ central.u16(time);
4024
+ central.u16(date);
4025
+ central.u32(checksum);
4026
+ central.u32(data.length);
4027
+ central.u32(data.length);
4028
+ central.u16(name.length);
4029
+ central.u16(0);
4030
+ central.u16(0);
4031
+ central.u16(0);
4032
+ central.u16(0);
4033
+ central.u32(0);
4034
+ central.u32(offset);
4035
+ central.raw(name);
4036
+ directory.push(central.bytes);
4037
+ offset += local.bytes.length + data.length;
4038
+ }
4039
+ const directorySize = directory.reduce((total, record) => total + record.length, 0);
4040
+ const end = writer(22);
4041
+ end.u32(0x06054b50);
4042
+ end.u16(0);
4043
+ end.u16(0);
4044
+ end.u16(entries.length);
4045
+ end.u16(entries.length);
4046
+ end.u32(directorySize);
4047
+ end.u32(offset);
4048
+ end.u16(0);
4049
+ const all = [...parts, ...directory, end.bytes];
4050
+ const size = all.reduce((total, part) => total + part.length, 0);
4051
+ const archive = new Uint8Array(size);
4052
+ let at = 0;
4053
+ for (const part of all) {
4054
+ archive.set(part, at);
4055
+ at += part.length;
4056
+ }
4057
+ return archive;
4058
+ }
4059
+
4060
+ /** The media type a browser and Excel both recognise. */
4061
+ const XLSX_MEDIA_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
4062
+ /** What the day numbers are counted from: 30 December 1899, which is Excel's zero. */
4063
+ const EXCEL_EPOCH$1 = Date.UTC(1899, 11, 30);
4064
+ /** Style slots, in the order `styles.xml` declares them. */
4065
+ const STYLE_PLAIN = 0;
4066
+ const STYLE_HEADER = 1;
4067
+ const STYLE_DATE = 2;
4068
+ /** XML has five characters that cannot be written as themselves, and one that must not be written at all. */
4069
+ function escapeXml(value) {
4070
+ return value
4071
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
4072
+ .replace(/&/g, '&amp;')
4073
+ .replace(/</g, '&lt;')
4074
+ .replace(/>/g, '&gt;')
4075
+ .replace(/"/g, '&quot;')
4076
+ .replace(/'/g, '&apos;');
4077
+ }
4078
+ /** A column's letter, as a spreadsheet names it: A, B, … Z, AA. */
4079
+ function letter(index) {
4080
+ let name = '';
4081
+ let rest = index;
4082
+ do {
4083
+ name = String.fromCharCode(65 + (rest % 26)) + name;
4084
+ rest = Math.floor(rest / 26) - 1;
4085
+ } while (rest >= 0);
4086
+ return name;
4087
+ }
4088
+ /**
4089
+ * A date as the number of days since Excel's zero, or null when the text is not a date.
4090
+ *
4091
+ * Only whole days and only the ISO shape, which is what a date column of this sheet holds. A
4092
+ * string that merely looks date-ish — `2024` — is left as text on purpose: guessing wrong turns a
4093
+ * catalogue number into a Tuesday.
4094
+ */
4095
+ function dateSerial(value) {
4096
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
4097
+ if (!match) {
4098
+ return null;
4099
+ }
4100
+ const stamp = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
4101
+ return Number.isNaN(stamp) ? null : Math.round((stamp - EXCEL_EPOCH$1) / 86_400_000);
4102
+ }
4103
+ /** One cell, as the worksheet spells it. */
4104
+ function cellXml(reference, value, column, options) {
4105
+ if (value === null || value === '') {
4106
+ return '';
4107
+ }
4108
+ if (typeof value === 'number') {
4109
+ return `<c r="${reference}"><v>${value}</v></c>`;
4110
+ }
4111
+ if (column?.kind === 'date') {
4112
+ const serial = dateSerial(value);
4113
+ if (serial !== null) {
4114
+ return `<c r="${reference}" s="${STYLE_DATE}"><v>${serial}</v></c>`;
4115
+ }
4116
+ }
4117
+ // What the reader saw, not the code underneath: a select cell exports the label somebody chose.
4118
+ // `values: 'stored'` is the round trip, and there the stored value is the point.
4119
+ const text = options.values === 'stored' || !column ? value : displayValue(value, column);
4120
+ // Inline rather than through the shared-strings table. A workbook of a few thousand cells does
4121
+ // not care about the repetition, and the table is a second part to keep in step for nothing.
4122
+ return `<c r="${reference}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(text)}</t></is></c>`;
4123
+ }
4124
+ /** A sheet name Excel will accept: no more than 31 characters and none of its reserved ones. */
4125
+ function sheetNameOf(name) {
4126
+ const clean = (name ?? 'Sheet1').replace(/[\\/?*[\]:]/g, ' ').trim();
4127
+ return clean ? clean.slice(0, 31) : 'Sheet1';
4128
+ }
4129
+ /** The parts of the workbook that never change. */
4130
+ const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4131
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>`;
4132
+ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4133
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
4134
+ const WORKBOOK_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4135
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`;
4136
+ /** Three slots: plain, a bold header, and a date. Everything else is Excel's default. */
4137
+ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4138
+ <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy\\-mm\\-dd"/></numFmts><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`;
4139
+ /**
4140
+ * Writes a sheet as an xlsx workbook.
4141
+ *
4142
+ * @param rows - The rows, as the sheet has them.
4143
+ * @param columns - The columns, which decide the order and the headers.
4144
+ * @param options - How to write it.
4145
+ * @returns The bytes of the file.
4146
+ */
4147
+ function sheetToXlsx(rows, columns, options = {}) {
4148
+ const values = sheetValues(rows, columns, { formulas: options.values !== 'stored' && options.formulas !== false });
4149
+ const withHeaders = options.headers !== false;
4150
+ const lines = [];
4151
+ if (withHeaders) {
4152
+ const cells = columns
4153
+ .map((column, index) => `<c r="${letter(index)}1" s="${STYLE_HEADER}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(column.header)}</t></is></c>`)
4154
+ .join('');
4155
+ lines.push(`<row r="1">${cells}</row>`);
4156
+ }
4157
+ values.forEach((line, rowIndex) => {
4158
+ const number = rowIndex + (withHeaders ? 2 : 1);
4159
+ const cells = line.map((value, index) => cellXml(`${letter(index)}${number}`, value, columns[index], options)).join('');
4160
+ lines.push(`<row r="${number}">${cells}</row>`);
4161
+ });
4162
+ const last = `${letter(Math.max(columns.length - 1, 0))}${Math.max(lines.length, 1)}`;
4163
+ const sheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4164
+ <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dimension ref="A1:${last}"/><sheetData>${lines.join('')}</sheetData></worksheet>`;
4165
+ const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4166
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${escapeXml(sheetNameOf(options.sheetName))}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
4167
+ // `[Content_Types].xml` goes first because that is where a reader looks first, and several
4168
+ // older ones will not look anywhere else for it.
4169
+ return zip([
4170
+ { path: '[Content_Types].xml', data: CONTENT_TYPES },
4171
+ { path: '_rels/.rels', data: ROOT_RELS },
4172
+ { path: 'xl/workbook.xml', data: workbook },
4173
+ { path: 'xl/_rels/workbook.xml.rels', data: WORKBOOK_RELS },
4174
+ { path: 'xl/styles.xml', data: STYLES },
4175
+ { path: 'xl/worksheets/sheet1.xml', data: sheet }
4176
+ ]);
4177
+ }
4178
+ /**
4179
+ * Hands the reader an xlsx file to save.
4180
+ *
4181
+ * @param filename - What to call it; `.xlsx` is added when missing.
4182
+ * @param bytes - The workbook, from `sheetToXlsx`.
4183
+ */
4184
+ function downloadXlsx(filename, bytes) {
4185
+ const name = /\.xlsx$/i.test(filename) ? filename : `${filename}.xlsx`;
4186
+ downloadBlob(name, new Blob([bytes], { type: XLSX_MEDIA_TYPE }));
4187
+ }
4188
+
4189
+ /**
4190
+ * Reading a zip archive, which is what an xlsx is.
4191
+ *
4192
+ * The writing side got away without compressing anything. Reading cannot: a workbook that came out
4193
+ * of Excel has every part deflated, and refusing those would mean refusing every real file. What
4194
+ * makes it possible without a dependency is that the platform already has the decompressor —
4195
+ * `DecompressionStream`, in every browser since 2023 and in Node since 18 — so all that is left is
4196
+ * the bookkeeping: find the directory at the end of the file, and for each entry work out where its
4197
+ * bytes start.
4198
+ *
4199
+ * Only the two storage methods that exist in practice are understood, stored and deflated. An
4200
+ * entry compressed any other way is left out rather than guessed at, and the caller sees a part it
4201
+ * asked for missing instead of a page of nonsense.
4202
+ */
4203
+ /** Where a reader looks for the directory: the last record in the file. */
4204
+ const END_OF_DIRECTORY = 0x06054b50;
4205
+ const DIRECTORY_ENTRY = 0x02014b50;
4206
+ /** Reads little-endian numbers, which is the only way a zip states a length. */
4207
+ function reader(bytes) {
4208
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
4209
+ return {
4210
+ u16: (at) => view.getUint16(at, true),
4211
+ u32: (at) => view.getUint32(at, true)
4212
+ };
4213
+ }
4214
+ /**
4215
+ * Undoes a deflated entry, with the decompressor the platform already has.
4216
+ *
4217
+ * Written against the stream reader directly rather than through `Blob` or `Response`, which are
4218
+ * the shorter spellings and are not there everywhere this runs.
4219
+ */
4220
+ async function inflate(bytes) {
4221
+ const stream = new DecompressionStream('deflate-raw');
4222
+ const writer = stream.writable.getWriter();
4223
+ // The cast is TypeScript's business, not the runtime's: a `Uint8Array` may sit on a shared
4224
+ // buffer as far as the types are concerned, and a stream writer will not say so.
4225
+ void writer.write(bytes).then(() => writer.close());
4226
+ const reader = stream.readable.getReader();
4227
+ const chunks = [];
4228
+ let size = 0;
4229
+ for (;;) {
4230
+ const { done, value } = await reader.read();
4231
+ if (done) {
4232
+ break;
4233
+ }
4234
+ chunks.push(value);
4235
+ size += value.length;
4236
+ }
4237
+ const whole = new Uint8Array(size);
4238
+ let at = 0;
4239
+ for (const chunk of chunks) {
4240
+ whole.set(chunk, at);
4241
+ at += chunk.length;
4242
+ }
4243
+ return whole;
4244
+ }
4245
+ /**
4246
+ * Takes an archive apart.
4247
+ *
4248
+ * @param bytes - The whole file.
4249
+ * @returns Each entry's contents, by its path inside the archive.
4250
+ * @throws When the file is not a zip at all, which for an xlsx means it is not a workbook.
4251
+ */
4252
+ async function unzip(bytes) {
4253
+ const { u16, u32 } = reader(bytes);
4254
+ let end = -1;
4255
+ // Backwards from the end, because the record that says where everything is may be followed by
4256
+ // a comment of any length up to 64k.
4257
+ for (let at = bytes.length - 22; at >= 0 && at >= bytes.length - 22 - 0xffff; at--) {
4258
+ if (u32(at) === END_OF_DIRECTORY) {
4259
+ end = at;
4260
+ break;
4261
+ }
4262
+ }
4263
+ if (end < 0) {
4264
+ throw new Error('Not a zip archive: no end-of-directory record.');
4265
+ }
4266
+ const count = u16(end + 10);
4267
+ const files = new Map();
4268
+ let at = u32(end + 16);
4269
+ for (let index = 0; index < count && u32(at) === DIRECTORY_ENTRY; index++) {
4270
+ const method = u16(at + 10);
4271
+ const compressed = u32(at + 20);
4272
+ const nameLength = u16(at + 28);
4273
+ const extraLength = u16(at + 30);
4274
+ const commentLength = u16(at + 32);
4275
+ const localAt = u32(at + 42);
4276
+ const path = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength));
4277
+ // The local header repeats the name and carries extra fields of its own length, so where
4278
+ // the data starts can only be read there — not worked out from the directory entry.
4279
+ const localNameLength = u16(localAt + 26);
4280
+ const localExtraLength = u16(localAt + 28);
4281
+ const from = localAt + 30 + localNameLength + localExtraLength;
4282
+ const raw = bytes.subarray(from, from + compressed);
4283
+ if (method === 0) {
4284
+ files.set(path, raw);
4285
+ }
4286
+ else if (method === 8) {
4287
+ files.set(path, await inflate(raw));
4288
+ }
4289
+ at += 46 + nameLength + extraLength + commentLength;
4290
+ }
4291
+ return files;
4292
+ }
4293
+
4294
+ /** Number formats that mean a date, as the format numbers them. */
4295
+ const DATE_FORMAT_IDS = new Set([
4296
+ 14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 45, 46, 47, 50, 51, 52, 53, 54, 55, 56, 57, 58
4297
+ ]);
4298
+ /** What Excel counts its days from. */
4299
+ const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
4300
+ const decoder = new TextDecoder();
4301
+ /** Undoes the five escapes XML writes, which is all these parts contain. */
4302
+ function unescapeXml(value) {
4303
+ return value
4304
+ .replace(/&lt;/g, '<')
4305
+ .replace(/&gt;/g, '>')
4306
+ .replace(/&quot;/g, '"')
4307
+ .replace(/&apos;/g, "'")
4308
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
4309
+ .replace(/&amp;/g, '&');
4310
+ }
4311
+ /** A column letter back to its place: A is 0, AA is 26. */
4312
+ function columnAt(reference) {
4313
+ const letters = /^([A-Z]+)/.exec(reference)?.[1] ?? 'A';
4314
+ let index = 0;
4315
+ for (const letter of letters) {
4316
+ index = index * 26 + (letter.charCodeAt(0) - 64);
4317
+ }
4318
+ return index - 1;
4319
+ }
4320
+ /** Every `<si>` of the shared-string table, in order, runs of formatting joined back together. */
4321
+ function sharedStrings(xml) {
4322
+ if (!xml) {
4323
+ return [];
4324
+ }
4325
+ return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((item) => [...item[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((piece) => unescapeXml(piece[1])).join(''));
4326
+ }
4327
+ /** Which style slots mean a date, worked out from the number format each one points at. */
4328
+ function dateStyles(xml) {
4329
+ const dates = new Set();
4330
+ if (!xml) {
4331
+ return dates;
4332
+ }
4333
+ const custom = new Map();
4334
+ for (const format of xml.matchAll(/<numFmt[^>]*numFmtId="(\d+)"[^>]*formatCode="([^"]*)"/g)) {
4335
+ custom.set(Number(format[1]), unescapeXml(format[2]));
4336
+ }
4337
+ const cellXfs = /<cellXfs[^>]*>([\s\S]*?)<\/cellXfs>/.exec(xml)?.[1] ?? '';
4338
+ [...cellXfs.matchAll(/<xf[^>]*\/?>/g)].forEach((slot, index) => {
4339
+ const id = Number(/numFmtId="(\d+)"/.exec(slot[0])?.[1] ?? 0);
4340
+ if (DATE_FORMAT_IDS.has(id)) {
4341
+ dates.add(index);
4342
+ return;
4343
+ }
4344
+ // A custom format is a date when it says so: day, month or year outside quoted text. The
4345
+ // stripping matters — `"day "0` is a label, not a date.
4346
+ const code = custom.get(id);
4347
+ if (code && /[ymd]/i.test(code.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, ''))) {
4348
+ dates.add(index);
4349
+ }
4350
+ });
4351
+ return dates;
4352
+ }
4353
+ /** A day count as an ISO date, which is what a date column of this sheet holds. */
4354
+ function isoDate(serial) {
4355
+ return new Date(EXCEL_EPOCH + Math.round(serial) * 86_400_000).toISOString().slice(0, 10);
4356
+ }
4357
+ /** Which worksheet part to read, following the workbook's own list rather than guessing at a name. */
4358
+ function worksheetPath(files, sheetName) {
4359
+ const workbook = files.has('xl/workbook.xml') ? decoder.decode(files.get('xl/workbook.xml')) : '';
4360
+ const rels = files.has('xl/_rels/workbook.xml.rels') ? decoder.decode(files.get('xl/_rels/workbook.xml.rels')) : '';
4361
+ const sheets = [...workbook.matchAll(/<sheet\s[^>]*\/?>/g)].map((sheet) => ({
4362
+ name: unescapeXml(/name="([^"]*)"/.exec(sheet[0])?.[1] ?? ''),
4363
+ rel: /r:id="([^"]*)"/.exec(sheet[0])?.[1] ?? ''
4364
+ }));
4365
+ const wanted = sheetName ? sheets.find((sheet) => sheet.name === sheetName) : sheets[0];
4366
+ const target = wanted ? new RegExp(`<Relationship[^>]*Id="${wanted.rel}"[^>]*Target="([^"]*)"`).exec(rels)?.[1] : undefined;
4367
+ if (target) {
4368
+ const path = target.replace(/^\/?xl\//, '').replace(/^\//, '');
4369
+ if (files.has(`xl/${path}`)) {
4370
+ return `xl/${path}`;
4371
+ }
4372
+ }
4373
+ // A workbook whose relationships could not be followed still has its sheets where everyone
4374
+ // puts them. Better to read the file than to refuse it over a part nobody will ever look at.
4375
+ return [...files.keys()].find((path) => path.startsWith('xl/worksheets/') && path.endsWith('.xml'));
4376
+ }
4377
+ /**
4378
+ * Reads a workbook's first sheet as a table of text.
4379
+ *
4380
+ * @param bytes - The whole file, as it was read or dropped.
4381
+ * @param options - How to read it.
4382
+ * @returns The headers, if it has them, and every row.
4383
+ * @throws When the file is not a workbook at all.
4384
+ */
4385
+ async function xlsxToTable(bytes, options = {}) {
4386
+ const files = await unzip(bytes);
4387
+ const path = worksheetPath(files, options.sheetName);
4388
+ if (!path) {
4389
+ throw new Error('Not an xlsx workbook: no worksheet inside it.');
4390
+ }
4391
+ const strings = sharedStrings(files.has('xl/sharedStrings.xml') ? decoder.decode(files.get('xl/sharedStrings.xml')) : undefined);
4392
+ const dates = dateStyles(files.has('xl/styles.xml') ? decoder.decode(files.get('xl/styles.xml')) : undefined);
4393
+ const sheet = decoder.decode(files.get(path));
4394
+ const rows = [];
4395
+ for (const row of sheet.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
4396
+ const line = [];
4397
+ for (const cell of row[1].matchAll(/<c(\s[^>]*)>([\s\S]*?)<\/c>/g)) {
4398
+ const attributes = cell[1];
4399
+ const at = columnAt(/r="([A-Z]+)/.exec(attributes)?.[1] ?? '');
4400
+ const type = /t="([^"]*)"/.exec(attributes)?.[1] ?? 'n';
4401
+ const style = Number(/s="(\d+)"/.exec(attributes)?.[1] ?? -1);
4402
+ const value = /<v>([\s\S]*?)<\/v>/.exec(cell[2])?.[1];
4403
+ let text = '';
4404
+ if (type === 's') {
4405
+ text = strings[Number(value ?? -1)] ?? '';
4406
+ }
4407
+ else if (type === 'inlineStr') {
4408
+ text = [...cell[2].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((piece) => unescapeXml(piece[1])).join('');
4409
+ }
4410
+ else if (type === 'b') {
4411
+ text = value === '1' ? 'true' : 'false';
4412
+ }
4413
+ else if (value !== undefined) {
4414
+ const number = Number(value);
4415
+ text = dates.has(style) && Number.isFinite(number) ? isoDate(number) : unescapeXml(value);
4416
+ }
4417
+ // Cells are absent where they are empty, so the gaps have to be filled or every value
4418
+ // after the first empty cell lands in the wrong column.
4419
+ while (line.length < at) {
4420
+ line.push('');
4421
+ }
4422
+ line[at] = text;
4423
+ }
4424
+ rows.push(line);
4425
+ }
4426
+ if (options.headers === false || !rows.length) {
4427
+ return { headers: null, rows };
4428
+ }
4429
+ return { headers: rows[0], rows: rows.slice(1) };
4430
+ }
4431
+ /**
4432
+ * Reads a workbook into rows keyed by column alias, matching its headers to the sheet's columns.
4433
+ *
4434
+ * @returns One record per row of the file, keyed by column alias.
4435
+ */
4436
+ async function xlsxToRecords(bytes, columns, options = {}) {
4437
+ return tableToRecords(await xlsxToTable(bytes, options), columns);
4438
+ }
4439
+
4440
+ /**
4441
+ * Registers the control adapter a sheet opens its list cells with.
4442
+ *
4443
+ * ```ts
4444
+ * import { provideHubSpreadsheetControls } from 'ng-hub-ui-spreadsheet';
4445
+ * import { hubFormControlAdapter } from 'ng-hub-ui-forms';
4446
+ *
4447
+ * providers: [provideHubSpreadsheetControls(hubFormControlAdapter)];
4448
+ * ```
4449
+ *
4450
+ * Every list column then opens with that control, with no template written per sheet. A single
4451
+ * column that needs something else still overrides it with `hubSpreadsheetEditor`, which wins.
4452
+ *
4453
+ * @param adapter - The implementation to use, e.g. `hubFormControlAdapter` from `ng-hub-ui-forms`.
4454
+ * @returns Providers to add to the application, or to one component.
4455
+ */
4456
+ function provideHubSpreadsheetControls(adapter) {
4457
+ return makeEnvironmentProviders([
4458
+ {
4459
+ provide: HUB_SPREADSHEET_CONTROLS,
4460
+ useValue: adapter
4461
+ }
4462
+ ]);
4463
+ }
4464
+
4465
+ /** English dictionary for the spreadsheet's own labels. */
4466
+ const locale$7 = {
4467
+ lang: 'en',
4468
+ data: {
4469
+ INSERT_ROW_ABOVE: 'Insert row above',
4470
+ INSERT_ROW_BELOW: 'Insert row below',
4471
+ DELETE_ROWS: 'Delete {{ count }} row(s)',
4472
+ INSERT_COLUMN_BEFORE: 'Insert column before',
4473
+ INSERT_COLUMN_AFTER: 'Insert column after',
4474
+ DELETE_COLUMNS: 'Delete {{ count }} column(s)',
4475
+ MERGE_CELLS: 'Merge {{ count }} cells',
4476
+ UNMERGE_CELLS: 'Split {{ count }} block(s)',
4477
+ ROW_ACTIONS: 'Row and column actions'
4478
+ }
4479
+ };
4480
+
4481
+ /** Spanish dictionary for the spreadsheet's own labels. */
4482
+ const locale$6 = {
4483
+ lang: 'es',
4484
+ data: {
4485
+ INSERT_ROW_ABOVE: 'Insertar fila encima',
4486
+ INSERT_ROW_BELOW: 'Insertar fila debajo',
4487
+ DELETE_ROWS: 'Borrar {{ count }} fila(s)',
4488
+ INSERT_COLUMN_BEFORE: 'Insertar columna antes',
4489
+ INSERT_COLUMN_AFTER: 'Insertar columna después',
4490
+ DELETE_COLUMNS: 'Borrar {{ count }} columna(s)',
4491
+ MERGE_CELLS: 'Combinar {{ count }} celdas',
4492
+ UNMERGE_CELLS: 'Separar {{ count }} bloque(s)',
4493
+ ROW_ACTIONS: 'Acciones de filas y columnas'
4494
+ }
4495
+ };
4496
+
4497
+ /** French dictionary for the spreadsheet's own labels. */
4498
+ const locale$5 = {
4499
+ lang: 'fr',
4500
+ data: {
4501
+ INSERT_ROW_ABOVE: 'Insérer une ligne au-dessus',
4502
+ INSERT_ROW_BELOW: 'Insérer une ligne en dessous',
4503
+ DELETE_ROWS: 'Supprimer {{ count }} ligne(s)',
4504
+ INSERT_COLUMN_BEFORE: 'Insérer une colonne avant',
4505
+ INSERT_COLUMN_AFTER: 'Insérer une colonne après',
4506
+ DELETE_COLUMNS: 'Supprimer {{ count }} colonne(s)',
4507
+ MERGE_CELLS: 'Fusionner {{ count }} cellules',
4508
+ UNMERGE_CELLS: 'Séparer {{ count }} bloc(s)',
4509
+ ROW_ACTIONS: 'Actions sur lignes et colonnes'
4510
+ }
4511
+ };
4512
+
4513
+ /** German dictionary for the spreadsheet's own labels. */
4514
+ const locale$4 = {
4515
+ lang: 'de',
4516
+ data: {
4517
+ INSERT_ROW_ABOVE: 'Zeile darüber einfügen',
4518
+ INSERT_ROW_BELOW: 'Zeile darunter einfügen',
4519
+ DELETE_ROWS: '{{ count }} Zeile(n) löschen',
4520
+ INSERT_COLUMN_BEFORE: 'Spalte davor einfügen',
4521
+ INSERT_COLUMN_AFTER: 'Spalte danach einfügen',
4522
+ DELETE_COLUMNS: '{{ count }} Spalte(n) löschen',
4523
+ MERGE_CELLS: '{{ count }} Zellen verbinden',
4524
+ UNMERGE_CELLS: '{{ count }} Block/Blöcke trennen',
4525
+ ROW_ACTIONS: 'Aktionen für Zeilen und Spalten'
4526
+ }
4527
+ };
4528
+
4529
+ /** Russian dictionary for the spreadsheet's own labels. */
4530
+ const locale$3 = {
4531
+ lang: 'ru',
4532
+ data: {
4533
+ INSERT_ROW_ABOVE: 'Вставить строку выше',
4534
+ INSERT_ROW_BELOW: 'Вставить строку ниже',
4535
+ DELETE_ROWS: 'Удалить строк: {{ count }}',
4536
+ INSERT_COLUMN_BEFORE: 'Вставить столбец слева',
4537
+ INSERT_COLUMN_AFTER: 'Вставить столбец справа',
4538
+ DELETE_COLUMNS: 'Удалить столбцов: {{ count }}',
4539
+ MERGE_CELLS: 'Объединить {{ count }} ячеек',
4540
+ UNMERGE_CELLS: 'Разделить {{ count }} блок(ов)',
4541
+ ROW_ACTIONS: 'Действия со строками и столбцами'
4542
+ }
4543
+ };
4544
+
4545
+ /** Japanese dictionary for the spreadsheet's own labels. */
4546
+ const locale$2 = {
4547
+ lang: 'ja',
4548
+ data: {
4549
+ INSERT_ROW_ABOVE: '上に行を挿入',
4550
+ INSERT_ROW_BELOW: '下に行を挿入',
4551
+ DELETE_ROWS: '{{ count }} 行を削除',
4552
+ INSERT_COLUMN_BEFORE: '左に列を挿入',
4553
+ INSERT_COLUMN_AFTER: '右に列を挿入',
4554
+ DELETE_COLUMNS: '{{ count }} 列を削除',
4555
+ MERGE_CELLS: '{{ count }} 個のセルを結合',
4556
+ UNMERGE_CELLS: '{{ count }} 個の結合を解除',
4557
+ ROW_ACTIONS: '行と列の操作'
4558
+ }
4559
+ };
4560
+
4561
+ /** Chinese dictionary for the spreadsheet's own labels. */
4562
+ const locale$1 = {
4563
+ lang: 'zh',
4564
+ data: {
4565
+ INSERT_ROW_ABOVE: '在上方插入行',
4566
+ INSERT_ROW_BELOW: '在下方插入行',
4567
+ DELETE_ROWS: '删除 {{ count }} 行',
4568
+ INSERT_COLUMN_BEFORE: '在左侧插入列',
4569
+ INSERT_COLUMN_AFTER: '在右侧插入列',
4570
+ DELETE_COLUMNS: '删除 {{ count }} 列',
4571
+ MERGE_CELLS: '合并 {{ count }} 个单元格',
4572
+ UNMERGE_CELLS: '拆分 {{ count }} 个合并块',
4573
+ ROW_ACTIONS: '行与列操作'
4574
+ }
4575
+ };
4576
+
4577
+ /** Arabic dictionary for the spreadsheet's own labels. */
4578
+ const locale = {
4579
+ lang: 'ar',
4580
+ data: {
4581
+ INSERT_ROW_ABOVE: 'إدراج صف أعلى',
4582
+ INSERT_ROW_BELOW: 'إدراج صف أسفل',
4583
+ DELETE_ROWS: 'حذف {{ count }} صف',
4584
+ INSERT_COLUMN_BEFORE: 'إدراج عمود قبل',
4585
+ INSERT_COLUMN_AFTER: 'إدراج عمود بعد',
4586
+ DELETE_COLUMNS: 'حذف {{ count }} عمود',
4587
+ MERGE_CELLS: 'دمج {{ count }} خلية',
4588
+ UNMERGE_CELLS: 'فصل {{ count }} كتلة',
4589
+ ROW_ACTIONS: 'إجراءات الصفوف والأعمدة'
4590
+ }
4591
+ };
4592
+
4593
+ /**
4594
+ * Generated bundle index. Do not edit.
4595
+ */
4596
+
4597
+ export { FORMULA_ERRORS, FORMULA_ERROR_TEXT, HUB_FORMULA_FUNCTIONS, HUB_SPREADSHEET_CONTROLS, HubSpreadsheetCellDirective, HubSpreadsheetComponent, HubSpreadsheetControlDirective, HubSpreadsheetEditorDirective, XLSX_MEDIA_TYPE, applySpanMerge, applySpanUnmerge, applySuggestion, locale as arSpreadsheetLocale, columnIndexOf, columnLabel, csvToRecords, csvToTable, danglingColumnKeys, locale$4 as deSpreadsheetLocale, displayValue, downloadText, downloadXlsx, duplicateColumnKeys, locale$7 as enSpreadsheetLocale, locale$6 as esSpreadsheetLocale, evaluateFormula, evaluateSheet, formatCoordinate, formulaErrorText, formulaFragment, locale$5 as frSpreadsheetLocale, isFormula, isStructureAllowed, isTruthy, locale$2 as jaSpreadsheetLocale, mergeRequestFor, moveCoordinate, nextColumnKey, optionFor, parseClipboardTable, parseCoordinate, parseDecimal, parseForColumn, parseFormula, provideHubSpreadsheetControls, resolveInsertIndex, rewriteCoordinates, rewriteRowFormulas, locale$3 as ruSpreadsheetLocale, serialiseClipboardTable, sheetToCsv, sheetToXlsx, sheetValues, sniffDelimiter, spansWithin, spreadsheetRecord, spreadsheetRecords, suggestFormula, tableToRecords, xlsxToRecords, xlsxToTable, locale$1 as zhSpreadsheetLocale };
4598
+ //# sourceMappingURL=ng-hub-ui-spreadsheet.mjs.map