@livenetworks/ashlar 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +189 -177
  2. package/js/COMPONENTS.md +55 -49
  3. package/js/index.js +22 -5
  4. package/js/ln-accordion/README.md +186 -137
  5. package/js/ln-accordion/ln-accordion.js +1 -1
  6. package/js/ln-ajax/README.md +73 -4
  7. package/js/ln-ajax/ln-ajax.js +1 -1
  8. package/js/ln-ajax/src/ln-ajax.js +13 -13
  9. package/js/ln-api-connector/README.md +261 -150
  10. package/js/ln-api-connector/ln-api-connector.js +1 -1
  11. package/js/ln-api-connector/src/ln-api-connector.js +368 -265
  12. package/js/ln-api-queue/README.md +219 -0
  13. package/js/ln-api-queue/ln-api-queue.js +1 -0
  14. package/js/ln-api-queue/src/ln-api-queue.js +320 -0
  15. package/js/ln-api-queue/src/queue-storage.js +455 -0
  16. package/js/ln-autoresize/README.md +47 -0
  17. package/js/ln-autoresize/ln-autoresize.js +1 -1
  18. package/js/ln-autosave/README.md +33 -1
  19. package/js/ln-autosave/ln-autosave.js +1 -1
  20. package/js/ln-autosave/src/ln-autosave.js +4 -4
  21. package/js/ln-circular-progress/README.md +27 -13
  22. package/js/ln-circular-progress/ln-circular-progress.js +1 -1
  23. package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
  24. package/js/ln-confirm/README.md +162 -86
  25. package/js/ln-confirm/_ln-confirm.scss +11 -0
  26. package/js/ln-confirm/ln-confirm.js +1 -1
  27. package/js/ln-confirm/src/ln-confirm.js +77 -41
  28. package/js/ln-core/README.md +896 -0
  29. package/js/ln-core/hash.js +92 -0
  30. package/js/ln-core/helpers.js +337 -8
  31. package/js/ln-core/index.js +4 -2
  32. package/js/ln-core/persist.js +29 -3
  33. package/js/ln-core/positioning.js +1 -35
  34. package/js/ln-core/window-cache.js +213 -0
  35. package/js/ln-couchdb-connector/README.md +177 -156
  36. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
  37. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
  38. package/js/ln-data-coordinator/README.md +536 -165
  39. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
  40. package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
  41. package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
  42. package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
  43. package/js/ln-data-store/README.md +241 -94
  44. package/js/ln-data-store/ln-data-store.js +1 -1
  45. package/js/ln-data-store/src/ln-data-store.js +694 -699
  46. package/js/ln-date/README.md +39 -1
  47. package/js/ln-date/ln-date-dev.scss +14 -0
  48. package/js/ln-date/ln-date.js +1 -1
  49. package/js/ln-date/src/ln-date.js +236 -178
  50. package/js/ln-debug/README.md +58 -0
  51. package/js/ln-debug/ln-debug.js +1 -0
  52. package/js/ln-debug/src/ln-debug.js +19 -0
  53. package/js/ln-dropdown/README.md +31 -8
  54. package/js/ln-dropdown/ln-dropdown.js +1 -1
  55. package/js/ln-dropdown/src/ln-dropdown.js +30 -13
  56. package/js/ln-editor/README.md +274 -0
  57. package/js/ln-editor/ln-editor.js +1 -0
  58. package/js/ln-editor/ln-editor.scss +43 -0
  59. package/js/ln-editor/src/ln-editor.js +619 -0
  60. package/js/ln-external-links/README.md +44 -5
  61. package/js/ln-external-links/ln-external-links.js +1 -1
  62. package/js/ln-fill/README.md +199 -0
  63. package/js/ln-fill/ln-fill.js +1 -0
  64. package/js/ln-fill/src/ln-fill.js +87 -0
  65. package/js/ln-filter/README.md +189 -99
  66. package/js/ln-filter/ln-filter-dev.scss +8 -0
  67. package/js/ln-filter/ln-filter.js +1 -1
  68. package/js/ln-filter/src/ln-filter.js +22 -7
  69. package/js/ln-form/README.md +212 -49
  70. package/js/ln-form/ln-form.js +1 -1
  71. package/js/ln-form/src/ln-form.js +99 -199
  72. package/js/ln-http/README.md +125 -89
  73. package/js/ln-http/ln-http.js +1 -1
  74. package/js/ln-http/src/ln-http.js +32 -13
  75. package/js/ln-icon/README.md +130 -0
  76. package/js/ln-icon/ln-icon.js +1 -0
  77. package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
  78. package/js/ln-link/README.md +47 -9
  79. package/js/ln-link/ln-link.js +1 -1
  80. package/js/ln-link/src/ln-link.js +22 -6
  81. package/js/ln-list/README.md +129 -0
  82. package/js/ln-list/ln-list.js +1 -0
  83. package/js/ln-list/ln-list.scss +28 -0
  84. package/js/ln-list/src/ln-list.js +1216 -0
  85. package/js/ln-modal/README.md +185 -154
  86. package/js/ln-modal/ln-modal-dev.scss +13 -0
  87. package/js/ln-modal/ln-modal.js +1 -1
  88. package/js/ln-modal/ln-modal.scss +28 -4
  89. package/js/ln-modal/src/ln-modal.js +137 -201
  90. package/js/ln-modal-coordinator/README.md +99 -0
  91. package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
  92. package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
  93. package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
  94. package/js/ln-nav/README.md +46 -3
  95. package/js/ln-nav/ln-nav.js +1 -1
  96. package/js/ln-nav/src/ln-nav.js +92 -127
  97. package/js/ln-number/README.md +28 -1
  98. package/js/ln-number/ln-number-dev.scss +13 -0
  99. package/js/ln-number/ln-number.js +1 -1
  100. package/js/ln-number/src/ln-number.js +443 -350
  101. package/js/ln-options/README.md +66 -0
  102. package/js/ln-options/ln-options.js +1 -0
  103. package/js/ln-options/src/ln-options.js +74 -0
  104. package/js/ln-popover/README.md +40 -5
  105. package/js/ln-popover/ln-popover.js +1 -1
  106. package/js/ln-popover/src/ln-popover.js +38 -11
  107. package/js/ln-progress/README.md +413 -442
  108. package/js/ln-progress/ln-progress.js +1 -1
  109. package/js/ln-progress/src/ln-progress.js +98 -150
  110. package/js/ln-router/README.md +432 -0
  111. package/js/ln-router/ln-router.js +1 -0
  112. package/js/ln-router/src/ln-router.js +539 -0
  113. package/js/ln-search/README.md +158 -83
  114. package/js/ln-search/ln-search-dev.scss +8 -0
  115. package/js/ln-search/ln-search.js +1 -1
  116. package/js/ln-search/src/ln-search.js +12 -9
  117. package/js/ln-slug/README.md +110 -0
  118. package/js/ln-slug/ln-slug.js +1 -0
  119. package/js/ln-slug/src/ln-slug.js +74 -0
  120. package/js/ln-sort/README.md +170 -0
  121. package/js/ln-sort/ln-sort-dev.scss +8 -0
  122. package/js/ln-sort/ln-sort.js +1 -0
  123. package/js/ln-sort/src/ln-sort.js +178 -0
  124. package/js/ln-sortable/README.md +133 -95
  125. package/js/ln-sortable/ln-sortable.js +1 -1
  126. package/js/ln-stat/README.md +55 -0
  127. package/js/ln-stat/ln-stat.js +1 -0
  128. package/js/ln-stat/src/ln-stat.js +55 -0
  129. package/js/ln-table/README.md +285 -101
  130. package/js/ln-table/ln-table-dev.scss +17 -0
  131. package/js/ln-table/ln-table.js +1 -1
  132. package/js/ln-table/ln-table.scss +1 -1
  133. package/js/ln-table/src/ln-table.js +1493 -473
  134. package/js/ln-table-coordinator/README.md +73 -0
  135. package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
  136. package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
  137. package/js/ln-tabs/README.md +221 -137
  138. package/js/ln-tabs/ln-tabs-dev.scss +18 -0
  139. package/js/ln-tabs/ln-tabs.js +1 -1
  140. package/js/ln-tabs/src/ln-tabs.js +41 -24
  141. package/js/ln-time/README.md +14 -0
  142. package/js/ln-time/ln-time.js +1 -1
  143. package/js/ln-time/src/ln-time.js +223 -192
  144. package/js/ln-toast/README.md +178 -122
  145. package/js/ln-toast/ln-toast.js +1 -15
  146. package/js/ln-toast/src/ln-toast.js +232 -210
  147. package/js/ln-toast/template.html +16 -12
  148. package/js/ln-toggle/README.md +61 -2
  149. package/js/ln-toggle/ln-toggle-dev.scss +13 -0
  150. package/js/ln-toggle/ln-toggle.js +1 -1
  151. package/js/ln-toggle/src/ln-toggle.js +56 -43
  152. package/js/ln-tooltip/README.md +95 -58
  153. package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
  154. package/js/ln-tooltip/ln-tooltip.js +1 -1
  155. package/js/ln-tooltip/src/ln-tooltip.js +45 -6
  156. package/js/ln-translations/README.md +118 -96
  157. package/js/ln-translations/ln-translations.js +1 -1
  158. package/js/ln-translations/src/ln-translations.js +10 -5
  159. package/js/ln-upload/README.md +85 -10
  160. package/js/ln-upload/ln-upload.js +1 -1
  161. package/js/ln-upload/src/ln-upload.js +26 -10
  162. package/js/ln-validate/README.md +72 -0
  163. package/js/ln-validate/ln-validate-dev.scss +15 -0
  164. package/js/ln-validate/ln-validate.js +1 -1
  165. package/js/ln-validate/src/ln-validate.js +64 -0
  166. package/package.json +61 -55
  167. package/scss/base/_global.scss +2 -2
  168. package/scss/base/_typography.scss +1 -2
  169. package/scss/components/_date.scss +1 -0
  170. package/scss/components/_dropdown.scss +0 -5
  171. package/scss/components/_editor.scss +5 -0
  172. package/scss/components/_filter.scss +26 -0
  173. package/scss/components/_form.scss +7 -0
  174. package/scss/components/_ln-table.scss +14 -5
  175. package/scss/components/_sort.scss +10 -0
  176. package/scss/components/_table.scss +68 -1
  177. package/scss/components/_toast.scss +68 -41
  178. package/scss/components/_toggle.scss +16 -0
  179. package/scss/config/_density.scss +205 -117
  180. package/scss/config/_icons.scss +10 -9
  181. package/scss/config/_tokens.scss +69 -47
  182. package/scss/config/mixins/_accordion.scss +2 -7
  183. package/scss/config/mixins/_alert.scss +4 -4
  184. package/scss/config/mixins/_breadcrumbs.scss +2 -2
  185. package/scss/config/mixins/_btn.scss +1 -1
  186. package/scss/config/mixins/_card.scss +89 -14
  187. package/scss/config/mixins/_collapsible.scss +3 -0
  188. package/scss/config/mixins/_colors.scss +1 -1
  189. package/scss/config/mixins/_diagnostics.scss +48 -0
  190. package/scss/config/mixins/_display.scss +1 -0
  191. package/scss/config/mixins/_dropdown.scss +3 -5
  192. package/scss/config/mixins/_editor.scss +124 -0
  193. package/scss/config/mixins/_empty-state.scss +7 -0
  194. package/scss/config/mixins/_filter.scss +47 -0
  195. package/scss/config/mixins/_footer.scss +1 -2
  196. package/scss/config/mixins/_form.scss +207 -21
  197. package/scss/config/mixins/_index.scss +4 -1
  198. package/scss/config/mixins/_ln-table.scss +51 -93
  199. package/scss/config/mixins/_modal.scss +149 -66
  200. package/scss/config/mixins/_nav.scss +3 -4
  201. package/scss/config/mixins/_popover.scss +39 -0
  202. package/scss/config/mixins/_sidebar.scss +1 -1
  203. package/scss/config/mixins/_sort.scss +42 -0
  204. package/scss/config/mixins/_table.scss +232 -38
  205. package/scss/config/mixins/_toast.scss +179 -152
  206. package/scss/config/mixins/_tooltip.scss +17 -7
  207. package/scss/config/mixins/_typography.scss +4 -2
  208. package/scss/ln-ashlar-dev.scss +39 -0
  209. package/scss/ln-ashlar.scss +8 -2
  210. package/js/ln-data-table/README.md +0 -110
  211. package/js/ln-data-table/ln-data-table.js +0 -1
  212. package/js/ln-data-table/ln-data-table.scss +0 -10
  213. package/js/ln-data-table/src/ln-data-table.js +0 -1103
  214. package/js/ln-icons/README.md +0 -88
  215. package/js/ln-icons/ln-icons.js +0 -1
  216. package/js/ln-table/ln-table-sort.js +0 -1
  217. package/js/ln-table/src/ln-table-sort.js +0 -168
  218. package/scss/components/_data-table.scss +0 -83
  219. package/scss/config/mixins/_data-table.scss +0 -346
@@ -96,11 +96,16 @@ import { dispatch } from '../../ln-core';
96
96
  // Combine consumer signal with wrapper controller.
97
97
  const controller = new AbortController();
98
98
  const consumerSignal = options.signal;
99
+ let abortHandler = null;
99
100
  if (consumerSignal) {
100
- if (consumerSignal.aborted) controller.abort(consumerSignal.reason);
101
- else consumerSignal.addEventListener('abort', function () {
101
+ if (consumerSignal.aborted) {
102
102
  controller.abort(consumerSignal.reason);
103
- }, { once: true });
103
+ } else {
104
+ abortHandler = function () {
105
+ controller.abort(consumerSignal.reason);
106
+ };
107
+ consumerSignal.addEventListener('abort', abortHandler, { once: true });
108
+ }
104
109
  }
105
110
 
106
111
  const merged = Object.assign({}, options, { signal: controller.signal });
@@ -108,6 +113,10 @@ import { dispatch } from '../../ln-core';
108
113
  _inflight.set(key, controller);
109
114
 
110
115
  return _origFetch(resource, merged).finally(function () {
116
+ // Clean up listener to prevent memory leak
117
+ if (consumerSignal && abortHandler) {
118
+ consumerSignal.removeEventListener('abort', abortHandler);
119
+ }
111
120
  // Only clear if THIS controller is still the one in the map.
112
121
  if (_inflight.get(key) === controller) _inflight.delete(key);
113
122
  });
@@ -119,12 +128,11 @@ import { dispatch } from '../../ln-core';
119
128
  // ─── Path B: ln-http:request event listener ───────────────────
120
129
 
121
130
  function _onRequest(e) {
122
- const opts = e.detail || {};
123
- if (!opts.url) return;
131
+ if (!e.detail || !e.detail.url) return;
124
132
 
125
133
  const target = e.target;
126
- const method = (opts.method || (opts.body ? 'POST' : 'GET')).toUpperCase();
127
- const userKey = opts.key;
134
+ const method = (e.detail.method || (e.detail.body ? 'POST' : 'GET')).toUpperCase();
135
+ const userKey = e.detail.key;
128
136
 
129
137
  // Explicit-key dedup — abort previous on same key, any method.
130
138
  if (userKey && _keyed.has(userKey)) {
@@ -134,24 +142,32 @@ import { dispatch } from '../../ln-core';
134
142
 
135
143
  // Combine consumer signal (if any) with our own controller.
136
144
  const controller = new AbortController();
137
- const consumerSignal = opts.signal;
145
+ const consumerSignal = e.detail.signal;
146
+ let abortHandler = null;
138
147
  if (consumerSignal) {
139
- if (consumerSignal.aborted) controller.abort(consumerSignal.reason);
140
- else consumerSignal.addEventListener('abort', function () {
148
+ if (consumerSignal.aborted) {
141
149
  controller.abort(consumerSignal.reason);
142
- }, { once: true });
150
+ } else {
151
+ abortHandler = function () {
152
+ controller.abort(consumerSignal.reason);
153
+ };
154
+ consumerSignal.addEventListener('abort', abortHandler, { once: true });
155
+ }
143
156
  }
144
157
 
145
158
  if (userKey) _keyed.set(userKey, controller);
146
159
 
147
160
  const fetchOptions = { method: method, signal: controller.signal };
148
- if (opts.body !== undefined) fetchOptions.body = opts.body;
161
+ if (e.detail.body !== undefined) fetchOptions.body = e.detail.body;
149
162
 
150
163
  // fetch() here IS the wrapped fetch — Path A still applies
151
164
  // for GET/HEAD on top of Path B's explicit-key dedup. Aborts
152
165
  // from either side are idempotent.
153
- window.fetch(opts.url, fetchOptions)
166
+ window.fetch(e.detail.url, fetchOptions)
154
167
  .then(function (response) {
168
+ if (consumerSignal && abortHandler) {
169
+ consumerSignal.removeEventListener('abort', abortHandler);
170
+ }
155
171
  if (userKey && _keyed.get(userKey) === controller) _keyed.delete(userKey);
156
172
  dispatch(target, 'ln-http:response', {
157
173
  ok: response.ok,
@@ -160,6 +176,9 @@ import { dispatch } from '../../ln-core';
160
176
  });
161
177
  })
162
178
  .catch(function (err) {
179
+ if (consumerSignal && abortHandler) {
180
+ consumerSignal.removeEventListener('abort', abortHandler);
181
+ }
163
182
  if (userKey && _keyed.get(userKey) === controller) _keyed.delete(userKey);
164
183
  if (err && err.name === 'AbortError') return; // silent on abort
165
184
  dispatch(target, 'ln-http:error', {
@@ -0,0 +1,130 @@
1
+ # ln-icon
2
+
3
+ A zero-dependency, local-first **On-Demand SVG Sprite Generator** that dynamically monitors, fetches, and compiles SVG icons at runtime.
4
+
5
+ Instead of bundling thousands of heavy vector paths or requiring complex manual build steps, it intercepts standard DOM `<use>` tags, fetches vector definitions from a remote CDN, caches them in `localStorage`, and injects them into a single unified hidden SVG sprite sheet.
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **Declarative On-Demand Rendering:** Icons are declared directly in HTML. The component monitors the DOM via `MutationObserver` for `<use>` references with `#ln-` and `#lnc-` prefixes. It only fetches and compiles icons that are actively present on the page.
12
+ 2. **Dual-Prefix Routing:**
13
+ - **`#ln-{name}`**: Automatically routes to the [Tabler Icons](https://tabler.io/icons) library fetched from a public CDN. No configuration required.
14
+ - **`#lnc-{name}`**: Routes to a custom corporate CDN defined via global window settings.
15
+ 3. **Local Caching Layer:** Fetched SVG path structures are instantly cached in `localStorage` under `lni:{id}`. Subsequent visits render icons instantly with zero network roundtrips.
16
+
17
+ ---
18
+
19
+ ## 📦 Minimal Blueprint
20
+
21
+ ### Native Tabler Icon
22
+ ```html
23
+ <svg class="ln-icon" aria-hidden="true">
24
+ <use href="#ln-icon-home"></use>
25
+ </svg>
26
+ ```
27
+
28
+ ### Custom Asset Icon
29
+ Define your custom CDN endpoint before importing the library:
30
+ ```html
31
+ <script>
32
+ window.LN_ICON_CUSTOM_CDN = "https://cdn.mycompany.com/assets/icons";
33
+ </script>
34
+ <script src="dist/ln-ashlar.iife.js" defer></script>
35
+
36
+ <!-- Renders icon from your custom CDN -->
37
+ <svg class="ln-icon" aria-hidden="true">
38
+ <use href="#ln-icon-custom-corporate-logo"></use>
39
+ </svg>
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 🛠️ Declarative API Contract
45
+
46
+ ### CSS Utility Classes
47
+
48
+ Configure icon sizes and alignments using standard CSS classes:
49
+
50
+ | Class | Size | Description |
51
+ | :--- | :--- | :--- |
52
+ | `ln-icon` | `1.25rem` | Base styles, sets `fill: none`, `stroke: currentColor`, inherits color. |
53
+ | `ln-icon--sm` | `1rem` | Small icon, designed for inline text badges or buttons. |
54
+ | `ln-icon--lg` | `1.5rem` | Large icon, designed for toolbar buttons. |
55
+ | `ln-icon--xl` | `4rem` | Extra-large icon, designed for empty state illustrations. |
56
+ | `ln-chevron` | — | Automatically rotates `90deg` when an ancestor `.is-active` class is toggled. |
57
+
58
+ ### Global Configuration (`window`)
59
+
60
+ Configure these properties before script initialization:
61
+
62
+ | Variable | Default | Description |
63
+ | :--- | :--- | :--- |
64
+ | `LN_ICON_CDN` | `https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline` | Base CDN URL for Tabler Icons. |
65
+ | `LN_ICON_CUSTOM_CDN` | `null` | Base CDN URL for custom `#lnc-` prefixed SVG resources. |
66
+
67
+ ---
68
+
69
+ ## ⚡ Dynamic Interaction Flow
70
+
71
+ ### Automated Mutation Observability
72
+ The loader observes the DOM continuously. When new content is injected (e.g. by `ln-ajax` or `ln-store`), any new icon `<use>` tag is intercepted, resolved, and rendered.
73
+
74
+ ### Dynamic Attribute Swaps
75
+ Modifying the `href` attribute of a `<use>` element dynamically via JavaScript triggers automatic resolution of the new target icon:
76
+ ```javascript
77
+ const useElement = document.querySelector('use');
78
+ // Dynamically fetches and switches the icon to a checkmark
79
+ useElement.setAttribute('href', '#ln-icon-check');
80
+ ```
81
+
82
+ ---
83
+
84
+ ## ⚠️ Common Pitfalls
85
+
86
+ - **Forgetting `ln-icon` Class:** Standard SVGs default to `100%` width/height. Failing to include the `ln-icon` class will cause the icon to blow up to full viewport size.
87
+ - **Incorrect Prefix Configuration:** Forgetting to define `window.LN_ICON_CUSTOM_CDN` when using `#lnc-` will cause the loader to fail silently with undefined endpoint errors.
88
+ - **Omitting `aria-hidden="true"`:** Screen readers attempt to read SVG nodes. Always decorate decorative icons with `aria-hidden="true"`, or include an `aria-label` on their parent button.
89
+
90
+ ---
91
+
92
+ ## 🔧 Internals
93
+
94
+ Source: `js/ln-icon/ln-icon.js`. Routing is purely prefix-based — no config lists, no runtime mapping. Symbol IDs in the generated sprite mirror the full `href` value minus `#` (e.g. `#ln-icon-custom-file-pdf` → `<symbol id="ln-icon-custom-file-pdf">`).
95
+
96
+ ### Scan
97
+
98
+ `_scan(root)` runs on init (`document`) and on every MutationObserver batch (each added node), matching `use[href^="#ln-icon-"], use[href^="#ln-icon-custom-"]` — the root node itself is also checked via `matches()` to cover single-node mutations. Each match calls `_load(href)`.
99
+
100
+ ### Dedup + caching
101
+
102
+ Two in-memory `Set`s (`loaded`, `pending`), keyed by full `href`, prevent duplicate fetches within a page load — `_load` bails immediately if either contains the href. Beyond that, fetched SVG content persists in `localStorage` under `lni:{id}` (plus a `lni:v` version key); `_load` checks the cache before `fetch()`, and a cache hit injects the symbol synchronously with zero network round-trip. Bumping the internal `CACHE_VERSION` clears all `lni:*` keys on next load, forcing a re-fetch. All `localStorage` access is wrapped in `try/catch` for private-browsing/storage-full environments.
103
+
104
+ ### Fetch + symbol injection
105
+
106
+ On a cache miss: resolve the CDN URL (`lnc-` → `LN_ICON_CUSTOM_CDN`, `ln-` → `LN_ICON_CDN`; `lnc-` bails silently if the custom CDN isn't configured), `fetch()`, then parse the raw SVG string — extract `viewBox` (fallback `'0 0 24 24'`), the inner content between the `<svg>` tags, and root presentation attributes (`fill`, `stroke`, `stroke-width`, `stroke-linecap`, `stroke-linejoin`). A `<symbol>` is built from these and appended to the sprite's `<defs>`; the raw SVG is also written back to `localStorage`. Errors delete the `pending` entry silently — the icon stays blank, no retry.
107
+
108
+ ### Sprite element
109
+
110
+ Created lazily on first icon load, inserted as `document.body`'s first child: `<svg id="ln-icon-sprite" hidden aria-hidden="true"><defs>...</defs></svg>`.
111
+
112
+ ### MutationObserver
113
+
114
+ Observes `document.body` with `{ childList: true, subtree: true }`, firing `_scan(node)` per added element — covers modals, dynamic lists, and any JS-rendered content (e.g. `ln-ajax`/`ln-store` swaps).
115
+
116
+ ### Color inheritance
117
+
118
+ Tabler SVGs use `stroke="currentColor"`, so rendered icons inherit the nearest ancestor's CSS `color`. The custom multi-color icons (`ln-icon-custom-file-pdf`, `ln-icon-custom-file-doc`, `ln-icon-custom-file-epub`) embed explicit stroke colors in their source and intentionally do not follow `currentColor`.
119
+
120
+ ### Checkbox exception
121
+
122
+ `<input type="checkbox">` is a replaced element and cannot contain children, so its checkmark cannot use the sprite — it's a `background-image` data URI in `_form.scss`, the only remaining data URI in the codebase.
123
+
124
+ ### Cross-component icon injection
125
+
126
+ Several components inject `<use>` elements dynamically rather than authoring them in HTML: `ln-toast` (`#ln-icon-x` dismiss button), `ln-upload` (`#lnc-file[-pdf|-doc|-epub]` per item, `#ln-icon-x` remove button), `ln-confirm` (swaps an existing `<use href>` to `#ln-icon-check` during confirm, restores on reset). All route through the same scan/fetch pipeline — no special-casing needed since detection is attribute-based, not author-time. `ln-sort` is the opposite case: its three trigger icons are statically authored in HTML (see `js/ln-sort/README.md`), never injected.
127
+
128
+ ### Offline behavior
129
+
130
+ Uncached icons fail silently (`.catch()`) if the page loads offline — the icon stays blank. For offline-first deployments, self-host the SVG set and point `window.LN_ICON_CDN` at a local server before the library initializes; `vite.config.js` copies `js/ln-icon/icons/*.svg` → `dist/icons/` at build for this purpose.
@@ -0,0 +1 @@
1
+ (function(){"use strict";(function(){const h="ln-icon-sprite",a="#ln-icon-",s="#ln-icon-custom-",d=new Set,l=new Set;let c=null;const y=(window.LN_ICON_CDN||"https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline").replace(/\/$/,""),g=(window.LN_ICON_CUSTOM_CDN||"").replace(/\/$/,""),u="lni:",m="lni:v",w="1";function v(){try{if(localStorage.getItem(m)!==w){for(let t=localStorage.length-1;t>=0;t--){const e=localStorage.key(t);e&&e.indexOf(u)===0&&localStorage.removeItem(e)}localStorage.setItem(m,w)}}catch{}}v();function N(){return c||(c=document.getElementById(h),c||(c=document.createElementNS("http://www.w3.org/2000/svg","svg"),c.id=h,c.setAttribute("hidden",""),c.setAttribute("aria-hidden","true"),c.appendChild(document.createElementNS("http://www.w3.org/2000/svg","defs")),document.body.insertBefore(c,document.body.firstChild))),c}function A(t){return t.indexOf(s)===0?g+"/"+t.slice(s.length)+".svg":y+"/"+t.slice(a.length)+".svg"}function S(t,e){const o=e.match(/viewBox="([^"]+)"/),n=o?o[1]:"0 0 24 24",i=e.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i),I=i?i[1].trim():"",p=e.match(/<svg([^>]*)>/i),O=p?p[1]:"",r=document.createElementNS("http://www.w3.org/2000/svg","symbol");r.id=t,r.setAttribute("viewBox",n),["fill","stroke","stroke-width","stroke-linecap","stroke-linejoin"].forEach(function(_){const b=O.match(new RegExp(_+'="([^"]*)"'));b&&r.setAttribute(_,b[1])}),r.innerHTML=I,N().querySelector("defs").appendChild(r)}function f(t){if(d.has(t)||l.has(t))return;if(t.indexOf(s)===0&&!g){console.warn("[ln-icon] Custom icon requested but no CUSTOM_CDN configured:",t);return}const e=t.slice(1);try{const n=localStorage.getItem(u+e);if(n){S(e,n),d.add(t);return}}catch{}l.add(t);const o=A(t);fetch(o).then(function(n){if(!n.ok)throw new Error(n.status);return n.text()}).then(function(n){S(e,n),d.add(t),l.delete(t);try{localStorage.setItem(u+e,n)}catch{}}).catch(function(n){console.error("[ln-icon] Fetch failed for:",e,n),l.delete(t)})}function C(t){const e='use[href^="'+a+'"], use[href^="'+s+'"]',o=t.querySelectorAll?t.querySelectorAll(e):[];if(t.matches&&t.matches(e)){const n=t.getAttribute("href");n&&f(n)}Array.prototype.forEach.call(o,function(n){const i=n.getAttribute("href");i&&f(i)})}function E(){C(document),new MutationObserver(function(t){t.forEach(function(e){if(e.type==="childList")e.addedNodes.forEach(function(o){o.nodeType===1&&C(o)});else if(e.type==="attributes"&&e.attributeName==="href"){const o=e.target.getAttribute("href");o&&(o.indexOf(a)===0||o.indexOf(s)===0)&&f(o)}})}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["href"]})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",E):E()})()})();
@@ -3,31 +3,31 @@
3
3
  // page loads resolve instantly without network requests.
4
4
  //
5
5
  // Two prefixes:
6
- // #ln-{name} → Tabler CDN (https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline)
7
- // #lnc-{name} → Custom CDN (window.LN_ICONS_CUSTOM_CDN)
6
+ // #ln-icon-{name} → Tabler CDN (https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline)
7
+ // #ln-icon-custom-{name} → Custom CDN (window.LN_ICON_CUSTOM_CDN)
8
8
  //
9
9
  // Config — set on window BEFORE this script loads:
10
- // window.LN_ICONS_CDN = 'https://...' (override Tabler CDN base)
11
- // window.LN_ICONS_CUSTOM_CDN = 'https://...' (required for lnc- icons)
10
+ // window.LN_ICON_CDN = 'https://...' (override Tabler CDN base)
11
+ // window.LN_ICON_CUSTOM_CDN = 'https://...' (required for custom icons)
12
12
  //
13
13
  // Cache: bump CACHE_VERSION to invalidate all cached icons.
14
14
  //
15
15
  // Usage:
16
- // <svg class="ln-icon" aria-hidden="true"><use href="#ln-home"></use></svg>
17
- // <svg class="ln-icon" aria-hidden="true"><use href="#lnc-file-pdf"></use></svg>
16
+ // <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-home"></use></svg>
17
+ // <svg class="ln-icon" aria-hidden="true"><use href="#ln-icon-custom-file-pdf"></use></svg>
18
18
 
19
19
  (function () {
20
- const SPRITE_ID = 'ln-icons-sprite';
21
- const PREFIX_LN = '#ln-';
22
- const PREFIX_LNC = '#lnc-';
20
+ const SPRITE_ID = 'ln-icon-sprite';
21
+ const PREFIX_LN = '#ln-icon-';
22
+ const PREFIX_LNC = '#ln-icon-custom-';
23
23
 
24
24
  const loaded = new Set();
25
25
  const pending = new Set();
26
26
  let spriteEl = null;
27
27
 
28
- // Pinned version for cache stability — bump explicitly when upgrading. Override: window.LN_ICONS_CDN
29
- const BASE_CDN = (window.LN_ICONS_CDN || 'https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline').replace(/\/$/, '');
30
- const CUSTOM_CDN = (window.LN_ICONS_CUSTOM_CDN || '').replace(/\/$/, '');
28
+ // Pinned version for cache stability — bump explicitly when upgrading. Override: window.LN_ICON_CDN
29
+ const BASE_CDN = (window.LN_ICON_CDN || 'https://cdn.jsdelivr.net/npm/@tabler/icons@3.31.0/icons/outline').replace(/\/$/, '');
30
+ const CUSTOM_CDN = (window.LN_ICON_CUSTOM_CDN || '').replace(/\/$/, '');
31
31
 
32
32
  const CACHE_PREFIX = 'lni:';
33
33
  const CACHE_VER_KEY = 'lni:v';
@@ -91,7 +91,10 @@
91
91
 
92
92
  function _load(href) {
93
93
  if (loaded.has(href) || pending.has(href)) return;
94
- if (href.indexOf(PREFIX_LNC) === 0 && !CUSTOM_CDN) return;
94
+ if (href.indexOf(PREFIX_LNC) === 0 && !CUSTOM_CDN) {
95
+ console.warn('[ln-icon] Custom icon requested but no CUSTOM_CDN configured:', href);
96
+ return;
97
+ }
95
98
 
96
99
  const id = href.slice(1);
97
100
 
@@ -106,8 +109,9 @@
106
109
  } catch (e) { /* proceed to fetch */ }
107
110
 
108
111
  pending.add(href);
112
+ const url = _url(href);
109
113
 
110
- fetch(_url(href))
114
+ fetch(url)
111
115
  .then(function (r) {
112
116
  if (!r.ok) throw new Error(r.status);
113
117
  return r.text();
@@ -118,7 +122,8 @@
118
122
  pending.delete(href);
119
123
  try { localStorage.setItem(CACHE_PREFIX + id, raw); } catch (e) { /* storage full */ }
120
124
  })
121
- .catch(function () {
125
+ .catch(function (err) {
126
+ console.error('[ln-icon] Fetch failed for:', id, err);
122
127
  pending.delete(href);
123
128
  });
124
129
  }
@@ -188,9 +188,8 @@ window.lnLink.init(root);
188
188
  window.lnLink.destroy(container);
189
189
  ```
190
190
 
191
- `init` traverses descendants; `destroy` does not. See
192
- [`docs/js/link.md`](../../docs/js/link.md) for the full mechanics
193
- behind this asymmetry.
191
+ `init` traverses descendants; `destroy` does not — see
192
+ [Cleanup asymmetry](#cleanup-asymmetry) below.
194
193
 
195
194
  ## What it does NOT do
196
195
 
@@ -255,18 +254,18 @@ click event for `ln-confirm` to intercept.
255
254
  For confirmation flows on row navigation, use `ln-link:navigate`
256
255
  with `e.preventDefault()` instead — see §Events.
257
256
 
258
- ### With ln-data-table
257
+ ### With ln-table
259
258
 
260
- `ln-data-table` inserts `<tr>` elements into a `<tbody>` from a
259
+ `ln-table` inserts `<tr>` elements into a `<tbody>` from a
261
260
  template. `ln-link`'s MutationObserver wires each new row as it
262
- arrives — no manual re-init is needed after `ln-data-table`
261
+ arrives — no manual re-init is needed after `ln-table`
263
262
  populates the body.
264
263
 
265
264
  ```html
266
265
  <table data-ln-link data-ln-table>
267
266
  <thead>...</thead>
268
267
  <tbody>
269
- <!-- ln-data-table inserts <tr> elements here dynamically;
268
+ <!-- ln-table inserts <tr> elements here dynamically;
270
269
  ln-link wires them automatically. -->
271
270
  </tbody>
272
271
  </table>
@@ -297,7 +296,46 @@ If you only need this component and want to avoid loading the full bundle, you c
297
296
 
298
297
  ## See also
299
298
 
300
- - [`../../docs/js/link.md`](../../docs/js/link.md) — architecture mirror (internal state, observer topology, click-flow, registration pattern)
301
- - [`../../docs/js/ajax.md`](../../docs/js/ajax.md) — ln-ajax interop details
299
+ - [`../ln-ajax/README.md`](../ln-ajax/README.md) — ln-ajax interop details
302
300
  - [`../../scss/config/mixins/_link.scss`](../../scss/config/mixins/_link.scss) — `link-row` and `link-status` mixins
303
301
  - [`../../demo/admin/src/pages/link.html`](../../demo/admin/src/pages/link.html) — interactive demo
302
+
303
+ ---
304
+
305
+ ## 🔧 Internals
306
+
307
+ Source: `js/ln-link/ln-link.js`. Imports `dispatchCancelable` and `guardBody` from `../ln-core`.
308
+
309
+ ### State surface
310
+
311
+ - `_statusEl` — module-level, a single shared `<div class="ln-link-status">` appended to `document.body` once, at first init. Never destroyed, even if all containers are.
312
+ - `container.lnLinkInit` — per-container flag, guards double-init; deleted on destroy.
313
+ - `row.lnLinkRow`, `row._lnLinkClick`, `row._lnLinkEnter` — per-row flag plus stored listener closures, kept so `removeEventListener` gets the exact same reference at destroy time. `_handleMouseLeave` has no closure and is shared across rows.
314
+ - No Proxy, no reactive store — this is a listener-attachment component.
315
+
316
+ ### Local `findElements`
317
+
318
+ Like `ln-ajax`, `ln-link` keeps a local `findElements` instead of the `ln-core` helper: it must check the root itself for `data-ln-link` AND iterate descendants, both calling `_initContainer` — a dual-path shape the shared helper's signature doesn't fit.
319
+
320
+ ### MutationObserver
321
+
322
+ Registered inside `guardBody`, watching `document.body` with `childList`/`subtree`/`attributes` (filtered to `data-ln-link`).
323
+
324
+ - **childList** — `findElements(node)` on every added element. Special-cased for `<tr>`: walks up via `closest('[data-ln-link]')` and calls `_initRow` directly, since a bare `<tr>` isn't itself a `[data-ln-link]` container and `findElements` would miss it — this is what makes `ln-table`'s appended rows wire automatically.
325
+ - **attributes** — re-runs `findElements(mutation.target)` when `data-ln-link` is set/changed programmatically.
326
+
327
+ ### Click handler order
328
+
329
+ `_handleClick`: interactive-child check (`closest('a, button, input, select, textarea')`) → resolve `row.querySelector('a')` → empty-href guard → modifier/middle-click branch (`window.open`, no event dispatched) → dispatch `ln-link:navigate` → cancel check → `link.click()`.
330
+
331
+ ### Status bar
332
+
333
+ Created once in `_createStatusBar`, before the observer wires up. `mouseenter` sets `textContent` and adds `.ln-link-status--visible`; `mouseleave` removes the class only (text is left stale, overwritten on the next show). Empty-href rows never trigger it.
334
+
335
+ ### Cleanup asymmetry
336
+
337
+ `init(root)` traverses descendants; `destroy(container)` operates on the exact element passed only — call it once per container. Always `destroy(container)` **before** replacing `innerHTML`: swapping first detaches the wired `<tr>` elements, so `destroy`'s row loop would walk the *new*, unwired rows and leak the old listeners.
338
+
339
+ ### Registration pattern
340
+
341
+ Assigns `window.lnLink = { init, destroy }` directly rather than the shared `registerComponent` helper used by newer components — equivalent behavior (double-load guard, observer, DOMContentLoaded wiring), implemented manually.
@@ -1 +1 @@
1
- (function(){"use strict";function v(r,t,l){const u=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:l||{}});return r.dispatchEvent(u),u}function d(r,t){if(!document.body){document.addEventListener("DOMContentLoaded",function(){d(r,t)}),console.warn("["+t+'] Script loaded before <body> — add "defer" to your <script> tag');return}r()}const a={};function _(r,t){a[r]=t}function E(r){return a[r]||{ingress:t=>t,egress:t=>t}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=_,window.lnCore.getDataMapper=E),(function(){const r="data-ln-link",t="lnLink";if(window[t]!==void 0)return;let l=null;function u(){l=document.createElement("div"),l.className="ln-link-status",document.body.appendChild(l)}function m(e){l&&(l.textContent=e,l.classList.add("ln-link-status--visible"))}function p(){l&&l.classList.remove("ln-link-status--visible")}function g(e,n){if(n.target.closest("a, button, input, select, textarea"))return;const i=e.querySelector("a");if(!i)return;const o=i.getAttribute("href");if(!o)return;if(n.ctrlKey||n.metaKey||n.button===1){window.open(o,"_blank");return}v(e,"ln-link:navigate",{target:e,href:o,link:i}).defaultPrevented||i.click()}function C(e){const n=e.querySelector("a");if(!n)return;const i=n.getAttribute("href");i&&m(i)}function f(){p()}function s(e){e[t+"Row"]||(e[t+"Row"]=!0,e.querySelector("a")&&(e._lnLinkClick=function(n){g(e,n)},e._lnLinkEnter=function(){C(e)},e.addEventListener("click",e._lnLinkClick),e.addEventListener("mouseenter",e._lnLinkEnter),e.addEventListener("mouseleave",f)))}function b(e){e[t+"Row"]&&(e._lnLinkClick&&e.removeEventListener("click",e._lnLinkClick),e._lnLinkEnter&&e.removeEventListener("mouseenter",e._lnLinkEnter),e.removeEventListener("mouseleave",f),delete e._lnLinkClick,delete e._lnLinkEnter,delete e[t+"Row"])}function h(e){if(!e[t+"Init"])return;const n=e.tagName;if(n==="TABLE"||n==="TBODY"){const i=n==="TABLE"&&e.querySelector("tbody")||e;for(const o of i.querySelectorAll("tr"))b(o)}else b(e);delete e[t+"Init"]}function L(e){if(e[t+"Init"])return;e[t+"Init"]=!0;const n=e.tagName;if(n==="TABLE"||n==="TBODY"){const i=n==="TABLE"&&e.querySelector("tbody")||e;for(const o of i.querySelectorAll("tr"))s(o)}else s(e)}function c(e){e.hasAttribute&&e.hasAttribute(r)&&L(e);const n=e.querySelectorAll?e.querySelectorAll("["+r+"]"):[];for(const i of n)L(i)}function S(){d(function(){new MutationObserver(function(n){for(const i of n)if(i.type==="childList")for(const o of i.addedNodes)o.nodeType===1&&(c(o),o.tagName==="TR"&&o.closest("["+r+"]")&&s(o));else i.type==="attributes"&&c(i.target)}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[r]})},"ln-link")}function k(e){c(e)}window[t]={init:k,destroy:h};function y(){u(),S(),k(document.body)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",y):y()})()})();
1
+ (function(){"use strict";if(typeof window<"u"){const t=console.warn;console.warn=function(...e){typeof e[0]=="string"&&(e[0].startsWith("[ln-")||e[0].startsWith("[lnCore"))&&!(document.documentElement.hasAttribute("data-ln-debug")||document.body&&document.body.hasAttribute("data-ln-debug"))||t.apply(console,e)}}const m={};function D(t,e){m[t]||(m[t]=document.querySelector('[data-ln-template="'+t+'"]'));const l=m[t];return l?l.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function O(t,e,l){const a=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:l||{}});return t.dispatchEvent(a),a}function L(t,e){if(!t||!e)return t;const l=t.querySelectorAll("[data-ln-field]");for(let i=0;i<l.length;i++){const r=l[i],o=r.getAttribute("data-ln-field");e[o]!=null&&(r.textContent=e[o])}const a=t.querySelectorAll("[data-ln-attr]");for(let i=0;i<a.length;i++){const r=a[i],o=r.getAttribute("data-ln-attr").split(",");for(let s=0;s<o.length;s++){const h=o[s].trim().split(":");if(h.length!==2)continue;const b=h[0].trim(),g=h[1].trim();e[g]!=null&&r.setAttribute(b,e[g])}}const d=t.querySelectorAll("[data-ln-show]");for(let i=0;i<d.length;i++){const r=d[i],o=r.getAttribute("data-ln-show");o in e&&r.classList.toggle("hidden",!e[o])}const c=t.querySelectorAll("[data-ln-class]");for(let i=0;i<c.length;i++){const r=c[i],o=r.getAttribute("data-ln-class").split(",");for(let s=0;s<o.length;s++){const h=o[s].trim().split(":");if(h.length!==2)continue;const b=h[0].trim(),g=h[1].trim();g in e&&r.classList.toggle(b,!!e[g])}}return t}function B(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const l=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let a=0;a<l.length;a++)l[a].dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));return t}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore._fillBound||(window.lnCore._fillBound=!0,document.addEventListener("ln-fill",function(t){if(!(!t.target.matches||!t.target.matches("[data-ln-fillable]")))if(t.detail)L(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let l=0;l<e.length;l++)e[l].textContent=""}})));function k(t,e){if(!t||!e)return t;const l=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;l.nextNode();){const c=l.currentNode;c.textContent.indexOf("{{")!==-1&&(c.textContent=c.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(i,r){return e[r]!==void 0?e[r]:""}))}const a=function(c,i){return e[i]!==void 0?e[i]:""},d=Array.from(t.querySelectorAll("*"));t.nodeType===1&&d.push(t);for(let c=0;c<d.length;c++){const i=d[c],r=i.attributes;for(let o=0;o<r.length;o++){const s=r[o];s.value.indexOf("{{")!==-1&&i.setAttribute(s.name,s.value.replace(/\{\{\s*(\w+)\s*\}\}/g,a))}}return t}function M(t,e,l,a,d,c){const i={};for(let o=0;o<t.children.length;o++){const s=t.children[o],h=s.getAttribute("data-ln-key");h&&(i[h]=s)}const r=document.createDocumentFragment();for(let o=0;o<e.length;o++){const s=e[o],h=String(a(s));let b=i[h];if(b)d(b,s,o);else{const g=D(l,c);if(!g||(k(g,s),b=g.firstElementChild,!b))continue;b.setAttribute("data-ln-key",h),d(b,s,o)}r.appendChild(b)}t.textContent="",t.appendChild(r)}function A(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){A(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}const v={};function R(t,e){v[t]=e}function N(t){return v[t]||{ingress:e=>e,egress:e=>e}}const _={};function E(t,e){if(!t||typeof e!="object")return;const l=t.toLowerCase().split("-")[0];_[l]=e}function F(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return _[e]||null}E("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=R,window.lnCore.getDataMapper=N,window.lnCore.registerLocaleFallback=E,window.lnCore.getLocaleFallback=F,window.lnCore.fillTemplate=k,window.lnCore.fill=L,window.lnCore.lnFill=B,window.lnCore.renderList=M);function S(t){return(t||"").replace(/^#/,"")}function y(t){const e=t===void 0?location.hash:t,l={},a=S(e);if(!a)return l;const d=a.split("&");for(let c=0;c<d.length;c++){const i=d[c];if(!i)continue;const r=i.indexOf(":"),o=r>-1?i.slice(0,r):i,s=r>-1?i.slice(r+1):"";if(o)try{l[o]=decodeURIComponent(s)}catch{l[o]=s}}return l}function I(t){if(!t)return null;const e=y();return t in e?e[t]:null}function W(t,e){if(!t)return;const l=y();e==null?delete l[t]:l[t]=String(e);const d=Object.keys(l).map(function(c){const i=l[c];return i===""?c:c+":"+encodeURIComponent(i)}).join("&");S(location.hash)!==d&&(location.hash=d)}function j(t){return t.button===1||t.ctrlKey||t.metaKey||t.shiftKey?!1:(t.preventDefault(),!0)}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.hashParse=y,window.lnCore.hashGet=I,window.lnCore.hashSet=W,window.lnCore.hashLinkClick=j),(function(){const t="data-ln-link",e="lnLink";if(window[e]!==void 0)return;let l=null;function a(){l=document.createElement("div"),l.className="ln-link-status",document.body.appendChild(l)}function d(n){l&&(l.textContent=n,l.classList.add("ln-link-status--visible"))}function c(){l&&l.classList.remove("ln-link-status--visible")}function i(n,f){if(f.target.closest("a, button, input, select, textarea"))return;const u=n.querySelector("a");if(!u)return;const p=u.getAttribute("href");if(!p)return;if(f.ctrlKey||f.metaKey||f.button===1){window.open(p,"_blank");return}O(n,"ln-link:navigate",{target:n,href:p,link:u}).defaultPrevented||u.click()}function r(n){const f=n.querySelector("a");if(!f)return;const u=f.getAttribute("href");u&&d(u)}function o(){c()}function s(n){n[e+"Row"]||!n.querySelector("a")||(n[e+"Row"]=!0,n._lnLinkClick=function(u){i(n,u)},n._lnLinkEnter=function(){r(n)},n.addEventListener("click",n._lnLinkClick),n.addEventListener("mouseenter",n._lnLinkEnter),n.addEventListener("mouseleave",o))}function h(n){n[e+"Row"]&&(n._lnLinkClick&&n.removeEventListener("click",n._lnLinkClick),n._lnLinkEnter&&n.removeEventListener("mouseenter",n._lnLinkEnter),n.removeEventListener("mouseleave",o),delete n._lnLinkClick,delete n._lnLinkEnter,delete n[e+"Row"])}function b(n){if(!n[e+"Init"])return;const f=n.tagName;if(f==="TABLE"||f==="TBODY"){const u=f==="TABLE"&&n.querySelector("tbody")||n;for(const p of u.querySelectorAll("tr"))h(p)}else h(n);delete n[e+"Init"]}function g(n){if(n[e+"Init"])return;n[e+"Init"]=!0;const f=n.tagName;if(f==="TABLE"||f==="TBODY"){const u=f==="TABLE"&&n.querySelector("tbody")||n;for(const p of u.querySelectorAll("tr"))s(p)}else s(n)}function w(n){n.hasAttribute&&n.hasAttribute(t)&&g(n);const f=n.querySelectorAll?n.querySelectorAll("["+t+"]"):[];for(const u of f)g(u)}function K(){A(function(){new MutationObserver(function(f){for(const u of f)if(u.type==="childList"){for(const p of u.addedNodes)if(p.nodeType===1){w(p);const C=p.closest("["+t+"]");if(C)if(p.tagName==="TR")s(p);else{const x=C.tagName;if(x==="TABLE"||x==="TBODY"){const U=p.querySelectorAll?p.querySelectorAll("tr"):[];for(const Y of U)s(Y)}}}}else u.type==="attributes"&&(u.target.hasAttribute&&u.target.hasAttribute(t)?w(u.target):b(u.target))}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[t]})},"ln-link")}function T(n){w(n)}window[e]={init:T,destroy:b};function q(){a(),K(),T(document.body)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",q):q()})()})();
@@ -66,9 +66,11 @@ import { dispatchCancelable, guardBody } from '../../ln-core';
66
66
 
67
67
  function _initRow(row) {
68
68
  if (row[DOM_ATTRIBUTE + 'Row']) return;
69
- row[DOM_ATTRIBUTE + 'Row'] = true;
70
69
 
71
- if (!row.querySelector('a')) return;
70
+ const link = row.querySelector('a');
71
+ if (!link) return;
72
+
73
+ row[DOM_ATTRIBUTE + 'Row'] = true;
72
74
 
73
75
  row._lnLinkClick = function (e) {
74
76
  _handleClick(row, e);
@@ -151,14 +153,28 @@ import { dispatchCancelable, guardBody } from '../../ln-core';
151
153
  if (node.nodeType === 1) {
152
154
  findElements(node);
153
155
 
154
- if (node.tagName === 'TR') {
155
- const parent = node.closest('[' + DOM_SELECTOR + ']');
156
- if (parent) _initRow(node);
156
+ const parent = node.closest('[' + DOM_SELECTOR + ']');
157
+ if (parent) {
158
+ if (node.tagName === 'TR') {
159
+ _initRow(node);
160
+ } else {
161
+ const tag = parent.tagName;
162
+ if (tag === 'TABLE' || tag === 'TBODY') {
163
+ const rows = node.querySelectorAll ? node.querySelectorAll('tr') : [];
164
+ for (const r of rows) {
165
+ _initRow(r);
166
+ }
167
+ }
168
+ }
157
169
  }
158
170
  }
159
171
  }
160
172
  } else if (mutation.type === 'attributes') {
161
- findElements(mutation.target);
173
+ if (mutation.target.hasAttribute && mutation.target.hasAttribute(DOM_SELECTOR)) {
174
+ findElements(mutation.target);
175
+ } else {
176
+ _destroyContainer(mutation.target);
177
+ }
162
178
  }
163
179
  }
164
180
  });
@@ -0,0 +1,129 @@
1
+ # ln-list
2
+
3
+ A unified, structure-agnostic, and local-first **Data Presenter Component** designed to render datasets in list, card, section, or grid layouts. It connects to `ln-data-store` via CustomEvents using the **Coordinator Pattern**, supporting client-side filtering, sorting, searching, and high-performance **Virtual Scrolling** for large datasets.
4
+
5
+ ---
6
+
7
+ ## 📦 Declarative Setup in HTML
8
+
9
+ ### 1. Simple SSR List
10
+ For a server-rendered list where the backend outputs `<li>` elements directly:
11
+
12
+ ```html
13
+ <form role="search" onsubmit="return false;">
14
+ <input type="search" data-ln-search="documents-list" placeholder="Search...">
15
+ </form>
16
+
17
+ <ul id="documents-list" data-ln-list="documents">
18
+ <li data-ln-item-id="1">Document A</li>
19
+ <li data-ln-item-id="2">Document B</li>
20
+ </ul>
21
+ ```
22
+
23
+ ### 2. Data-Driven Grid List (with Virtual Scroll)
24
+ Opted-in by adding the `data-ln-list-source` attribute. It clones and renders the specified `<template>`:
25
+
26
+ ```html
27
+ <form role="search" onsubmit="return false;">
28
+ <input type="search" data-ln-search="documents-grid" placeholder="Search...">
29
+ </form>
30
+
31
+ <section id="documents-grid"
32
+ data-ln-list="documents"
33
+ data-ln-list-source="documents"
34
+ data-ln-list-selectable>
35
+
36
+ <!-- Items container -->
37
+ <ul class="grid-layout" data-ln-list-body></ul>
38
+
39
+ <!-- Row Template (Must contain an element with data-ln-item attribute) -->
40
+ <template data-ln-template="documents-row">
41
+ <li data-ln-item class="card-item">
42
+ <header>
43
+ <input type="checkbox" data-ln-item-select>
44
+ <h3 data-ln-field="title"></h3>
45
+ </header>
46
+ <p>{{ description }}</p>
47
+ <button type="button" data-ln-item-action="delete">Delete</button>
48
+ </li>
49
+ </template>
50
+
51
+ <!-- Empty States -->
52
+ <template data-ln-template="documents-empty">
53
+ <div class="empty-state">No documents found.</div>
54
+ </template>
55
+
56
+ <template data-ln-template="documents-empty-filtered">
57
+ <div class="empty-state">No matching documents found.</div>
58
+ </template>
59
+ </section>
60
+ ```
61
+
62
+ ---
63
+
64
+ ## 🛠️ Attributes Reference
65
+
66
+ | Attribute | Elements | Description |
67
+ | :--- | :--- | :--- |
68
+ | `data-ln-list` | Root wrapper | Component identifier. Target must carry a unique `id`. |
69
+ | `data-ln-list-source` | Root wrapper | Opt-in indicator for Data-Driven Mode. |
70
+ | `data-ln-list-selectable` | Root wrapper | Enables checkbox-based item selections. |
71
+ | `data-ln-list-window="N"` | Root wrapper | Opt-in server-side sliding-window virtualization. `N` sets the resident-item cap (default 1000). Requires Data-Driven Mode. Observable: add/remove toggles windowed mode ON/OFF live; changing `N` while windowed reconfigures the live cache. `data-ln-list-window-page`, `data-ln-list-window-threshold`, and `data-ln-list-count` are likewise observable. |
72
+
73
+ ---
74
+
75
+ ## ⚡ DOM Events
76
+
77
+ ### Listened Events
78
+
79
+ * `ln-list:set-data` `{ data, total, filtered }`: Hydrates/renders the items. Windowed mode expects `{ offset, queryGen }` echoed back — routed into the internal window cache.
80
+ * `ln-list:set-loading` `{ loading: true|false }`: Toggles the loading dimming overlay class (`.ln-list--loading`).
81
+ * `ln-list:page-failed` `{ offset }`: Windowed mode — the coordinator reports the page fetch at `offset` failed. Releases it from the cache's in-flight set; no auto-retry, the next `ensure()` requests it again.
82
+ * `ln-list:request-revalidate`: Windowed mode — the coordinator asks the cache to revalidate the currently visible page after a local mutation. Stale rows stay visible, no jump to page 0.
83
+ * `ln-search:change` `{ term }`: Captures search query from `data-ln-search` inputs.
84
+ * `ln-sort:change` `{ field, column, direction }`: Sets `currentSort` and re-sorts (data-driven only). Ignored when `field` is `null` (index-only events have nothing to key a record by).
85
+
86
+ ### Emitted Events
87
+
88
+ * `ln-list:request-data` `{ list, search, sort, filters }`: Requests data query from the Coordinator. Windowed mode (`data-ln-list-window`) adds `{ offset, limit, queryGen }`. Disabling windowed mode live issues a fresh full `ln-list:request-data` (no `offset`/`limit`) to repopulate the complete dataset.
89
+ * `ln-list:ready` `{ total }`: Fired when initial markup parsing completes.
90
+ * `ln-list:rendered` `{ list, total, visible }`: Fired after items have been drawn to DOM.
91
+ * `ln-list:item-click` `{ list, id, record }`: Fired when clicking item body (excluding buttons, anchors, inputs).
92
+ * `ln-list:item-action` `{ list, id, action, record }`: Fired when clicking `[data-ln-item-action]`.
93
+ * `ln-list:select` `{ list, selectedIds, count }`: Fired when selection updates.
94
+ * `ln-list:select-all` `{ list, selected: true|false }`: Fired when select-all triggers.
95
+
96
+ ---
97
+
98
+ ## 🔧 Internals
99
+
100
+ Source: `js/ln-list/src/ln-list.js`.
101
+
102
+ ### Spacer element matches container semantics
103
+
104
+ Virtual scroll (both plain and windowed) needs top/bottom spacer elements to preserve scroll height without rendering off-screen rows. If `[data-ln-list-body]` is a `<ul>`/`<ol>`, spacers are `<li class="ln-list__spacer">` to stay HTML5-valid; any other container gets `<div class="ln-list__spacer">`.
105
+
106
+ ### `_renderVirtual()` vs `_renderWindowed()`
107
+
108
+ Both compute the visible `start`/`end` index range from the measured first-child height and the nearest scrollable ancestor's viewport, then mount spacers and clone the row template for the visible slice via `fillTemplate`/`fill`. They differ only in row source: `_renderVirtual()` reads `this._data[i]` (the full parsed/fetched dataset); `_renderWindowed()` reads `this._cache.get(i)` (the resident sliding window) and renders a blank placeholder spacer — no shimmer — for indices outside the cache, calling `this._cache.ensure(startRow, endRow)` every pass to debounce-fetch missing rows.
109
+
110
+ ### Windowed mode (`data-ln-list-window="N"`)
111
+
112
+ Owns an `ln-core.createWindowCache` instance (`N` = resident cap, default 1000) instead of the full dataset. `ln-list:request-data` gains `{offset, limit, queryGen}`; `ln-list:set-data` echoes `{offset, queryGen}` back into `cache.ingest()`. Select-all is hidden under `data-ln-list-selectable` + windowed mode together — a windowed list can't select rows it hasn't fetched.
113
+
114
+ Every windowing attribute is live-observable, no re-init required: `data-ln-list-window-page`/`-threshold`/`data-ln-list-count` reconfigure the cache via `configure()`/`setGrandTotal()`. Adding `data-ln-list-window` to an already-initialized, non-windowed, data-driven list seeds the cache from resident items and enables windowing live. Removing it destroys the cache and issues one full (no `offset`/`limit`) `ln-list:request-data` to repopulate before rendering non-windowed again.
115
+
116
+ ### `_parseChildren()`
117
+
118
+ Parses static children inside `[data-ln-list-body]` into `this._data` at mount (excluding existing spacers), and measures the first child's height for `this._itemHeight`.
119
+
120
+ ### Sort (`ln-sort:change`)
121
+
122
+ Data-driven only — the SSR branch has no sort logic. `field === null` events (index-only, from an
123
+ `ln-sort` instance authored without `data-ln-sort-field`) are ignored; `ln-list` records are
124
+ field-keyed, not positional, so there is nothing to sort by without a field name. On a matching
125
+ event: `preventDefault()`, `currentSort = { field, direction }` (or `null` on `direction: 'none'`),
126
+ then mirrors `ln-table`'s own data-driven sort handling exactly — windowed lists call
127
+ `_requestData()` (cache invalidate + re-fetch, same scheme as `ln-table`'s windowed
128
+ `_requestData()`); non-windowed lists re-sort the already-fetched in-memory data directly
129
+ (`_applyFilterAndSort()` + `_render()`, no server round-trip).