@livenetworks/ashlar 2.1.10 → 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,8 +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
- 'data-ln-table-loading': { type: 'marker', description: 'Loading indicator element in footer or template' },
67
25
  'data-ln-table-select-all-label': { type: 'string', description: 'Accessibility label for select-all header trigger' }
68
26
  };
69
27
 
@@ -74,17 +32,30 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
74
32
  ? new Intl.Collator(document.documentElement.lang || undefined, { sensitivity: 'base' })
75
33
  : null;
76
34
 
77
- function _formatNum(n, dom) {
78
- if (n == null || isNaN(n)) return '';
79
- try {
80
- return new Intl.NumberFormat(getLocale(dom)).format(n);
81
- } catch (e) {
82
- 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;
83
54
  }
84
55
  }
85
56
 
86
- function _findScrollContainer(el) {
87
- let p = el.parentElement;
57
+ function _findScrollContainer(el, table) {
58
+ let p = table ? table.parentElement : el;
88
59
  while (p && p !== document.body && p !== document.documentElement) {
89
60
  const cs = getComputedStyle(p);
90
61
  const oy = cs.overflowY;
@@ -144,6 +115,11 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
144
115
  this._scrollHandler = null;
145
116
  this._scrollContainer = null;
146
117
  this._colgroup = null;
118
+ this._rowPool = null;
119
+ this._topSpacer = null;
120
+ this._topTd = null;
121
+ this._bottomSpacer = null;
122
+ this._bottomTd = null;
147
123
 
148
124
  const self = this;
149
125
 
@@ -222,11 +198,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
222
198
  this._lastFiltered = 0;
223
199
  this._hasInitialSeed = false;
224
200
 
225
- // Windowed (sliding-window) server-side virtualization — opt-in
226
- this._windowed = false;
227
- this._cache = null;
228
- if (this.isDataDriven && dom.hasAttribute('data-ln-table-window')) this._enterWindowedMode();
229
-
230
201
  // --- Event listeners ---
231
202
  this._onSetData = function (e) {
232
203
  const detail = e.detail || {};
@@ -238,18 +209,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
238
209
  return;
239
210
  }
240
211
 
241
- if (self._windowed) {
242
- // Only an accepted, authoritative page ends the refresh. A declined
243
- // one means the source has not resolved the new query yet; a
244
- // provisional one is the store's own answer, rendered while the
245
- // server query is still out.
246
- if (self._cache.ingest(detail) && !detail.provisional) {
247
- dom.classList.remove('ln-table--loading');
248
- dom.setAttribute('aria-busy', 'false');
249
- }
250
- return;
251
- }
252
-
253
212
  self._data = newData;
254
213
  self._lastTotal = newTotal;
255
214
  self._lastFiltered = detail.filtered != null ? detail.filtered : self._data.length;
@@ -295,33 +254,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
295
254
  };
296
255
  dom.addEventListener('ln-table:set-loading', this._onSetLoading);
297
256
 
298
- this._onPageFailed = function (e) {
299
- if (!self._windowed || !self._cache) return;
300
- self._cache.release(e.detail && e.detail.offset);
301
- };
302
- dom.addEventListener('ln-table:page-failed', this._onPageFailed);
303
-
304
- this._onRequestRevalidate = function () {
305
- if (!self._windowed || !self._cache) return;
306
- self._cache.revalidate();
307
- };
308
- dom.addEventListener('ln-table:request-revalidate', this._onRequestRevalidate);
309
-
310
- // The source owns the query, so a query change reaches the view as a
311
- // restart order, not as query state — the window drops to page 0 and
312
- // the composed query is resolved at serve time.
313
- this._onRequestInvalidate = function (e) {
314
- if (!self._windowed || !self._cache) return;
315
- if (e && e.detail && e.detail.query) {
316
- const q = e.detail.query;
317
- if (q.search !== undefined) self.currentSearch = q.search;
318
- if (q.filters !== undefined) self.currentFilters = q.filters;
319
- if (q.sort !== undefined) self.currentSort = q.sort;
320
- }
321
- self._requestData();
322
- };
323
- dom.addEventListener('ln-table:request-invalidate', this._onRequestInvalidate);
324
-
325
257
  // --- Sort ---
326
258
  this._onSort = function (e) {
327
259
  if (!e.detail || e.detail.field == null) return;
@@ -331,11 +263,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
331
263
  };
332
264
  dom.addEventListener('ln-sort:change', this._onSort);
333
265
 
334
- // D4 — windowed tables cannot select rows they have never fetched
335
- if (this._windowed && this._selectable && this._selectAllCheckbox) {
336
- this._selectAllCheckbox.classList.add('hidden');
337
- }
338
-
339
266
  // --- Row Click & Actions ---
340
267
  this._onRowClick = function (e) {
341
268
  if (e.target.closest('[data-ln-table-row-select]')) return;
@@ -377,8 +304,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
377
304
  };
378
305
  if (this.tbody) this.tbody.addEventListener('click', this._onRowAction);
379
306
 
380
-
381
-
382
307
  // Local hydration of initial SSR rows or initial loading state
383
308
  if (this.tbody && this.tbody.rows.length > 0) {
384
309
  this._parseRows();
@@ -390,16 +315,12 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
390
315
  // Initial request-data deferred so coordinators have attached listeners
391
316
  setTimeout(function () {
392
317
  if (!self.dom || !document.contains(self.dom)) return;
393
- if (self._windowed) {
394
- self._kickWindowInitial();
395
- } else {
396
- dispatch(dom, 'ln-table:request-data', {
397
- table: self.name,
398
- sort: self.currentSort,
399
- filters: self.currentFilters,
400
- search: self.currentSearch
401
- });
402
- }
318
+ dispatch(dom, 'ln-table:request-data', {
319
+ table: self.name,
320
+ sort: self.currentSort,
321
+ filters: self.currentFilters,
322
+ search: self.currentSearch
323
+ });
403
324
  }, 0);
404
325
 
405
326
  } else {
@@ -577,7 +498,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
577
498
  if (!this.tbody) return;
578
499
 
579
500
  if (this.isDataDriven) {
580
- if (this._windowed) { this._renderWindowed(); return; }
581
501
  const total = this._lastTotal;
582
502
  const filtered = this.visibleCount;
583
503
 
@@ -625,8 +545,15 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
625
545
  };
626
546
 
627
547
  _component.prototype._renderAll = function () {
628
- if (this.isDataDriven) {
629
- 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)) {
630
557
  const frag = document.createDocumentFragment();
631
558
 
632
559
  for (let i = 0; i < data.length; i++) {
@@ -640,7 +567,6 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
640
567
  if (this._selectable) this._updateSelectAll();
641
568
  } else {
642
569
  const html = [];
643
- const data = this._filteredData;
644
570
  for (let i = 0; i < data.length; i++) html.push(data[i].html);
645
571
 
646
572
  this.tbody.innerHTML = html.join('');
@@ -663,10 +589,7 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
663
589
  this._rowHeight = this.tbody.rows[0].offsetHeight || 40;
664
590
  } else {
665
591
  let tempRow = null;
666
- if (this._windowed) {
667
- const sample = this._cache ? this._cache.peek() : null;
668
- tempRow = sample ? this._buildRow(sample) : this._buildPlaceholderRow();
669
- } else if (this.isDataDriven && this._data.length > 0) {
592
+ if (this.isDataDriven && this._data.length > 0) {
670
593
  tempRow = this._buildRow(this._data[0]);
671
594
  }
672
595
  if (tempRow && this.tbody) {
@@ -677,18 +600,21 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
677
600
  }
678
601
  }
679
602
 
680
- if (this.isDataDriven) {
681
- this._scrollContainer = _findScrollContainer(this.dom);
682
- } else {
683
- this._scrollContainer = null;
684
- }
603
+ this._scrollContainer = _findScrollContainer(this.dom, this.table);
685
604
  const scrollTarget = this._scrollContainer || window;
686
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
+
687
613
  this._scrollHandler = function () {
688
614
  if (self._rafId) return;
689
615
  self._rafId = requestAnimationFrame(function () {
690
616
  self._rafId = null;
691
- self._windowed ? self._renderWindowed() : self._renderVirtual();
617
+ self._renderVirtual();
692
618
  });
693
619
  };
694
620
 
@@ -713,123 +639,205 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
713
639
  }
714
640
  this._vStart = -1;
715
641
  this._vEnd = -1;
642
+ this._rowPool = null;
643
+ this._topSpacer = null;
644
+ this._topTd = null;
645
+ this._bottomSpacer = null;
646
+ this._bottomTd = null;
716
647
  };
717
648
 
718
- _component.prototype._renderVirtual = function () {
719
- const data = this._filteredData;
720
- const total = data.length;
721
- const rowH = this._rowHeight;
722
- if (!rowH || !total) return;
649
+ // ─── Row Pool (Cheetah / Glide style DOM Recycling) ────────
723
650
 
724
- const theadH = this.thead ? this.thead.offsetHeight : 0;
725
- const sc = this._scrollContainer;
726
- let scrollIntoData;
727
- let viewportH;
651
+ _component.prototype._compileRowBindings = function (tr) {
652
+ const bindings = {
653
+ textNodes: [],
654
+ attrs: [],
655
+ cellAttrs: []
656
+ };
728
657
 
729
- if (sc) {
730
- const tableRect = this.table.getBoundingClientRect();
731
- const scRect = sc.getBoundingClientRect();
732
- const dataStartInContainer = (tableRect.top - scRect.top) + sc.scrollTop + theadH;
733
- scrollIntoData = sc.scrollTop - dataStartInContainer;
734
- viewportH = sc.clientHeight;
735
- } else {
736
- const tableRect = this.table.getBoundingClientRect();
737
- const tableTopInPage = tableRect.top + window.scrollY;
738
- const dataStartInPage = tableTopInPage + theadH;
739
- scrollIntoData = window.scrollY - dataStartInPage;
740
- 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
+ }
741
670
  }
742
671
 
743
- const vw = calculateVirtualWindow(scrollIntoData, viewportH, rowH, total, BUFFER_ROWS);
744
- const startRow = vw.start;
745
- const endRow = vw.end;
746
-
747
- if (startRow === this._vStart && endRow === this._vEnd) return;
748
- this._vStart = startRow;
749
- 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
+ }
750
690
 
751
- const colSpan = this.ths.length || 1;
752
- const topH = vw.topPadding;
753
- 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
+ }
754
703
 
755
- if (this.isDataDriven) {
756
- const frag = document.createDocumentFragment();
704
+ tr._lnBindings = bindings;
705
+ return bindings;
706
+ };
757
707
 
758
- if (topH > 0) {
759
- const topSpacer = document.createElement('tr');
760
- topSpacer.className = 'ln-table__spacer';
761
- topSpacer.setAttribute('aria-hidden', 'true');
762
- const topTd = document.createElement('td');
763
- topTd.setAttribute('colspan', colSpan);
764
- topTd.style.height = topH + 'px';
765
- topSpacer.appendChild(topTd);
766
- 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);
767
714
  }
715
+ }
768
716
 
769
- for (let i = startRow; i < endRow; i++) {
770
- const tr = this._buildRow(data[i]);
771
- if (tr) frag.appendChild(tr);
772
- }
717
+ let tr = clone ? (clone.querySelector('[data-ln-table-row]') || clone.firstElementChild) : null;
773
718
 
774
- if (bottomH > 0) {
775
- const bottomSpacer = document.createElement('tr');
776
- bottomSpacer.className = 'ln-table__spacer';
777
- bottomSpacer.setAttribute('aria-hidden', 'true');
778
- const bottomTd = document.createElement('td');
779
- bottomTd.setAttribute('colspan', colSpan);
780
- bottomTd.style.height = bottomH + 'px';
781
- bottomSpacer.appendChild(bottomTd);
782
- 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">`);
783
722
  }
723
+ return null;
724
+ }
784
725
 
785
- this.tbody.replaceChildren(frag);
726
+ this._compileRowBindings(tr);
727
+ return tr;
728
+ };
786
729
 
787
- if (this._selectable) this._updateSelectAll();
788
- } else {
789
- 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
+ }
790
736
 
791
- if (topH > 0) {
792
- html += '<tr class="ln-table__spacer" aria-hidden="true"><td colspan="' +
793
- colSpan + '" style="height:' + topH + 'px;padding:0;border:none"></td></tr>';
794
- }
795
- for (let i = startRow; i < endRow; i++) html += data[i].html;
796
- if (bottomH > 0) {
797
- html += '<tr class="ln-table__spacer" aria-hidden="true"><td colspan="' +
798
- 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;
799
752
  }
753
+ }
800
754
 
801
- 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
+ }
802
766
 
803
- 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
+ }
804
774
  }
805
- };
806
775
 
807
- // ─── 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
+ }
808
784
 
809
- _component.prototype._buildPlaceholderRow = function () {
810
- const tr = document.createElement('tr');
811
- tr.className = 'ln-table__placeholder';
812
- tr.setAttribute('aria-hidden', 'true');
813
- const td = document.createElement('td');
814
- td.setAttribute('colspan', this.ths.length || 1);
815
- td.style.height = this._rowHeight + 'px';
816
- tr.appendChild(td);
817
- 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
+ }
818
792
  };
819
793
 
820
- _component.prototype._renderWindowed = function () {
821
- if (this.isLoaded && this._cache.logicalTotal === 0) {
822
- this._disableVirtualScroll();
823
- this._showEmptyState();
824
- 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
+ }
825
825
  }
826
826
 
827
- 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
+ };
828
834
 
835
+ _component.prototype._renderVirtual = function () {
836
+ const data = this._filteredData;
837
+ const total = data.length;
829
838
  const rowH = this._rowHeight;
830
- if (!rowH) return;
839
+ if (!rowH || !total) return;
831
840
 
832
- const total = this._cache.logicalTotal;
833
841
  const theadH = this.thead ? this.thead.offsetHeight : 0;
834
842
  const sc = this._scrollContainer;
835
843
  let scrollIntoData;
@@ -854,60 +862,72 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
854
862
  const endRow = vw.end;
855
863
 
856
864
  if (startRow === this._vStart && endRow === this._vEnd) return;
865
+ this._vStart = startRow;
866
+ this._vEnd = endRow;
857
867
 
858
868
  const colSpan = this.ths.length || 1;
859
- const topH = vw.topPadding;
860
- const bottomH = vw.bottomPadding;
861
-
862
- const frag = document.createDocumentFragment();
863
-
864
- if (topH > 0) {
865
- const topSpacer = document.createElement('tr');
866
- topSpacer.className = 'ln-table__spacer';
867
- topSpacer.setAttribute('aria-hidden', 'true');
868
- const topTd = document.createElement('td');
869
- topTd.setAttribute('colspan', colSpan);
870
- topTd.style.height = topH + 'px';
871
- topSpacer.appendChild(topTd);
872
- 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';
873
879
  }
874
880
 
875
- for (let i = startRow; i < endRow; i++) {
876
- if (this._cache.has(i)) {
877
- const tr = this._buildRow(this._cache.get(i));
878
- if (tr) frag.appendChild(tr);
879
- } else {
880
- frag.appendChild(this._buildPlaceholderRow());
881
- }
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';
882
887
  }
883
888
 
884
- if (bottomH > 0) {
885
- const bottomSpacer = document.createElement('tr');
886
- bottomSpacer.className = 'ln-table__spacer';
887
- bottomSpacer.setAttribute('aria-hidden', 'true');
888
- const bottomTd = document.createElement('td');
889
- bottomTd.setAttribute('colspan', colSpan);
890
- bottomTd.style.height = bottomH + 'px';
891
- bottomSpacer.appendChild(bottomTd);
892
- frag.appendChild(bottomSpacer);
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
+ }
908
+ } else {
909
+ rowEl.style.display = 'none';
910
+ }
893
911
  }
894
912
 
895
- this.tbody.replaceChildren(frag);
913
+ if (this._selectable) this._updateSelectAll();
914
+ };
896
915
 
897
- // Select-all is disabled in windowed mode (D4) — no _updateSelectAll() call.
898
- this._vStart = startRow;
899
- this._vEnd = endRow;
900
916
 
901
- this._cache.ensure(startRow, endRow);
902
- };
903
917
 
904
918
  // ─── Loading state ──────────────────────────────────────────
905
919
 
906
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;
907
926
  const colSpan = this.ths.length || 1;
908
927
  let loadingEl = null;
909
928
 
910
- let clone = cloneTemplateScoped(this.dom, this.name + '-loading', 'ln-table');
929
+ let clone = cloneTemplateScoped(this.dom, this.name + '-loading', 'ln-table')
930
+ || cloneTemplateScoped(this.dom, 'table-loading', 'ln-table');
911
931
 
912
932
  if (!clone) {
913
933
  const genericTpl = this.dom.querySelector('template[data-ln-table-loading]');
@@ -940,9 +960,13 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
940
960
  // ─── Empty state ───────────────────────────────────────────
941
961
 
942
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;
943
968
  const colSpan = this.ths.length || 1;
944
969
  let emptyEl = null;
945
-
946
970
  let clone = null;
947
971
 
948
972
  if (this.isDataDriven) {
@@ -954,7 +978,8 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
954
978
  const isFiltered = filtered === 0 && total > 0;
955
979
 
956
980
  const templateName = isFiltered ? (this.name + '-empty-filtered') : (this.name + '-empty');
957
- 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');
958
983
 
959
984
  if (!clone) {
960
985
  const genericTpl = this.dom.querySelector('template[data-ln-table-empty]');
@@ -967,30 +992,29 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
967
992
  }
968
993
  }
969
994
  }
970
-
971
- if (clone) {
972
- if (clone.tagName === 'TR') {
973
- emptyEl = clone;
974
- } else {
975
- const td = document.createElement('td');
976
- td.setAttribute('colspan', String(colSpan));
977
- td.appendChild(clone);
978
- const tr = document.createElement('tr');
979
- tr.className = 'ln-table__empty';
980
- tr.appendChild(td);
981
- 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);
982
1002
  }
983
1003
  }
984
- } else {
985
- const tpl = this.dom.querySelector('template[' + EMPTY_TEMPLATE + ']');
986
- const td = document.createElement('td');
987
- td.setAttribute('colspan', String(colSpan));
988
- if (tpl) td.appendChild(document.importNode(tpl.content, true));
989
-
990
- const tr = document.createElement('tr');
991
- tr.className = 'ln-table__empty';
992
- tr.appendChild(td);
993
- 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
+ }
994
1018
  }
995
1019
 
996
1020
  if (emptyEl) {
@@ -1043,28 +1067,10 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1043
1067
  temp.innerHTML = record.html;
1044
1068
  tr = temp.firstElementChild;
1045
1069
  } else {
1046
- tr = document.createElement('tr');
1047
- tr.setAttribute('data-ln-table-row', '');
1048
- const ths = this.ths;
1049
-
1050
- for (let j = 0; j < ths.length; j++) {
1051
- const isSelectCol = ths[j].hasAttribute('data-ln-table-col-select');
1052
- const td = document.createElement('td');
1053
-
1054
- if (isSelectCol) {
1055
- const cb = document.createElement('input');
1056
- cb.type = 'checkbox';
1057
- cb.setAttribute('data-ln-table-row-select', '');
1058
- cb.setAttribute('aria-label', 'Select row');
1059
- td.appendChild(cb);
1060
- } else {
1061
- const colName = ths[j].getAttribute('data-ln-table-col');
1062
- if (colName && record[colName] != null) {
1063
- td.textContent = String(record[colName]);
1064
- }
1065
- }
1066
- 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">`);
1067
1072
  }
1073
+ return null;
1068
1074
  }
1069
1075
  } else {
1070
1076
  this._fillRow(tr, record);
@@ -1085,119 +1091,9 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1085
1091
  };
1086
1092
 
1087
1093
  _component.prototype._requestData = function () {
1088
- if (this._windowed) {
1089
- this.dom.classList.add('ln-table--loading');
1090
- this._cache.invalidate({
1091
- sort: this.currentSort,
1092
- filters: this.currentFilters,
1093
- search: this.currentSearch
1094
- });
1095
- return;
1096
- }
1097
1094
  requestData(this, 'ln-table:request-data', 'table');
1098
1095
  };
1099
1096
 
1100
- // ─── Windowed Mode — enter/exit/seed (live toggle) ──────────
1101
-
1102
- _component.prototype._enterWindowedMode = function () {
1103
- const self = this;
1104
- const dom = this.dom;
1105
- const winAttr = parseInt(dom.getAttribute('data-ln-table-window'), 10);
1106
- const pageAttr = parseInt(dom.getAttribute('data-ln-table-window-page'), 10);
1107
- const threshAttr = parseInt(dom.getAttribute('data-ln-table-window-threshold'), 10);
1108
-
1109
- // Cache change → sync totals off the cache, re-render (pull), notify.
1110
- this._onCacheChange = function () {
1111
- if (!self._windowed || !self._cache) return;
1112
- self.totalCount = self._cache.grandTotal;
1113
- self.visibleCount = self._cache.logicalTotal;
1114
- self._lastTotal = self._cache.grandTotal;
1115
- self.isLoaded = true;
1116
- self._vStart = -1;
1117
- self._vEnd = -1;
1118
- self._render();
1119
- self._updateFooter();
1120
- dispatch(dom, 'ln-table:rendered', {
1121
- table: self.name,
1122
- total: self.totalCount,
1123
- visible: self.visibleCount
1124
- });
1125
- };
1126
- this._renderBatch = createBatcher(this._onCacheChange);
1127
-
1128
- this._cache = createWindowCache({
1129
- windowSize: winAttr > 0 ? winAttr : WINDOW_DEFAULT,
1130
- pageSize: pageAttr > 0 ? pageAttr : WINDOW_PAGE,
1131
- threshold: threshAttr >= 0 ? threshAttr : 25,
1132
- fetchDebounce: FETCH_DEBOUNCE,
1133
- requestPage: function (query, offset, limit) {
1134
- dispatch(dom, 'ln-table:request-data', {
1135
- table: self.name,
1136
- sort: query.sort,
1137
- filters: query.filters,
1138
- search: query.search,
1139
- offset: offset,
1140
- limit: limit,
1141
- queryGen: self._cache.queryGen
1142
- });
1143
- },
1144
- onChange: this._renderBatch
1145
- });
1146
-
1147
- this._windowed = true;
1148
-
1149
- // D4 — windowed tables cannot select rows they have never fetched
1150
- if (this._selectable && this._selectAllCheckbox) {
1151
- this._selectAllCheckbox.classList.add('hidden');
1152
- }
1153
- };
1154
-
1155
- _component.prototype._kickWindowInitial = function () {
1156
- if (this._data.length > 0) {
1157
- // SSR-seeded / warm-seeded: page 0 is already resident, the grand
1158
- // total is declared in markup — no initial fetch needed. No
1159
- // queryGen on the seed — it must never be dropped as stale.
1160
- let declaredTotal = parseInt(this.dom.getAttribute('data-ln-table-count'), 10);
1161
- if (isNaN(declaredTotal) && this._totalSpan) {
1162
- const spanText = this._totalSpan.textContent.replace(/[^\d]/g, '');
1163
- if (spanText) declaredTotal = parseInt(spanText, 10);
1164
- }
1165
- const seedTotal = (declaredTotal > 0) ? declaredTotal : this._data.length;
1166
- this._cache.ingest({
1167
- data: this._data,
1168
- offset: 0,
1169
- total: seedTotal,
1170
- filtered: seedTotal
1171
- });
1172
- } else {
1173
- this.dom.classList.add('ln-table--loading');
1174
- this._cache.requestInitial({
1175
- sort: this.currentSort,
1176
- filters: this.currentFilters,
1177
- search: this.currentSearch
1178
- });
1179
- }
1180
- };
1181
-
1182
- _component.prototype._exitWindowedMode = function () {
1183
- this._disableVirtualScroll();
1184
- if (this._cache) this._cache.destroy();
1185
- this._cache = null;
1186
- this._windowed = false;
1187
- this._renderBatch = null;
1188
- this._onCacheChange = null;
1189
- if (this._selectAllCheckbox) {
1190
- this._selectAllCheckbox.classList.remove('hidden');
1191
- }
1192
- this._rowHeight = 0;
1193
- this._vStart = -1;
1194
- this._vEnd = -1;
1195
- this._data = [];
1196
- this._filteredData = [];
1197
- this.dom.classList.add('ln-table--loading');
1198
- this._requestData();
1199
- };
1200
-
1201
1097
  // ─── Selection Helpers ─────────────────────────────────────
1202
1098
 
1203
1099
  _component.prototype._updateSelectAll = function () {
@@ -1370,11 +1266,11 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1370
1266
  const isFiltered = filtered < total;
1371
1267
 
1372
1268
  if (this._totalSpan) {
1373
- this._totalSpan.textContent = _formatNum(total, this.dom);
1269
+ _setNumberValue(this._totalSpan, total);
1374
1270
  }
1375
1271
 
1376
1272
  if (this._filteredSpan) {
1377
- this._filteredSpan.textContent = isFiltered ? _formatNum(filtered, this.dom) : '';
1273
+ _setNumberValue(this._filteredSpan, isFiltered ? filtered : null);
1378
1274
  }
1379
1275
 
1380
1276
  if (this._filteredWrap) {
@@ -1383,7 +1279,7 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1383
1279
 
1384
1280
  if (this._selectedSpan) {
1385
1281
  const count = this.selectedIds ? this.selectedIds.size : 0;
1386
- this._selectedSpan.textContent = count > 0 ? _formatNum(count, this.dom) : '';
1282
+ _setNumberValue(this._selectedSpan, count > 0 ? count : null);
1387
1283
  if (this._selectedWrap) {
1388
1284
  this._selectedWrap.classList.toggle('hidden', count === 0);
1389
1285
  }
@@ -1404,15 +1300,11 @@ import { calculateSelectionState, calculateVirtualWindow, toggleRowSelection, to
1404
1300
  if (this.isDataDriven) {
1405
1301
  this.dom.removeEventListener('ln-table:set-data', this._onSetData);
1406
1302
  this.dom.removeEventListener('ln-table:set-loading', this._onSetLoading);
1407
- this.dom.removeEventListener('ln-table:page-failed', this._onPageFailed);
1408
- this.dom.removeEventListener('ln-table:request-revalidate', this._onRequestRevalidate);
1409
- this.dom.removeEventListener('ln-table:request-invalidate', this._onRequestInvalidate);
1410
1303
  this.dom.removeEventListener('ln-sort:change', this._onSort);
1411
1304
  if (this.tbody) {
1412
1305
  this.tbody.removeEventListener('click', this._onRowClick);
1413
1306
  this.tbody.removeEventListener('click', this._onRowAction);
1414
1307
  }
1415
- if (this._cache) this._cache.destroy();
1416
1308
  } else {
1417
1309
  if (this._emptyTbodyObserver) {
1418
1310
  this._emptyTbodyObserver.disconnect();