@lnsy/data-table 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/README.md +229 -0
- package/package.json +77 -0
- package/src/data-table.css +327 -0
- package/src/file-io.js +203 -0
- package/src/formula.js +632 -0
- package/src/index.js +20 -0
- package/src/table-component.js +1031 -0
- package/src/wikilinks.js +71 -0
- package/styles/fonts.css +111 -0
- package/styles/variables.css +151 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <data-table> — a dependency-free tabular data component.
|
|
3
|
+
*
|
|
4
|
+
* This is not just a spreadsheet: one component, three presentation modes,
|
|
5
|
+
* all backed by the same engine.
|
|
6
|
+
*
|
|
7
|
+
* mode="spreadsheet" Full grid. Formulas (=SUM(A1:A5)), endpoint calls
|
|
8
|
+
* (=HTTP(…), =ENDPOINT(…)), wikilinks, copy/paste.
|
|
9
|
+
* mode="table" Searchable, sortable table. First row is the header.
|
|
10
|
+
* mode="list" Searchable single-column list with wikilink support.
|
|
11
|
+
*
|
|
12
|
+
* Attributes:
|
|
13
|
+
* mode spreadsheet | table | list (default: spreadsheet)
|
|
14
|
+
* read-only disable editing
|
|
15
|
+
* rows / cols initial grid size (spreadsheet mode)
|
|
16
|
+
* wikilink-base-url prefix for [[wikilink]] hrefs; without it, clicks
|
|
17
|
+
* dispatch a bubbling `wikilink-navigate` event.
|
|
18
|
+
*
|
|
19
|
+
* Events:
|
|
20
|
+
* change any cell edited { detail: { row, col, value } }
|
|
21
|
+
* selection-change active cell moved { detail: { row, col } }
|
|
22
|
+
* wikilink-navigate a [[link]] was clicked { detail: { target } }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { FormulaEngine, indexToCol, displayValue } from './formula.js';
|
|
26
|
+
import { renderCellValue } from './wikilinks.js';
|
|
27
|
+
|
|
28
|
+
class DataTable extends HTMLElement {
|
|
29
|
+
static get observedAttributes() {
|
|
30
|
+
return ['mode', 'read-only', 'rows', 'cols', 'wikilink-base-url'];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
connectedCallback() {
|
|
34
|
+
if (!this._initialized) {
|
|
35
|
+
this._initialized = true;
|
|
36
|
+
this._data = this._data || [];
|
|
37
|
+
this._engine = new FormulaEngine(() => this._data);
|
|
38
|
+
// Bind global-ish listeners exactly once; _build() may run many times.
|
|
39
|
+
this._bindClipboard();
|
|
40
|
+
this._bindResizeHandles();
|
|
41
|
+
this._build();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
46
|
+
if (oldValue === newValue) return;
|
|
47
|
+
if (!this._initialized) return;
|
|
48
|
+
if (['mode', 'read-only'].includes(name)) {
|
|
49
|
+
this._build();
|
|
50
|
+
} else if (name === 'wikilink-base-url') {
|
|
51
|
+
this._applyWikilinkBase();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Configuration ────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
get mode() {
|
|
58
|
+
return this.getAttribute('mode') || 'spreadsheet';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get readOnly() {
|
|
62
|
+
return this.hasAttribute('read-only');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Register named endpoints usable via =ENDPOINT("name"). */
|
|
66
|
+
setEndpoints(endpoints) {
|
|
67
|
+
this._engine.setEndpoints(endpoints);
|
|
68
|
+
this.recalculate();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setData(data) {
|
|
72
|
+
const norm = (v) => (v === null || v === undefined ? '' : String(v));
|
|
73
|
+
this._data = Array.isArray(data)
|
|
74
|
+
? data.map((row) => (Array.isArray(row) ? row.map(norm) : [norm(row)]))
|
|
75
|
+
: [];
|
|
76
|
+
this._build();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getData() {
|
|
80
|
+
return this._data.map((row) => [...row]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Computed (formula-evaluated) value of a cell, formatted for display. */
|
|
84
|
+
getComputedValue(row, col) {
|
|
85
|
+
const key = `${row}:${col}`;
|
|
86
|
+
if (this._engine.values.has(key)) {
|
|
87
|
+
return displayValue(this._engine.values.get(key));
|
|
88
|
+
}
|
|
89
|
+
const raw = this._engine.getRaw(row, col);
|
|
90
|
+
return raw === '' ? '' : String(raw);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setCellValue(row, col, value) {
|
|
94
|
+
this._ensureSize(row + 1, col + 1);
|
|
95
|
+
this._data[row][col] = value;
|
|
96
|
+
this.recalculate();
|
|
97
|
+
this._updateCellDisplay(row, col);
|
|
98
|
+
this.emitChange(row, col, value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async recalculate() {
|
|
102
|
+
await this._engine.recalcAll();
|
|
103
|
+
if (this.mode === 'spreadsheet') {
|
|
104
|
+
this._refreshAllCells();
|
|
105
|
+
} else if (this.mode === 'table') {
|
|
106
|
+
this._renderTableView();
|
|
107
|
+
} else if (this.mode === 'list') {
|
|
108
|
+
this._renderListView();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
emitChange(row, col, value) {
|
|
113
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
114
|
+
bubbles: true,
|
|
115
|
+
detail: { row, col, value },
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── DOM construction ─────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
_build() {
|
|
122
|
+
this.innerHTML = '';
|
|
123
|
+
this.classList.toggle('read-only', this.readOnly);
|
|
124
|
+
this.setAttribute('tabindex', '0');
|
|
125
|
+
|
|
126
|
+
this._applyWikilinkBase();
|
|
127
|
+
this._selection = null; // { anchor:{r,c}, focus:{r,c} }
|
|
128
|
+
this._editing = null;
|
|
129
|
+
this._sortColumn = null;
|
|
130
|
+
this._sortDirection = 'asc';
|
|
131
|
+
|
|
132
|
+
switch (this.mode) {
|
|
133
|
+
case 'table': this._buildTableMode(); break;
|
|
134
|
+
case 'list': this._buildListMode(); break;
|
|
135
|
+
default: this._buildSpreadsheetMode();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.recalculate();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_applyWikilinkBase() {
|
|
142
|
+
const base = this.getAttribute('wikilink-base-url') || '';
|
|
143
|
+
if (base) {
|
|
144
|
+
this.dataset.wikilinkBase = base;
|
|
145
|
+
} else {
|
|
146
|
+
delete this.dataset.wikilinkBase;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_ensureSize(rows, cols) {
|
|
151
|
+
while (this._data.length < rows) {
|
|
152
|
+
this._data.push([]);
|
|
153
|
+
}
|
|
154
|
+
for (const row of this._data) {
|
|
155
|
+
while (row.length < cols) row.push('');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Spreadsheet mode ─────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
_buildSpreadsheetMode() {
|
|
162
|
+
const rows = Math.max(parseInt(this.getAttribute('rows'), 10) || 50, 1);
|
|
163
|
+
const cols = Math.max(parseInt(this.getAttribute('cols'), 10) || 12, 1);
|
|
164
|
+
this._ensureSize(rows, cols);
|
|
165
|
+
|
|
166
|
+
const root = document.createElement('div');
|
|
167
|
+
root.className = 'dt-root dt-spreadsheet';
|
|
168
|
+
|
|
169
|
+
// Formula bar
|
|
170
|
+
const bar = document.createElement('div');
|
|
171
|
+
bar.className = 'dt-formula-bar';
|
|
172
|
+
const refLabel = document.createElement('span');
|
|
173
|
+
refLabel.className = 'dt-cell-ref';
|
|
174
|
+
const formulaInput = document.createElement('input');
|
|
175
|
+
formulaInput.className = 'dt-formula-input';
|
|
176
|
+
formulaInput.type = 'text';
|
|
177
|
+
formulaInput.spellcheck = false;
|
|
178
|
+
formulaInput.placeholder = 'Enter a value or =FORMULA()';
|
|
179
|
+
bar.append(refLabel, formulaInput);
|
|
180
|
+
this._refLabel = refLabel;
|
|
181
|
+
this._formulaInput = formulaInput;
|
|
182
|
+
|
|
183
|
+
formulaInput.addEventListener('keydown', (e) => {
|
|
184
|
+
// Keep formula-bar keystrokes out of the grid's keyboard handlers.
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
if (e.key === 'Enter') {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
this._commitFormulaBar();
|
|
189
|
+
this._grid.focus({ preventScroll: true });
|
|
190
|
+
} else if (e.key === 'Escape') {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
// Restore the bar from the cell regardless of focus, so the blur
|
|
193
|
+
// handler cannot commit the reverted text.
|
|
194
|
+
const { r, c } = this._selection.anchor;
|
|
195
|
+
this._formulaInput.value = String(this._engine.getRaw(r, c) ?? '');
|
|
196
|
+
this._grid.focus({ preventScroll: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
formulaInput.addEventListener('blur', () => this._commitFormulaBar());
|
|
200
|
+
|
|
201
|
+
// Grid
|
|
202
|
+
this._grid = document.createElement('div');
|
|
203
|
+
this._grid.className = 'dt-grid-scroll';
|
|
204
|
+
this._grid.tabIndex = -1; // focusable so keyboard nav works after click
|
|
205
|
+
const table = this._renderGridTable();
|
|
206
|
+
this._grid.appendChild(table);
|
|
207
|
+
|
|
208
|
+
// Footer controls
|
|
209
|
+
const footer = document.createElement('div');
|
|
210
|
+
footer.className = 'dt-footer';
|
|
211
|
+
const addRowBtn = document.createElement('button');
|
|
212
|
+
addRowBtn.type = 'button';
|
|
213
|
+
addRowBtn.textContent = '+ Row';
|
|
214
|
+
addRowBtn.addEventListener('click', () => this.insertRow());
|
|
215
|
+
const status = document.createElement('span');
|
|
216
|
+
status.className = 'dt-status';
|
|
217
|
+
this._statusEl = status;
|
|
218
|
+
footer.append(addRowBtn, status);
|
|
219
|
+
|
|
220
|
+
root.append(bar, this._grid, footer);
|
|
221
|
+
this.appendChild(root);
|
|
222
|
+
this._applyGridMetrics();
|
|
223
|
+
|
|
224
|
+
this._selectCell(0, 0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_renderGridTable() {
|
|
228
|
+
const table = document.createElement('table');
|
|
229
|
+
table.className = 'dt-grid';
|
|
230
|
+
|
|
231
|
+
const colgroup = document.createElement('colgroup');
|
|
232
|
+
colgroup.className = 'dt-cols';
|
|
233
|
+
colgroup.appendChild(document.createElement('col'));
|
|
234
|
+
for (let c = 0; c < this._colCount(); c++) {
|
|
235
|
+
colgroup.appendChild(document.createElement('col'));
|
|
236
|
+
}
|
|
237
|
+
table.appendChild(colgroup);
|
|
238
|
+
|
|
239
|
+
const thead = document.createElement('thead');
|
|
240
|
+
const headRow = document.createElement('tr');
|
|
241
|
+
const corner = document.createElement('th');
|
|
242
|
+
corner.className = 'dt-corner';
|
|
243
|
+
headRow.appendChild(corner);
|
|
244
|
+
for (let c = 0; c < this._colCount(); c++) {
|
|
245
|
+
const th = document.createElement('th');
|
|
246
|
+
th.className = 'dt-col-header';
|
|
247
|
+
th.textContent = indexToCol(c);
|
|
248
|
+
headRow.appendChild(th);
|
|
249
|
+
}
|
|
250
|
+
thead.appendChild(headRow);
|
|
251
|
+
|
|
252
|
+
const tbody = document.createElement('tbody');
|
|
253
|
+
for (let r = 0; r < this._rowCount(); r++) {
|
|
254
|
+
tbody.appendChild(this._renderGridRow(r));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
table.append(thead, tbody);
|
|
258
|
+
return table;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_colCount() {
|
|
262
|
+
return Math.max(...this._data.map((r) => r.length), 1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_renderGridRow(r) {
|
|
266
|
+
const tr = document.createElement('tr');
|
|
267
|
+
const rowHeader = document.createElement('th');
|
|
268
|
+
rowHeader.className = 'dt-row-header';
|
|
269
|
+
rowHeader.textContent = r + 1;
|
|
270
|
+
tr.appendChild(rowHeader);
|
|
271
|
+
for (let c = 0; c < this._colCount(); c++) {
|
|
272
|
+
tr.appendChild(this._renderCell(r, c));
|
|
273
|
+
}
|
|
274
|
+
return tr;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_renderCell(r, c) {
|
|
278
|
+
const td = document.createElement('td');
|
|
279
|
+
td.className = 'dt-cell';
|
|
280
|
+
td.dataset.row = r;
|
|
281
|
+
td.dataset.col = c;
|
|
282
|
+
td.tabIndex = -1;
|
|
283
|
+
|
|
284
|
+
const computed = this.getComputedValue(r, c);
|
|
285
|
+
if (computed !== '') {
|
|
286
|
+
td.appendChild(renderCellValue(computed));
|
|
287
|
+
}
|
|
288
|
+
if (this._engine.formulas.has(`${r}:${c}`)) {
|
|
289
|
+
td.classList.add('dt-has-formula');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!this.readOnly) {
|
|
293
|
+
td.addEventListener('mousedown', (e) => this._onCellMouseDown(e, r, c));
|
|
294
|
+
td.addEventListener('mouseenter', () => this._onCellMouseEnter(r, c));
|
|
295
|
+
td.addEventListener('dblclick', () => this._startEdit(r, c));
|
|
296
|
+
} else {
|
|
297
|
+
td.addEventListener('mousedown', (e) => {
|
|
298
|
+
this._selectCell(r, c, e.shiftKey);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return td;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_rowCount() {
|
|
308
|
+
return this._data.length;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Column & row resizing (spreadsheet mode) ─────────────────
|
|
312
|
+
|
|
313
|
+
static RESIZE_EDGE_PX = 6;
|
|
314
|
+
static MIN_COL_WIDTH = 40;
|
|
315
|
+
static MIN_ROW_HEIGHT = 20;
|
|
316
|
+
static DEFAULT_COL_WIDTH = 100;
|
|
317
|
+
|
|
318
|
+
_colWidth(c) {
|
|
319
|
+
this._colWidths = this._colWidths || {};
|
|
320
|
+
return this._colWidths[c] ?? DataTable.DEFAULT_COL_WIDTH;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
_rowHeight(r) {
|
|
324
|
+
this._rowHeights = this._rowHeights || {};
|
|
325
|
+
return this._rowHeights[r] ?? this._defaultRowHeight();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_defaultRowHeight() {
|
|
329
|
+
if (this.__defaultRowH == null) {
|
|
330
|
+
const td = this.querySelector('.dt-grid td.dt-cell');
|
|
331
|
+
this.__defaultRowH = td ? td.offsetHeight : 30;
|
|
332
|
+
}
|
|
333
|
+
return this.__defaultRowH;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Wire hover cursor + drag-to-resize on header edges. Bound once. */
|
|
337
|
+
_bindResizeHandles() {
|
|
338
|
+
this.addEventListener('mousemove', (e) => {
|
|
339
|
+
if (this._resizing) return;
|
|
340
|
+
const hit = this._resizeTargetAt(e);
|
|
341
|
+
const el = hit ? hit.el : null;
|
|
342
|
+
if (el !== this._resizeHoverEl) {
|
|
343
|
+
this._resizeHoverEl?.classList.remove('dt-col-resize', 'dt-row-resize');
|
|
344
|
+
this._resizeHoverEl = el;
|
|
345
|
+
if (hit) {
|
|
346
|
+
hit.el.classList.add(hit.type === 'col' ? 'dt-col-resize' : 'dt-row-resize');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
this.addEventListener('mouseleave', () => {
|
|
352
|
+
this._resizeHoverEl?.classList.remove('dt-col-resize', 'dt-row-resize');
|
|
353
|
+
this._resizeHoverEl = null;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
this.addEventListener('mousedown', (e) => this._onResizeMouseDown(e));
|
|
357
|
+
this.addEventListener('dblclick', (e) => this._onResizeDblClick(e));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Header element under the pointer's resize edge, if any. */
|
|
361
|
+
_resizeTargetAt(e) {
|
|
362
|
+
if (this.mode !== 'spreadsheet') return null;
|
|
363
|
+
const th = e.target instanceof Element ? e.target.closest('th') : null;
|
|
364
|
+
if (!th) return null;
|
|
365
|
+
const rect = th.getBoundingClientRect();
|
|
366
|
+
const edge = DataTable.RESIZE_EDGE_PX;
|
|
367
|
+
|
|
368
|
+
if (th.classList.contains('dt-col-header') && rect.right - e.clientX <= edge) {
|
|
369
|
+
const index = Array.prototype.indexOf.call(th.parentElement.children, th) - 1;
|
|
370
|
+
return { el: th, type: 'col', index };
|
|
371
|
+
}
|
|
372
|
+
if (th.classList.contains('dt-row-header') && rect.bottom - e.clientY <= edge) {
|
|
373
|
+
const index = Array.prototype.indexOf.call(th.closest('tbody').children, th.parentElement);
|
|
374
|
+
return { el: th, type: 'row', index };
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
_onResizeMouseDown(e) {
|
|
380
|
+
const hit = this._resizeTargetAt(e);
|
|
381
|
+
if (!hit || hit.index < 0) return;
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
e.stopPropagation();
|
|
384
|
+
|
|
385
|
+
const { type, index } = hit;
|
|
386
|
+
const startPos = type === 'col' ? e.clientX : e.clientY;
|
|
387
|
+
const startSize = type === 'col' ? this._colWidth(index) : this._rowHeight(index);
|
|
388
|
+
|
|
389
|
+
const onMove = (ev) => {
|
|
390
|
+
const delta = (type === 'col' ? ev.clientX : ev.clientY) - startPos;
|
|
391
|
+
if (type === 'col') {
|
|
392
|
+
this._colWidths[index] = Math.max(DataTable.MIN_COL_WIDTH, startSize + delta);
|
|
393
|
+
} else {
|
|
394
|
+
this._rowHeights[index] = Math.max(DataTable.MIN_ROW_HEIGHT, startSize + delta);
|
|
395
|
+
}
|
|
396
|
+
this._applyGridMetrics();
|
|
397
|
+
};
|
|
398
|
+
const onUp = () => {
|
|
399
|
+
document.removeEventListener('mousemove', onMove);
|
|
400
|
+
document.removeEventListener('mouseup', onUp);
|
|
401
|
+
document.body.classList.remove('dt-resizing', 'dt-col-resizing', 'dt-row-resizing');
|
|
402
|
+
this._resizing = false;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
this._resizing = true;
|
|
406
|
+
document.body.classList.add(
|
|
407
|
+
'dt-resizing',
|
|
408
|
+
type === 'col' ? 'dt-col-resizing' : 'dt-row-resizing'
|
|
409
|
+
);
|
|
410
|
+
document.addEventListener('mousemove', onMove);
|
|
411
|
+
document.addEventListener('mouseup', onUp);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Double-click an edge to reset that column/row to its default size. */
|
|
415
|
+
_onResizeDblClick(e) {
|
|
416
|
+
const hit = this._resizeTargetAt(e);
|
|
417
|
+
if (!hit || hit.index < 0) return;
|
|
418
|
+
e.preventDefault();
|
|
419
|
+
e.stopPropagation();
|
|
420
|
+
if (hit.type === 'col') delete this._colWidths[hit.index];
|
|
421
|
+
else delete this._rowHeights[hit.index];
|
|
422
|
+
this._applyGridMetrics();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Push stored column widths / row heights into the DOM. */
|
|
426
|
+
_applyGridMetrics() {
|
|
427
|
+
if (this.mode !== 'spreadsheet') return;
|
|
428
|
+
const table = this.querySelector('.dt-grid');
|
|
429
|
+
if (!table) return;
|
|
430
|
+
|
|
431
|
+
let bodyTotal = 0;
|
|
432
|
+
const cols = table.querySelectorAll('colgroup.dt-cols col');
|
|
433
|
+
cols.forEach((col, i) => {
|
|
434
|
+
if (i === 0) {
|
|
435
|
+
col.style.width = '3rem'; // row-header gutter
|
|
436
|
+
} else {
|
|
437
|
+
const w = Math.round(this._colWidth(i - 1));
|
|
438
|
+
col.style.width = w + 'px';
|
|
439
|
+
bodyTotal += w;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
table.style.width = `calc(3rem + ${bodyTotal}px)`;
|
|
443
|
+
|
|
444
|
+
const rows = table.tBodies[0]?.children ?? [];
|
|
445
|
+
for (let r = 0; r < rows.length; r++) {
|
|
446
|
+
rows[r].style.height = this._rowHeight(r) + 'px';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Selection & keyboard (spreadsheet mode) ──────────────────
|
|
451
|
+
|
|
452
|
+
_onCellMouseDown(event, r, c) {
|
|
453
|
+
if (this._editing && (this._editing.r !== r || this._editing.c !== c)) {
|
|
454
|
+
this._commitEdit();
|
|
455
|
+
}
|
|
456
|
+
this._dragging = true;
|
|
457
|
+
this._selectCell(r, c, event.shiftKey);
|
|
458
|
+
event.preventDefault();
|
|
459
|
+
this._grid.focus({ preventScroll: true });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
_onCellMouseEnter(r, c) {
|
|
463
|
+
if (this._dragging && this._selection) {
|
|
464
|
+
this._selection.focus = { r, c };
|
|
465
|
+
this._paintSelection();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
_selectCell(r, c, extend = false) {
|
|
470
|
+
const maxR = this._rowCount() - 1;
|
|
471
|
+
const maxC = this._colCount() - 1;
|
|
472
|
+
r = Math.min(Math.max(r, 0), maxR);
|
|
473
|
+
c = Math.min(Math.max(c, 0), maxC);
|
|
474
|
+
|
|
475
|
+
if (extend && this._selection) {
|
|
476
|
+
this._selection.focus = { r, c };
|
|
477
|
+
} else {
|
|
478
|
+
this._selection = { anchor: { r, c }, focus: { r, c } };
|
|
479
|
+
}
|
|
480
|
+
this._paintSelection();
|
|
481
|
+
this._syncFormulaBar();
|
|
482
|
+
|
|
483
|
+
this.dispatchEvent(new CustomEvent('selection-change', {
|
|
484
|
+
bubbles: true,
|
|
485
|
+
detail: { row: this._selection.focus.r, col: this._selection.focus.c },
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_paintSelection() {
|
|
490
|
+
const grid = this.querySelector('.dt-grid');
|
|
491
|
+
if (!grid || !this._selection) return;
|
|
492
|
+
grid.querySelectorAll('.dt-active').forEach((el) => el.classList.remove('dt-active'));
|
|
493
|
+
grid.querySelectorAll('.dt-in-range').forEach((el) => el.classList.remove('dt-in-range'));
|
|
494
|
+
|
|
495
|
+
const { anchor, focus } = this._normalizedSelection();
|
|
496
|
+
const activeTd = this._cellElement(focus.r, focus.c);
|
|
497
|
+
if (activeTd) activeTd.classList.add('dt-active');
|
|
498
|
+
|
|
499
|
+
for (let r = anchor.r; r <= focus.r; r++) {
|
|
500
|
+
for (let c = anchor.c; c <= focus.c; c++) {
|
|
501
|
+
const td = this._cellElement(r, c);
|
|
502
|
+
if (td && !(r === focus.r && c === focus.c)) {
|
|
503
|
+
td.classList.add('dt-in-range');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
_normalizedSelection() {
|
|
510
|
+
const { anchor, focus } = this._selection;
|
|
511
|
+
return {
|
|
512
|
+
anchor: {
|
|
513
|
+
r: Math.min(anchor.r, focus.r),
|
|
514
|
+
c: Math.min(anchor.c, focus.c),
|
|
515
|
+
},
|
|
516
|
+
focus: {
|
|
517
|
+
r: Math.max(anchor.r, focus.r),
|
|
518
|
+
c: Math.max(anchor.c, focus.c),
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
_cellElement(r, c) {
|
|
524
|
+
return this.querySelector(`td[data-row="${r}"][data-col="${c}"]`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_handleKeydown(event) {
|
|
528
|
+
if (this.mode !== 'spreadsheet' || !this._selection) return;
|
|
529
|
+
if (this._editing) return; // the editor input handles its own keys
|
|
530
|
+
if (event.ctrlKey || event.metaKey) return; // clipboard shortcuts pass through
|
|
531
|
+
|
|
532
|
+
const { anchor, focus } = this._selection;
|
|
533
|
+
const moveKeys = {
|
|
534
|
+
ArrowUp: [-1, 0], ArrowDown: [1, 0],
|
|
535
|
+
ArrowLeft: [0, -1], ArrowRight: [0, 1],
|
|
536
|
+
Tab: [0, event.shiftKey ? -1 : 1],
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
if (moveKeys[event.key]) {
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
const [dr, dc] = moveKeys[event.key];
|
|
542
|
+
if (event.shiftKey && event.key.startsWith('Arrow')) {
|
|
543
|
+
this._selection.focus = {
|
|
544
|
+
r: focus.r + dr, c: focus.c + dc,
|
|
545
|
+
};
|
|
546
|
+
this._paintSelection();
|
|
547
|
+
} else {
|
|
548
|
+
const target = event.key === 'Tab' ? focus : anchor;
|
|
549
|
+
this._selectCell(target.r + dr, target.c + dc);
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (event.key === 'Enter') {
|
|
555
|
+
event.preventDefault();
|
|
556
|
+
if (!this.readOnly) this._startEdit(focus.r, focus.c);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
561
|
+
event.preventDefault();
|
|
562
|
+
if (!this.readOnly) this.clearSelection();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (event.key.length === 1 && !event.altKey) {
|
|
567
|
+
if (!this.readOnly) {
|
|
568
|
+
event.preventDefault();
|
|
569
|
+
this._startEdit(focus.r, focus.c, event.key);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Editing ──────────────────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
_startEdit(r, c, initialText = null) {
|
|
577
|
+
if (this.readOnly || this._editing) return;
|
|
578
|
+
const td = this._cellElement(r, c);
|
|
579
|
+
if (!td) return;
|
|
580
|
+
|
|
581
|
+
const raw = this._engine.getRaw(r, c);
|
|
582
|
+
const input = document.createElement('input');
|
|
583
|
+
input.className = 'dt-editor';
|
|
584
|
+
input.type = 'text';
|
|
585
|
+
input.value = initialText !== null ? initialText : String(raw ?? '');
|
|
586
|
+
td.textContent = '';
|
|
587
|
+
td.appendChild(input);
|
|
588
|
+
input.focus({ preventScroll: true });
|
|
589
|
+
if (initialText === null) input.select();
|
|
590
|
+
|
|
591
|
+
this._editing = { r, c, input };
|
|
592
|
+
|
|
593
|
+
input.addEventListener('keydown', (e) => {
|
|
594
|
+
if (e.key === 'Enter') {
|
|
595
|
+
e.preventDefault();
|
|
596
|
+
this._commitEdit();
|
|
597
|
+
this._selectCell(r + 1, c);
|
|
598
|
+
this._grid?.focus({ preventScroll: true });
|
|
599
|
+
} else if (e.key === 'Tab') {
|
|
600
|
+
e.preventDefault();
|
|
601
|
+
this._commitEdit();
|
|
602
|
+
this._selectCell(r, c + (e.shiftKey ? -1 : 1));
|
|
603
|
+
this._grid?.focus({ preventScroll: true });
|
|
604
|
+
} else if (e.key === 'Escape') {
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
this._cancelEdit();
|
|
607
|
+
this._grid?.focus({ preventScroll: true });
|
|
608
|
+
}
|
|
609
|
+
e.stopPropagation();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
input.addEventListener('blur', () => {
|
|
613
|
+
if (this._editing && this._editing.input === input) {
|
|
614
|
+
this._commitEdit();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
_commitEdit() {
|
|
620
|
+
if (!this._editing) return;
|
|
621
|
+
const { r, c, input } = this._editing;
|
|
622
|
+
const value = input.value;
|
|
623
|
+
this._editing = null;
|
|
624
|
+
this.setCellValue(r, c, value);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
_cancelEdit() {
|
|
628
|
+
if (!this._editing) return;
|
|
629
|
+
const { r, c } = this._editing;
|
|
630
|
+
this._editing = null;
|
|
631
|
+
this._updateCellDisplay(r, c);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
_commitFormulaBar() {
|
|
635
|
+
if (!this._formulaInput || !this._selection || this.readOnly) return;
|
|
636
|
+
const { r, c } = this._selection.anchor;
|
|
637
|
+
const value = this._formulaInput.value;
|
|
638
|
+
if (value !== String(this._engine.getRaw(r, c))) {
|
|
639
|
+
this.setCellValue(r, c, value);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
_syncFormulaBar() {
|
|
644
|
+
if (!this._formulaInput || !this._selection) return;
|
|
645
|
+
const { r, c } = this._selection.anchor;
|
|
646
|
+
if (document.activeElement === this._formulaInput) return;
|
|
647
|
+
this._refLabel.textContent = `${indexToCol(c)}${r + 1}`;
|
|
648
|
+
this._formulaInput.value = String(this._engine.getRaw(r, c) ?? '');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
_updateCellDisplay(r, c) {
|
|
652
|
+
const td = this._cellElement(r, c);
|
|
653
|
+
if (!td) return;
|
|
654
|
+
td.classList.toggle(
|
|
655
|
+
'dt-has-formula',
|
|
656
|
+
this._engine.formulas.has(`${r}:${c}`)
|
|
657
|
+
);
|
|
658
|
+
const computed = this.getComputedValue(r, c);
|
|
659
|
+
td.textContent = '';
|
|
660
|
+
if (computed !== '') {
|
|
661
|
+
td.appendChild(renderCellValue(computed));
|
|
662
|
+
}
|
|
663
|
+
this._syncFormulaBar();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
_refreshAllCells() {
|
|
667
|
+
if (this.mode !== 'spreadsheet') return;
|
|
668
|
+
for (let r = 0; r < this._rowCount(); r++) {
|
|
669
|
+
for (let c = 0; c < this._colCount(); c++) {
|
|
670
|
+
this._updateCellDisplay(r, c);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
this._paintSelection();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── Structural edits ─────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
insertRow(count = 1) {
|
|
679
|
+
const totalCols = this._colCount();
|
|
680
|
+
for (let i = 0; i < count; i++) {
|
|
681
|
+
this._data.push(Array(totalCols).fill(''));
|
|
682
|
+
}
|
|
683
|
+
this._syncGridDimensions();
|
|
684
|
+
this.recalculate();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
insertColumn() {
|
|
688
|
+
this._ensureSize(this._rowCount(), this._colCount() + 1);
|
|
689
|
+
const headRow = this.querySelector('.dt-grid thead tr');
|
|
690
|
+
if (!headRow) return;
|
|
691
|
+
const c = this._colCount() - 1;
|
|
692
|
+
const th = document.createElement('th');
|
|
693
|
+
th.className = 'dt-col-header';
|
|
694
|
+
th.textContent = indexToCol(c);
|
|
695
|
+
headRow.appendChild(th);
|
|
696
|
+
for (let r = 0; r < this._rowCount(); r++) {
|
|
697
|
+
const row = this.querySelector(`.dt-grid tbody tr:nth-child(${r + 1})`);
|
|
698
|
+
if (row) row.appendChild(this._renderCell(r, c));
|
|
699
|
+
}
|
|
700
|
+
const colgroup = this.querySelector('.dt-grid colgroup.dt-cols');
|
|
701
|
+
if (colgroup) colgroup.appendChild(document.createElement('col'));
|
|
702
|
+
this._applyGridMetrics();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Grow the rendered grid (spreadsheet mode) so every row/column present in
|
|
707
|
+
* the data model has a DOM element.
|
|
708
|
+
*/
|
|
709
|
+
_syncGridDimensions() {
|
|
710
|
+
const table = this.querySelector('.dt-grid');
|
|
711
|
+
if (!table) return;
|
|
712
|
+
|
|
713
|
+
// Columns
|
|
714
|
+
const headRow = table.querySelector('thead tr');
|
|
715
|
+
while (headRow && headRow.children.length - 1 < this._colCount()) {
|
|
716
|
+
const c = headRow.children.length - 1;
|
|
717
|
+
const th = document.createElement('th');
|
|
718
|
+
th.className = 'dt-col-header';
|
|
719
|
+
th.textContent = indexToCol(c);
|
|
720
|
+
headRow.appendChild(th);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Rows
|
|
724
|
+
const tbody = table.querySelector('tbody');
|
|
725
|
+
while (tbody && tbody.children.length < this._rowCount()) {
|
|
726
|
+
const r = tbody.children.length;
|
|
727
|
+
tbody.appendChild(this._renderGridRow(r));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Keep the colgroup in step with new columns.
|
|
731
|
+
const colgroup = table.querySelector('colgroup.dt-cols');
|
|
732
|
+
while (colgroup && colgroup.children.length < this._colCount() + 1) {
|
|
733
|
+
colgroup.appendChild(document.createElement('col'));
|
|
734
|
+
}
|
|
735
|
+
this._applyGridMetrics();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
clearSelection() {
|
|
739
|
+
if (!this._selection) return;
|
|
740
|
+
const { anchor, focus } = this._normalizedSelection();
|
|
741
|
+
for (let r = anchor.r; r <= focus.r; r++) {
|
|
742
|
+
for (let c = anchor.c; c <= focus.c; c++) {
|
|
743
|
+
if (this._data[r]) this._data[r][c] = '';
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
this.recalculate().then(() => this.emitChange(anchor.r, anchor.c, ''));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── Copy / Cut / Paste & keyboard bindings ─────────────────
|
|
750
|
+
|
|
751
|
+
_bindClipboard() {
|
|
752
|
+
this.addEventListener('keydown', (e) => this._handleKeydown(e));
|
|
753
|
+
|
|
754
|
+
document.addEventListener('mouseup', () => { this._dragging = false; });
|
|
755
|
+
|
|
756
|
+
// When the event originates from a text field (cell editor or the formula
|
|
757
|
+
// bar), let the browser handle copy/cut/paste natively instead of
|
|
758
|
+
// hijacking it for grid operations.
|
|
759
|
+
const targetsTextField = (e) =>
|
|
760
|
+
e.composedPath().some(
|
|
761
|
+
(el) => el instanceof HTMLElement && el.matches('input, textarea')
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
this.addEventListener('copy', (e) => {
|
|
765
|
+
if (targetsTextField(e)) return;
|
|
766
|
+
if (this.mode !== 'spreadsheet' || this._editing || !this._selection) return;
|
|
767
|
+
e.preventDefault();
|
|
768
|
+
const tsv = this.selectionToTSV();
|
|
769
|
+
e.clipboardData.setData('text/plain', tsv);
|
|
770
|
+
this._setStatus(`Copied ${this._selectionSize()} cells`);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
this.addEventListener('cut', (e) => {
|
|
774
|
+
if (targetsTextField(e)) return;
|
|
775
|
+
if (this.mode !== 'spreadsheet' || this.readOnly || this._editing || !this._selection) return;
|
|
776
|
+
e.preventDefault();
|
|
777
|
+
const tsv = this.selectionToTSV();
|
|
778
|
+
e.clipboardData.setData('text/plain', tsv);
|
|
779
|
+
this.clearSelection();
|
|
780
|
+
this._setStatus(`Cut ${this._selectionSize()} cells`);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
this.addEventListener('paste', (e) => {
|
|
784
|
+
if (targetsTextField(e)) return;
|
|
785
|
+
if (this.mode !== 'spreadsheet' || this.readOnly || this._editing) return;
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
const text = e.clipboardData.getData('text/plain');
|
|
788
|
+
if (text) this.pasteText(text);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
selectionToTSV() {
|
|
793
|
+
if (!this._selection) return '';
|
|
794
|
+
const { anchor, focus } = this._normalizedSelection();
|
|
795
|
+
const lines = [];
|
|
796
|
+
for (let r = anchor.r; r <= focus.r; r++) {
|
|
797
|
+
const row = [];
|
|
798
|
+
for (let c = anchor.c; c <= focus.c; c++) {
|
|
799
|
+
row.push(String(this._engine.getRaw(r, c) ?? '').replace(/\t|\n/g, ' '));
|
|
800
|
+
}
|
|
801
|
+
lines.push(row.join('\t'));
|
|
802
|
+
}
|
|
803
|
+
return lines.join('\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Parse TSV text and write it starting at the active cell. */
|
|
807
|
+
pasteText(text) {
|
|
808
|
+
if (!this._selection) return;
|
|
809
|
+
const startRow = this._selection.anchor.r;
|
|
810
|
+
const startCol = this._selection.anchor.c;
|
|
811
|
+
const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
812
|
+
.split('\n')
|
|
813
|
+
.filter((line, i, arr) => line !== '' || i < arr.length - 1)
|
|
814
|
+
.map((line) => line.split('\t'));
|
|
815
|
+
|
|
816
|
+
const neededRows = startRow + rows.length;
|
|
817
|
+
const neededCols = startCol + Math.max(...rows.map((r) => r.length));
|
|
818
|
+
this._ensureSize(neededRows, neededCols);
|
|
819
|
+
this._syncGridDimensions();
|
|
820
|
+
|
|
821
|
+
for (let i = 0; i < rows.length; i++) {
|
|
822
|
+
for (let j = 0; j < rows[i].length; j++) {
|
|
823
|
+
this._data[startRow + i][startCol + j] = rows[i][j];
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Extend selection over pasted region.
|
|
828
|
+
this._selection = {
|
|
829
|
+
anchor: { r: startRow, c: startCol },
|
|
830
|
+
focus: { r: neededRows - 1, c: neededCols - 1 },
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
this.recalculate().then(() => {
|
|
834
|
+
this._refreshAllCells();
|
|
835
|
+
this._paintSelection();
|
|
836
|
+
this.emitChange(startRow, startCol, '');
|
|
837
|
+
});
|
|
838
|
+
this._setStatus(`Pasted ${rows.length}×${rows[0].length} block`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
copySelection() {
|
|
842
|
+
const tsv = this.selectionToTSV();
|
|
843
|
+
navigator.clipboard.writeText(tsv).then(() => {
|
|
844
|
+
this._setStatus(`Copied ${this._selectionSize()} cells`);
|
|
845
|
+
}).catch((err) => {
|
|
846
|
+
console.warn('Clipboard write failed:', err);
|
|
847
|
+
this._setStatus('Copy failed — clipboard access blocked');
|
|
848
|
+
});
|
|
849
|
+
return tsv;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
_selectionSize() {
|
|
853
|
+
if (!this._selection) return 0;
|
|
854
|
+
const { anchor, focus } = this._normalizedSelection();
|
|
855
|
+
return (focus.r - anchor.r + 1) * (focus.c - anchor.c + 1);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
_setStatus(text) {
|
|
859
|
+
if (this._statusEl) {
|
|
860
|
+
this._statusEl.textContent = text;
|
|
861
|
+
clearTimeout(this._statusTimer);
|
|
862
|
+
this._statusTimer = setTimeout(() => {
|
|
863
|
+
this._statusEl.textContent = '';
|
|
864
|
+
}, 2500);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ── Table mode ───────────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
_buildTableMode() {
|
|
871
|
+
const root = document.createElement('div');
|
|
872
|
+
root.className = 'dt-root dt-table-mode';
|
|
873
|
+
|
|
874
|
+
const toolbar = document.createElement('div');
|
|
875
|
+
toolbar.className = 'dt-toolbar';
|
|
876
|
+
const label = document.createElement('label');
|
|
877
|
+
label.textContent = 'Search';
|
|
878
|
+
label.htmlFor = 'dt-search-' + Date.now();
|
|
879
|
+
const search = document.createElement('input');
|
|
880
|
+
search.type = 'search';
|
|
881
|
+
search.id = label.htmlFor;
|
|
882
|
+
search.placeholder = 'Filter records…';
|
|
883
|
+
search.autocomplete = 'off';
|
|
884
|
+
search.addEventListener('input', () => {
|
|
885
|
+
this._searchTerm = search.value.trim().toLowerCase();
|
|
886
|
+
this._renderTableView();
|
|
887
|
+
});
|
|
888
|
+
const count = document.createElement('span');
|
|
889
|
+
count.className = 'dt-count';
|
|
890
|
+
this._countEl = count;
|
|
891
|
+
toolbar.append(label, search, count);
|
|
892
|
+
this._searchTerm = '';
|
|
893
|
+
|
|
894
|
+
this._grid = document.createElement('div');
|
|
895
|
+
this._grid.className = 'dt-grid-scroll';
|
|
896
|
+
root.append(toolbar, this._grid);
|
|
897
|
+
this.appendChild(root);
|
|
898
|
+
|
|
899
|
+
this._renderTableView();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Rows visible after filtering/sorting → original data indexes. */
|
|
903
|
+
_tableViewIndexes() {
|
|
904
|
+
const indexes = [];
|
|
905
|
+
for (let r = 1; r < this._rowCount(); r++) {
|
|
906
|
+
indexes.push(r);
|
|
907
|
+
}
|
|
908
|
+
let view = indexes;
|
|
909
|
+
if (this._searchTerm) {
|
|
910
|
+
view = view.filter((r) =>
|
|
911
|
+
(this._data[r] || []).some((cell) =>
|
|
912
|
+
String(cell ?? '').toLowerCase().includes(this._searchTerm))
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
if (this._sortColumn !== null) {
|
|
916
|
+
const col = this._sortColumn;
|
|
917
|
+
const dir = this._sortDirection === 'asc' ? 1 : -1;
|
|
918
|
+
view = [...view].sort((a, b) =>
|
|
919
|
+
dir * String(this._data[a]?.[col] ?? '').localeCompare(
|
|
920
|
+
String(this._data[b]?.[col] ?? ''), undefined, { numeric: true }
|
|
921
|
+
));
|
|
922
|
+
}
|
|
923
|
+
return view;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
_renderTableView() {
|
|
927
|
+
const headers = (this._data[0] || []).map((h) => String(h ?? ''));
|
|
928
|
+
const view = this._tableViewIndexes();
|
|
929
|
+
|
|
930
|
+
const table = document.createElement('table');
|
|
931
|
+
table.className = 'dt-grid dt-data-table';
|
|
932
|
+
|
|
933
|
+
const thead = document.createElement('thead');
|
|
934
|
+
const headRow = document.createElement('tr');
|
|
935
|
+
headers.forEach((header, c) => {
|
|
936
|
+
const th = document.createElement('th');
|
|
937
|
+
th.className = 'dt-col-header dt-sortable';
|
|
938
|
+
th.textContent = header;
|
|
939
|
+
if (this._sortColumn === c) {
|
|
940
|
+
th.classList.add('dt-sorted');
|
|
941
|
+
th.textContent += this._sortDirection === 'asc' ? ' ↑' : ' ↓';
|
|
942
|
+
}
|
|
943
|
+
th.addEventListener('click', () => {
|
|
944
|
+
if (this._sortColumn === c) {
|
|
945
|
+
this._sortDirection = this._sortDirection === 'asc' ? 'desc' : 'asc';
|
|
946
|
+
} else {
|
|
947
|
+
this._sortColumn = c;
|
|
948
|
+
this._sortDirection = 'asc';
|
|
949
|
+
}
|
|
950
|
+
this._renderTableView();
|
|
951
|
+
});
|
|
952
|
+
headRow.appendChild(th);
|
|
953
|
+
});
|
|
954
|
+
thead.appendChild(headRow);
|
|
955
|
+
|
|
956
|
+
const tbody = document.createElement('tbody');
|
|
957
|
+
for (const dataIndex of view) {
|
|
958
|
+
const tr = document.createElement('tr');
|
|
959
|
+
headers.forEach((_, c) => {
|
|
960
|
+
const td = document.createElement('td');
|
|
961
|
+
td.className = 'dt-cell';
|
|
962
|
+
const computed = this.getComputedValue(dataIndex, c);
|
|
963
|
+
if (computed !== '') td.appendChild(renderCellValue(computed));
|
|
964
|
+
tr.appendChild(td);
|
|
965
|
+
});
|
|
966
|
+
tbody.appendChild(tr);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
table.append(thead, tbody);
|
|
970
|
+
this._grid.textContent = '';
|
|
971
|
+
this._grid.appendChild(table);
|
|
972
|
+
|
|
973
|
+
if (this._countEl) {
|
|
974
|
+
this._countEl.textContent =
|
|
975
|
+
`${view.length} of ${Math.max(this._rowCount() - 1, 0)} records`;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ── List mode ────────────────────────────────────────────────
|
|
980
|
+
|
|
981
|
+
_buildListMode() {
|
|
982
|
+
const root = document.createElement('div');
|
|
983
|
+
root.className = 'dt-root dt-list-mode';
|
|
984
|
+
|
|
985
|
+
const toolbar = document.createElement('div');
|
|
986
|
+
toolbar.className = 'dt-toolbar';
|
|
987
|
+
const label = document.createElement('label');
|
|
988
|
+
label.textContent = 'Search';
|
|
989
|
+
label.htmlFor = 'dt-list-search-' + Date.now();
|
|
990
|
+
const search = document.createElement('input');
|
|
991
|
+
search.type = 'search';
|
|
992
|
+
search.id = label.htmlFor;
|
|
993
|
+
search.placeholder = 'Filter items…';
|
|
994
|
+
search.autocomplete = 'off';
|
|
995
|
+
search.addEventListener('input', () => {
|
|
996
|
+
this._searchTerm = search.value.trim().toLowerCase();
|
|
997
|
+
this._renderListView();
|
|
998
|
+
});
|
|
999
|
+
toolbar.append(label, search);
|
|
1000
|
+
|
|
1001
|
+
this._listEl = document.createElement('ul');
|
|
1002
|
+
this._listEl.className = 'dt-list';
|
|
1003
|
+
root.append(toolbar, this._listEl);
|
|
1004
|
+
this.appendChild(root);
|
|
1005
|
+
|
|
1006
|
+
this._searchTerm = '';
|
|
1007
|
+
this._renderListView();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
_renderListView() {
|
|
1011
|
+
const ul = this._listEl;
|
|
1012
|
+
ul.textContent = '';
|
|
1013
|
+
|
|
1014
|
+
let shown = 0;
|
|
1015
|
+
for (let r = 0; r < this._rowCount(); r++) {
|
|
1016
|
+
const raw = String(this.getComputedValue(r, 0) ?? '');
|
|
1017
|
+
if (raw === '') continue;
|
|
1018
|
+
if (this._searchTerm && !raw.toLowerCase().includes(this._searchTerm)) continue;
|
|
1019
|
+
shown++;
|
|
1020
|
+
const li = document.createElement('li');
|
|
1021
|
+
li.appendChild(renderCellValue(raw));
|
|
1022
|
+
ul.appendChild(li);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (!customElements.get('data-table')) {
|
|
1028
|
+
customElements.define('data-table', DataTable);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export default DataTable;
|