@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,345 @@
1
+ import { registerComponent, dispatch, hashGet, hashSet, hashParse, hashLinkClick } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-modal-coordinator';
5
+ const DOM_ATTRIBUTE = 'lnModalCoordinator';
6
+
7
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
8
+
9
+ // Helper to resolve target modal from trigger element / wrapper / ID
10
+ function _findTargetModal(triggerEl, modalId) {
11
+ if (modalId) {
12
+ if (triggerEl) {
13
+ const wrapper = triggerEl.closest('[' + DOM_SELECTOR + ']');
14
+ if (wrapper) {
15
+ if (wrapper.id === modalId && wrapper.hasAttribute('data-ln-modal')) return wrapper;
16
+ const scoped = wrapper.querySelector('#' + CSS.escape(modalId) + '[data-ln-modal], [data-ln-modal="' + modalId + '"]');
17
+ if (scoped) return scoped;
18
+ }
19
+ }
20
+ const el = document.getElementById(modalId) || document.querySelector('[data-ln-modal="' + modalId + '"]');
21
+ if (el) return el;
22
+ }
23
+
24
+ if (triggerEl) {
25
+ const wrapper = triggerEl.closest('[' + DOM_SELECTOR + ']');
26
+ if (wrapper) {
27
+ if (wrapper.hasAttribute('data-ln-modal')) return wrapper;
28
+ const scoped = wrapper.querySelector('[data-ln-modal]');
29
+ if (scoped) return scoped;
30
+ }
31
+ const closest = triggerEl.closest('[data-ln-modal]');
32
+ if (closest) return closest;
33
+ }
34
+
35
+ return document.querySelector('[data-ln-modal]');
36
+ }
37
+
38
+ // Hash param for a modal open. Edit opens must carry a TRUTHY segment so
39
+ // foreign hashchanges never route them through the empty-param reset branch
40
+ // (which would wipe the edit fill). Prefer the real entity id
41
+ // (data-ln-fill-id) for a bookmarkable URL; fall back to the non-addressable
42
+ // marker 'edit' when the trigger fills inline with no id. New opens carry ''.
43
+ function _hashParamForOpen(mode, triggerEl) {
44
+ if (mode !== 'edit') return '';
45
+ if (triggerEl) {
46
+ const fillId = triggerEl.getAttribute('data-ln-fill-id');
47
+ if (fillId) return fillId;
48
+ }
49
+ return 'edit';
50
+ }
51
+
52
+ // Helper to reset all forms and display fields inside a modal panel
53
+ function _resetModalForm(modal) {
54
+ if (!modal) return;
55
+ const fields = modal.querySelectorAll('[data-ln-field]');
56
+ for (let i = 0; i < fields.length; i++) {
57
+ fields[i].textContent = '';
58
+ }
59
+ const forms = modal.querySelectorAll('form');
60
+ for (let i = 0; i < forms.length; i++) {
61
+ if (window.lnCore && typeof window.lnCore.lnFill === 'function') {
62
+ window.lnCore.lnFill(forms[i], null);
63
+ } else {
64
+ forms[i].reset();
65
+ }
66
+ }
67
+ }
68
+
69
+ // ─── Native Submit Pending Intake & Hash Clear ───────────
70
+
71
+ document.addEventListener('submit', function (e) {
72
+ if (e.defaultPrevented) return;
73
+ const form = e.target;
74
+ const modal = form.closest('[data-ln-modal]');
75
+ if (modal && modal.id) {
76
+ try {
77
+ sessionStorage.setItem('ln-modal-pending:' + modal.id, 'true');
78
+ } catch (err) {}
79
+ // Clear hash before native submit/reload so browser reloads clean
80
+ hashSet(modal.id, null);
81
+ }
82
+ });
83
+
84
+ // ─── Trigger & Hash Navigation Delegation ──────────────
85
+
86
+ document.addEventListener('click', function (e) {
87
+ if (e.ctrlKey || e.metaKey || e.button === 1) return;
88
+
89
+ // 1. Handle trigger click [data-ln-modal-for="modalId"]
90
+ const trigger = e.target.closest('[data-ln-modal-for]');
91
+ if (trigger) {
92
+ const modalId = trigger.getAttribute('data-ln-modal-for');
93
+ const target = _findTargetModal(trigger, modalId);
94
+ if (target && target.lnModal) {
95
+ e.preventDefault();
96
+
97
+ const MODAL_RESERVED = { lnModalFor: true, lnModalClose: true, lnModalMode: true };
98
+ const record = {};
99
+ const ds = trigger.dataset;
100
+ for (const key in ds) {
101
+ if (!key.startsWith('lnModal')) continue;
102
+ if (MODAL_RESERVED[key]) continue;
103
+ const suffix = key.slice(7);
104
+ if (!suffix) continue;
105
+ record[suffix.charAt(0).toLowerCase() + suffix.slice(1)] = ds[key];
106
+ }
107
+
108
+ const hasRecord = Object.keys(record).length > 0;
109
+
110
+ if (trigger.hasAttribute('data-ln-modal-mode')) {
111
+ target.dataset.lnModalMode = trigger.getAttribute('data-ln-modal-mode');
112
+ } else {
113
+ target.dataset.lnModalMode = hasRecord ? 'edit' : 'new';
114
+ }
115
+
116
+ if (hasRecord && window.lnCore && typeof window.lnCore.fill === 'function') {
117
+ window.lnCore.fill(target, record);
118
+ } else if (target.dataset.lnModalMode === 'new') {
119
+ _resetModalForm(target);
120
+ }
121
+
122
+ const current = target.getAttribute('data-ln-modal');
123
+ if (current === 'open') {
124
+ dispatch(target, 'ln-modal:request-close', {});
125
+ } else {
126
+ // Restore per-open hash-write (pre-extraction invariant): every
127
+ // open leaves a URL segment so _syncHashModals never closes it.
128
+ if (target.id) {
129
+ hashSet(target.id, _hashParamForOpen(target.dataset.lnModalMode, trigger));
130
+ }
131
+ dispatch(target, 'ln-modal:request-open', {});
132
+ }
133
+ }
134
+ return;
135
+ }
136
+
137
+ // 2. Handle hash anchor click <a href="#modalId"> / <a href="#modalId:param">
138
+ const hashAnchor = e.target.closest('a[href^="#"]');
139
+ if (hashAnchor) {
140
+ const map = hashParse(hashAnchor.getAttribute('href'));
141
+ for (const ns in map) {
142
+ const el = document.getElementById(ns);
143
+ if (el && el.lnModal) {
144
+ if (!hashLinkClick(e)) return;
145
+ hashSet(ns, map[ns]);
146
+ return;
147
+ }
148
+ }
149
+ }
150
+ });
151
+
152
+ // ─── Before Open Reset Gate ────────────────────────────
153
+
154
+ document.addEventListener('ln-modal:before-open', function (e) {
155
+ const modal = e.target;
156
+ if (!modal || !modal.lnModal) return;
157
+
158
+ const mode = modal.dataset.lnModalMode || 'new';
159
+ if (mode === 'new') {
160
+ _resetModalForm(modal);
161
+ }
162
+ });
163
+
164
+ // ─── Open Event Fill Dispatcher ───────────────────────
165
+
166
+ document.addEventListener('ln-modal:open', function (e) {
167
+ const modal = e.target;
168
+ if (!modal || !modal.lnModal || !modal.id) return;
169
+
170
+ let param = hashGet(modal.id);
171
+
172
+ // Non-trigger opens (programmatic / SSR-via-mutation) reach here with no
173
+ // segment — adopt one so foreign hashchanges never close the modal.
174
+ // Trigger opens already wrote their segment, so this is skipped for them.
175
+ if (param === null) {
176
+ param = _hashParamForOpen(modal.dataset.lnModalMode, null);
177
+ hashSet(modal.id, param);
178
+ }
179
+
180
+ if (param) {
181
+ modal.dataset.lnModalMode = 'edit';
182
+ modal.dispatchEvent(new CustomEvent('ln-fill:request', {
183
+ bubbles: true,
184
+ detail: { id: param }
185
+ }));
186
+ } else {
187
+ modal.dataset.lnModalMode = 'new';
188
+ _resetModalForm(modal);
189
+ }
190
+ });
191
+
192
+ // ─── Hash Sync Listener & Pending Submit Resolution ────
193
+
194
+ let _inSync = false;
195
+ function _syncHashModals() {
196
+ if (_inSync) return;
197
+ _inSync = true;
198
+ try {
199
+ const modals = document.querySelectorAll('[data-ln-modal][id]');
200
+ for (let i = 0; i < modals.length; i++) {
201
+ const modal = modals[i];
202
+ if (!modal.lnModal) continue;
203
+ const hashNs = modal.id;
204
+
205
+ const pendingKey = 'ln-modal-pending:' + hashNs;
206
+ let isPending = false;
207
+ try {
208
+ isPending = sessionStorage.getItem(pendingKey) === 'true';
209
+ } catch (err) {}
210
+
211
+ if (isPending) {
212
+ try {
213
+ sessionStorage.removeItem(pendingKey);
214
+ } catch (err) {}
215
+
216
+ const hasErrors = !!(
217
+ document.querySelector('.has-error, [data-ln-validate-error], .form-error, .alert-danger') ||
218
+ modal.querySelector('.has-error, [data-ln-validate-error], .form-error, .alert-danger')
219
+ );
220
+
221
+ if (!hasErrors) {
222
+ hashSet(hashNs, null);
223
+ dispatch(modal, 'ln-modal:request-close', {});
224
+ _resetModalForm(modal);
225
+ continue;
226
+ } else {
227
+ modal.dataset.lnModalMode = 'edit';
228
+ dispatch(modal, 'ln-modal:request-open', {});
229
+ continue;
230
+ }
231
+ }
232
+
233
+ const param = hashGet(hashNs);
234
+ const present = param !== null;
235
+ const isOpen = modal.lnModal.isOpen;
236
+
237
+ if (present) {
238
+ const mode = param ? 'edit' : 'new';
239
+ modal.dataset.lnModalMode = mode;
240
+ if (!isOpen) {
241
+ dispatch(modal, 'ln-modal:request-open', {});
242
+ } else {
243
+ // Modal is already open, but hash param changed dynamically (e.g. #modal:42 -> #modal:5)
244
+ if (param) {
245
+ modal.dispatchEvent(new CustomEvent('ln-fill:request', {
246
+ bubbles: true,
247
+ detail: { id: param }
248
+ }));
249
+ } else {
250
+ _resetModalForm(modal);
251
+ }
252
+ }
253
+ } else if (isOpen) {
254
+ dispatch(modal, 'ln-modal:request-close', {});
255
+ }
256
+ }
257
+ } finally {
258
+ _inSync = false;
259
+ }
260
+ }
261
+
262
+ // SSR modals authored data-ln-modal="open" never pass through a trigger or
263
+ // hash anchor, so they carry no URL segment. Adopt them into the hash BEFORE
264
+ // the first sync, so _syncHashModals sees them present and keeps them open
265
+ // instead of closing them as a phantom back-navigation. Skips modals that
266
+ // already have a segment (e.g. an SSR deep-link URL).
267
+ function _adoptOpenModals() {
268
+ const openModals = document.querySelectorAll('[data-ln-modal="open"][id]');
269
+ for (let i = 0; i < openModals.length; i++) {
270
+ const modal = openModals[i];
271
+ if (!modal.lnModal) continue;
272
+ if (hashGet(modal.id) !== null) continue;
273
+ hashSet(modal.id, _hashParamForOpen(modal.dataset.lnModalMode, null));
274
+ }
275
+ }
276
+
277
+ window.addEventListener('hashchange', _syncHashModals);
278
+
279
+ function _bootSync() {
280
+ _adoptOpenModals();
281
+ _syncHashModals();
282
+ }
283
+
284
+ if (document.readyState === 'loading') {
285
+ document.addEventListener('DOMContentLoaded', function () {
286
+ setTimeout(_bootSync, 0);
287
+ });
288
+ } else {
289
+ setTimeout(_bootSync, 0);
290
+ }
291
+
292
+ // ─── Form Submission Auto-Close Mediation ────────────────
293
+
294
+ function _handleFormSuccess(e) {
295
+ const modal = e.target.closest('[data-ln-modal]');
296
+ if (!modal || !modal.lnModal) return;
297
+
298
+ if (modal.id) {
299
+ try {
300
+ sessionStorage.removeItem('ln-modal-pending:' + modal.id);
301
+ } catch (err) {}
302
+ hashSet(modal.id, null);
303
+ }
304
+
305
+ dispatch(modal, 'ln-modal:request-close', {});
306
+ _resetModalForm(modal);
307
+ }
308
+
309
+ document.addEventListener('ln-form:success', _handleFormSuccess);
310
+ document.addEventListener('ln-ajax:success', _handleFormSuccess);
311
+
312
+ // ─── Hash Cleanup & Reset on Modal Close ───────────────
313
+
314
+ document.addEventListener('ln-modal:close', function (e) {
315
+ const modal = e.target;
316
+ if (!modal || !modal.lnModal) return;
317
+
318
+ if (modal.id) {
319
+ try {
320
+ sessionStorage.removeItem('ln-modal-pending:' + modal.id);
321
+ } catch (err) {}
322
+ if (hashGet(modal.id) !== null) {
323
+ hashSet(modal.id, null);
324
+ }
325
+ }
326
+
327
+ if (modal.dataset.lnModalMode === 'new') {
328
+ _resetModalForm(modal);
329
+ }
330
+ });
331
+
332
+ // ─── Component Constructor & Registration ────────────
333
+
334
+ function _component(dom) {
335
+ this.dom = dom;
336
+ return this;
337
+ }
338
+
339
+ _component.prototype.destroy = function () {
340
+ if (!this.dom[DOM_ATTRIBUTE]) return;
341
+ delete this.dom[DOM_ATTRIBUTE];
342
+ };
343
+
344
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-modal-coordinator');
345
+ })();
@@ -3,8 +3,6 @@
3
3
  Active link highlighter — automatically marks the active link in navigation based on the current URL.
4
4
  Works with `pushState` (ln-ajax) and `popstate` (browser back/forward).
5
5
 
6
- For internal mechanics — singleton `history.pushState` patch, per-instance MutationObserver, URL normalization — see [`docs/js/nav.md`](../../docs/js/nav.md).
7
-
8
6
  ## Integration
9
7
 
10
8
  ### In-Bundle (Standard Integration)
@@ -28,11 +26,14 @@ If you only need the active link highlighter component, load the compiled zero-d
28
26
  | Attribute | On | Description |
29
27
  |-----------|-----|-------------|
30
28
  | `data-ln-nav="className"` | `<nav>` element | CSS class to apply to the active link (any class name) |
29
+ | `data-ln-nav-exact` | `<nav>` element | Opt out of parent-prefix matching — only exact URL matches activate a link. Read at init; adding it after initialization requires re-init. |
31
30
 
32
31
  ## Behavior
33
32
 
34
33
  - Compares each `<a href>` inside the nav against `window.location.pathname`.
35
- - **Match rule**: exact match (`/users` == `/users`) OR parent-prefix match (`/users` matches `/users/42`). Root (`/`) is exact-only — it never matches as a parent. Trailing slashes are normalized (`/users/` == `/users`).
34
+ - **Match rule (default)**: exact match (`/users` == `/users`) OR parent-prefix match (`/users` matches `/users/42`). Root (`/`) is exact-only — it never matches as a parent. Trailing slashes are normalized (`/users/` == `/users`).
35
+ - **Exact-only mode** (`data-ln-nav-exact`): only strict URL equality activates a link. Parent-prefix matching is disabled.
36
+ - Active links receive both the CSS class AND `aria-current="page"` (accessibility). Inactive links have `aria-current` removed.
36
37
  - Re-runs on `popstate` (browser back/forward) and on `history.pushState` (ln-ajax navigation) automatically — no configuration.
37
38
  - New `<a>` elements added under the nav after init are scored immediately (per-instance MutationObserver).
38
39
 
@@ -68,3 +69,45 @@ nav a {
68
69
  ## API
69
70
 
70
71
  `data-ln-nav` is the contract — setting the attribute on a connected `<nav>` is sufficient (the document-level MutationObserver picks it up). For custom roots that the observer does not watch (Shadow DOM, iframe), call `window.lnNav(rootElement)` to upgrade manually. Each `[data-ln-nav]` element exposes `element.lnNav.destroy()` for teardown.
72
+
73
+ ---
74
+
75
+ ## ⚡ DOM Events
76
+
77
+ All events bubble from the `<nav>` element.
78
+
79
+ | Event | Cancelable | Payload (`detail`) | Description |
80
+ |---|---|---|---|
81
+ | `ln-nav:before-update` | Yes | `{ target }` | Fires at the start of every `update()` pass. Call `e.preventDefault()` to skip the highlight recalculation. |
82
+ | `ln-nav:update` | No | `{ target }` | Fires after active classes / `aria-current` have been applied to matching links. |
83
+ | `ln-nav:destroyed` | No | `{ target }` | Fires inside `destroy()`, after listeners/observer are torn down. |
84
+
85
+ ---
86
+
87
+ ## 🔧 Internals
88
+
89
+ Source: `js/ln-nav/ln-nav.js`. Registered via `registerComponent` with `extraAttributes: ['data-ln-nav-exact']` and an `onAttributeChange` bridge — the shared core handles instantiation, body-guarding, and teardown.
90
+
91
+ ### Singleton `pushState` patch
92
+
93
+ `history.pushState` is monkey-patched once per page, guarded by `history._lnNavPatched`. Every `[data-ln-nav]` instance pushes its `updateHandler` onto a shared `_pushStateCallbacks` array; the patched `pushState` calls the original, then invokes every registered handler. This is the only mechanism that catches URL changes from `pushState`-based navigation (e.g. `ln-ajax`); `popstate` is wired separately for back/forward.
94
+
95
+ ### Per-instance MutationObserver
96
+
97
+ Each instance also watches its own container (`childList`/`subtree`) so dynamically inserted/removed anchors (e.g. an AJAX-rendered menu) trigger `update()` without waiting for a URL change.
98
+
99
+ ### URL normalization & matching
100
+
101
+ Both `link.href` and `location.pathname` go through `new URL(href, location.href)` then a trailing-slash strip (root falls back to `/`). Hash-only links, `mailto:`/`tel:`/`javascript:`, and cross-host links are excluded outright. Match rule: exact equality, or (unless `data-ln-nav-exact`) parent-prefix — `normalizedCurrent.startsWith(normalizedHref + '/')`, with `/` excluded from the parent rule so root doesn't match everything.
102
+
103
+ ### `update()` sequence
104
+
105
+ Dispatches cancelable `ln-nav:before-update` (a listener calling `preventDefault()` aborts) → adds `activeClass` + `aria-current="page"` to matching links, removes both from non-matching ones → dispatches bubbling `ln-nav:update`.
106
+
107
+ ### Attribute bridge
108
+
109
+ `_syncAttribute(el, attrName)` runs on `data-ln-nav`/`data-ln-nav-exact` mutation: a class-name change clears the old class from every anchor before caching the new one and re-running `update()`; an exact-mode change updates the flag and re-runs `update()`.
110
+
111
+ ### Teardown
112
+
113
+ `destroy()` disconnects the local observer, removes the `popstate` listener, splices `updateHandler` out of the global `_pushStateCallbacks` array, and dispatches `ln-nav:destroyed`.
@@ -1 +1 @@
1
- (function(){"use strict";function y(r,s){if(!document.body){document.addEventListener("DOMContentLoaded",function(){y(r,s)}),console.warn("["+s+'] Script loaded before <body> — add "defer" to your <script> tag');return}r()}const h={};function m(r,s){h[r]=s}function g(r){return h[r]||{ingress:s=>s,egress:s=>s}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=m,window.lnCore.getDataMapper=g),(function(){const r="data-ln-nav",s="lnNav";if(window[s]!==void 0)return;const w=new WeakMap,l=[];if(!history._lnNavPatched){const t=history.pushState;history.pushState=function(){t.apply(history,arguments);for(const o of l)o()},history._lnNavPatched=!0}function f(t){if(!t.hasAttribute(r)||w.has(t))return;const o=t.getAttribute(r);if(!o)return;const e=S(t,o);w.set(t,e),t[s]=e}function S(t,o){let e=Array.from(t.querySelectorAll("a"));p(e,o,window.location.pathname);const n=function(){e=Array.from(t.querySelectorAll("a")),p(e,o,window.location.pathname)};window.addEventListener("popstate",n),l.push(n);const a=new MutationObserver(function(d){for(const u of d)if(u.type==="childList"){for(const i of u.addedNodes)if(i.nodeType===1){if(i.tagName==="A")e.push(i),p([i],o,window.location.pathname);else if(i.querySelectorAll){const c=Array.from(i.querySelectorAll("a"));e=e.concat(c),p(c,o,window.location.pathname)}}for(const i of u.removedNodes)if(i.nodeType===1){if(i.tagName==="A")e=e.filter(function(c){return c!==i});else if(i.querySelectorAll){const c=Array.from(i.querySelectorAll("a"));e=e.filter(function(v){return!c.includes(v)})}}}});return a.observe(t,{childList:!0,subtree:!0}),{navElement:t,activeClass:o,observer:a,updateHandler:n,destroy:function(){a.disconnect(),window.removeEventListener("popstate",n);const d=l.indexOf(n);d!==-1&&l.splice(d,1),w.delete(t),delete t[s]}}}function b(t){try{return new URL(t,window.location.href).pathname.replace(/\/$/,"")||"/"}catch{return t.replace(/\/$/,"")||"/"}}function p(t,o,e){const n=b(e);for(const a of t){const d=a.getAttribute("href");if(!d)continue;const u=b(d);a.classList.remove(o);const i=u===n,c=u!=="/"&&n.startsWith(u+"/");(i||c)&&a.classList.add(o)}}function L(){y(function(){new MutationObserver(function(o){for(const e of o)if(e.type==="childList"){for(const n of e.addedNodes)if(n.nodeType===1&&(n.hasAttribute&&n.hasAttribute(r)&&f(n),n.querySelectorAll))for(const a of n.querySelectorAll("["+r+"]"))f(a)}else e.type==="attributes"&&e.target.hasAttribute&&e.target.hasAttribute(r)&&f(e.target)}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[r]})},"ln-nav")}window[s]=f;function A(){for(const t of document.querySelectorAll("["+r+"]"))f(t)}L(),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",A):A()})()})();
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 w={};function D(t,e){w[t]||(w[t]=document.querySelector('[data-ln-template="'+t+'"]'));const i=w[t];return i?i.content.cloneNode(!0):(console.warn("["+(e||"ln-core")+'] Template "'+t+'" not found'),null)}function A(t,e,i){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:i||{}}))}function W(t,e,i){const c=new CustomEvent(e,{bubbles:!0,cancelable:!0,detail:i||{}});return t.dispatchEvent(c),c}function x(t,e){if(!t||!e)return t;const i=t.querySelectorAll("[data-ln-field]");for(let n=0;n<i.length;n++){const o=i[n],r=o.getAttribute("data-ln-field");e[r]!=null&&(o.textContent=e[r])}const c=t.querySelectorAll("[data-ln-attr]");for(let n=0;n<c.length;n++){const o=c[n],r=o.getAttribute("data-ln-attr").split(",");for(let l=0;l<r.length;l++){const s=r[l].trim().split(":");if(s.length!==2)continue;const u=s[0].trim(),f=s[1].trim();e[f]!=null&&o.setAttribute(u,e[f])}}const d=t.querySelectorAll("[data-ln-show]");for(let n=0;n<d.length;n++){const o=d[n],r=o.getAttribute("data-ln-show");r in e&&o.classList.toggle("hidden",!e[r])}const a=t.querySelectorAll("[data-ln-class]");for(let n=0;n<a.length;n++){const o=a[n],r=o.getAttribute("data-ln-class").split(",");for(let l=0;l<r.length;l++){const s=r[l].trim().split(":");if(s.length!==2)continue;const u=s[0].trim(),f=s[1].trim();f in e&&o.classList.toggle(u,!!e[f])}}return t}function j(t,e){t.matches&&t.matches("[data-ln-form], [data-ln-fillable]")&&t.dispatchEvent(new CustomEvent("ln-fill",{detail:e??null,bubbles:!0}));const i=t.querySelectorAll("[data-ln-form], [data-ln-fillable]");for(let c=0;c<i.length;c++)i[c].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)x(t.target,t.detail);else{const e=t.target.querySelectorAll("[data-ln-field]");for(let i=0;i<e.length;i++)e[i].textContent=""}})));function L(t,e){if(!t||!e)return t;const i=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;i.nextNode();){const a=i.currentNode;a.textContent.indexOf("{{")!==-1&&(a.textContent=a.textContent.replace(/\{\{\s*(\w+)\s*\}\}/g,function(n,o){return e[o]!==void 0?e[o]:""}))}const c=function(a,n){return e[n]!==void 0?e[n]:""},d=Array.from(t.querySelectorAll("*"));t.nodeType===1&&d.push(t);for(let a=0;a<d.length;a++){const n=d[a],o=n.attributes;for(let r=0;r<o.length;r++){const l=o[r];l.value.indexOf("{{")!==-1&&n.setAttribute(l.name,l.value.replace(/\{\{\s*(\w+)\s*\}\}/g,c))}}return t}function M(t,e,i,c,d,a){const n={};for(let r=0;r<t.children.length;r++){const l=t.children[r],s=l.getAttribute("data-ln-key");s&&(n[s]=l)}const o=document.createDocumentFragment();for(let r=0;r<e.length;r++){const l=e[r],s=String(c(l));let u=n[s];if(u)d(u,l,r);else{const f=D(i,a);if(!f||(L(f,l),u=f.firstElementChild,!u))continue;u.setAttribute("data-ln-key",s),d(u,l,r)}o.appendChild(u)}t.textContent="",t.appendChild(o)}function S(t,e){if(!document.body){document.addEventListener("DOMContentLoaded",function(){S(t,e)}),console.warn("["+e+'] Script loaded before <body> — add "defer" to your <script> tag');return}t()}function b(t,e,i,c){if(t.nodeType!==1)return;const a=e.indexOf("[")!==-1||e.indexOf(".")!==-1||e.indexOf("#")!==-1?e:"["+e+"]",n=Array.from(t.querySelectorAll(a));t.matches&&t.matches(a)&&n.push(t);for(const o of n)o[i]||(o[i]=new c(o))}function N(t,e,i,c,d={}){const a=d.extraAttributes||[],n=d.onAttributeChange||null,o=d.onInit||null;function r(l){const s=l||document.body;b(s,t,e,i),o&&o(s)}return S(function(){const l=new MutationObserver(function(u){for(let f=0;f<u.length;f++){const h=u[f];if(h.type==="childList"){for(let p=0;p<h.addedNodes.length;p++){const m=h.addedNodes[p];m.nodeType===1&&(b(m,t,e,i),o&&o(m))}for(let p=0;p<h.removedNodes.length;p++){const m=h.removedNodes[p];if(m.nodeType===1){const T=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",y=Array.from(m.querySelectorAll(T));m.matches&&m.matches(T)&&y.push(m);for(let C=0;C<y.length;C++){const q=y[C];if(!document.contains(q)){const v=q[e];v&&typeof v.destroy=="function"&&v.destroy()}}}}}else h.type==="attributes"&&(n&&h.target[e]?n(h.target,h.attributeName):(b(h.target,t,e,i),o&&o(h.target)))}});let s=[];if(t.indexOf("[")!==-1){const u=/\[([\w-]+)/g;let f;for(;(f=u.exec(t))!==null;)s.push(f[1])}else s.push(t);l.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:s.concat(a)})},c),window[e]=r,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){r(document.body)}):r(document.body),r}const k={};function H(t,e){k[t]=e}function P(t){return k[t]||{ingress:e=>e,egress:e=>e}}const O={};function E(t,e){if(!t||typeof e!="object")return;const i=t.toLowerCase().split("-")[0];O[i]=e}function F(t){if(!t)return null;const e=t.toLowerCase().split("-")[0];return O[e]||null}E("mk",{monthsLong:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],monthsShort:["јан","фев","мар","апр","мај","јун","јул","авг","септ","окт","ноем","дек"],daysLong:["недела","понеделник","вторник","среда","четврток","петок","сабота"],daysShort:["нед","пон","вт","ср","чет","пет","саб"]}),typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=H,window.lnCore.getDataMapper=P,window.lnCore.registerLocaleFallback=E,window.lnCore.getLocaleFallback=F,window.lnCore.fillTemplate=L,window.lnCore.fill=x,window.lnCore.lnFill=j,window.lnCore.renderList=M);function _(t){return(t||"").replace(/^#/,"")}function g(t){const e=t===void 0?location.hash:t,i={},c=_(e);if(!c)return i;const d=c.split("&");for(let a=0;a<d.length;a++){const n=d[a];if(!n)continue;const o=n.indexOf(":"),r=o>-1?n.slice(0,o):n,l=o>-1?n.slice(o+1):"";if(r)try{i[r]=decodeURIComponent(l)}catch{i[r]=l}}return i}function B(t){if(!t)return null;const e=g();return t in e?e[t]:null}function I(t,e){if(!t)return;const i=g();e==null?delete i[t]:i[t]=String(e);const d=Object.keys(i).map(function(a){const n=i[a];return n===""?a:a+":"+encodeURIComponent(n)}).join("&");_(location.hash)!==d&&(location.hash=d)}function R(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=g,window.lnCore.hashGet=B,window.lnCore.hashSet=I,window.lnCore.hashLinkClick=R),(function(){const t="data-ln-nav",e="lnNav";if(window[e]!==void 0)return;const i=[];if(!history._lnNavPatched){const n=history.pushState;history.pushState=function(){n.apply(history,arguments);for(const o of i)o()},history._lnNavPatched=!0}function c(n){return this.dom=n,this.activeClass=n.getAttribute(t)||"active",this.exact=n.hasAttribute("data-ln-nav-exact"),this.updateHandler=()=>this.update(),window.addEventListener("popstate",this.updateHandler),i.push(this.updateHandler),this.observer=new MutationObserver(()=>this.update()),this.observer.observe(n,{childList:!0,subtree:!0}),this.update(),this}c.prototype.update=function(){if(!this.activeClass||W(this.dom,"ln-nav:before-update",{target:this.dom}).defaultPrevented)return;const o=Array.from(this.dom.querySelectorAll("a")),r=window.location.pathname,l=d(r);for(const s of o){const u=s.getAttribute("href");if(!u||u==="#"||u.startsWith("#")||u.startsWith("javascript:")||u.startsWith("mailto:")||u.startsWith("tel:")){s.classList.remove(this.activeClass),s.removeAttribute("aria-current");continue}if(s.hostname&&s.hostname!==window.location.hostname){s.classList.remove(this.activeClass),s.removeAttribute("aria-current");continue}const f=d(u),h=f===l,p=!this.exact&&f!=="/"&&l.startsWith(f+"/");h||p?(s.classList.add(this.activeClass),s.setAttribute("aria-current","page")):(s.classList.remove(this.activeClass),s.removeAttribute("aria-current"))}A(this.dom,"ln-nav:update",{target:this.dom})},c.prototype.destroy=function(){if(!this.dom[e])return;this.observer&&this.observer.disconnect(),window.removeEventListener("popstate",this.updateHandler);const n=i.indexOf(this.updateHandler);n!==-1&&i.splice(n,1),A(this.dom,"ln-nav:destroyed",{target:this.dom}),delete this.dom[e]};function d(n){try{return new URL(n,window.location.href).pathname.replace(/\/$/,"")||"/"}catch{return n.replace(/\/$/,"")||"/"}}function a(n,o){const r=n[e];if(r){if(o===t){if(!n.hasAttribute(t)){r.destroy();return}const l=r.activeClass,s=n.getAttribute(t)||"active";if(l!==s){const u=n.querySelectorAll("a");for(const f of u)l&&f.classList.remove(l);r.activeClass=s}}else o==="data-ln-nav-exact"&&(r.exact=n.hasAttribute("data-ln-nav-exact"));r.update()}}N(t,e,c,"ln-nav",{extraAttributes:["data-ln-nav-exact"],onAttributeChange:a})})()})();