@livenetworks/ashlar 1.3.2

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.
Files changed (232) hide show
  1. package/README.md +177 -0
  2. package/js/COMPONENTS.md +1102 -0
  3. package/js/index.js +41 -0
  4. package/js/ln-accordion/README.md +137 -0
  5. package/js/ln-accordion/ln-accordion.js +1 -0
  6. package/js/ln-accordion/src/ln-accordion.js +41 -0
  7. package/js/ln-ajax/README.md +91 -0
  8. package/js/ln-ajax/ln-ajax.js +1 -0
  9. package/js/ln-ajax/src/ln-ajax.js +277 -0
  10. package/js/ln-api-connector/README.md +150 -0
  11. package/js/ln-api-connector/ln-api-connector.js +1 -0
  12. package/js/ln-api-connector/src/ln-api-connector.js +265 -0
  13. package/js/ln-autoresize/README.md +80 -0
  14. package/js/ln-autoresize/ln-autoresize.js +1 -0
  15. package/js/ln-autoresize/src/ln-autoresize.js +47 -0
  16. package/js/ln-autosave/README.md +92 -0
  17. package/js/ln-autosave/ln-autosave.js +1 -0
  18. package/js/ln-autosave/src/ln-autosave.js +147 -0
  19. package/js/ln-circular-progress/README.md +161 -0
  20. package/js/ln-circular-progress/ln-circular-progress.js +1 -0
  21. package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
  22. package/js/ln-confirm/README.md +86 -0
  23. package/js/ln-confirm/_ln-confirm.scss +13 -0
  24. package/js/ln-confirm/ln-confirm.js +1 -0
  25. package/js/ln-confirm/src/ln-confirm.js +131 -0
  26. package/js/ln-core/crypto.js +83 -0
  27. package/js/ln-core/helpers.js +411 -0
  28. package/js/ln-core/index.js +5 -0
  29. package/js/ln-core/persist.js +71 -0
  30. package/js/ln-core/positioning.js +207 -0
  31. package/js/ln-core/reactive.js +74 -0
  32. package/js/ln-couchdb-connector/README.md +156 -0
  33. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
  34. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
  35. package/js/ln-data-coordinator/README.md +165 -0
  36. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
  37. package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
  38. package/js/ln-data-store/README.md +94 -0
  39. package/js/ln-data-store/ln-data-store.js +1 -0
  40. package/js/ln-data-store/src/ln-data-store.js +699 -0
  41. package/js/ln-data-table/README.md +110 -0
  42. package/js/ln-data-table/ln-data-table.js +1 -0
  43. package/js/ln-data-table/ln-data-table.scss +10 -0
  44. package/js/ln-data-table/src/ln-data-table.js +1103 -0
  45. package/js/ln-date/README.md +151 -0
  46. package/js/ln-date/ln-date.js +1 -0
  47. package/js/ln-date/src/ln-date.js +442 -0
  48. package/js/ln-dropdown/README.md +117 -0
  49. package/js/ln-dropdown/ln-dropdown.js +1 -0
  50. package/js/ln-dropdown/ln-dropdown.scss +15 -0
  51. package/js/ln-dropdown/src/ln-dropdown.js +174 -0
  52. package/js/ln-external-links/README.md +341 -0
  53. package/js/ln-external-links/ln-external-links.js +1 -0
  54. package/js/ln-external-links/src/ln-external-links.js +116 -0
  55. package/js/ln-filter/README.md +99 -0
  56. package/js/ln-filter/ln-filter.js +1 -0
  57. package/js/ln-filter/ln-filter.scss +7 -0
  58. package/js/ln-filter/src/ln-filter.js +404 -0
  59. package/js/ln-form/README.md +101 -0
  60. package/js/ln-form/ln-form.js +1 -0
  61. package/js/ln-form/src/ln-form.js +199 -0
  62. package/js/ln-http/README.md +89 -0
  63. package/js/ln-http/ln-http.js +1 -0
  64. package/js/ln-http/src/ln-http.js +219 -0
  65. package/js/ln-icons/README.md +88 -0
  66. package/js/ln-icons/ln-icons.js +1 -0
  67. package/js/ln-icons/src/ln-icons.js +169 -0
  68. package/js/ln-link/README.md +303 -0
  69. package/js/ln-link/ln-link.js +1 -0
  70. package/js/ln-link/src/ln-link.js +196 -0
  71. package/js/ln-modal/README.md +154 -0
  72. package/js/ln-modal/ln-modal.js +1 -0
  73. package/js/ln-modal/ln-modal.scss +11 -0
  74. package/js/ln-modal/src/ln-modal.js +201 -0
  75. package/js/ln-nav/README.md +70 -0
  76. package/js/ln-nav/ln-nav.js +1 -0
  77. package/js/ln-nav/src/ln-nav.js +177 -0
  78. package/js/ln-number/README.md +122 -0
  79. package/js/ln-number/ln-number.js +1 -0
  80. package/js/ln-number/src/ln-number.js +302 -0
  81. package/js/ln-popover/README.md +127 -0
  82. package/js/ln-popover/ln-popover.js +1 -0
  83. package/js/ln-popover/src/ln-popover.js +288 -0
  84. package/js/ln-progress/README.md +442 -0
  85. package/js/ln-progress/ln-progress.js +1 -0
  86. package/js/ln-progress/src/ln-progress.js +150 -0
  87. package/js/ln-search/README.md +83 -0
  88. package/js/ln-search/ln-search.js +1 -0
  89. package/js/ln-search/ln-search.scss +7 -0
  90. package/js/ln-search/src/ln-search.js +114 -0
  91. package/js/ln-sortable/README.md +95 -0
  92. package/js/ln-sortable/ln-sortable.js +1 -0
  93. package/js/ln-sortable/src/ln-sortable.js +203 -0
  94. package/js/ln-table/README.md +101 -0
  95. package/js/ln-table/ln-table-sort.js +1 -0
  96. package/js/ln-table/ln-table.js +1 -0
  97. package/js/ln-table/ln-table.scss +11 -0
  98. package/js/ln-table/src/ln-table-sort.js +168 -0
  99. package/js/ln-table/src/ln-table.js +473 -0
  100. package/js/ln-tabs/README.md +137 -0
  101. package/js/ln-tabs/ln-tabs.js +1 -0
  102. package/js/ln-tabs/src/ln-tabs.js +171 -0
  103. package/js/ln-time/README.md +81 -0
  104. package/js/ln-time/ln-time.js +1 -0
  105. package/js/ln-time/src/ln-time.js +192 -0
  106. package/js/ln-toast/README.md +122 -0
  107. package/js/ln-toast/ln-toast.js +15 -0
  108. package/js/ln-toast/src/ln-toast.js +210 -0
  109. package/js/ln-toast/template.html +14 -0
  110. package/js/ln-toggle/README.md +137 -0
  111. package/js/ln-toggle/ln-toggle.js +1 -0
  112. package/js/ln-toggle/src/ln-toggle.js +139 -0
  113. package/js/ln-tooltip/README.md +58 -0
  114. package/js/ln-tooltip/ln-tooltip.js +1 -0
  115. package/js/ln-tooltip/ln-tooltip.scss +9 -0
  116. package/js/ln-tooltip/src/ln-tooltip.js +169 -0
  117. package/js/ln-translations/README.md +96 -0
  118. package/js/ln-translations/ln-translations.js +1 -0
  119. package/js/ln-translations/src/ln-translations.js +275 -0
  120. package/js/ln-upload/README.md +180 -0
  121. package/js/ln-upload/ln-upload.js +1 -0
  122. package/js/ln-upload/ln-upload.scss +20 -0
  123. package/js/ln-upload/src/ln-upload.js +407 -0
  124. package/js/ln-validate/README.md +108 -0
  125. package/js/ln-validate/ln-validate.js +1 -0
  126. package/js/ln-validate/src/ln-validate.js +160 -0
  127. package/package.json +55 -0
  128. package/scss/base/_global.scss +83 -0
  129. package/scss/base/_reset.scss +17 -0
  130. package/scss/base/_typography.scss +125 -0
  131. package/scss/components/_accordion.scss +34 -0
  132. package/scss/components/_ajax.scss +15 -0
  133. package/scss/components/_alert.scss +5 -0
  134. package/scss/components/_app-shell.scss +15 -0
  135. package/scss/components/_avatar.scss +6 -0
  136. package/scss/components/_breadcrumbs.scss +33 -0
  137. package/scss/components/_button.scss +20 -0
  138. package/scss/components/_card.scss +10 -0
  139. package/scss/components/_chip.scss +5 -0
  140. package/scss/components/_circular-progress.scss +29 -0
  141. package/scss/components/_confirm.scss +5 -0
  142. package/scss/components/_data-table.scss +83 -0
  143. package/scss/components/_dropdown.scss +25 -0
  144. package/scss/components/_empty-state.scss +22 -0
  145. package/scss/components/_form.scss +100 -0
  146. package/scss/components/_layout.scss +8 -0
  147. package/scss/components/_link.scss +11 -0
  148. package/scss/components/_ln-table.scss +60 -0
  149. package/scss/components/_loader.scss +6 -0
  150. package/scss/components/_modal.scss +20 -0
  151. package/scss/components/_nav.scss +9 -0
  152. package/scss/components/_page-header.scss +10 -0
  153. package/scss/components/_popover.scss +10 -0
  154. package/scss/components/_progress.scss +17 -0
  155. package/scss/components/_prose.scss +5 -0
  156. package/scss/components/_scrollbar.scss +32 -0
  157. package/scss/components/_sections.scss +12 -0
  158. package/scss/components/_sidebar.scss +5 -0
  159. package/scss/components/_stat-card.scss +5 -0
  160. package/scss/components/_status-badge.scss +4 -0
  161. package/scss/components/_stepper.scss +5 -0
  162. package/scss/components/_table.scss +19 -0
  163. package/scss/components/_tabs.scss +21 -0
  164. package/scss/components/_timeline.scss +14 -0
  165. package/scss/components/_toast.scss +41 -0
  166. package/scss/components/_toggle.scss +81 -0
  167. package/scss/components/_tooltip.scss +18 -0
  168. package/scss/components/_translations.scss +111 -0
  169. package/scss/components/_upload.scss +51 -0
  170. package/scss/config/_breakpoints.scss +72 -0
  171. package/scss/config/_density.scss +117 -0
  172. package/scss/config/_icons.scss +37 -0
  173. package/scss/config/_mixins.scss +13 -0
  174. package/scss/config/_theme.scss +216 -0
  175. package/scss/config/_tokens.scss +419 -0
  176. package/scss/config/mixins/_accordion.scss +52 -0
  177. package/scss/config/mixins/_ajax.scss +39 -0
  178. package/scss/config/mixins/_alert.scss +82 -0
  179. package/scss/config/mixins/_app-shell.scss +312 -0
  180. package/scss/config/mixins/_avatar.scss +109 -0
  181. package/scss/config/mixins/_borders.scss +36 -0
  182. package/scss/config/mixins/_breadcrumbs.scss +72 -0
  183. package/scss/config/mixins/_breakpoints.scss +62 -0
  184. package/scss/config/mixins/_btn.scss +179 -0
  185. package/scss/config/mixins/_card.scss +338 -0
  186. package/scss/config/mixins/_chip.scss +66 -0
  187. package/scss/config/mixins/_circular-progress.scss +71 -0
  188. package/scss/config/mixins/_collapsible.scss +24 -0
  189. package/scss/config/mixins/_colors.scss +46 -0
  190. package/scss/config/mixins/_confirm.scss +31 -0
  191. package/scss/config/mixins/_data-table.scss +346 -0
  192. package/scss/config/mixins/_display.scss +32 -0
  193. package/scss/config/mixins/_dropdown.scss +143 -0
  194. package/scss/config/mixins/_empty-state.scss +30 -0
  195. package/scss/config/mixins/_focus.scss +55 -0
  196. package/scss/config/mixins/_footer.scss +42 -0
  197. package/scss/config/mixins/_form.scss +601 -0
  198. package/scss/config/mixins/_index.scss +58 -0
  199. package/scss/config/mixins/_interaction.scss +15 -0
  200. package/scss/config/mixins/_kbd.scss +22 -0
  201. package/scss/config/mixins/_layout.scss +117 -0
  202. package/scss/config/mixins/_link.scss +55 -0
  203. package/scss/config/mixins/_ln-table.scss +420 -0
  204. package/scss/config/mixins/_loader.scss +26 -0
  205. package/scss/config/mixins/_modal.scss +66 -0
  206. package/scss/config/mixins/_motion.scss +19 -0
  207. package/scss/config/mixins/_nav.scss +273 -0
  208. package/scss/config/mixins/_page-header.scss +69 -0
  209. package/scss/config/mixins/_popover.scss +25 -0
  210. package/scss/config/mixins/_position.scss +32 -0
  211. package/scss/config/mixins/_progress.scss +56 -0
  212. package/scss/config/mixins/_prose.scss +127 -0
  213. package/scss/config/mixins/_shadows.scss +8 -0
  214. package/scss/config/mixins/_sidebar.scss +95 -0
  215. package/scss/config/mixins/_sizing.scss +6 -0
  216. package/scss/config/mixins/_spacing.scss +19 -0
  217. package/scss/config/mixins/_stat-card.scss +68 -0
  218. package/scss/config/mixins/_status-badge.scss +83 -0
  219. package/scss/config/mixins/_stepper.scss +78 -0
  220. package/scss/config/mixins/_table.scss +215 -0
  221. package/scss/config/mixins/_tabs.scss +64 -0
  222. package/scss/config/mixins/_timeline.scss +69 -0
  223. package/scss/config/mixins/_toast.scss +148 -0
  224. package/scss/config/mixins/_tooltip.scss +111 -0
  225. package/scss/config/mixins/_transitions.scss +10 -0
  226. package/scss/config/mixins/_translations.scss +124 -0
  227. package/scss/config/mixins/_typography.scss +57 -0
  228. package/scss/config/mixins/_upload.scss +168 -0
  229. package/scss/ln-ashlar.scss +62 -0
  230. package/scss/tabler-icons.txt +5039 -0
  231. package/scss/utilities/_animations.scss +83 -0
  232. package/scss/utilities/_utilities.scss +49 -0
@@ -0,0 +1,1103 @@
1
+ import { cloneTemplateScoped, dispatch, fill, fillTemplate, registerComponent } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-data-table';
5
+ const DOM_ATTRIBUTE = 'lnDataTable';
6
+
7
+ // Tuning constant — duplicated in ln-table for component independence
8
+ const VIRTUAL_THRESHOLD = 200;
9
+ const BUFFER_ROWS = 15;
10
+
11
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
12
+
13
+ // Singleton formatter — same locale for all instances
14
+ const _numFmt = typeof Intl !== 'undefined'
15
+ ? new Intl.NumberFormat(document.documentElement.lang || undefined)
16
+ : null;
17
+
18
+ function _formatNum(n) {
19
+ return _numFmt ? _numFmt.format(n) : String(n);
20
+ }
21
+
22
+ // Walk ancestors, find the nearest element that establishes a vertical
23
+ // scroll context. Returns null if no such ancestor exists (the page
24
+ // itself is the scroll surface — caller falls back to window).
25
+ function _findScrollContainer(el) {
26
+ let p = el.parentElement;
27
+ while (p && p !== document.body && p !== document.documentElement) {
28
+ const cs = getComputedStyle(p);
29
+ const oy = cs.overflowY;
30
+ if (oy === 'auto' || oy === 'scroll') return p;
31
+ p = p.parentElement;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // ─── Component ─────────────────────────────────────────────
37
+
38
+ function _component(dom) {
39
+ this.dom = dom;
40
+ this.name = dom.getAttribute(DOM_SELECTOR) || '';
41
+ this.table = dom.querySelector('table');
42
+ this.tbody = dom.querySelector('[data-ln-data-table-body]') || dom.querySelector('tbody');
43
+ this.thead = dom.querySelector('thead');
44
+ this.ths = this.thead ? Array.from(this.thead.querySelectorAll('th')) : [];
45
+
46
+ // Instance state
47
+ this.isLoaded = false;
48
+ this.totalCount = 0;
49
+ this.visibleCount = 0;
50
+ this.currentSort = null;
51
+ this.currentFilters = {};
52
+ this.currentSearch = '';
53
+ this.selectedIds = new Set();
54
+
55
+ // Internal data cache (last received payload)
56
+ this._data = [];
57
+ this._lastTotal = 0;
58
+ this._lastFiltered = 0;
59
+
60
+ // Column filter options cache — grows additively; never shrinks on filtered payloads
61
+ this._filterOptions = {};
62
+ this._filterableFields = this.ths
63
+ .filter(function (th) {
64
+ return th.getAttribute('data-ln-col') && th.querySelector('[data-ln-col-filter]');
65
+ })
66
+ .map(function (th) { return th.getAttribute('data-ln-col'); });
67
+
68
+ // Virtual scroll state
69
+ this._virtual = false;
70
+ this._rowHeight = 0;
71
+ this._vStart = -1;
72
+ this._vEnd = -1;
73
+ this._rafId = null;
74
+ this._scrollHandler = null;
75
+ this._scrollContainer = null;
76
+
77
+
78
+
79
+ // Footer elements
80
+ this._totalSpan = dom.querySelector('[data-ln-data-table-total]');
81
+ this._filteredSpan = dom.querySelector('[data-ln-data-table-filtered]');
82
+
83
+ // Filtered separator — the parent that wraps "· X filtered" text
84
+ // Hide it when there's no active filtering
85
+ if (this._filteredSpan) {
86
+ this._filteredWrap = this._filteredSpan.parentElement !== dom
87
+ ? this._filteredSpan.closest('[data-ln-data-table-filtered-wrap]') || this._filteredSpan.parentNode
88
+ : null;
89
+ }
90
+
91
+ // Selected count — the parent that wraps "· X selected" text
92
+ // Hidden when nothing is selected
93
+ this._selectedSpan = dom.querySelector('[data-ln-data-table-selected]');
94
+ if (this._selectedSpan) {
95
+ this._selectedWrap = this._selectedSpan.parentElement !== dom
96
+ ? this._selectedSpan.closest('[data-ln-data-table-selected-wrap]') || this._selectedSpan.parentNode
97
+ : null;
98
+ }
99
+
100
+ const self = this;
101
+
102
+ // ─── set-data event ────────────────────────────────────
103
+ this._onSetData = function (e) {
104
+ const detail = e.detail || {};
105
+ self._data = detail.data || [];
106
+ self._lastTotal = detail.total != null ? detail.total : self._data.length;
107
+ self._lastFiltered = detail.filtered != null ? detail.filtered : self._data.length;
108
+
109
+ self.totalCount = self._lastTotal;
110
+ self.visibleCount = self._lastFiltered;
111
+ self.isLoaded = true;
112
+
113
+ self._updateFilterOptions(detail.filterOptions);
114
+
115
+ // Invalidate virtual-scroll window cache: data changed, so the
116
+ // cached startRow/endRow in _renderVirtual no longer reflect
117
+ // the current _data. Without this, sort/filter changes that
118
+ // preserve the viewport window would early-return and keep
119
+ // stale rows.
120
+ self._vStart = -1;
121
+ self._vEnd = -1;
122
+
123
+ self._renderRows();
124
+ self._updateFooter();
125
+
126
+ dispatch(dom, 'ln-data-table:rendered', {
127
+ table: self.name,
128
+ total: self.totalCount,
129
+ visible: self.visibleCount
130
+ });
131
+ };
132
+ dom.addEventListener('ln-data-table:set-data', this._onSetData);
133
+
134
+ // ─── set-loading event ─────────────────────────────────
135
+ this._onSetLoading = function (e) {
136
+ const loading = e.detail && e.detail.loading;
137
+ dom.classList.toggle('ln-data-table--loading', !!loading);
138
+ if (loading) {
139
+ self.isLoaded = false;
140
+ }
141
+ };
142
+ dom.addEventListener('ln-data-table:set-loading', this._onSetLoading);
143
+
144
+ // ─── Sort — click on [data-ln-col-sort] buttons ───────
145
+ this._sortButtons = Array.from(dom.querySelectorAll('[data-ln-col-sort]'));
146
+ this._onSortClick = function (e) {
147
+ const btn = e.target.closest('[data-ln-col-sort]');
148
+ if (!btn) return;
149
+ const th = btn.closest('th');
150
+ if (!th) return;
151
+ const field = th.getAttribute('data-ln-col');
152
+ if (!field) return;
153
+ self._handleSort(field, th);
154
+ };
155
+ if (this.thead) {
156
+ this.thead.addEventListener('click', this._onSortClick);
157
+ }
158
+
159
+ // ─── Filter — click on [data-ln-col-filter] buttons ───
160
+ this._activeDropdown = null; // { field, th, el }
161
+ this._onFilterClick = function (e) {
162
+ const btn = e.target.closest('[data-ln-col-filter]');
163
+ if (!btn) return;
164
+ e.stopPropagation();
165
+ const th = btn.closest('th');
166
+ if (!th) return;
167
+ const field = th.getAttribute('data-ln-col');
168
+ if (!field) return;
169
+
170
+ // Toggle: close if already open for this field
171
+ if (self._activeDropdown && self._activeDropdown.field === field) {
172
+ self._closeFilterDropdown();
173
+ return;
174
+ }
175
+ self._openFilterDropdown(field, th, btn);
176
+ };
177
+ if (this.thead) {
178
+ this.thead.addEventListener('click', this._onFilterClick);
179
+ }
180
+
181
+ // Outside click closes filter dropdown
182
+ this._onDocClick = function () {
183
+ if (self._activeDropdown) self._closeFilterDropdown();
184
+ };
185
+ document.addEventListener('click', this._onDocClick);
186
+
187
+ // Clear all filters button
188
+ this._onClearAll = function (e) {
189
+ const btn = e.target.closest('[data-ln-data-table-clear-all]');
190
+ if (!btn) return;
191
+ self.currentFilters = {};
192
+ self._updateFilterIndicators();
193
+ dispatch(dom, 'ln-data-table:clear-filters', { table: self.name });
194
+ self._requestData();
195
+ };
196
+ dom.addEventListener('click', this._onClearAll);
197
+
198
+ // ─── Selection — row checkboxes + select-all ──────────
199
+ this._selectable = dom.hasAttribute('data-ln-data-table-selectable');
200
+ this._selectableActive = false;
201
+ if (this._selectable) {
202
+ this._enableSelection();
203
+ }
204
+
205
+ // ─── Row click + Row actions ───────────────────────────
206
+ this._onRowClick = function (e) {
207
+ // Skip if clicking checkbox, action button, link, or button
208
+ if (e.target.closest('[data-ln-row-select]')) return;
209
+ if (e.target.closest('[data-ln-row-action]')) return;
210
+ if (e.target.closest('a') || e.target.closest('button')) return;
211
+
212
+ // Allow ctrl/meta/middle-click to pass through
213
+ if (e.ctrlKey || e.metaKey || e.button === 1) return;
214
+
215
+ const tr = e.target.closest('[data-ln-row]');
216
+ if (!tr) return;
217
+
218
+ const id = tr.getAttribute('data-ln-row-id');
219
+ const record = tr._lnRecord || {};
220
+
221
+ dispatch(dom, 'ln-data-table:row-click', {
222
+ table: self.name,
223
+ id: id,
224
+ record: record
225
+ });
226
+ };
227
+ if (this.tbody) this.tbody.addEventListener('click', this._onRowClick);
228
+
229
+ // Row action buttons
230
+ this._onRowAction = function (e) {
231
+ const btn = e.target.closest('[data-ln-row-action]');
232
+ if (!btn) return;
233
+
234
+ e.stopPropagation();
235
+ const tr = btn.closest('[data-ln-row]');
236
+ if (!tr) return;
237
+
238
+ const action = btn.getAttribute('data-ln-row-action');
239
+ const id = tr.getAttribute('data-ln-row-id');
240
+ const record = tr._lnRecord || {};
241
+
242
+ dispatch(dom, 'ln-data-table:row-action', {
243
+ table: self.name,
244
+ id: id,
245
+ action: action,
246
+ record: record
247
+ });
248
+ };
249
+ if (this.tbody) this.tbody.addEventListener('click', this._onRowAction);
250
+
251
+ // ─── Search — input on [data-ln-data-table-search] ────
252
+ this._searchInput = dom.querySelector('[data-ln-data-table-search]');
253
+ if (this._searchInput) {
254
+ this._onSearchInput = function () {
255
+ self.currentSearch = self._searchInput.value;
256
+ dispatch(dom, 'ln-data-table:search', {
257
+ table: self.name,
258
+ query: self.currentSearch
259
+ });
260
+ self._requestData();
261
+ };
262
+ this._searchInput.addEventListener('input', this._onSearchInput);
263
+ }
264
+
265
+ // ─── Keyboard navigation ──────────────────────────────
266
+ this._focusedRowIndex = -1;
267
+
268
+ this._onKeydown = function (e) {
269
+ // Only handle keys when table area has focus
270
+ if (!dom.contains(document.activeElement) && document.activeElement !== document.body) return;
271
+ // Don't intercept when typing in search/filter inputs
272
+ if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
273
+
274
+ // / focuses search (only when no input is active — checked above)
275
+ if (e.key === '/') {
276
+ if (self._searchInput) {
277
+ e.preventDefault();
278
+ self._searchInput.focus();
279
+ }
280
+ return;
281
+ }
282
+
283
+ const rows = self.tbody ? Array.from(self.tbody.querySelectorAll('[data-ln-row]')) : [];
284
+ if (!rows.length) return;
285
+
286
+ switch (e.key) {
287
+ case 'ArrowDown':
288
+ e.preventDefault();
289
+ self._focusedRowIndex = Math.min(self._focusedRowIndex + 1, rows.length - 1);
290
+ self._focusRow(rows);
291
+ break;
292
+ case 'ArrowUp':
293
+ e.preventDefault();
294
+ self._focusedRowIndex = Math.max(self._focusedRowIndex - 1, 0);
295
+ self._focusRow(rows);
296
+ break;
297
+ case 'Home':
298
+ e.preventDefault();
299
+ self._focusedRowIndex = 0;
300
+ self._focusRow(rows);
301
+ break;
302
+ case 'End':
303
+ e.preventDefault();
304
+ self._focusedRowIndex = rows.length - 1;
305
+ self._focusRow(rows);
306
+ break;
307
+ case 'Enter':
308
+ if (self._focusedRowIndex >= 0 && self._focusedRowIndex < rows.length) {
309
+ e.preventDefault();
310
+ const tr = rows[self._focusedRowIndex];
311
+ dispatch(dom, 'ln-data-table:row-click', {
312
+ table: self.name,
313
+ id: tr.getAttribute('data-ln-row-id'),
314
+ record: tr._lnRecord || {}
315
+ });
316
+ }
317
+ break;
318
+ case ' ':
319
+ if (self._selectable && self._focusedRowIndex >= 0 && self._focusedRowIndex < rows.length) {
320
+ e.preventDefault();
321
+ const cb = rows[self._focusedRowIndex].querySelector('[data-ln-row-select]');
322
+ if (cb) {
323
+ cb.checked = !cb.checked;
324
+ cb.dispatchEvent(new Event('change', { bubbles: true }));
325
+ }
326
+ }
327
+ break;
328
+ case 'Escape':
329
+ if (self._activeDropdown) {
330
+ self._closeFilterDropdown();
331
+ }
332
+ break;
333
+ }
334
+ };
335
+ document.addEventListener('keydown', this._onKeydown);
336
+
337
+ // Fire initial request-data so coordinator knows table is ready
338
+ dispatch(dom, 'ln-data-table:request-data', {
339
+ table: this.name,
340
+ sort: this.currentSort,
341
+ filters: this.currentFilters,
342
+ search: this.currentSearch
343
+ });
344
+
345
+ return this;
346
+ }
347
+
348
+ // ─── Sort ──────────────────────────────────────────────────
349
+
350
+ _component.prototype._handleSort = function (field, th) {
351
+ let newDir;
352
+
353
+ if (!this.currentSort || this.currentSort.field !== field) {
354
+ newDir = 'asc';
355
+ } else if (this.currentSort.direction === 'asc') {
356
+ newDir = 'desc';
357
+ } else {
358
+ newDir = null;
359
+ }
360
+
361
+ // Reset all sort classes
362
+ for (let i = 0; i < this.ths.length; i++) {
363
+ this.ths[i].classList.remove('ln-sort-asc', 'ln-sort-desc');
364
+ }
365
+
366
+ if (newDir) {
367
+ this.currentSort = { field: field, direction: newDir };
368
+ th.classList.add(newDir === 'asc' ? 'ln-sort-asc' : 'ln-sort-desc');
369
+ } else {
370
+ this.currentSort = null;
371
+ }
372
+
373
+ dispatch(this.dom, 'ln-data-table:sort', {
374
+ table: this.name,
375
+ field: field,
376
+ direction: newDir
377
+ });
378
+
379
+ this._requestData();
380
+ };
381
+
382
+ // ─── Request data (shared helper) ──────────────────────────
383
+
384
+ _component.prototype._requestData = function () {
385
+ dispatch(this.dom, 'ln-data-table:request-data', {
386
+ table: this.name,
387
+ sort: this.currentSort,
388
+ filters: this.currentFilters,
389
+ search: this.currentSearch
390
+ });
391
+ };
392
+
393
+ // ─── Selection helpers ─────────────────────────────────────
394
+
395
+ _component.prototype._updateSelectAll = function () {
396
+ if (!this._selectAllCheckbox || !this.tbody) return;
397
+ const rows = this.tbody.querySelectorAll('[data-ln-row]');
398
+ let allSelected = rows.length > 0;
399
+ for (let i = 0; i < rows.length; i++) {
400
+ const id = rows[i].getAttribute('data-ln-row-id');
401
+ if (id != null && !this.selectedIds.has(id)) {
402
+ allSelected = false;
403
+ break;
404
+ }
405
+ }
406
+ this._selectAllCheckbox.checked = allSelected;
407
+ };
408
+
409
+ Object.defineProperty(_component.prototype, 'selectedCount', {
410
+ get: function () { return this.selectedIds.size; },
411
+ set: function () { /* computed from selectedIds */ }
412
+ });
413
+
414
+ _component.prototype._enableSelection = function () {
415
+ if (this._selectableActive) return;
416
+ this._selectableActive = true;
417
+
418
+ const self = this;
419
+ this._onSelectionChange = function (e) {
420
+ const checkbox = e.target.closest('[data-ln-row-select]');
421
+ if (!checkbox) return;
422
+ const tr = checkbox.closest('[data-ln-row]');
423
+ if (!tr) return;
424
+ const id = tr.getAttribute('data-ln-row-id');
425
+ if (id == null) return;
426
+
427
+ if (checkbox.checked) {
428
+ self.selectedIds.add(id);
429
+ tr.classList.add('ln-row-selected');
430
+ } else {
431
+ self.selectedIds.delete(id);
432
+ tr.classList.remove('ln-row-selected');
433
+ }
434
+
435
+ self.selectedCount = self.selectedIds.size;
436
+ self._updateSelectAll();
437
+ self._updateFooter();
438
+
439
+ dispatch(self.dom, 'ln-data-table:select', {
440
+ table: self.name,
441
+ selectedIds: self.selectedIds,
442
+ count: self.selectedCount
443
+ });
444
+ };
445
+ if (this.tbody) this.tbody.addEventListener('change', this._onSelectionChange);
446
+
447
+ // Select-all checkbox in header
448
+ this._selectAllCheckbox = this.dom.querySelector('[data-ln-col-select] input[type="checkbox"]')
449
+ || this.dom.querySelector('[data-ln-col-select]');
450
+ if (this._selectAllCheckbox && this._selectAllCheckbox.tagName === 'TH') {
451
+ // Create a checkbox inside the th if it doesn't have one
452
+ const cb = document.createElement('input');
453
+ cb.type = 'checkbox';
454
+ cb.setAttribute('aria-label', 'Select all');
455
+ this._selectAllCheckbox.appendChild(cb);
456
+ this._selectAllCheckbox = cb;
457
+ }
458
+
459
+ if (this._selectAllCheckbox) {
460
+ this._onSelectAll = function () {
461
+ const checked = self._selectAllCheckbox.checked;
462
+ const rows = self.tbody ? self.tbody.querySelectorAll('[data-ln-row]') : [];
463
+
464
+ for (let i = 0; i < rows.length; i++) {
465
+ const id = rows[i].getAttribute('data-ln-row-id');
466
+ const rowCb = rows[i].querySelector('[data-ln-row-select]');
467
+ if (id == null) continue;
468
+
469
+ if (checked) {
470
+ self.selectedIds.add(id);
471
+ rows[i].classList.add('ln-row-selected');
472
+ } else {
473
+ self.selectedIds.delete(id);
474
+ rows[i].classList.remove('ln-row-selected');
475
+ }
476
+ if (rowCb) rowCb.checked = checked;
477
+ }
478
+
479
+ self.selectedCount = self.selectedIds.size;
480
+ dispatch(self.dom, 'ln-data-table:select-all', {
481
+ table: self.name,
482
+ selected: checked
483
+ });
484
+ dispatch(self.dom, 'ln-data-table:select', {
485
+ table: self.name,
486
+ selectedIds: self.selectedIds,
487
+ count: self.selectedCount
488
+ });
489
+ self._updateFooter();
490
+ };
491
+ this._selectAllCheckbox.addEventListener('change', this._onSelectAll);
492
+ }
493
+
494
+ // Sync initial checkbox state (browser form restore)
495
+ if (this.tbody) {
496
+ const rows = this.tbody.querySelectorAll('[data-ln-row]');
497
+ for (let i = 0; i < rows.length; i++) {
498
+ const cb = rows[i].querySelector('[data-ln-row-select]');
499
+ const id = rows[i].getAttribute('data-ln-row-id');
500
+ if (cb && cb.checked && id != null) {
501
+ this.selectedIds.add(id);
502
+ rows[i].classList.add('ln-row-selected');
503
+ }
504
+ }
505
+ this.selectedCount = this.selectedIds.size;
506
+ if (this.selectedCount > 0) this._updateSelectAll();
507
+ }
508
+ };
509
+
510
+ _component.prototype._disableSelection = function () {
511
+ if (!this._selectableActive) return;
512
+ this._selectableActive = false;
513
+
514
+ if (this.tbody && this._onSelectionChange) {
515
+ this.tbody.removeEventListener('change', this._onSelectionChange);
516
+ }
517
+ if (this._selectAllCheckbox && this._onSelectAll) {
518
+ this._selectAllCheckbox.removeEventListener('change', this._onSelectAll);
519
+ }
520
+
521
+ // Remove dynamically created checkbox in TH
522
+ const th = this.dom.querySelector('[data-ln-col-select]');
523
+ if (th) {
524
+ const cb = th.querySelector('input[type="checkbox"]');
525
+ if (cb) {
526
+ cb.remove();
527
+ }
528
+ }
529
+ this._selectAllCheckbox = null;
530
+
531
+ // Clear selections
532
+ this.selectedIds.clear();
533
+ this.selectedCount = 0;
534
+
535
+ // Clear visual state of rows
536
+ if (this.tbody) {
537
+ const rows = this.tbody.querySelectorAll('[data-ln-row]');
538
+ for (let i = 0; i < rows.length; i++) {
539
+ rows[i].classList.remove('ln-row-selected');
540
+ const cb = rows[i].querySelector('[data-ln-row-select]');
541
+ if (cb) cb.checked = false;
542
+ }
543
+ }
544
+
545
+ this._updateFooter();
546
+ };
547
+
548
+ // ─── Keyboard focus ───────────────────────────────────────
549
+
550
+ _component.prototype._focusRow = function (rows) {
551
+ // Remove focus from all
552
+ for (let i = 0; i < rows.length; i++) {
553
+ rows[i].classList.remove('ln-row-focused');
554
+ rows[i].removeAttribute('tabindex');
555
+ }
556
+
557
+ if (this._focusedRowIndex >= 0 && this._focusedRowIndex < rows.length) {
558
+ const row = rows[this._focusedRowIndex];
559
+ row.classList.add('ln-row-focused');
560
+ row.setAttribute('tabindex', '0');
561
+ row.focus();
562
+ row.scrollIntoView({ block: 'nearest' });
563
+ }
564
+ };
565
+
566
+ // ─── Filter dropdown ───────────────────────────────────────
567
+
568
+ _component.prototype._openFilterDropdown = function (field, th, btn) {
569
+ this._closeFilterDropdown();
570
+
571
+ // Try table-scoped template first, fall back to generic
572
+ const clone = cloneTemplateScoped(this.dom, this.name + '-column-filter', 'ln-data-table')
573
+ || cloneTemplateScoped(this.dom, 'column-filter', 'ln-data-table');
574
+ if (!clone) return;
575
+
576
+ const dropdown = clone.firstElementChild;
577
+ if (!dropdown) return;
578
+
579
+ // Populate checkboxes with unique values from data
580
+ const uniqueValues = this._getUniqueValues(field);
581
+ const optionsList = dropdown.querySelector('[data-ln-filter-options]');
582
+ const searchInput = dropdown.querySelector('[data-ln-filter-search]');
583
+ const activeValues = this.currentFilters[field] || [];
584
+ const self = this;
585
+
586
+ // Hide search if <=8 values
587
+ if (searchInput && uniqueValues.length <= 8) {
588
+ searchInput.classList.add('hidden');
589
+ }
590
+
591
+ // Build checkbox items
592
+ if (optionsList) {
593
+ // Update the "All" reset checkbox status if statically present in template
594
+ const resetCheckbox = optionsList.querySelector('[data-ln-filter-reset]');
595
+ if (resetCheckbox) {
596
+ resetCheckbox.checked = activeValues.length === 0;
597
+ }
598
+
599
+ // Get the options item template (scoped first, fall back to generic)
600
+ const itemTmpl = cloneTemplateScoped(dropdown, this.name + '-column-filter-item', 'ln-data-table')
601
+ || cloneTemplateScoped(dropdown, 'column-filter-item', 'ln-data-table');
602
+
603
+ if (itemTmpl) {
604
+ for (let i = 0; i < uniqueValues.length; i++) {
605
+ const val = uniqueValues[i];
606
+ const itemClone = itemTmpl.cloneNode(true);
607
+ fill(itemClone, { value: val });
608
+
609
+ const checkbox = itemClone.querySelector('input[type="checkbox"]');
610
+ if (checkbox) {
611
+ checkbox.value = val;
612
+ checkbox.checked = activeValues.length > 0 && activeValues.indexOf(val) !== -1;
613
+ }
614
+ optionsList.appendChild(itemClone);
615
+ }
616
+ }
617
+
618
+ // Checkbox change handler — mutual exclusion + filter update
619
+ optionsList.addEventListener('change', function (e) {
620
+ if (e.target.type !== 'checkbox') return;
621
+ self._applyFilterMutualExclusion(e.target, optionsList);
622
+ self._onFilterChange(field, optionsList);
623
+ });
624
+ }
625
+
626
+ // Search within dropdown values
627
+ if (searchInput) {
628
+ searchInput.addEventListener('input', function () {
629
+ const term = searchInput.value.toLowerCase();
630
+ const items = optionsList.querySelectorAll('li');
631
+ for (let i = 0; i < items.length; i++) {
632
+ const text = items[i].textContent.toLowerCase();
633
+ items[i].classList.toggle('hidden', term && text.indexOf(term) === -1);
634
+ }
635
+ });
636
+ }
637
+
638
+ // Clear filter button
639
+ const clearBtn = dropdown.querySelector('[data-ln-filter-clear]');
640
+ if (clearBtn) {
641
+ clearBtn.addEventListener('click', function () {
642
+ delete self.currentFilters[field];
643
+ self._closeFilterDropdown();
644
+ self._updateFilterIndicators();
645
+ dispatch(self.dom, 'ln-data-table:filter', {
646
+ table: self.name,
647
+ field: field,
648
+ values: []
649
+ });
650
+ self._requestData();
651
+ });
652
+ }
653
+
654
+ // Append to th — CSS handles positioning (th is relative, dropdown is absolute)
655
+ th.appendChild(dropdown);
656
+
657
+ this._activeDropdown = { field: field, th: th, el: dropdown };
658
+
659
+ // Stop click propagation inside dropdown
660
+ dropdown.addEventListener('click', function (e) { e.stopPropagation(); });
661
+ };
662
+
663
+ _component.prototype._closeFilterDropdown = function () {
664
+ if (!this._activeDropdown) return;
665
+ if (this._activeDropdown.el && this._activeDropdown.el.parentNode) {
666
+ this._activeDropdown.el.parentNode.removeChild(this._activeDropdown.el);
667
+ }
668
+ this._activeDropdown = null;
669
+ };
670
+
671
+ // ─── Filter sentinel mutual exclusion ──────────────────────
672
+ // Same pattern as ln-filter: "All" sentinel + value checkboxes.
673
+
674
+ _component.prototype._applyFilterMutualExclusion = function (cb, optionsList) {
675
+ const isReset = cb.hasAttribute('data-ln-filter-reset');
676
+ const resetCb = optionsList.querySelector('[data-ln-filter-reset]');
677
+ const valueCbs = optionsList.querySelectorAll('input[type="checkbox"]:not([data-ln-filter-reset])');
678
+
679
+ if (isReset) {
680
+ // "All" clicked — force checked, uncheck all values
681
+ cb.checked = true;
682
+ for (let i = 0; i < valueCbs.length; i++) valueCbs[i].checked = false;
683
+ } else if (cb.checked) {
684
+ // Value checked — uncheck "All"
685
+ if (resetCb) resetCb.checked = false;
686
+ } else {
687
+ // Value unchecked — if none left, re-check "All"
688
+ let any = false;
689
+ for (let i = 0; i < valueCbs.length; i++) {
690
+ if (valueCbs[i].checked) { any = true; break; }
691
+ }
692
+ if (!any && resetCb) resetCb.checked = true;
693
+ }
694
+ };
695
+
696
+ _component.prototype._onFilterChange = function (field, optionsList) {
697
+ const resetCb = optionsList.querySelector('[data-ln-filter-reset]');
698
+ const valueCbs = optionsList.querySelectorAll('input[type="checkbox"]:not([data-ln-filter-reset])');
699
+ const checked = [];
700
+
701
+ for (let i = 0; i < valueCbs.length; i++) {
702
+ if (valueCbs[i].checked) checked.push(valueCbs[i].value);
703
+ }
704
+
705
+ // "All" checked or nothing checked = no filter active
706
+ const isReset = (resetCb && resetCb.checked) || checked.length === 0;
707
+ if (isReset) {
708
+ delete this.currentFilters[field];
709
+ } else {
710
+ this.currentFilters[field] = checked;
711
+ }
712
+
713
+ this._updateFilterIndicators();
714
+
715
+ dispatch(this.dom, 'ln-data-table:filter', {
716
+ table: this.name,
717
+ field: field,
718
+ values: isReset ? [] : checked
719
+ });
720
+
721
+ this._requestData();
722
+ };
723
+
724
+ _component.prototype._updateFilterOptions = function (authoritative) {
725
+ if (authoritative !== null && typeof authoritative === 'object' && !Array.isArray(authoritative)) {
726
+ // Coordinator-supplied authoritative values — replace per-field
727
+ const keys = Object.keys(authoritative);
728
+ for (let i = 0; i < keys.length; i++) {
729
+ const field = keys[i];
730
+ const vals = authoritative[field];
731
+ if (!Array.isArray(vals)) continue;
732
+ const seen = {};
733
+ const unique = [];
734
+ for (let j = 0; j < vals.length; j++) {
735
+ const s = String(vals[j]);
736
+ if (!seen[s]) { seen[s] = true; unique.push(s); }
737
+ }
738
+ this._filterOptions[field] = unique.sort();
739
+ }
740
+ } else {
741
+ // Auto-derive from current payload — additive merge only
742
+ const fields = this._filterableFields;
743
+ const data = this._data;
744
+ for (let fi = 0; fi < fields.length; fi++) {
745
+ const field = fields[fi];
746
+ if (!this._filterOptions[field]) this._filterOptions[field] = [];
747
+ const existing = this._filterOptions[field];
748
+ const seen = {};
749
+ for (let k = 0; k < existing.length; k++) { seen[existing[k]] = true; }
750
+ for (let i = 0; i < data.length; i++) {
751
+ const val = data[i][field];
752
+ if (val != null) {
753
+ const s = String(val);
754
+ if (!seen[s]) { seen[s] = true; existing.push(s); }
755
+ }
756
+ }
757
+ existing.sort();
758
+ }
759
+ }
760
+ };
761
+
762
+ _component.prototype._getUniqueValues = function (field) {
763
+ return (this._filterOptions[field] || []).slice().sort();
764
+ };
765
+
766
+ _component.prototype._updateFilterIndicators = function () {
767
+ const ths = this.ths;
768
+ for (let i = 0; i < ths.length; i++) {
769
+ const th = ths[i];
770
+ const field = th.getAttribute('data-ln-col');
771
+ if (!field) continue;
772
+ const btn = th.querySelector('[data-ln-col-filter]');
773
+ if (!btn) continue;
774
+ const hasFilter = this.currentFilters[field] && this.currentFilters[field].length > 0;
775
+ btn.classList.toggle('ln-filter-active', !!hasFilter);
776
+ }
777
+ };
778
+
779
+ // ─── Row rendering ─────────────────────────────────────────
780
+
781
+ _component.prototype._renderRows = function () {
782
+ if (!this.tbody) return;
783
+
784
+ const data = this._data;
785
+ const total = this._lastTotal;
786
+ const filtered = this._lastFiltered;
787
+
788
+ // Empty state: no data at all
789
+ if (total === 0) {
790
+ this._disableVirtualScroll();
791
+ this._showEmptyState(this.name + '-empty');
792
+ return;
793
+ }
794
+
795
+ // Empty state: filters returned zero
796
+ if (data.length === 0 || filtered === 0) {
797
+ this._disableVirtualScroll();
798
+ this._showEmptyState(this.name + '-empty-filtered');
799
+ return;
800
+ }
801
+
802
+ if (data.length > VIRTUAL_THRESHOLD) {
803
+ this._enableVirtualScroll();
804
+ this._renderVirtual();
805
+ } else {
806
+ this._disableVirtualScroll();
807
+ this._renderAll();
808
+ }
809
+ };
810
+
811
+ _component.prototype._renderAll = function () {
812
+ const data = this._data;
813
+ const frag = document.createDocumentFragment();
814
+
815
+ for (let i = 0; i < data.length; i++) {
816
+ const tr = this._buildRow(data[i]);
817
+ if (!tr) break;
818
+ frag.appendChild(tr);
819
+ }
820
+
821
+ this.tbody.textContent = '';
822
+ this.tbody.appendChild(frag);
823
+
824
+ if (this._selectable) this._updateSelectAll();
825
+ };
826
+
827
+ _component.prototype._buildRow = function (record) {
828
+ const clone = cloneTemplateScoped(this.dom, this.name + '-row', 'ln-data-table');
829
+ if (!clone) return null;
830
+
831
+ const tr = clone.querySelector('[data-ln-row]') || clone.firstElementChild;
832
+ if (!tr) return null;
833
+
834
+ this._fillRow(tr, record);
835
+
836
+ tr._lnRecord = record;
837
+ if (record.id != null) {
838
+ tr.setAttribute('data-ln-row-id', record.id);
839
+ }
840
+
841
+ // Restore selection state
842
+ if (this._selectable && record.id != null && this.selectedIds.has(String(record.id))) {
843
+ tr.classList.add('ln-row-selected');
844
+ const rowCb = tr.querySelector('[data-ln-row-select]');
845
+ if (rowCb) rowCb.checked = true;
846
+ }
847
+
848
+ return tr;
849
+ };
850
+
851
+ // ─── Virtual scroll ────────────────────────────────────────
852
+
853
+ _component.prototype._enableVirtualScroll = function () {
854
+ if (this._virtual) return;
855
+ this._virtual = true;
856
+ this._vStart = -1;
857
+ this._vEnd = -1;
858
+ const self = this;
859
+
860
+ // Measure row height from first row
861
+ if (!this._rowHeight) {
862
+ const tempRow = this._buildRow(this._data[0]);
863
+ if (tempRow) {
864
+ this.tbody.textContent = '';
865
+ this.tbody.appendChild(tempRow);
866
+ this._rowHeight = tempRow.offsetHeight || 40;
867
+ this.tbody.textContent = '';
868
+ }
869
+ }
870
+
871
+ // Resolve the scroll target every time we enable — the DOM may have
872
+ // moved between the previous disable and now (re-parenting, modal
873
+ // open, list crosses VIRTUAL_THRESHOLD again after a filter).
874
+ // null → page scrolls; we listen on window.
875
+ this._scrollContainer = _findScrollContainer(this.dom);
876
+ const scrollTarget = this._scrollContainer || window;
877
+
878
+ this._scrollHandler = function () {
879
+ if (self._rafId) return;
880
+ self._rafId = requestAnimationFrame(function () {
881
+ self._rafId = null;
882
+ self._renderVirtual();
883
+ });
884
+ };
885
+
886
+ scrollTarget.addEventListener('scroll', this._scrollHandler, { passive: true });
887
+ // Viewport resize always matters (changes visible-row count), even
888
+ // when scrolling happens inside a container.
889
+ window.addEventListener('resize', this._scrollHandler, { passive: true });
890
+ };
891
+
892
+ _component.prototype._disableVirtualScroll = function () {
893
+ if (!this._virtual) return;
894
+ this._virtual = false;
895
+
896
+ if (this._scrollHandler) {
897
+ const scrollTarget = this._scrollContainer || window;
898
+ scrollTarget.removeEventListener('scroll', this._scrollHandler);
899
+ window.removeEventListener('resize', this._scrollHandler);
900
+ this._scrollHandler = null;
901
+ }
902
+ this._scrollContainer = null;
903
+ if (this._rafId) {
904
+ cancelAnimationFrame(this._rafId);
905
+ this._rafId = null;
906
+ }
907
+ this._vStart = -1;
908
+ this._vEnd = -1;
909
+ };
910
+
911
+ _component.prototype._renderVirtual = function () {
912
+ const data = this._data;
913
+ const total = data.length;
914
+ const rowH = this._rowHeight;
915
+ if (!rowH || !total) return;
916
+
917
+ const theadH = this.thead ? this.thead.offsetHeight : 0;
918
+ const sc = this._scrollContainer;
919
+ let scrollIntoData;
920
+ let viewportH;
921
+
922
+ if (sc) {
923
+ // Scrolling happens inside an ancestor element. Compute table
924
+ // position relative to that container, using its scrollTop and
925
+ // clientHeight as the viewport.
926
+ const tableRect = this.table.getBoundingClientRect();
927
+ const scRect = sc.getBoundingClientRect();
928
+ const dataStartInContainer = (tableRect.top - scRect.top) + sc.scrollTop + theadH;
929
+ scrollIntoData = sc.scrollTop - dataStartInContainer;
930
+ viewportH = sc.clientHeight;
931
+ } else {
932
+ // No scrolling ancestor — page scrolls. Use window coords.
933
+ const tableRect = this.table.getBoundingClientRect();
934
+ const tableTopInPage = tableRect.top + window.scrollY;
935
+ const dataStartInPage = tableTopInPage + theadH;
936
+ scrollIntoData = window.scrollY - dataStartInPage;
937
+ viewportH = window.innerHeight;
938
+ }
939
+
940
+ let startRow = Math.max(0, Math.floor(scrollIntoData / rowH) - BUFFER_ROWS);
941
+ startRow = Math.min(startRow, total);
942
+ const endRow = Math.min(startRow + Math.ceil(viewportH / rowH) + (BUFFER_ROWS * 2), total);
943
+
944
+ if (startRow === this._vStart && endRow === this._vEnd) return;
945
+ this._vStart = startRow;
946
+ this._vEnd = endRow;
947
+
948
+ const colSpan = this.ths.length || 1;
949
+ const topH = startRow * rowH;
950
+ const bottomH = (total - endRow) * rowH;
951
+ const frag = document.createDocumentFragment();
952
+
953
+ // Top spacer
954
+ if (topH > 0) {
955
+ const topSpacer = document.createElement('tr');
956
+ topSpacer.className = 'ln-data-table__spacer';
957
+ topSpacer.setAttribute('aria-hidden', 'true');
958
+ const topTd = document.createElement('td');
959
+ topTd.setAttribute('colspan', colSpan);
960
+ topTd.style.height = topH + 'px';
961
+ topSpacer.appendChild(topTd);
962
+ frag.appendChild(topSpacer);
963
+ }
964
+
965
+ // Visible rows
966
+ for (let i = startRow; i < endRow; i++) {
967
+ const tr = this._buildRow(data[i]);
968
+ if (tr) frag.appendChild(tr);
969
+ }
970
+
971
+ // Bottom spacer
972
+ if (bottomH > 0) {
973
+ const bottomSpacer = document.createElement('tr');
974
+ bottomSpacer.className = 'ln-data-table__spacer';
975
+ bottomSpacer.setAttribute('aria-hidden', 'true');
976
+ const bottomTd = document.createElement('td');
977
+ bottomTd.setAttribute('colspan', colSpan);
978
+ bottomTd.style.height = bottomH + 'px';
979
+ bottomSpacer.appendChild(bottomTd);
980
+ frag.appendChild(bottomSpacer);
981
+ }
982
+
983
+ this.tbody.textContent = '';
984
+ this.tbody.appendChild(frag);
985
+
986
+ if (this._selectable) this._updateSelectAll();
987
+ };
988
+
989
+ // ─── Fill row cells ────────────────────────────────────────
990
+
991
+ _component.prototype._fillRow = function (tr, record) {
992
+ // Native double curly brace interpolation: {{ field }}
993
+ fillTemplate(tr, record);
994
+
995
+ // data-ln-cell-attr="field:attr" → setAttribute
996
+ const cellAttrs = tr.querySelectorAll('[data-ln-cell-attr]');
997
+ for (let i = 0; i < cellAttrs.length; i++) {
998
+ const el = cellAttrs[i];
999
+ const pairs = el.getAttribute('data-ln-cell-attr').split(',');
1000
+ for (let j = 0; j < pairs.length; j++) {
1001
+ const parts = pairs[j].trim().split(':');
1002
+ if (parts.length !== 2) continue;
1003
+ const field = parts[0].trim();
1004
+ const attr = parts[1].trim();
1005
+ if (record[field] != null) {
1006
+ el.setAttribute(attr, record[field]);
1007
+ }
1008
+ }
1009
+ }
1010
+ };
1011
+
1012
+ // ─── Empty state ───────────────────────────────────────────
1013
+
1014
+ _component.prototype._showEmptyState = function (templateName) {
1015
+ const clone = cloneTemplateScoped(this.dom, templateName, 'ln-data-table');
1016
+ this.tbody.textContent = '';
1017
+ if (clone) {
1018
+ this.tbody.appendChild(clone);
1019
+ }
1020
+ };
1021
+
1022
+ // ─── Footer ────────────────────────────────────────────────
1023
+
1024
+ _component.prototype._updateFooter = function () {
1025
+ const total = this._lastTotal;
1026
+ const filtered = this._lastFiltered;
1027
+ const isFiltered = filtered < total;
1028
+
1029
+ if (this._totalSpan) {
1030
+ this._totalSpan.textContent = _formatNum(total);
1031
+ }
1032
+
1033
+ if (this._filteredSpan) {
1034
+ this._filteredSpan.textContent = isFiltered ? _formatNum(filtered) : '';
1035
+ }
1036
+
1037
+ // Hide filtered wrapper when not filtering
1038
+ if (this._filteredWrap) {
1039
+ this._filteredWrap.classList.toggle('hidden', !isFiltered);
1040
+ }
1041
+
1042
+ if (this._selectedSpan) {
1043
+ const count = this.selectedIds.size;
1044
+ this._selectedSpan.textContent = count > 0 ? _formatNum(count) : '';
1045
+ if (this._selectedWrap) {
1046
+ this._selectedWrap.classList.toggle('hidden', count === 0);
1047
+ }
1048
+ }
1049
+ };
1050
+
1051
+ // ─── Destroy ───────────────────────────────────────────────
1052
+
1053
+ _component.prototype.destroy = function () {
1054
+ if (!this.dom[DOM_ATTRIBUTE]) return;
1055
+ this.dom.removeEventListener('ln-data-table:set-data', this._onSetData);
1056
+ this.dom.removeEventListener('ln-data-table:set-loading', this._onSetLoading);
1057
+ if (this.thead) {
1058
+ this.thead.removeEventListener('click', this._onSortClick);
1059
+ this.thead.removeEventListener('click', this._onFilterClick);
1060
+ }
1061
+ document.removeEventListener('click', this._onDocClick);
1062
+ document.removeEventListener('keydown', this._onKeydown);
1063
+ if (this._searchInput) this._searchInput.removeEventListener('input', this._onSearchInput);
1064
+ if (this.tbody) {
1065
+ this.tbody.removeEventListener('click', this._onRowClick);
1066
+ this.tbody.removeEventListener('click', this._onRowAction);
1067
+ }
1068
+ if (this._onSelectionChange && this.tbody) this.tbody.removeEventListener('change', this._onSelectionChange);
1069
+ if (this._selectAllCheckbox && this._onSelectAll) this._selectAllCheckbox.removeEventListener('change', this._onSelectAll);
1070
+ this.dom.removeEventListener('click', this._onClearAll);
1071
+
1072
+ this._closeFilterDropdown();
1073
+ this._disableVirtualScroll();
1074
+ this._data = [];
1075
+ delete this.dom[DOM_ATTRIBUTE];
1076
+ };
1077
+
1078
+ // ─── Attribute Sync ────────────────────────────────────────
1079
+
1080
+ function _syncAttribute(el, attrName) {
1081
+ const instance = el[DOM_ATTRIBUTE];
1082
+ if (!instance) return;
1083
+
1084
+ if (attrName === 'data-ln-data-table-selectable') {
1085
+ const hasSelectable = el.hasAttribute('data-ln-data-table-selectable');
1086
+ if (hasSelectable !== instance._selectable) {
1087
+ instance._selectable = hasSelectable;
1088
+ if (hasSelectable) {
1089
+ instance._enableSelection();
1090
+ } else {
1091
+ instance._disableSelection();
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ // ─── Init ──────────────────────────────────────────────────
1098
+
1099
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-data-table', {
1100
+ extraAttributes: ['data-ln-data-table-selectable'],
1101
+ onAttributeChange: _syncAttribute
1102
+ });
1103
+ })();