@livenetworks/ashlar 2.1.9 → 2.1.11

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.
@@ -1,60 +1,20 @@
1
- import { cloneTemplateScoped, createBatcher, createWindowCache, dispatch, fill, fillTemplate, getLocale, readValue, registerComponent, requestData, attrBool, attrStr, attrSpec, defineAttrs } from '../../ln-core';
2
- import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, toggleSelectAll } from './table-model.js';
1
+ import { cloneTemplateScoped, dispatch, fill, fillTemplate, readValue, registerComponent, requestData, attrBool, attrStr, attrSpec, defineAttrs } from '../../ln-core';
2
+ import { calculateSelectionState, calculateVirtualWindow, calculatePoolSize, toggleRowSelection, toggleSelectAll } from './table-model.js';
3
3
 
4
4
  (function () {
5
5
  const DOM_SELECTOR = 'data-ln-table';
6
6
  const DOM_ATTRIBUTE = 'lnTable';
7
- const EMPTY_TEMPLATE = 'data-ln-table-empty';
8
7
  // Tuning constant — duplicated in ln-data-table for component independence
9
8
  const VIRTUAL_THRESHOLD = 200;
10
9
  const BUFFER_ROWS = 15;
11
- const WINDOW_DEFAULT = 1000;
12
- const WINDOW_PAGE = 200;
13
- const FETCH_DEBOUNCE = 120;
14
10
 
15
11
  if (window[DOM_ATTRIBUTE] !== undefined) return;
16
12
 
17
13
  // ─── Attribute Contract (SSOT) ──────────────────────────
18
- function _applyWindow(inst, val) {
19
- if (!inst || !inst.isDataDriven) return;
20
- const present = inst.dom.hasAttribute('data-ln-table-window');
21
- if (present && !inst._windowed) {
22
- inst._enterWindowedMode();
23
- inst._kickWindowInitial();
24
- } else if (!present && inst._windowed) {
25
- inst._exitWindowedMode();
26
- } else if (present && inst._windowed) {
27
- const v = parseInt(val, 10);
28
- if (v > 0) inst._cache.configure({ windowSize: v });
29
- }
30
- }
31
-
32
- function _applyWindowPage(inst, val) {
33
- if (!inst || !inst.isDataDriven || !inst._windowed || !inst._cache) return;
34
- const v = parseInt(val, 10);
35
- if (v > 0) inst._cache.configure({ pageSize: v });
36
- }
37
-
38
- function _applyWindowThreshold(inst, val) {
39
- if (!inst || !inst.isDataDriven || !inst._windowed || !inst._cache) return;
40
- const v = parseInt(val, 10);
41
- if (v >= 0) inst._cache.configure({ threshold: v });
42
- }
43
-
44
- function _applyCount(inst, val) {
45
- if (!inst || !inst.isDataDriven || !inst._windowed || !inst._cache) return;
46
- const v = parseInt(val, 10);
47
- if (v >= 0) inst._cache.setGrandTotal(v);
48
- }
49
-
50
14
  const ATTRIBUTES = {
51
15
  'data-ln-table': { prop: 'name', type: 'string', read: attrStr, fallback: '', description: 'Table instance name or identifier' },
52
16
  'data-ln-table-source': { prop: 'source', type: 'string', read: attrStr, fallback: '', description: 'Source store or coordinator addressing' },
53
17
  'data-ln-table-selectable': { prop: '_selectable', type: 'boolean', read: attrBool, description: 'Enables row selection check controls' },
54
- 'data-ln-table-window': { type: 'integer', fallback: WINDOW_DEFAULT, min: 10, effect: _applyWindow, description: 'Virtual scrolling window size in rows' },
55
- 'data-ln-table-window-page': { type: 'integer', fallback: WINDOW_PAGE, min: 5, effect: _applyWindowPage, description: 'Virtual scrolling slice page size' },
56
- 'data-ln-table-window-threshold': { type: 'integer', fallback: 50, min: 0, effect: _applyWindowThreshold, description: 'Scroll threshold margin in pixels to trigger page fetch' },
57
- 'data-ln-table-count': { type: 'integer', min: 0, effect: _applyCount, description: 'Total record count override for virtual scrollbar calculation' },
58
18
  'data-ln-table-row': { type: 'marker', description: 'Table row template element' },
59
19
  'data-ln-table-row-id': { type: 'string', description: 'Record identifier on row element' },
60
20
  'data-ln-table-row-action': { type: 'trigger', description: 'Action trigger inside a table row' },
@@ -62,7 +22,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
62
22
  'data-ln-table-col': { type: 'string', description: 'Column header identifier or field mapping' },
63
23
  'data-ln-table-col-select': { type: 'trigger', description: 'Header select-all checkbox trigger' },
64
24
  'data-ln-table-cell-attr': { type: 'string', description: 'Cell attribute template mapping' },
65
- 'data-ln-table-empty': { type: 'marker', description: 'Container for table empty state' },
66
25
  'data-ln-table-select-all-label': { type: 'string', description: 'Accessibility label for select-all header trigger' }
67
26
  };
68
27
 
@@ -73,17 +32,30 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
73
32
  ? new Intl.Collator(document.documentElement.lang || undefined, { sensitivity: 'base' })
74
33
  : null;
75
34
 
76
- function _formatNum(n, dom) {
77
- if (n == null || isNaN(n)) return '';
78
- try {
79
- return new Intl.NumberFormat(getLocale(dom)).format(n);
80
- } catch (e) {
81
- return String(n);
35
+ function _setNumberValue(el, val) {
36
+ if (!el) return;
37
+ if (val == null || isNaN(val)) {
38
+ el.removeAttribute('data-ln-value');
39
+ if ('value' in el && el.tagName === 'DATA') el.value = '';
40
+ if (el.lnNumber) {
41
+ el.lnNumber.value = NaN;
42
+ } else {
43
+ el.textContent = '';
44
+ }
45
+ return;
46
+ }
47
+ const s = String(val);
48
+ el.setAttribute('data-ln-value', s);
49
+ if ('value' in el && el.tagName === 'DATA') el.value = s;
50
+ if (el.lnNumber) {
51
+ el.lnNumber.value = val;
52
+ } else {
53
+ el.textContent = s;
82
54
  }
83
55
  }
84
56
 
85
- function _findScrollContainer(el) {
86
- let p = el.parentElement;
57
+ function _findScrollContainer(el, table) {
58
+ let p = table ? table.parentElement : el;
87
59
  while (p && p !== document.body && p !== document.documentElement) {
88
60
  const cs = getComputedStyle(p);
89
61
  const oy = cs.overflowY;
@@ -119,6 +91,10 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
119
91
  ? this._selectedSpan.parentElement
120
92
  : null;
121
93
  }
94
+ this._loadingIndicator = dom.querySelector('[data-ln-table-loading]');
95
+ if (this._loadingIndicator) {
96
+ this._loadingIndicator.classList.add('hidden');
97
+ }
122
98
 
123
99
  this.isDataDriven = dom.hasAttribute('data-ln-table-source');
124
100
 
@@ -139,6 +115,11 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
139
115
  this._scrollHandler = null;
140
116
  this._scrollContainer = null;
141
117
  this._colgroup = null;
118
+ this._rowPool = null;
119
+ this._topSpacer = null;
120
+ this._topTd = null;
121
+ this._bottomSpacer = null;
122
+ this._bottomTd = null;
142
123
 
143
124
  const self = this;
144
125
 
@@ -206,6 +187,7 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
206
187
 
207
188
  if (this.isDataDriven) {
208
189
  this.isLoaded = false;
190
+ this._initialLoaded = false;
209
191
  this.totalCount = 0;
210
192
  this.visibleCount = 0;
211
193
  this.currentSort = null;
@@ -216,11 +198,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
216
198
  this._lastFiltered = 0;
217
199
  this._hasInitialSeed = false;
218
200
 
219
- // Windowed (sliding-window) server-side virtualization — opt-in
220
- this._windowed = false;
221
- this._cache = null;
222
- if (this.isDataDriven && dom.hasAttribute('data-ln-table-window')) this._enterWindowedMode();
223
-
224
201
  // --- Event listeners ---
225
202
  this._onSetData = function (e) {
226
203
  const detail = e.detail || {};
@@ -232,15 +209,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
232
209
  return;
233
210
  }
234
211
 
235
- if (self._windowed) {
236
- // Only an accepted, authoritative page ends the refresh. A declined
237
- // one means the source has not resolved the new query yet; a
238
- // provisional one is the store's own answer, rendered while the
239
- // server query is still out.
240
- if (self._cache.ingest(detail) && !detail.provisional) dom.classList.remove('ln-table--loading');
241
- return;
242
- }
243
-
244
212
  self._data = newData;
245
213
  self._lastTotal = newTotal;
246
214
  self._lastFiltered = detail.filtered != null ? detail.filtered : self._data.length;
@@ -248,9 +216,14 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
248
216
  self.totalCount = self._lastTotal;
249
217
  self.visibleCount = self._lastFiltered;
250
218
  self.isLoaded = true;
219
+ self._initialLoaded = true;
251
220
  self._hasInitialSeed = false;
252
221
 
253
222
  dom.classList.remove('ln-table--loading');
223
+ dom.setAttribute('aria-busy', 'false');
224
+ if (self._loadingIndicator) {
225
+ self._loadingIndicator.classList.add('hidden');
226
+ }
254
227
 
255
228
  self._vStart = -1;
256
229
  self._vEnd = -1;
@@ -268,40 +241,18 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
268
241
  dom.addEventListener('ln-table:set-data', this._onSetData);
269
242
 
270
243
  this._onSetLoading = function (e) {
271
- const loading = e.detail && e.detail.loading;
272
- dom.classList.toggle('ln-table--loading', !!loading);
273
- if (loading) {
244
+ const loading = !!(e.detail && e.detail.loading);
245
+ dom.classList.toggle('ln-table--loading', loading);
246
+ dom.setAttribute('aria-busy', loading ? 'true' : 'false');
247
+ if (loading && !self._initialLoaded) {
274
248
  self.isLoaded = false;
249
+ self._showLoadingState();
275
250
  }
276
- };
277
- dom.addEventListener('ln-table:set-loading', this._onSetLoading);
278
-
279
- this._onPageFailed = function (e) {
280
- if (!self._windowed || !self._cache) return;
281
- self._cache.release(e.detail && e.detail.offset);
282
- };
283
- dom.addEventListener('ln-table:page-failed', this._onPageFailed);
284
-
285
- this._onRequestRevalidate = function () {
286
- if (!self._windowed || !self._cache) return;
287
- self._cache.revalidate();
288
- };
289
- dom.addEventListener('ln-table:request-revalidate', this._onRequestRevalidate);
290
-
291
- // The source owns the query, so a query change reaches the view as a
292
- // restart order, not as query state — the window drops to page 0 and
293
- // the composed query is resolved at serve time.
294
- this._onRequestInvalidate = function (e) {
295
- if (!self._windowed || !self._cache) return;
296
- if (e && e.detail && e.detail.query) {
297
- const q = e.detail.query;
298
- if (q.search !== undefined) self.currentSearch = q.search;
299
- if (q.filters !== undefined) self.currentFilters = q.filters;
300
- if (q.sort !== undefined) self.currentSort = q.sort;
251
+ if (self._loadingIndicator) {
252
+ self._loadingIndicator.classList.toggle('hidden', !(loading && self._initialLoaded));
301
253
  }
302
- self._requestData();
303
254
  };
304
- dom.addEventListener('ln-table:request-invalidate', this._onRequestInvalidate);
255
+ dom.addEventListener('ln-table:set-loading', this._onSetLoading);
305
256
 
306
257
  // --- Sort ---
307
258
  this._onSort = function (e) {
@@ -312,11 +263,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
312
263
  };
313
264
  dom.addEventListener('ln-sort:change', this._onSort);
314
265
 
315
- // D4 — windowed tables cannot select rows they have never fetched
316
- if (this._windowed && this._selectable && this._selectAllCheckbox) {
317
- this._selectAllCheckbox.classList.add('hidden');
318
- }
319
-
320
266
  // --- Row Click & Actions ---
321
267
  this._onRowClick = function (e) {
322
268
  if (e.target.closest('[data-ln-table-row-select]')) return;
@@ -358,26 +304,23 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
358
304
  };
359
305
  if (this.tbody) this.tbody.addEventListener('click', this._onRowAction);
360
306
 
361
-
362
-
363
- // Local hydration of initial SSR rows
307
+ // Local hydration of initial SSR rows or initial loading state
364
308
  if (this.tbody && this.tbody.rows.length > 0) {
365
309
  this._parseRows();
310
+ } else {
311
+ this._showLoadingState();
312
+ dom.setAttribute('aria-busy', 'true');
366
313
  }
367
314
 
368
315
  // Initial request-data deferred so coordinators have attached listeners
369
316
  setTimeout(function () {
370
317
  if (!self.dom || !document.contains(self.dom)) return;
371
- if (self._windowed) {
372
- self._kickWindowInitial();
373
- } else {
374
- dispatch(dom, 'ln-table:request-data', {
375
- table: self.name,
376
- sort: self.currentSort,
377
- filters: self.currentFilters,
378
- search: self.currentSearch
379
- });
380
- }
318
+ dispatch(dom, 'ln-table:request-data', {
319
+ table: self.name,
320
+ sort: self.currentSort,
321
+ filters: self.currentFilters,
322
+ search: self.currentSearch
323
+ });
381
324
  }, 0);
382
325
 
383
326
  } else {
@@ -555,18 +498,25 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
555
498
  if (!this.tbody) return;
556
499
 
557
500
  if (this.isDataDriven) {
558
- if (this._windowed) { this._renderWindowed(); return; }
559
501
  const total = this._lastTotal;
560
502
  const filtered = this.visibleCount;
561
503
 
562
504
  if (total === 0) {
563
505
  this._disableVirtualScroll();
506
+ if (!this.isLoaded) {
507
+ this._showLoadingState();
508
+ return;
509
+ }
564
510
  this._showEmptyState();
565
511
  return;
566
512
  }
567
513
 
568
514
  if (this._filteredData.length === 0 || filtered === 0) {
569
515
  this._disableVirtualScroll();
516
+ if (!this.isLoaded) {
517
+ this._showLoadingState();
518
+ return;
519
+ }
570
520
  this._showEmptyState();
571
521
  return;
572
522
  }
@@ -595,8 +545,15 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
595
545
  };
596
546
 
597
547
  _component.prototype._renderAll = function () {
598
- if (this.isDataDriven) {
599
- const data = this._filteredData;
548
+ this._rowPool = null;
549
+ this._topSpacer = null;
550
+ this._topTd = null;
551
+ this._bottomSpacer = null;
552
+ this._bottomTd = null;
553
+
554
+ const data = this._filteredData;
555
+
556
+ if (this.isDataDriven || (data.length > 0 && !data[0].html)) {
600
557
  const frag = document.createDocumentFragment();
601
558
 
602
559
  for (let i = 0; i < data.length; i++) {
@@ -610,7 +567,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
610
567
  if (this._selectable) this._updateSelectAll();
611
568
  } else {
612
569
  const html = [];
613
- const data = this._filteredData;
614
570
  for (let i = 0; i < data.length; i++) html.push(data[i].html);
615
571
 
616
572
  this.tbody.innerHTML = html.join('');
@@ -633,10 +589,7 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
633
589
  this._rowHeight = this.tbody.rows[0].offsetHeight || 40;
634
590
  } else {
635
591
  let tempRow = null;
636
- if (this._windowed) {
637
- const sample = this._cache ? this._cache.peek() : null;
638
- tempRow = sample ? this._buildRow(sample) : this._buildPlaceholderRow();
639
- } else if (this.isDataDriven && this._data.length > 0) {
592
+ if (this.isDataDriven && this._data.length > 0) {
640
593
  tempRow = this._buildRow(this._data[0]);
641
594
  }
642
595
  if (tempRow && this.tbody) {
@@ -647,18 +600,21 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
647
600
  }
648
601
  }
649
602
 
650
- if (this.isDataDriven) {
651
- this._scrollContainer = _findScrollContainer(this.dom);
652
- } else {
653
- this._scrollContainer = null;
654
- }
603
+ this._scrollContainer = _findScrollContainer(this.dom, this.table);
655
604
  const scrollTarget = this._scrollContainer || window;
656
605
 
606
+ // Disable browser scroll anchoring on table, tbody, and scroll container
607
+ // to prevent runaway scroll feedback loops when spacer heights dynamically resize.
608
+ if (this.dom) this.dom.style.overflowAnchor = 'none';
609
+ if (this.table) this.table.style.overflowAnchor = 'none';
610
+ if (this.tbody) this.tbody.style.overflowAnchor = 'none';
611
+ if (this._scrollContainer) this._scrollContainer.style.overflowAnchor = 'none';
612
+
657
613
  this._scrollHandler = function () {
658
614
  if (self._rafId) return;
659
615
  self._rafId = requestAnimationFrame(function () {
660
616
  self._rafId = null;
661
- self._windowed ? self._renderWindowed() : self._renderVirtual();
617
+ self._renderVirtual();
662
618
  });
663
619
  };
664
620
 
@@ -683,123 +639,205 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
683
639
  }
684
640
  this._vStart = -1;
685
641
  this._vEnd = -1;
642
+ this._rowPool = null;
643
+ this._topSpacer = null;
644
+ this._topTd = null;
645
+ this._bottomSpacer = null;
646
+ this._bottomTd = null;
686
647
  };
687
648
 
688
- _component.prototype._renderVirtual = function () {
689
- const data = this._filteredData;
690
- const total = data.length;
691
- const rowH = this._rowHeight;
692
- if (!rowH || !total) return;
649
+ // ─── Row Pool (Cheetah / Glide style DOM Recycling) ────────
693
650
 
694
- const theadH = this.thead ? this.thead.offsetHeight : 0;
695
- const sc = this._scrollContainer;
696
- let scrollIntoData;
697
- let viewportH;
651
+ _component.prototype._compileRowBindings = function (tr) {
652
+ const bindings = {
653
+ textNodes: [],
654
+ attrs: [],
655
+ cellAttrs: []
656
+ };
698
657
 
699
- if (sc) {
700
- const tableRect = this.table.getBoundingClientRect();
701
- const scRect = sc.getBoundingClientRect();
702
- const dataStartInContainer = (tableRect.top - scRect.top) + sc.scrollTop + theadH;
703
- scrollIntoData = sc.scrollTop - dataStartInContainer;
704
- viewportH = sc.clientHeight;
705
- } else {
706
- const tableRect = this.table.getBoundingClientRect();
707
- const tableTopInPage = tableRect.top + window.scrollY;
708
- const dataStartInPage = tableTopInPage + theadH;
709
- scrollIntoData = window.scrollY - dataStartInPage;
710
- viewportH = window.innerHeight;
658
+ const walker = document.createTreeWalker(tr, NodeFilter.SHOW_TEXT);
659
+ while (walker.nextNode()) {
660
+ const node = walker.currentNode;
661
+ if (node.textContent.indexOf('{{') !== -1) {
662
+ const parent = node.parentElement;
663
+ bindings.textNodes.push({
664
+ node: node,
665
+ raw: node.textContent,
666
+ el: parent,
667
+ isLeaf: !!(parent && parent.children.length === 0)
668
+ });
669
+ }
711
670
  }
712
671
 
713
- const vw = calculateVirtualWindow(scrollIntoData, viewportH, rowH, total, BUFFER_ROWS);
714
- const startRow = vw.start;
715
- const endRow = vw.end;
716
-
717
- if (startRow === this._vStart && endRow === this._vEnd) return;
718
- this._vStart = startRow;
719
- this._vEnd = endRow;
672
+ const elements = Array.from(tr.querySelectorAll('*'));
673
+ if (tr.nodeType === 1) elements.push(tr);
674
+
675
+ for (let i = 0; i < elements.length; i++) {
676
+ const el = elements[i];
677
+ if (el.hasAttribute('data-ln-table-cell-attr')) {
678
+ const pairs = el.getAttribute('data-ln-table-cell-attr').split(',');
679
+ for (let j = 0; j < pairs.length; j++) {
680
+ const parts = pairs[j].trim().split(':');
681
+ if (parts.length === 2) {
682
+ bindings.cellAttrs.push({
683
+ el: el,
684
+ field: parts[0].trim(),
685
+ attr: parts[1].trim()
686
+ });
687
+ }
688
+ }
689
+ }
720
690
 
721
- const colSpan = this.ths.length || 1;
722
- const topH = vw.topPadding;
723
- const bottomH = vw.bottomPadding;
691
+ const attrs = el.attributes;
692
+ for (let j = 0; j < attrs.length; j++) {
693
+ const a = attrs[j];
694
+ if (a.value.indexOf('{{') !== -1) {
695
+ bindings.attrs.push({
696
+ el: el,
697
+ name: a.name,
698
+ raw: a.value
699
+ });
700
+ }
701
+ }
702
+ }
724
703
 
725
- if (this.isDataDriven) {
726
- const frag = document.createDocumentFragment();
704
+ tr._lnBindings = bindings;
705
+ return bindings;
706
+ };
727
707
 
728
- if (topH > 0) {
729
- const topSpacer = document.createElement('tr');
730
- topSpacer.className = 'ln-table__spacer';
731
- topSpacer.setAttribute('aria-hidden', 'true');
732
- const topTd = document.createElement('td');
733
- topTd.setAttribute('colspan', colSpan);
734
- topTd.style.height = topH + 'px';
735
- topSpacer.appendChild(topTd);
736
- frag.appendChild(topSpacer);
708
+ _component.prototype._createTemplateRow = function () {
709
+ let clone = cloneTemplateScoped(this.dom, this.name + '-row', 'ln-table');
710
+ if (!clone) {
711
+ const genericTpl = this.dom.querySelector('template[data-ln-table-row]');
712
+ if (genericTpl) {
713
+ clone = document.importNode(genericTpl.content, true);
737
714
  }
715
+ }
738
716
 
739
- for (let i = startRow; i < endRow; i++) {
740
- const tr = this._buildRow(data[i]);
741
- if (tr) frag.appendChild(tr);
742
- }
717
+ let tr = clone ? (clone.querySelector('[data-ln-table-row]') || clone.firstElementChild) : null;
743
718
 
744
- if (bottomH > 0) {
745
- const bottomSpacer = document.createElement('tr');
746
- bottomSpacer.className = 'ln-table__spacer';
747
- bottomSpacer.setAttribute('aria-hidden', 'true');
748
- const bottomTd = document.createElement('td');
749
- bottomTd.setAttribute('colspan', colSpan);
750
- bottomTd.style.height = bottomH + 'px';
751
- bottomSpacer.appendChild(bottomTd);
752
- frag.appendChild(bottomSpacer);
719
+ if (!tr) {
720
+ if (window.lnDebug || (typeof document !== 'undefined' && document.body && document.body.hasAttribute('data-ln-debug'))) {
721
+ console.warn(`[ln-table] Missing row template: <template data-ln-template="${this.name}-row">`);
753
722
  }
723
+ return null;
724
+ }
754
725
 
755
- this.tbody.replaceChildren(frag);
726
+ this._compileRowBindings(tr);
727
+ return tr;
728
+ };
756
729
 
757
- if (this._selectable) this._updateSelectAll();
758
- } else {
759
- let html = '';
730
+ _component.prototype._updateRow = function (tr, record) {
731
+ if (!record) return;
732
+ tr._lnRecord = record;
733
+ if (record.id != null) {
734
+ tr.setAttribute('data-ln-table-row-id', record.id);
735
+ }
760
736
 
761
- if (topH > 0) {
762
- html += '<tr class="ln-table__spacer" aria-hidden="true"><td colspan="' +
763
- colSpan + '" style="height:' + topH + 'px;padding:0;border:none"></td></tr>';
764
- }
765
- for (let i = startRow; i < endRow; i++) html += data[i].html;
766
- if (bottomH > 0) {
767
- html += '<tr class="ln-table__spacer" aria-hidden="true"><td colspan="' +
768
- colSpan + '" style="height:' + bottomH + 'px;padding:0;border:none"></td></tr>';
737
+ const bindings = tr._lnBindings || this._compileRowBindings(tr);
738
+
739
+ const textNodes = bindings.textNodes;
740
+ for (let i = 0; i < textNodes.length; i++) {
741
+ const item = textNodes[i];
742
+ const val = item.raw.replace(
743
+ /\{\{\s*(\w+)\s*\}\}/g,
744
+ function (_, key) { return record[key] !== undefined && record[key] !== null ? record[key] : ''; }
745
+ );
746
+ if (item.isLeaf && item.el) {
747
+ item.el.textContent = val;
748
+ } else if (item.node && item.node.parentNode) {
749
+ item.node.textContent = val;
750
+ } else if (item.el) {
751
+ item.el.textContent = val;
769
752
  }
753
+ }
770
754
 
771
- this.tbody.innerHTML = html;
755
+ const attrs = bindings.attrs;
756
+ for (let i = 0; i < attrs.length; i++) {
757
+ const item = attrs[i];
758
+ item.el.setAttribute(
759
+ item.name,
760
+ item.raw.replace(
761
+ /\{\{\s*(\w+)\s*\}\}/g,
762
+ function (_, key) { return record[key] !== undefined && record[key] !== null ? record[key] : ''; }
763
+ )
764
+ );
765
+ }
772
766
 
773
- if (this._selectable) this._restoreSelection();
767
+ const cellAttrs = bindings.cellAttrs;
768
+ for (let i = 0; i < cellAttrs.length; i++) {
769
+ const item = cellAttrs[i];
770
+ const val = record[item.field];
771
+ if (val != null) {
772
+ item.el.setAttribute(item.attr, val);
773
+ }
774
774
  }
775
- };
776
775
 
777
- // ─── Windowed virtualization (opt-in sliding window) ───────
776
+ if (bindings.textNodes.length === 0 && bindings.attrs.length === 0 && this.ths.length > 0) {
777
+ for (let j = 0; j < this.ths.length; j++) {
778
+ const colName = this.ths[j].getAttribute('data-ln-table-col');
779
+ if (colName && j < tr.cells.length && record[colName] != null) {
780
+ tr.cells[j].textContent = String(record[colName]);
781
+ }
782
+ }
783
+ }
778
784
 
779
- _component.prototype._buildPlaceholderRow = function () {
780
- const tr = document.createElement('tr');
781
- tr.className = 'ln-table__placeholder';
782
- tr.setAttribute('aria-hidden', 'true');
783
- const td = document.createElement('td');
784
- td.setAttribute('colspan', this.ths.length || 1);
785
- td.style.height = this._rowHeight + 'px';
786
- tr.appendChild(td);
787
- return tr;
785
+ if (this._selectable) {
786
+ const id = record.id != null ? String(record.id) : null;
787
+ const isSelected = id != null && this.selectedIds.has(id);
788
+ tr.classList.toggle('ln-row-selected', isSelected);
789
+ const rowCb = tr.querySelector('[data-ln-table-row-select]');
790
+ if (rowCb) rowCb.checked = isSelected;
791
+ }
788
792
  };
789
793
 
790
- _component.prototype._renderWindowed = function () {
791
- if (this.isLoaded && this._cache.logicalTotal === 0) {
792
- this._disableVirtualScroll();
793
- this._showEmptyState();
794
- return;
794
+ _component.prototype._ensureRowPool = function (requiredCount, colSpan) {
795
+ if (!this._rowPool) {
796
+ this._rowPool = [];
797
+ this._topSpacer = document.createElement('tr');
798
+ this._topSpacer.className = 'ln-table__spacer';
799
+ this._topSpacer.setAttribute('aria-hidden', 'true');
800
+ this._topSpacer.style.overflowAnchor = 'none';
801
+ this._topTd = document.createElement('td');
802
+ this._topTd.setAttribute('colspan', colSpan);
803
+ this._topTd.style.padding = '0';
804
+ this._topTd.style.border = 'none';
805
+ this._topTd.style.overflowAnchor = 'none';
806
+ this._topSpacer.appendChild(this._topTd);
807
+
808
+ this._bottomSpacer = document.createElement('tr');
809
+ this._bottomSpacer.className = 'ln-table__spacer';
810
+ this._bottomSpacer.setAttribute('aria-hidden', 'true');
811
+ this._bottomSpacer.style.overflowAnchor = 'none';
812
+ this._bottomTd = document.createElement('td');
813
+ this._bottomTd.setAttribute('colspan', colSpan);
814
+ this._bottomTd.style.padding = '0';
815
+ this._bottomTd.style.border = 'none';
816
+ this._bottomTd.style.overflowAnchor = 'none';
817
+ this._bottomSpacer.appendChild(this._bottomTd);
818
+
819
+ this.tbody.replaceChildren(this._topSpacer, this._bottomSpacer);
820
+ } else {
821
+ if (this._topTd && this._topTd.getAttribute('colspan') !== String(colSpan)) {
822
+ this._topTd.setAttribute('colspan', colSpan);
823
+ this._bottomTd.setAttribute('colspan', colSpan);
824
+ }
795
825
  }
796
826
 
797
- if (!this._virtual) this._enableVirtualScroll();
827
+ while (this._rowPool.length < requiredCount) {
828
+ const tr = this._createTemplateRow();
829
+ if (!tr) break;
830
+ this._rowPool.push(tr);
831
+ this.tbody.insertBefore(tr, this._bottomSpacer);
832
+ }
833
+ };
798
834
 
835
+ _component.prototype._renderVirtual = function () {
836
+ const data = this._filteredData;
837
+ const total = data.length;
799
838
  const rowH = this._rowHeight;
800
- if (!rowH) return;
839
+ if (!rowH || !total) return;
801
840
 
802
- const total = this._cache.logicalTotal;
803
841
  const theadH = this.thead ? this.thead.offsetHeight : 0;
804
842
  const sc = this._scrollContainer;
805
843
  let scrollIntoData;
@@ -824,59 +862,111 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
824
862
  const endRow = vw.end;
825
863
 
826
864
  if (startRow === this._vStart && endRow === this._vEnd) return;
865
+ this._vStart = startRow;
866
+ this._vEnd = endRow;
827
867
 
828
868
  const colSpan = this.ths.length || 1;
829
- const topH = vw.topPadding;
830
- const bottomH = vw.bottomPadding;
831
-
832
- const frag = document.createDocumentFragment();
833
-
834
- if (topH > 0) {
835
- const topSpacer = document.createElement('tr');
836
- topSpacer.className = 'ln-table__spacer';
837
- topSpacer.setAttribute('aria-hidden', 'true');
838
- const topTd = document.createElement('td');
839
- topTd.setAttribute('colspan', colSpan);
840
- topTd.style.height = topH + 'px';
841
- topSpacer.appendChild(topTd);
842
- frag.appendChild(topSpacer);
869
+ const neededRows = endRow - startRow;
870
+
871
+ this._ensureRowPool(neededRows, colSpan);
872
+
873
+ if (vw.topPadding > 0) {
874
+ this._topTd.style.height = vw.topPadding + 'px';
875
+ this._topSpacer.style.display = '';
876
+ } else {
877
+ this._topTd.style.height = '0px';
878
+ this._topSpacer.style.display = 'none';
843
879
  }
844
880
 
845
- for (let i = startRow; i < endRow; i++) {
846
- if (this._cache.has(i)) {
847
- const tr = this._buildRow(this._cache.get(i));
848
- if (tr) frag.appendChild(tr);
881
+ if (vw.bottomPadding > 0) {
882
+ this._bottomTd.style.height = vw.bottomPadding + 'px';
883
+ this._bottomSpacer.style.display = '';
884
+ } else {
885
+ this._bottomTd.style.height = '0px';
886
+ this._bottomSpacer.style.display = 'none';
887
+ }
888
+
889
+ for (let k = 0; k < this._rowPool.length; k++) {
890
+ const rowEl = this._rowPool[k];
891
+ const dataIndex = startRow + k;
892
+ if (k < neededRows && dataIndex < total) {
893
+ rowEl.style.display = '';
894
+ const rec = data[dataIndex];
895
+ if (rec && rec.html) {
896
+ const temp = document.createElement('tbody');
897
+ temp.innerHTML = rec.html;
898
+ const parsedTr = temp.firstElementChild;
899
+ if (parsedTr) {
900
+ rowEl.innerHTML = parsedTr.innerHTML;
901
+ rowEl.className = parsedTr.className;
902
+ rowEl._lnRecord = rec;
903
+ if (rec.id != null) rowEl.setAttribute('data-ln-table-row-id', rec.id);
904
+ }
905
+ } else {
906
+ this._updateRow(rowEl, rec);
907
+ }
849
908
  } else {
850
- frag.appendChild(this._buildPlaceholderRow());
909
+ rowEl.style.display = 'none';
851
910
  }
852
911
  }
853
912
 
854
- if (bottomH > 0) {
855
- const bottomSpacer = document.createElement('tr');
856
- bottomSpacer.className = 'ln-table__spacer';
857
- bottomSpacer.setAttribute('aria-hidden', 'true');
858
- const bottomTd = document.createElement('td');
859
- bottomTd.setAttribute('colspan', colSpan);
860
- bottomTd.style.height = bottomH + 'px';
861
- bottomSpacer.appendChild(bottomTd);
862
- frag.appendChild(bottomSpacer);
863
- }
913
+ if (this._selectable) this._updateSelectAll();
914
+ };
864
915
 
865
- this.tbody.replaceChildren(frag);
866
916
 
867
- // Select-all is disabled in windowed mode (D4) — no _updateSelectAll() call.
868
- this._vStart = startRow;
869
- this._vEnd = endRow;
870
917
 
871
- this._cache.ensure(startRow, endRow);
918
+ // ─── Loading state ──────────────────────────────────────────
919
+
920
+ _component.prototype._showLoadingState = function () {
921
+ this._rowPool = null;
922
+ this._topSpacer = null;
923
+ this._topTd = null;
924
+ this._bottomSpacer = null;
925
+ this._bottomTd = null;
926
+ const colSpan = this.ths.length || 1;
927
+ let loadingEl = null;
928
+
929
+ let clone = cloneTemplateScoped(this.dom, this.name + '-loading', 'ln-table')
930
+ || cloneTemplateScoped(this.dom, 'table-loading', 'ln-table');
931
+
932
+ if (!clone) {
933
+ const genericTpl = this.dom.querySelector('template[data-ln-table-loading]');
934
+ if (genericTpl) {
935
+ clone = document.importNode(genericTpl.content, true);
936
+ }
937
+ }
938
+
939
+ if (clone) {
940
+ if (clone.tagName === 'TR') {
941
+ loadingEl = clone;
942
+ } else {
943
+ const td = document.createElement('td');
944
+ td.setAttribute('colspan', String(colSpan));
945
+ td.appendChild(clone);
946
+ const tr = document.createElement('tr');
947
+ tr.className = 'table-empty ln-table__empty';
948
+ tr.appendChild(td);
949
+ loadingEl = tr;
950
+ }
951
+ }
952
+
953
+ if (loadingEl) {
954
+ this.tbody.replaceChildren(loadingEl);
955
+ } else {
956
+ this.tbody.replaceChildren();
957
+ }
872
958
  };
873
959
 
874
960
  // ─── Empty state ───────────────────────────────────────────
875
961
 
876
962
  _component.prototype._showEmptyState = function () {
963
+ this._rowPool = null;
964
+ this._topSpacer = null;
965
+ this._topTd = null;
966
+ this._bottomSpacer = null;
967
+ this._bottomTd = null;
877
968
  const colSpan = this.ths.length || 1;
878
969
  let emptyEl = null;
879
-
880
970
  let clone = null;
881
971
 
882
972
  if (this.isDataDriven) {
@@ -888,7 +978,8 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
888
978
  const isFiltered = filtered === 0 && total > 0;
889
979
 
890
980
  const templateName = isFiltered ? (this.name + '-empty-filtered') : (this.name + '-empty');
891
- clone = cloneTemplateScoped(this.dom, templateName, 'ln-table');
981
+ clone = cloneTemplateScoped(this.dom, templateName, 'ln-table')
982
+ || cloneTemplateScoped(this.dom, isFiltered ? 'table-empty-filtered' : 'table-empty', 'ln-table');
892
983
 
893
984
  if (!clone) {
894
985
  const genericTpl = this.dom.querySelector('template[data-ln-table-empty]');
@@ -901,30 +992,29 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
901
992
  }
902
993
  }
903
994
  }
904
-
905
- if (clone) {
906
- if (clone.tagName === 'TR') {
907
- emptyEl = clone;
908
- } else {
909
- const td = document.createElement('td');
910
- td.setAttribute('colspan', String(colSpan));
911
- td.appendChild(clone);
912
- const tr = document.createElement('tr');
913
- tr.className = 'ln-table__empty';
914
- tr.appendChild(td);
915
- emptyEl = tr;
995
+ } else {
996
+ clone = cloneTemplateScoped(this.dom, this.name + '-empty', 'ln-table')
997
+ || cloneTemplateScoped(this.dom, 'table-empty', 'ln-table');
998
+ if (!clone) {
999
+ const genericTpl = this.dom.querySelector('template[data-ln-table-empty]');
1000
+ if (genericTpl) {
1001
+ clone = document.importNode(genericTpl.content, true);
916
1002
  }
917
1003
  }
918
- } else {
919
- const tpl = this.dom.querySelector('template[' + EMPTY_TEMPLATE + ']');
920
- const td = document.createElement('td');
921
- td.setAttribute('colspan', String(colSpan));
922
- if (tpl) td.appendChild(document.importNode(tpl.content, true));
923
-
924
- const tr = document.createElement('tr');
925
- tr.className = 'ln-table__empty';
926
- tr.appendChild(td);
927
- emptyEl = tr;
1004
+ }
1005
+
1006
+ if (clone) {
1007
+ if (clone.tagName === 'TR') {
1008
+ emptyEl = clone;
1009
+ } else {
1010
+ const td = document.createElement('td');
1011
+ td.setAttribute('colspan', String(colSpan));
1012
+ td.appendChild(clone);
1013
+ const tr = document.createElement('tr');
1014
+ tr.className = 'ln-table__empty';
1015
+ tr.appendChild(td);
1016
+ emptyEl = tr;
1017
+ }
928
1018
  }
929
1019
 
930
1020
  if (emptyEl) {
@@ -977,28 +1067,10 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
977
1067
  temp.innerHTML = record.html;
978
1068
  tr = temp.firstElementChild;
979
1069
  } else {
980
- tr = document.createElement('tr');
981
- tr.setAttribute('data-ln-table-row', '');
982
- const ths = this.ths;
983
-
984
- for (let j = 0; j < ths.length; j++) {
985
- const isSelectCol = ths[j].hasAttribute('data-ln-table-col-select');
986
- const td = document.createElement('td');
987
-
988
- if (isSelectCol) {
989
- const cb = document.createElement('input');
990
- cb.type = 'checkbox';
991
- cb.setAttribute('data-ln-table-row-select', '');
992
- cb.setAttribute('aria-label', 'Select row');
993
- td.appendChild(cb);
994
- } else {
995
- const colName = ths[j].getAttribute('data-ln-table-col');
996
- if (colName && record[colName] != null) {
997
- td.textContent = String(record[colName]);
998
- }
999
- }
1000
- tr.appendChild(td);
1070
+ if (window.lnDebug || (typeof document !== 'undefined' && document.body && document.body.hasAttribute('data-ln-debug'))) {
1071
+ console.warn(`[ln-table] Missing row template: <template data-ln-template="${this.name}-row">`);
1001
1072
  }
1073
+ return null;
1002
1074
  }
1003
1075
  } else {
1004
1076
  this._fillRow(tr, record);
@@ -1019,119 +1091,9 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1019
1091
  };
1020
1092
 
1021
1093
  _component.prototype._requestData = function () {
1022
- if (this._windowed) {
1023
- this.dom.classList.add('ln-table--loading');
1024
- this._cache.invalidate({
1025
- sort: this.currentSort,
1026
- filters: this.currentFilters,
1027
- search: this.currentSearch
1028
- });
1029
- return;
1030
- }
1031
1094
  requestData(this, 'ln-table:request-data', 'table');
1032
1095
  };
1033
1096
 
1034
- // ─── Windowed Mode — enter/exit/seed (live toggle) ──────────
1035
-
1036
- _component.prototype._enterWindowedMode = function () {
1037
- const self = this;
1038
- const dom = this.dom;
1039
- const winAttr = parseInt(dom.getAttribute('data-ln-table-window'), 10);
1040
- const pageAttr = parseInt(dom.getAttribute('data-ln-table-window-page'), 10);
1041
- const threshAttr = parseInt(dom.getAttribute('data-ln-table-window-threshold'), 10);
1042
-
1043
- // Cache change → sync totals off the cache, re-render (pull), notify.
1044
- this._onCacheChange = function () {
1045
- if (!self._windowed || !self._cache) return;
1046
- self.totalCount = self._cache.grandTotal;
1047
- self.visibleCount = self._cache.logicalTotal;
1048
- self._lastTotal = self._cache.grandTotal;
1049
- self.isLoaded = true;
1050
- self._vStart = -1;
1051
- self._vEnd = -1;
1052
- self._render();
1053
- self._updateFooter();
1054
- dispatch(dom, 'ln-table:rendered', {
1055
- table: self.name,
1056
- total: self.totalCount,
1057
- visible: self.visibleCount
1058
- });
1059
- };
1060
- this._renderBatch = createBatcher(this._onCacheChange);
1061
-
1062
- this._cache = createWindowCache({
1063
- windowSize: winAttr > 0 ? winAttr : WINDOW_DEFAULT,
1064
- pageSize: pageAttr > 0 ? pageAttr : WINDOW_PAGE,
1065
- threshold: threshAttr >= 0 ? threshAttr : 25,
1066
- fetchDebounce: FETCH_DEBOUNCE,
1067
- requestPage: function (query, offset, limit) {
1068
- dispatch(dom, 'ln-table:request-data', {
1069
- table: self.name,
1070
- sort: query.sort,
1071
- filters: query.filters,
1072
- search: query.search,
1073
- offset: offset,
1074
- limit: limit,
1075
- queryGen: self._cache.queryGen
1076
- });
1077
- },
1078
- onChange: this._renderBatch
1079
- });
1080
-
1081
- this._windowed = true;
1082
-
1083
- // D4 — windowed tables cannot select rows they have never fetched
1084
- if (this._selectable && this._selectAllCheckbox) {
1085
- this._selectAllCheckbox.classList.add('hidden');
1086
- }
1087
- };
1088
-
1089
- _component.prototype._kickWindowInitial = function () {
1090
- if (this._data.length > 0) {
1091
- // SSR-seeded / warm-seeded: page 0 is already resident, the grand
1092
- // total is declared in markup — no initial fetch needed. No
1093
- // queryGen on the seed — it must never be dropped as stale.
1094
- let declaredTotal = parseInt(this.dom.getAttribute('data-ln-table-count'), 10);
1095
- if (isNaN(declaredTotal) && this._totalSpan) {
1096
- const spanText = this._totalSpan.textContent.replace(/[^\d]/g, '');
1097
- if (spanText) declaredTotal = parseInt(spanText, 10);
1098
- }
1099
- const seedTotal = (declaredTotal > 0) ? declaredTotal : this._data.length;
1100
- this._cache.ingest({
1101
- data: this._data,
1102
- offset: 0,
1103
- total: seedTotal,
1104
- filtered: seedTotal
1105
- });
1106
- } else {
1107
- this.dom.classList.add('ln-table--loading');
1108
- this._cache.requestInitial({
1109
- sort: this.currentSort,
1110
- filters: this.currentFilters,
1111
- search: this.currentSearch
1112
- });
1113
- }
1114
- };
1115
-
1116
- _component.prototype._exitWindowedMode = function () {
1117
- this._disableVirtualScroll();
1118
- if (this._cache) this._cache.destroy();
1119
- this._cache = null;
1120
- this._windowed = false;
1121
- this._renderBatch = null;
1122
- this._onCacheChange = null;
1123
- if (this._selectAllCheckbox) {
1124
- this._selectAllCheckbox.classList.remove('hidden');
1125
- }
1126
- this._rowHeight = 0;
1127
- this._vStart = -1;
1128
- this._vEnd = -1;
1129
- this._data = [];
1130
- this._filteredData = [];
1131
- this.dom.classList.add('ln-table--loading');
1132
- this._requestData();
1133
- };
1134
-
1135
1097
  // ─── Selection Helpers ─────────────────────────────────────
1136
1098
 
1137
1099
  _component.prototype._updateSelectAll = function () {
@@ -1304,11 +1266,11 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1304
1266
  const isFiltered = filtered < total;
1305
1267
 
1306
1268
  if (this._totalSpan) {
1307
- this._totalSpan.textContent = _formatNum(total, this.dom);
1269
+ _setNumberValue(this._totalSpan, total);
1308
1270
  }
1309
1271
 
1310
1272
  if (this._filteredSpan) {
1311
- this._filteredSpan.textContent = isFiltered ? _formatNum(filtered, this.dom) : '';
1273
+ _setNumberValue(this._filteredSpan, isFiltered ? filtered : null);
1312
1274
  }
1313
1275
 
1314
1276
  if (this._filteredWrap) {
@@ -1317,7 +1279,7 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1317
1279
 
1318
1280
  if (this._selectedSpan) {
1319
1281
  const count = this.selectedIds ? this.selectedIds.size : 0;
1320
- this._selectedSpan.textContent = count > 0 ? _formatNum(count, this.dom) : '';
1282
+ _setNumberValue(this._selectedSpan, count > 0 ? count : null);
1321
1283
  if (this._selectedWrap) {
1322
1284
  this._selectedWrap.classList.toggle('hidden', count === 0);
1323
1285
  }
@@ -1338,15 +1300,11 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1338
1300
  if (this.isDataDriven) {
1339
1301
  this.dom.removeEventListener('ln-table:set-data', this._onSetData);
1340
1302
  this.dom.removeEventListener('ln-table:set-loading', this._onSetLoading);
1341
- this.dom.removeEventListener('ln-table:page-failed', this._onPageFailed);
1342
- this.dom.removeEventListener('ln-table:request-revalidate', this._onRequestRevalidate);
1343
- this.dom.removeEventListener('ln-table:request-invalidate', this._onRequestInvalidate);
1344
1303
  this.dom.removeEventListener('ln-sort:change', this._onSort);
1345
1304
  if (this.tbody) {
1346
1305
  this.tbody.removeEventListener('click', this._onRowClick);
1347
1306
  this.tbody.removeEventListener('click', this._onRowAction);
1348
1307
  }
1349
- if (this._cache) this._cache.destroy();
1350
1308
  } else {
1351
1309
  if (this._emptyTbodyObserver) {
1352
1310
  this._emptyTbodyObserver.disconnect();
@@ -1365,6 +1323,7 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1365
1323
  this._colgroup = null;
1366
1324
  }
1367
1325
  if (this.table) this.table.style.tableLayout = '';
1326
+ this._loadingIndicator = null;
1368
1327
  this._data = [];
1369
1328
  this._filteredData = [];
1370
1329
  delete this.dom[DOM_ATTRIBUTE];