@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
@@ -0,0 +1,92 @@
1
+ // ─── Hash codec (URL fragment as namespaced state) ─────────
2
+ //
3
+ // Grammar: #nsA:valA&nsB:valB
4
+ // Each component owns ONE namespace, ignores all others, and PRESERVES
5
+ // foreign segments on write — this is what makes component isolation work.
6
+ //
7
+ // Three-state value model:
8
+ // absent → hashGet returns null (e.g. closed)
9
+ // #ns → hashGet returns '' (present, no value — e.g. "new")
10
+ // #ns:val → hashGet returns 'val' (present, with param)
11
+ //
12
+ // Write mechanism: `location.hash =` (fires hashchange; identical value is a
13
+ // no-op so there are no loops). Never pushState here.
14
+
15
+ function _strip(str) {
16
+ return (str || '').replace(/^#/, '');
17
+ }
18
+
19
+ export function hashParse(str) {
20
+ const raw = (str === undefined) ? location.hash : str;
21
+ const map = {};
22
+ const h = _strip(raw);
23
+ if (!h) return map;
24
+ const parts = h.split('&');
25
+ for (let i = 0; i < parts.length; i++) {
26
+ const part = parts[i];
27
+ if (!part) continue;
28
+ const sep = part.indexOf(':');
29
+ const ns = (sep > -1 ? part.slice(0, sep) : part);
30
+ const val = (sep > -1 ? part.slice(sep + 1) : '');
31
+ if (!ns) continue;
32
+ try { map[ns] = decodeURIComponent(val); }
33
+ catch (e) { map[ns] = val; }
34
+ }
35
+ return map;
36
+ }
37
+
38
+ export function hashGet(ns) {
39
+ if (!ns) return null;
40
+ const map = hashParse();
41
+ return (ns in map) ? map[ns] : null;
42
+ }
43
+
44
+ export function hashSet(ns, value) {
45
+ if (!ns) return;
46
+ const map = hashParse();
47
+ if (value === null || value === undefined) {
48
+ delete map[ns];
49
+ } else {
50
+ map[ns] = String(value);
51
+ }
52
+ const keys = Object.keys(map);
53
+ const next = keys.map(function (k) {
54
+ const v = map[k];
55
+ return (v === '') ? k : (k + ':' + encodeURIComponent(v));
56
+ }).join('&');
57
+ // Setting an identical hash is a no-op (no hashchange) → no loops.
58
+ if (_strip(location.hash) === next) return;
59
+ location.hash = next;
60
+ }
61
+
62
+ // ─── Hash-link click guard (shared by ln-tabs & ln-modal) ──
63
+ //
64
+ // A "hash link" is an <a href="#…"> whose click a component wants to drive
65
+ // through the codec instead of native fragment navigation (native nav replaces
66
+ // the WHOLE fragment and wipes foreign segments). Both ln-tabs and ln-modal
67
+ // funnel their own anchor clicks through here so the guard + preventDefault
68
+ // live in ONE place.
69
+ //
70
+ // Returns:
71
+ // false → modifier / middle / shift-click: caller must return and let the
72
+ // browser do native open-in-new-tab / new-window. No preventDefault.
73
+ // true → ordinary click: preventDefault() has been called; caller proceeds
74
+ // with its OWN write (hashSet merge, or a component-specific branch
75
+ // such as ln-tabs' already-current setAttribute).
76
+ //
77
+ // Deliberately does NOT call hashSet — ln-tabs needs an "already-current value
78
+ // → setAttribute active directly (no hash write)" branch, so the write differs
79
+ // per component. The shared, reusable surface is the guard + preventDefault.
80
+ export function hashLinkClick(e) {
81
+ if (e.button === 1 || e.ctrlKey || e.metaKey || e.shiftKey) return false;
82
+ e.preventDefault();
83
+ return true;
84
+ }
85
+
86
+ if (typeof window !== 'undefined') {
87
+ window.lnCore = window.lnCore || {};
88
+ window.lnCore.hashParse = hashParse;
89
+ window.lnCore.hashGet = hashGet;
90
+ window.lnCore.hashSet = hashSet;
91
+ window.lnCore.hashLinkClick = hashLinkClick;
92
+ }
@@ -1,3 +1,23 @@
1
+ // ─── Global Console Warning Interceptor (Production Mode) ──
2
+ if (typeof window !== 'undefined') {
3
+ const originalWarn = console.warn;
4
+ console.warn = function (...args) {
5
+ const isLibraryWarning = typeof args[0] === 'string' &&
6
+ (args[0].startsWith('[ln-') || args[0].startsWith('[lnCore'));
7
+
8
+ if (isLibraryWarning) {
9
+ const isDebug =
10
+ document.documentElement.hasAttribute('data-ln-debug') ||
11
+ (document.body && document.body.hasAttribute('data-ln-debug'));
12
+
13
+ if (!isDebug) {
14
+ return;
15
+ }
16
+ }
17
+ originalWarn.apply(console, args);
18
+ };
19
+ }
20
+
1
21
  // ─── Template Cache ────────────────────────────────────────
2
22
  const _tmplCache = {};
3
23
 
@@ -36,6 +56,40 @@ export function dispatchCancelable(element, eventName, detail) {
36
56
  return event;
37
57
  }
38
58
 
59
+ /**
60
+ * Re-filter / re-sort / re-render a data-driven list-like component, then
61
+ * notify the coordinator that fresh data is requested.
62
+ *
63
+ * Shared by ln-list and ln-table. The passed `component` MUST expose this
64
+ * instance contract:
65
+ * - _applyFilterAndSort() method
66
+ * - _render() method
67
+ * - _updateFooter() method
68
+ * - _vStart, _vEnd number (reset to -1 to force a full re-render)
69
+ * - dom the component root element (dispatch target)
70
+ * - name the component's name (becomes detail[keyName])
71
+ * - currentSort, currentFilters, currentSearch
72
+ *
73
+ * @param {Object} component component instance satisfying the contract above
74
+ * @param {string} eventName e.g. 'ln-table:request-data' / 'ln-list:request-data'
75
+ * @param {string} keyName payload key for the name, e.g. 'table' / 'list'
76
+ */
77
+ export function requestData(component, eventName, keyName) {
78
+ component._applyFilterAndSort();
79
+ component._vStart = -1;
80
+ component._vEnd = -1;
81
+ component._render();
82
+ component._updateFooter();
83
+
84
+ const detail = {
85
+ sort: component.currentSort,
86
+ filters: component.currentFilters,
87
+ search: component.currentSearch
88
+ };
89
+ detail[keyName] = component.name;
90
+ dispatch(component.dom, eventName, detail);
91
+ }
92
+
39
93
  // ─── Declarative DOM Binding ───────────────────────────────
40
94
 
41
95
  export function fill(root, data) {
@@ -96,10 +150,54 @@ export function fill(root, data) {
96
150
  return root;
97
151
  }
98
152
 
153
+ // ─── Event-driven fill fan-out ─────────────────────────────
154
+
155
+ /**
156
+ * Dispatch `ln-fill` at every [data-ln-form] and [data-ln-fillable]
157
+ * descendant of `container`. Fillables self-handle; null → clear/reset.
158
+ */
159
+ export function lnFill(container, record) {
160
+ // Dispatch at container itself when it is a form or fillable.
161
+ // Enables lnFill(formEl, record) to reach ln-form's listener, which
162
+ // guards e.target === self.dom. Modal callers (lnFill(modalEl, …)
163
+ // where modalEl is [data-ln-modal]) do not match, so they are unaffected.
164
+ if (container.matches && container.matches('[data-ln-form], [data-ln-fillable]')) {
165
+ container.dispatchEvent(new CustomEvent('ln-fill', { detail: record ?? null, bubbles: true }));
166
+ }
167
+ const targets = container.querySelectorAll('[data-ln-form], [data-ln-fillable]');
168
+ for (let i = 0; i < targets.length; i++) {
169
+ targets[i].dispatchEvent(new CustomEvent('ln-fill', { detail: record ?? null, bubbles: true }));
170
+ }
171
+ return container;
172
+ }
173
+
174
+ // Delegated display-fillable handler — installed once per page (idempotent
175
+ // across standalone component bundles that each inline ln-core helpers).
176
+ // Forms are NOT [data-ln-fillable]; ln-form's own listener handles them.
177
+ if (typeof window !== 'undefined') {
178
+ window.lnCore = window.lnCore || {};
179
+ if (!window.lnCore._fillBound) {
180
+ window.lnCore._fillBound = true;
181
+ document.addEventListener('ln-fill', function (e) {
182
+ if (!e.target.matches || !e.target.matches('[data-ln-fillable]')) return;
183
+ if (e.detail) {
184
+ fill(e.target, e.detail);
185
+ } else {
186
+ const fields = e.target.querySelectorAll('[data-ln-field]');
187
+ for (let i = 0; i < fields.length; i++) {
188
+ fields[i].textContent = '';
189
+ }
190
+ }
191
+ });
192
+ }
193
+ }
194
+
99
195
  // ─── Template Text-Node Placeholders ──────────────────────
100
196
 
101
197
  export function fillTemplate(clone, data) {
102
198
  if (!clone || !data) return clone;
199
+
200
+ // ── Pass 1: text nodes ──────────────────────────────────
103
201
  const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
104
202
  while (walker.nextNode()) {
105
203
  const node = walker.currentNode;
@@ -110,6 +208,27 @@ export function fillTemplate(clone, data) {
110
208
  );
111
209
  }
112
210
  }
211
+
212
+ // ── Pass 2: element attributes ──────────────────────────
213
+ // Uses setAttribute — never innerHTML — so injection risk is the same
214
+ // as data-ln-attr (the existing declarative attribute-fill mechanism).
215
+ // Complements data-ln-table-cell-attr (field→attr stamping); both coexist.
216
+ const _replaceTokens = function (_, key) { return data[key] !== undefined ? data[key] : ''; };
217
+ const elements = Array.from(clone.querySelectorAll('*'));
218
+ // Include the root itself when clone is an Element (e.g. a <tr> passed
219
+ // directly by ln-table._fillRow) — querySelectorAll does not include root.
220
+ if (clone.nodeType === 1) elements.push(clone);
221
+ for (let i = 0; i < elements.length; i++) {
222
+ const el = elements[i];
223
+ const attrs = el.attributes;
224
+ for (let j = 0; j < attrs.length; j++) {
225
+ const attr = attrs[j];
226
+ if (attr.value.indexOf('{{') !== -1) {
227
+ el.setAttribute(attr.name, attr.value.replace(/\{\{\s*(\w+)\s*\}\}/g, _replaceTokens));
228
+ }
229
+ }
230
+ }
231
+
113
232
  return clone;
114
233
  }
115
234
 
@@ -223,17 +342,46 @@ export function isVisible(el) {
223
342
 
224
343
  // ─── Form Serialization ───────────────────────────────────
225
344
 
226
- export function serializeForm(form) {
345
+ // Effective HTTP method for a form: hidden _method input (non-empty) wins,
346
+ // else the form's own `method` attribute. Shared by ln-form's submit gate
347
+ // and ln-data-coordinator's native-submit claim so both read the identical
348
+ // literal DOM state — no drift between the two independent checks.
349
+ export function resolveFormMethod(form) {
350
+ const methodInput = form.querySelector('input[name="_method"]');
351
+ const raw = (methodInput && methodInput.value !== '') ? methodInput.value : form.method;
352
+ return (raw || '').toUpperCase();
353
+ }
354
+
355
+ export function serializeForm(form, opts) {
356
+ const typed = !!(opts && opts.typed);
357
+ const exclude = opts && opts.exclude;
227
358
  const data = {};
228
359
  const elements = form.elements;
229
360
 
361
+ // Pre-count checkbox names in typed mode to distinguish single-checkbox
362
+ // (→ boolean) from same-name checkbox groups (→ array of checked values).
363
+ const checkboxCounts = {};
364
+ if (typed) {
365
+ for (let i = 0; i < elements.length; i++) {
366
+ const el = elements[i];
367
+ if (el.name && el.type === 'checkbox' && !el.disabled) {
368
+ checkboxCounts[el.name] = (checkboxCounts[el.name] || 0) + 1;
369
+ }
370
+ }
371
+ }
372
+
230
373
  for (let i = 0; i < elements.length; i++) {
231
374
  const el = elements[i];
232
375
  if (!el.name || el.disabled || el.type === 'file' || el.type === 'submit' || el.type === 'button') continue;
376
+ if (exclude && el.matches && el.matches(exclude)) continue;
233
377
 
234
378
  if (el.type === 'checkbox') {
235
- if (!data[el.name]) data[el.name] = [];
236
- if (el.checked) data[el.name].push(el.value);
379
+ if (typed && checkboxCounts[el.name] === 1) {
380
+ data[el.name] = el.checked; // single checkbox → boolean
381
+ } else {
382
+ if (!data[el.name]) data[el.name] = [];
383
+ if (el.checked) data[el.name].push(el.value); // group → array (unchanged)
384
+ }
237
385
  } else if (el.type === 'radio') {
238
386
  if (el.checked) data[el.name] = el.value;
239
387
  } else if (el.type === 'select-multiple') {
@@ -241,6 +389,11 @@ export function serializeForm(form) {
241
389
  for (let j = 0; j < el.options.length; j++) {
242
390
  if (el.options[j].selected) data[el.name].push(el.options[j].value);
243
391
  }
392
+ } else if (typed && el.type === 'hidden') {
393
+ data[el.name] = el.value; // hidden → always string (never coerce)
394
+ } else if (typed && (el.type === 'number' || el.type === 'range')) {
395
+ const n = Number(el.value);
396
+ data[el.name] = (el.value === '' || isNaN(n)) ? null : n; // number/range → Number | null
244
397
  } else {
245
398
  data[el.name] = el.value;
246
399
  }
@@ -249,18 +402,57 @@ export function serializeForm(form) {
249
402
  return data;
250
403
  }
251
404
 
405
+ // Coerce a value to boolean for single-checkbox fill.
406
+ // Flat data-* values arrive as strings; "false", "0", "", "off", "no"
407
+ // (case-insensitive, trimmed) are treated as unchecked.
408
+ function _coerceBool(value) {
409
+ if (typeof value !== 'string') return !!value;
410
+ const v = value.trim().toLowerCase();
411
+ return v !== 'false' && v !== '0' && v !== '' && v !== 'off' && v !== 'no';
412
+ }
413
+
252
414
  export function populateForm(form, data) {
253
415
  const elements = form.elements;
254
416
  const filled = [];
255
417
 
418
+ // Pre-count checkbox names to distinguish single-checkbox (→ bool)
419
+ // from same-name checkbox groups (→ comma-separated list membership).
420
+ // NOTE: unlike serializeForm, populateForm fills disabled elements too,
421
+ // so the count must include disabled members (no !el.disabled guard) —
422
+ // otherwise a group with a disabled box reverts to single-checkbox coercion.
423
+ const checkboxCounts = {};
256
424
  for (let i = 0; i < elements.length; i++) {
257
425
  const el = elements[i];
258
- if (!el.name || !(el.name in data) || el.type === 'file' || el.type === 'submit' || el.type === 'button') continue;
426
+ if (el.name && el.type === 'checkbox') {
427
+ checkboxCounts[el.name] = (checkboxCounts[el.name] || 0) + 1;
428
+ }
429
+ }
259
430
 
260
- const value = data[el.name];
431
+ for (let i = 0; i < elements.length; i++) {
432
+ const el = elements[i];
433
+ if (el.type === 'file' || el.type === 'submit' || el.type === 'button') continue;
434
+
435
+ // Decoupled-key fill: data-ln-fill-as wins over name for record matching.
436
+ // Write (fill) path only — serializeForm stays name-based.
437
+ const matchKey = el.getAttribute('data-ln-fill-as') || el.name;
438
+ if (!matchKey || !(matchKey in data)) continue;
439
+
440
+ const value = data[matchKey];
261
441
 
262
442
  if (el.type === 'checkbox') {
263
- el.checked = Array.isArray(value) ? value.indexOf(el.value) !== -1 : !!value;
443
+ if (Array.isArray(value)) {
444
+ // Programmatic array path — membership check.
445
+ el.checked = value.indexOf(el.value) !== -1;
446
+ } else if (checkboxCounts[el.name] > 1) {
447
+ // Same-name checkbox group + scalar: treat as comma-separated list.
448
+ // "admin,editor" → ['admin','editor'] → membership check.
449
+ const list = String(value).split(',').map(function (s) { return s.trim(); });
450
+ el.checked = list.indexOf(el.value) !== -1;
451
+ } else {
452
+ // Single checkbox — boolean coercion.
453
+ // "false"/"0"/"off"/"no"/"" → unchecked; anything else → checked.
454
+ el.checked = _coerceBool(value);
455
+ }
264
456
  filled.push(el);
265
457
  } else if (el.type === 'radio') {
266
458
  el.checked = el.value === String(value);
@@ -283,9 +475,83 @@ export function populateForm(form, data) {
283
475
 
284
476
  // ─── Locale Detection ─────────────────────────────────────
285
477
 
478
+ const _standardTerritories = {
479
+ mk: 'mk-MK',
480
+ de: 'de-DE',
481
+ fr: 'fr-FR',
482
+ es: 'es-ES',
483
+ it: 'it-IT',
484
+ nl: 'nl-NL',
485
+ pt: 'pt-PT',
486
+ ru: 'ru-RU',
487
+ bg: 'bg-BG',
488
+ hr: 'hr-HR',
489
+ sr: 'sr-RS',
490
+ sq: 'sq-AL',
491
+ el: 'el-GR',
492
+ en: 'en-US'
493
+ };
494
+
286
495
  export function getLocale(el) {
287
- const langEl = el.closest('[lang]');
288
- return (langEl ? langEl.lang : null) || navigator.language;
496
+ const langEl = el ? el.closest('[lang]') : null;
497
+ const raw = (langEl ? (langEl.getAttribute('lang') || langEl.lang) : null)
498
+ || (document.documentElement ? (document.documentElement.getAttribute('lang') || document.documentElement.lang) : null)
499
+ || navigator.language;
500
+ if (!raw) return 'en-US';
501
+ const lower = raw.trim().toLowerCase();
502
+ if (lower.indexOf('-') === -1 && _standardTerritories[lower]) {
503
+ return _standardTerritories[lower];
504
+ }
505
+ return raw;
506
+ }
507
+
508
+ // ─── Raw Value Read ───────────────────────────────────────
509
+
510
+ /**
511
+ * Read the raw machine value behind a formatted cell/item display.
512
+ * Returns the `data-ln-value` attribute if present, else trimmed textContent.
513
+ * Single read path for value-based sort/filter across components.
514
+ */
515
+ export function readValue(el) {
516
+ return el.hasAttribute('data-ln-value')
517
+ ? el.getAttribute('data-ln-value')
518
+ : el.textContent.trim();
519
+ }
520
+
521
+ // ─── Value Comparison Primitives ───────────────────────────
522
+
523
+ /**
524
+ * Detect the comparison type for a column/field from its current value set.
525
+ * Type is locked ONCE per sort operation (not per pair) — comparing types
526
+ * per-pair breaks comparator transitivity and produces wrong ordering.
527
+ * All non-empty values must be finite numbers for 'number'; otherwise 'string'.
528
+ */
529
+ export function detectValueType(values) {
530
+ let hasValue = false;
531
+ for (let i = 0; i < values.length; i++) {
532
+ const v = values[i];
533
+ if (v === '' || v == null) continue;
534
+ hasValue = true;
535
+ if (!Number.isFinite(Number(v))) return 'string';
536
+ }
537
+ return hasValue ? 'number' : 'string';
538
+ }
539
+
540
+ /**
541
+ * Compare two raw values given a pre-locked type (see detectValueType).
542
+ * Pass an Intl.Collator instance for locale-aware string comparison —
543
+ * built once by the caller outside the sort loop, never per pair.
544
+ */
545
+ export function compareValues(a, b, type, collator) {
546
+ if (type === 'number') {
547
+ const na = parseFloat(a);
548
+ const nb = parseFloat(b);
549
+ return (isNaN(na) ? 0 : na) - (isNaN(nb) ? 0 : nb);
550
+ }
551
+ const strA = a != null ? String(a) : '';
552
+ const strB = b != null ? String(b) : '';
553
+ if (collator) return collator.compare(strA, strB);
554
+ return strA < strB ? -1 : strA > strB ? 1 : 0;
289
555
  }
290
556
 
291
557
  // ─── Value Property Interception ───────────────────────────
@@ -407,6 +673,30 @@ export function registerComponent(selector, attribute, ComponentFn, componentTag
407
673
 
408
674
  // ─── HTTP / URL Helpers ────────────────────────────────────
409
675
 
676
+ /**
677
+ * Check if a click event on a link should be intercepted by an in-app navigator.
678
+ * Intercept only left clicks, same-origin, not target="_blank", no download,
679
+ * not mailto/tel, and not pure hash/empty.
680
+ *
681
+ * @param {MouseEvent} event
682
+ * @param {HTMLAnchorElement} anchor
683
+ * @returns {boolean}
684
+ */
685
+ export function shouldInterceptLink(event, anchor) {
686
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) {
687
+ return false;
688
+ }
689
+ if (!anchor) return false;
690
+ const href = anchor.getAttribute('href');
691
+ if (!href) return false;
692
+ if (anchor.getAttribute('target') === '_blank') return false;
693
+ if (anchor.hasAttribute('download')) return false;
694
+ if (href.startsWith('mailto:') || href.startsWith('tel:')) return false;
695
+ if (href === '#' || href.startsWith('#')) return false;
696
+ if (anchor.hostname && anchor.hostname !== window.location.hostname) return false;
697
+ return true;
698
+ }
699
+
410
700
  /**
411
701
  * Build a URL from segments, stripping duplicate slashes.
412
702
  */
@@ -450,10 +740,49 @@ export function getDataMapper(name) {
450
740
  return _dataMappers[name] || { ingress: r => r, egress: r => r };
451
741
  }
452
742
 
743
+ // ─── Locale Fallback Registry ───────────────────────────────
744
+ const _localeFallbacks = {};
745
+
746
+ export function registerLocaleFallback(langPrefix, dictionary) {
747
+ if (!langPrefix || typeof dictionary !== 'object') return;
748
+ const prefix = langPrefix.toLowerCase().split('-')[0];
749
+ _localeFallbacks[prefix] = dictionary;
750
+ }
751
+
752
+ export function getLocaleFallback(lang) {
753
+ if (!lang) return null;
754
+ const prefix = lang.toLowerCase().split('-')[0];
755
+ return _localeFallbacks[prefix] || null;
756
+ }
757
+
758
+ // Default Macedonian locale fallback registration
759
+ registerLocaleFallback('mk', {
760
+ monthsLong: [
761
+ 'јануари', 'февруари', 'март', 'април', 'мај', 'јуни',
762
+ 'јули', 'август', 'септември', 'октомври', 'ноември', 'декември'
763
+ ],
764
+ monthsShort: [
765
+ 'јан', 'фев', 'мар', 'апр', 'мај', 'јун',
766
+ 'јул', 'авг', 'септ', 'окт', 'ноем', 'дек'
767
+ ],
768
+ daysLong: [
769
+ 'недела', 'понеделник', 'вторник', 'среда', 'четврток', 'петок', 'сабота'
770
+ ],
771
+ daysShort: [
772
+ 'нед', 'пон', 'вт', 'ср', 'чет', 'пет', 'саб'
773
+ ]
774
+ });
775
+
453
776
  if (typeof window !== 'undefined') {
454
777
  window.lnCore = window.lnCore || {};
455
778
  window.lnCore.registerDataMapper = registerDataMapper;
456
779
  window.lnCore.getDataMapper = getDataMapper;
780
+ window.lnCore.registerLocaleFallback = registerLocaleFallback;
781
+ window.lnCore.getLocaleFallback = getLocaleFallback;
782
+ window.lnCore.fillTemplate = fillTemplate;
783
+ window.lnCore.fill = fill;
784
+ window.lnCore.lnFill = lnFill;
785
+ window.lnCore.renderList = renderList;
457
786
  }
458
787
 
459
788
 
@@ -1,5 +1,7 @@
1
- export { cloneTemplate, cloneTemplateScoped, dispatch, dispatchCancelable, fill, fillTemplate, renderList, buildDict, guardBody, findElements, isVisible, serializeForm, populateForm, getLocale, registerComponent, buildUrl, getHeaders, parseHeaders, registerDataMapper, getDataMapper, interceptValueProperty } from './helpers.js';
1
+ export { cloneTemplate, cloneTemplateScoped, dispatch, dispatchCancelable, requestData, fill, lnFill, fillTemplate, renderList, buildDict, guardBody, findElements, isVisible, serializeForm, populateForm, resolveFormMethod, getLocale, readValue, detectValueType, compareValues, registerComponent, shouldInterceptLink, buildUrl, getHeaders, parseHeaders, registerDataMapper, getDataMapper, interceptValueProperty, registerLocaleFallback, getLocaleFallback } from './helpers.js';
2
2
  export { reactiveState, deepReactive, createBatcher } from './reactive.js';
3
+ export { createWindowCache } from './window-cache.js';
3
4
  export { persistGet, persistSet, persistRemove, persistClear } from './persist.js';
4
- export { computePlacement, teleportToBody, measureHidden } from './positioning.js';
5
+ export { hashParse, hashGet, hashSet, hashLinkClick } from './hash.js';
6
+ export { computePlacement, measureHidden } from './positioning.js';
5
7
  export { setCryptoKey, getCryptoKey, encryptData, decryptData } from './crypto.js';
@@ -3,7 +3,7 @@
3
3
  // Key format: ln:{component}:{pathname}:{elementId}
4
4
  // Example: ln:filter:/admin/users:status-filter
5
5
  //
6
- // Each component passes its own name ("filter", "table-sort", "toggle", "tabs"),
6
+ // Each component passes its own name ("filter", "sort", "toggle", "tabs"),
7
7
  // so keys are namespaced per component — no cross-component collision risk.
8
8
  // Element ID comes from data-ln-persist="key" or el.id.
9
9
  //
@@ -12,6 +12,24 @@
12
12
 
13
13
  const PREFIX = 'ln:';
14
14
 
15
+ let _isStorageAvailable = null;
16
+ function _checkStorage() {
17
+ if (_isStorageAvailable !== null) return _isStorageAvailable;
18
+ try {
19
+ if (typeof localStorage === 'undefined') {
20
+ _isStorageAvailable = false;
21
+ return false;
22
+ }
23
+ const testKey = '__ln_test__';
24
+ localStorage.setItem(testKey, testKey);
25
+ localStorage.removeItem(testKey);
26
+ _isStorageAvailable = true;
27
+ } catch (e) {
28
+ _isStorageAvailable = false;
29
+ }
30
+ return _isStorageAvailable;
31
+ }
32
+
15
33
  function _pageKey() {
16
34
  const path = location.pathname.replace(/\/+$/, '').toLowerCase();
17
35
  return path || '/';
@@ -28,6 +46,7 @@ function _resolveKey(component, el) {
28
46
  }
29
47
 
30
48
  export function persistGet(component, el) {
49
+ if (!_checkStorage()) return null;
31
50
  const key = _resolveKey(component, el);
32
51
  if (!key) return null;
33
52
  try {
@@ -39,16 +58,22 @@ export function persistGet(component, el) {
39
58
  }
40
59
 
41
60
  export function persistSet(component, el, value) {
61
+ if (!_checkStorage()) return;
42
62
  const key = _resolveKey(component, el);
43
63
  if (!key) return;
44
64
  try {
45
- localStorage.setItem(key, JSON.stringify(value));
65
+ if (value === null || value === undefined) {
66
+ localStorage.removeItem(key);
67
+ } else {
68
+ localStorage.setItem(key, JSON.stringify(value));
69
+ }
46
70
  } catch (e) {
47
- // localStorage full or disabled — silent
71
+ // localStorage full — silent
48
72
  }
49
73
  }
50
74
 
51
75
  export function persistRemove(component, el) {
76
+ if (!_checkStorage()) return;
52
77
  const key = _resolveKey(component, el);
53
78
  if (!key) return;
54
79
  try {
@@ -57,6 +82,7 @@ export function persistRemove(component, el) {
57
82
  }
58
83
 
59
84
  export function persistClear(component) {
85
+ if (!_checkStorage()) return;
60
86
  try {
61
87
  const prefix = PREFIX + component + ':';
62
88
  const toRemove = [];
@@ -2,8 +2,7 @@
2
2
  //
3
3
  // Pure helpers for floating UI (popovers, tooltips, dropdowns).
4
4
  // No DOM side effects in computePlacement — it takes a rect and
5
- // returns coordinates. teleportToBody and measureHidden touch DOM
6
- // because they have to.
5
+ // returns coordinates. measureHidden touches DOM because it has to.
7
6
 
8
7
  /**
9
8
  * Compute viewport coordinates for a floating element relative to an
@@ -138,39 +137,6 @@ export function computePlacement(anchorRect, floatingSize, preferred, offset) {
138
137
  return { top: top, left: left, placement: chosen.side };
139
138
  }
140
139
 
141
- /**
142
- * Move an element into <body>, leaving a placeholder comment so
143
- * teleportBack can put it back exactly where it was. Returns a
144
- * cleanup function — call it to restore the element to its origin.
145
- *
146
- * The element receives `position: fixed` style automatically (the
147
- * caller is responsible for top/left). To avoid violating the
148
- * "no inline styles" rule, the caller should set position via a
149
- * CSS rule on its component selector (e.g. [data-ln-popover]
150
- * already has position: fixed in its co-located scss). teleportToBody
151
- * itself does NOT set any inline styles.
152
- *
153
- * @param {HTMLElement} el
154
- * @returns {Function} cleanup — restores el to its original parent.
155
- */
156
- export function teleportToBody(el) {
157
- if (!el || el.parentNode === document.body) {
158
- // Already in body or no element — return a no-op cleanup.
159
- return function () {};
160
- }
161
-
162
- const originalParent = el.parentNode;
163
- const placeholder = document.createComment('ln-teleport');
164
- originalParent.insertBefore(placeholder, el);
165
- document.body.appendChild(el);
166
-
167
- return function restore() {
168
- if (!placeholder.parentNode) return;
169
- placeholder.parentNode.insertBefore(el, placeholder);
170
- placeholder.parentNode.removeChild(placeholder);
171
- };
172
- }
173
-
174
140
  /**
175
141
  * Measure an element that may be hidden via display:none.
176
142
  * Temporarily applies visibility:hidden + display:block so we can