@livenetworks/ashlar 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +177 -0
  2. package/js/COMPONENTS.md +1102 -0
  3. package/js/index.js +41 -0
  4. package/js/ln-accordion/README.md +137 -0
  5. package/js/ln-accordion/ln-accordion.js +1 -0
  6. package/js/ln-accordion/src/ln-accordion.js +41 -0
  7. package/js/ln-ajax/README.md +91 -0
  8. package/js/ln-ajax/ln-ajax.js +1 -0
  9. package/js/ln-ajax/src/ln-ajax.js +277 -0
  10. package/js/ln-api-connector/README.md +150 -0
  11. package/js/ln-api-connector/ln-api-connector.js +1 -0
  12. package/js/ln-api-connector/src/ln-api-connector.js +265 -0
  13. package/js/ln-autoresize/README.md +80 -0
  14. package/js/ln-autoresize/ln-autoresize.js +1 -0
  15. package/js/ln-autoresize/src/ln-autoresize.js +47 -0
  16. package/js/ln-autosave/README.md +92 -0
  17. package/js/ln-autosave/ln-autosave.js +1 -0
  18. package/js/ln-autosave/src/ln-autosave.js +147 -0
  19. package/js/ln-circular-progress/README.md +161 -0
  20. package/js/ln-circular-progress/ln-circular-progress.js +1 -0
  21. package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
  22. package/js/ln-confirm/README.md +86 -0
  23. package/js/ln-confirm/_ln-confirm.scss +13 -0
  24. package/js/ln-confirm/ln-confirm.js +1 -0
  25. package/js/ln-confirm/src/ln-confirm.js +131 -0
  26. package/js/ln-core/crypto.js +83 -0
  27. package/js/ln-core/helpers.js +411 -0
  28. package/js/ln-core/index.js +5 -0
  29. package/js/ln-core/persist.js +71 -0
  30. package/js/ln-core/positioning.js +207 -0
  31. package/js/ln-core/reactive.js +74 -0
  32. package/js/ln-couchdb-connector/README.md +156 -0
  33. package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
  34. package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
  35. package/js/ln-data-coordinator/README.md +165 -0
  36. package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
  37. package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
  38. package/js/ln-data-store/README.md +94 -0
  39. package/js/ln-data-store/ln-data-store.js +1 -0
  40. package/js/ln-data-store/src/ln-data-store.js +699 -0
  41. package/js/ln-data-table/README.md +110 -0
  42. package/js/ln-data-table/ln-data-table.js +1 -0
  43. package/js/ln-data-table/ln-data-table.scss +10 -0
  44. package/js/ln-data-table/src/ln-data-table.js +1103 -0
  45. package/js/ln-date/README.md +151 -0
  46. package/js/ln-date/ln-date.js +1 -0
  47. package/js/ln-date/src/ln-date.js +442 -0
  48. package/js/ln-dropdown/README.md +117 -0
  49. package/js/ln-dropdown/ln-dropdown.js +1 -0
  50. package/js/ln-dropdown/ln-dropdown.scss +15 -0
  51. package/js/ln-dropdown/src/ln-dropdown.js +174 -0
  52. package/js/ln-external-links/README.md +341 -0
  53. package/js/ln-external-links/ln-external-links.js +1 -0
  54. package/js/ln-external-links/src/ln-external-links.js +116 -0
  55. package/js/ln-filter/README.md +99 -0
  56. package/js/ln-filter/ln-filter.js +1 -0
  57. package/js/ln-filter/ln-filter.scss +7 -0
  58. package/js/ln-filter/src/ln-filter.js +404 -0
  59. package/js/ln-form/README.md +101 -0
  60. package/js/ln-form/ln-form.js +1 -0
  61. package/js/ln-form/src/ln-form.js +199 -0
  62. package/js/ln-http/README.md +89 -0
  63. package/js/ln-http/ln-http.js +1 -0
  64. package/js/ln-http/src/ln-http.js +219 -0
  65. package/js/ln-icons/README.md +88 -0
  66. package/js/ln-icons/ln-icons.js +1 -0
  67. package/js/ln-icons/src/ln-icons.js +169 -0
  68. package/js/ln-link/README.md +303 -0
  69. package/js/ln-link/ln-link.js +1 -0
  70. package/js/ln-link/src/ln-link.js +196 -0
  71. package/js/ln-modal/README.md +154 -0
  72. package/js/ln-modal/ln-modal.js +1 -0
  73. package/js/ln-modal/ln-modal.scss +11 -0
  74. package/js/ln-modal/src/ln-modal.js +201 -0
  75. package/js/ln-nav/README.md +70 -0
  76. package/js/ln-nav/ln-nav.js +1 -0
  77. package/js/ln-nav/src/ln-nav.js +177 -0
  78. package/js/ln-number/README.md +122 -0
  79. package/js/ln-number/ln-number.js +1 -0
  80. package/js/ln-number/src/ln-number.js +302 -0
  81. package/js/ln-popover/README.md +127 -0
  82. package/js/ln-popover/ln-popover.js +1 -0
  83. package/js/ln-popover/src/ln-popover.js +288 -0
  84. package/js/ln-progress/README.md +442 -0
  85. package/js/ln-progress/ln-progress.js +1 -0
  86. package/js/ln-progress/src/ln-progress.js +150 -0
  87. package/js/ln-search/README.md +83 -0
  88. package/js/ln-search/ln-search.js +1 -0
  89. package/js/ln-search/ln-search.scss +7 -0
  90. package/js/ln-search/src/ln-search.js +114 -0
  91. package/js/ln-sortable/README.md +95 -0
  92. package/js/ln-sortable/ln-sortable.js +1 -0
  93. package/js/ln-sortable/src/ln-sortable.js +203 -0
  94. package/js/ln-table/README.md +101 -0
  95. package/js/ln-table/ln-table-sort.js +1 -0
  96. package/js/ln-table/ln-table.js +1 -0
  97. package/js/ln-table/ln-table.scss +11 -0
  98. package/js/ln-table/src/ln-table-sort.js +168 -0
  99. package/js/ln-table/src/ln-table.js +473 -0
  100. package/js/ln-tabs/README.md +137 -0
  101. package/js/ln-tabs/ln-tabs.js +1 -0
  102. package/js/ln-tabs/src/ln-tabs.js +171 -0
  103. package/js/ln-time/README.md +81 -0
  104. package/js/ln-time/ln-time.js +1 -0
  105. package/js/ln-time/src/ln-time.js +192 -0
  106. package/js/ln-toast/README.md +122 -0
  107. package/js/ln-toast/ln-toast.js +15 -0
  108. package/js/ln-toast/src/ln-toast.js +210 -0
  109. package/js/ln-toast/template.html +14 -0
  110. package/js/ln-toggle/README.md +137 -0
  111. package/js/ln-toggle/ln-toggle.js +1 -0
  112. package/js/ln-toggle/src/ln-toggle.js +139 -0
  113. package/js/ln-tooltip/README.md +58 -0
  114. package/js/ln-tooltip/ln-tooltip.js +1 -0
  115. package/js/ln-tooltip/ln-tooltip.scss +9 -0
  116. package/js/ln-tooltip/src/ln-tooltip.js +169 -0
  117. package/js/ln-translations/README.md +96 -0
  118. package/js/ln-translations/ln-translations.js +1 -0
  119. package/js/ln-translations/src/ln-translations.js +275 -0
  120. package/js/ln-upload/README.md +180 -0
  121. package/js/ln-upload/ln-upload.js +1 -0
  122. package/js/ln-upload/ln-upload.scss +20 -0
  123. package/js/ln-upload/src/ln-upload.js +407 -0
  124. package/js/ln-validate/README.md +108 -0
  125. package/js/ln-validate/ln-validate.js +1 -0
  126. package/js/ln-validate/src/ln-validate.js +160 -0
  127. package/package.json +55 -0
  128. package/scss/base/_global.scss +83 -0
  129. package/scss/base/_reset.scss +17 -0
  130. package/scss/base/_typography.scss +125 -0
  131. package/scss/components/_accordion.scss +34 -0
  132. package/scss/components/_ajax.scss +15 -0
  133. package/scss/components/_alert.scss +5 -0
  134. package/scss/components/_app-shell.scss +15 -0
  135. package/scss/components/_avatar.scss +6 -0
  136. package/scss/components/_breadcrumbs.scss +33 -0
  137. package/scss/components/_button.scss +20 -0
  138. package/scss/components/_card.scss +10 -0
  139. package/scss/components/_chip.scss +5 -0
  140. package/scss/components/_circular-progress.scss +29 -0
  141. package/scss/components/_confirm.scss +5 -0
  142. package/scss/components/_data-table.scss +83 -0
  143. package/scss/components/_dropdown.scss +25 -0
  144. package/scss/components/_empty-state.scss +22 -0
  145. package/scss/components/_form.scss +100 -0
  146. package/scss/components/_layout.scss +8 -0
  147. package/scss/components/_link.scss +11 -0
  148. package/scss/components/_ln-table.scss +60 -0
  149. package/scss/components/_loader.scss +6 -0
  150. package/scss/components/_modal.scss +20 -0
  151. package/scss/components/_nav.scss +9 -0
  152. package/scss/components/_page-header.scss +10 -0
  153. package/scss/components/_popover.scss +10 -0
  154. package/scss/components/_progress.scss +17 -0
  155. package/scss/components/_prose.scss +5 -0
  156. package/scss/components/_scrollbar.scss +32 -0
  157. package/scss/components/_sections.scss +12 -0
  158. package/scss/components/_sidebar.scss +5 -0
  159. package/scss/components/_stat-card.scss +5 -0
  160. package/scss/components/_status-badge.scss +4 -0
  161. package/scss/components/_stepper.scss +5 -0
  162. package/scss/components/_table.scss +19 -0
  163. package/scss/components/_tabs.scss +21 -0
  164. package/scss/components/_timeline.scss +14 -0
  165. package/scss/components/_toast.scss +41 -0
  166. package/scss/components/_toggle.scss +81 -0
  167. package/scss/components/_tooltip.scss +18 -0
  168. package/scss/components/_translations.scss +111 -0
  169. package/scss/components/_upload.scss +51 -0
  170. package/scss/config/_breakpoints.scss +72 -0
  171. package/scss/config/_density.scss +117 -0
  172. package/scss/config/_icons.scss +37 -0
  173. package/scss/config/_mixins.scss +13 -0
  174. package/scss/config/_theme.scss +216 -0
  175. package/scss/config/_tokens.scss +419 -0
  176. package/scss/config/mixins/_accordion.scss +52 -0
  177. package/scss/config/mixins/_ajax.scss +39 -0
  178. package/scss/config/mixins/_alert.scss +82 -0
  179. package/scss/config/mixins/_app-shell.scss +312 -0
  180. package/scss/config/mixins/_avatar.scss +109 -0
  181. package/scss/config/mixins/_borders.scss +36 -0
  182. package/scss/config/mixins/_breadcrumbs.scss +72 -0
  183. package/scss/config/mixins/_breakpoints.scss +62 -0
  184. package/scss/config/mixins/_btn.scss +179 -0
  185. package/scss/config/mixins/_card.scss +338 -0
  186. package/scss/config/mixins/_chip.scss +66 -0
  187. package/scss/config/mixins/_circular-progress.scss +71 -0
  188. package/scss/config/mixins/_collapsible.scss +24 -0
  189. package/scss/config/mixins/_colors.scss +46 -0
  190. package/scss/config/mixins/_confirm.scss +31 -0
  191. package/scss/config/mixins/_data-table.scss +346 -0
  192. package/scss/config/mixins/_display.scss +32 -0
  193. package/scss/config/mixins/_dropdown.scss +143 -0
  194. package/scss/config/mixins/_empty-state.scss +30 -0
  195. package/scss/config/mixins/_focus.scss +55 -0
  196. package/scss/config/mixins/_footer.scss +42 -0
  197. package/scss/config/mixins/_form.scss +601 -0
  198. package/scss/config/mixins/_index.scss +58 -0
  199. package/scss/config/mixins/_interaction.scss +15 -0
  200. package/scss/config/mixins/_kbd.scss +22 -0
  201. package/scss/config/mixins/_layout.scss +117 -0
  202. package/scss/config/mixins/_link.scss +55 -0
  203. package/scss/config/mixins/_ln-table.scss +420 -0
  204. package/scss/config/mixins/_loader.scss +26 -0
  205. package/scss/config/mixins/_modal.scss +66 -0
  206. package/scss/config/mixins/_motion.scss +19 -0
  207. package/scss/config/mixins/_nav.scss +273 -0
  208. package/scss/config/mixins/_page-header.scss +69 -0
  209. package/scss/config/mixins/_popover.scss +25 -0
  210. package/scss/config/mixins/_position.scss +32 -0
  211. package/scss/config/mixins/_progress.scss +56 -0
  212. package/scss/config/mixins/_prose.scss +127 -0
  213. package/scss/config/mixins/_shadows.scss +8 -0
  214. package/scss/config/mixins/_sidebar.scss +95 -0
  215. package/scss/config/mixins/_sizing.scss +6 -0
  216. package/scss/config/mixins/_spacing.scss +19 -0
  217. package/scss/config/mixins/_stat-card.scss +68 -0
  218. package/scss/config/mixins/_status-badge.scss +83 -0
  219. package/scss/config/mixins/_stepper.scss +78 -0
  220. package/scss/config/mixins/_table.scss +215 -0
  221. package/scss/config/mixins/_tabs.scss +64 -0
  222. package/scss/config/mixins/_timeline.scss +69 -0
  223. package/scss/config/mixins/_toast.scss +148 -0
  224. package/scss/config/mixins/_tooltip.scss +111 -0
  225. package/scss/config/mixins/_transitions.scss +10 -0
  226. package/scss/config/mixins/_translations.scss +124 -0
  227. package/scss/config/mixins/_typography.scss +57 -0
  228. package/scss/config/mixins/_upload.scss +168 -0
  229. package/scss/ln-ashlar.scss +62 -0
  230. package/scss/tabler-icons.txt +5039 -0
  231. package/scss/utilities/_animations.scss +83 -0
  232. package/scss/utilities/_utilities.scss +49 -0
@@ -0,0 +1,101 @@
1
+ # ln-form
2
+
3
+ A high-performance **Form Coordinator** that upgrades native HTML forms. It acts as the orchestration layer: coordinating bulk population (`fill`), native reset procedures, reactive validation state gating, and debounced auto-submission.
4
+
5
+ It delegates per-field validation rules to the `ln-validate` primitive and visual formatting to SCSS layout mixins, focusing purely on gating the submission flow and serializing form data into clean JSON.
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **The Form as Coordinator:** `ln-form` maintains no internal registry of validation rules or values. It listens to bubbled DOM events (`ln-validate:valid`, `ln-validate:invalid`) and enforces a single rule: *the form submit button is disabled until all marked fields are valid and at least one has been touched.*
12
+ 2. **Zero Network Coupling:** The component does not make XHR or fetch requests. On successful submission, it serializes values and dispatches an uncancellable `ln-form:submit` CustomEvent carrying the payload. Network integration belongs entirely in a separate transport layer (`ln-http` or a custom controller).
13
+ 3. **Reactive Integrity:** Data flows strictly through DOM events. Programmatic changes must trigger standard events (`input` / `change`) so that dependent primitives (`ln-validate`, `ln-autoresize`) can react in synchrony.
14
+
15
+ ---
16
+
17
+ ## 📦 Minimal Blueprint
18
+
19
+ ```html
20
+ <form id="user-form" data-ln-form>
21
+ <!-- Sibling elements wrapped in a semantic form-element -->
22
+ <div class="form-element">
23
+ <label for="username">Username</label>
24
+ <input id="username" name="username" required data-ln-validate>
25
+ <ul data-ln-validate-errors>
26
+ <li class="hidden" data-ln-validate-error="required">Username is required</li>
27
+ </ul>
28
+ </div>
29
+
30
+ <!-- Form Actions Footer -->
31
+ <ul class="form-actions">
32
+ <li><button type="button" data-ln-modal-close>Cancel</button></li>
33
+ <li><button type="submit">Save</button></li>
34
+ </ul>
35
+ </form>
36
+ ```
37
+
38
+ > [!WARNING]
39
+ > Always set `type="button"` on Cancel buttons. Otherwise, the browser defaults to `type="submit"` and triggers validation/submission.
40
+
41
+ ---
42
+
43
+ ## 🛠️ Declarative API Contract
44
+
45
+ ### HTML Attributes
46
+
47
+ | Attribute | Elements | Description |
48
+ | :--- | :--- | :--- |
49
+ | `data-ln-form` | `<form>` | Initializes the coordinator. Evaluates initial button states. |
50
+ | `data-ln-form-auto` | `<form>` | Automatically submits the form on any user value change. |
51
+ | `data-ln-form-debounce="ms"` | `<form>` | Debounce duration in milliseconds before auto-submitting. |
52
+
53
+ ### JS API
54
+
55
+ Access the coordinator instance directly via the `lnForm` property on the form element:
56
+
57
+ ```javascript
58
+ const form = document.getElementById('user-form');
59
+
60
+ // 1. Bulk populate fields by name (fires synthetic input/change events)
61
+ form.lnForm.fill({ username: 'dalibor', role: 'admin' });
62
+
63
+ // 2. Force-validate all fields and trigger submission if clean
64
+ form.lnForm.submit();
65
+
66
+ // 3. Clear all fields, reset validity states, and re-enable the fresh guard
67
+ form.lnForm.reset();
68
+
69
+ // 4. Check if all data-ln-validate fields are valid (Boolean getter)
70
+ if (form.lnForm.isValid) { ... }
71
+
72
+ // 5. Clean up listeners and destroy the instance
73
+ form.lnForm.destroy();
74
+ ```
75
+
76
+ ---
77
+
78
+ ## ⚡ DOM Events
79
+
80
+ ### Emitted
81
+
82
+ | Event | Bubbles | Payload | Description |
83
+ | :--- | :--- | :--- | :--- |
84
+ | `ln-form:submit` | Yes | `{ data: Object }` | Dispatched with serialized form key-values on valid submission. |
85
+ | `ln-form:reset-complete` | Yes | `{ target: HTMLElement }` | Dispatched after a complete reactive reset cycle. |
86
+ | `ln-form:destroyed` | Yes | `{ target: HTMLElement }` | Dispatched when the coordinator is torn down. |
87
+
88
+ ### Received
89
+
90
+ | Event | Payload | Description |
91
+ | :--- | :--- | :--- |
92
+ | `ln-form:fill` | `{ key: value }` | Triggers form population. (Prefer direct `form.lnForm.fill()` API). |
93
+ | `ln-form:reset` | None | Triggers form reset. (Prefer direct `form.lnForm.reset()` API). |
94
+
95
+ ---
96
+
97
+ ## ⚠️ Common Pitfalls
98
+
99
+ - **Setting `input.value` directly:** Doing `input.value = 'new'` is silent in the DOM. Neither validation nor layout systems will detect the change. **Always** use `form.lnForm.fill()` or manually dispatch an `input` or `change` event.
100
+ - **Relying on Native Form Resets:** Clicking a `<button type="reset">` only reverts DOM attributes. It does not trigger synthetic events, leaving textareas at expanded heights and custom controls out of sync. Use `form.lnForm.reset()` instead.
101
+ - **Debounced fill on auto-submit forms:** Calling `fill()` on an auto-submit form will trigger an automatic submission after the debounce timeout.
@@ -0,0 +1 @@
1
+ (function(){"use strict";function m(l,t,u){l.dispatchEvent(new CustomEvent(t,{bubbles:!0,detail:u||{}}))}function y(l,t){if(!document.body){document.addEventListener("DOMContentLoaded",function(){y(l,t)}),console.warn("["+t+'] Script loaded before <body> — add "defer" to your <script> tag');return}l()}function p(l,t,u,a){if(l.nodeType!==1)return;const o=t.indexOf("[")!==-1||t.indexOf(".")!==-1||t.indexOf("#")!==-1?t:"["+t+"]",d=Array.from(l.querySelectorAll(o));l.matches&&l.matches(o)&&d.push(l);for(const e of d)e[u]||(e[u]=new a(e))}function g(l){const t={},u=l.elements;for(let a=0;a<u.length;a++){const i=u[a];if(!(!i.name||i.disabled||i.type==="file"||i.type==="submit"||i.type==="button"))if(i.type==="checkbox")t[i.name]||(t[i.name]=[]),i.checked&&t[i.name].push(i.value);else if(i.type==="radio")i.checked&&(t[i.name]=i.value);else if(i.type==="select-multiple"){t[i.name]=[];for(let o=0;o<i.options.length;o++)i.options[o].selected&&t[i.name].push(i.options[o].value)}else t[i.name]=i.value}return t}function E(l,t){const u=l.elements,a=[];for(let i=0;i<u.length;i++){const o=u[i];if(!o.name||!(o.name in t)||o.type==="file"||o.type==="submit"||o.type==="button")continue;const d=t[o.name];if(o.type==="checkbox")o.checked=Array.isArray(d)?d.indexOf(o.value)!==-1:!!d,a.push(o);else if(o.type==="radio")o.checked=o.value===String(d),a.push(o);else if(o.type==="select-multiple"){if(Array.isArray(d))for(let e=0;e<o.options.length;e++)o.options[e].selected=d.indexOf(o.options[e].value)!==-1;a.push(o)}else o.value=d,a.push(o)}return a}function A(l,t,u,a,i={}){const o=i.extraAttributes||[],d=i.onAttributeChange||null,e=i.onInit||null;function n(s){const r=s||document.body;p(r,l,t,u),e&&e(r)}return y(function(){const s=new MutationObserver(function(c){for(let h=0;h<c.length;h++){const f=c[h];if(f.type==="childList")for(let b=0;b<f.addedNodes.length;b++){const v=f.addedNodes[b];v.nodeType===1&&(p(v,l,t,u),e&&e(v))}else f.type==="attributes"&&(d&&f.target[t]?d(f.target,f.attributeName):(p(f.target,l,t,u),e&&e(f.target)))}});let r=[];if(l.indexOf("[")!==-1){const c=/\[([\w-]+)/g;let h;for(;(h=c.exec(l))!==null;)r.push(h[1])}else r.push(l);s.observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:r.concat(o)})},a),window[t]=n,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){n(document.body)}):n(document.body),n}const _={};function L(l,t){_[l]=t}function T(l){return _[l]||{ingress:t=>t,egress:t=>t}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=L,window.lnCore.getDataMapper=T),(function(){const l="data-ln-form",t="lnForm",u="data-ln-form-auto",a="data-ln-form-debounce",i="data-ln-validate",o="lnValidate";if(window[t]!==void 0)return;function d(e){this.dom=e,this._debounceTimer=null;const n=this;if(this._onValid=function(){n._updateSubmitButton()},this._onInvalid=function(){n._updateSubmitButton()},this._onSubmit=function(s){s.preventDefault(),n.submit()},this._onFill=function(s){s.detail&&n.fill(s.detail)},this._onFormReset=function(){n.reset()},this._onNativeReset=function(){setTimeout(function(){n._resetValidation()},0)},e.addEventListener("ln-validate:valid",this._onValid),e.addEventListener("ln-validate:invalid",this._onInvalid),e.addEventListener("submit",this._onSubmit),e.addEventListener("ln-form:fill",this._onFill),e.addEventListener("ln-form:reset",this._onFormReset),e.addEventListener("reset",this._onNativeReset),this._onAutoInput=null,e.hasAttribute(u)){const s=parseInt(e.getAttribute(a))||0;this._onAutoInput=function(){s>0?(clearTimeout(n._debounceTimer),n._debounceTimer=setTimeout(function(){n.submit()},s)):n.submit()},e.addEventListener("input",this._onAutoInput),e.addEventListener("change",this._onAutoInput)}return this._updateSubmitButton(),this}d.prototype._updateSubmitButton=function(){const e=this.dom.querySelectorAll('button[type="submit"], input[type="submit"]');if(!e.length)return;const n=this.dom.querySelectorAll("["+i+"]");let s=!1;if(n.length>0){let r=!1,c=!1;for(let h=0;h<n.length;h++){const f=n[h][o];f&&f._touched&&(r=!0),n[h].checkValidity()||(c=!0)}s=c||!r}for(let r=0;r<e.length;r++)e[r].disabled=s},d.prototype.fill=function(e){const n=E(this.dom,e);for(let s=0;s<n.length;s++){const r=n[s],c=r.tagName==="SELECT"||r.type==="checkbox"||r.type==="radio";r.dispatchEvent(new Event(c?"change":"input",{bubbles:!0}))}},d.prototype.submit=function(){const e=this.dom.querySelectorAll("["+i+"]");let n=!0;for(let r=0;r<e.length;r++){const c=e[r][o];c&&(c.validate()||(n=!1))}if(!n)return;const s=g(this.dom);m(this.dom,"ln-form:submit",{data:s})},d.prototype.reset=function(){this.dom.reset();const e=this.dom.querySelectorAll("input, textarea, select");for(let n=0;n<e.length;n++){const s=e[n],r=s.tagName==="SELECT"||s.type==="checkbox"||s.type==="radio";s.dispatchEvent(new Event(r?"change":"input",{bubbles:!0}))}this._resetValidation(),m(this.dom,"ln-form:reset-complete",{target:this.dom})},d.prototype._resetValidation=function(){const e=this.dom.querySelectorAll("["+i+"]");for(let n=0;n<e.length;n++){const s=e[n][o];s&&s.reset()}this._updateSubmitButton()},Object.defineProperty(d.prototype,"isValid",{get:function(){const e=this.dom.querySelectorAll("["+i+"]");for(let n=0;n<e.length;n++)if(!e[n].checkValidity())return!1;return!0}}),d.prototype.destroy=function(){this.dom[t]&&(this.dom.removeEventListener("ln-validate:valid",this._onValid),this.dom.removeEventListener("ln-validate:invalid",this._onInvalid),this.dom.removeEventListener("submit",this._onSubmit),this.dom.removeEventListener("ln-form:fill",this._onFill),this.dom.removeEventListener("ln-form:reset",this._onFormReset),this.dom.removeEventListener("reset",this._onNativeReset),this._onAutoInput&&(this.dom.removeEventListener("input",this._onAutoInput),this.dom.removeEventListener("change",this._onAutoInput)),clearTimeout(this._debounceTimer),m(this.dom,"ln-form:destroyed",{target:this.dom}),delete this.dom[t])},A(l,t,d,"ln-form")})()})();
@@ -0,0 +1,199 @@
1
+ import { dispatch, serializeForm, populateForm, registerComponent } from '../../ln-core';
2
+
3
+ (function () {
4
+ const DOM_SELECTOR = 'data-ln-form';
5
+ const DOM_ATTRIBUTE = 'lnForm';
6
+ const AUTO_ATTR = 'data-ln-form-auto';
7
+ const DEBOUNCE_ATTR = 'data-ln-form-debounce';
8
+ const VALIDATE_SELECTOR = 'data-ln-validate';
9
+ const VALIDATE_ATTRIBUTE = 'lnValidate';
10
+
11
+ if (window[DOM_ATTRIBUTE] !== undefined) return;
12
+
13
+ // ─── Component ─────────────────────────────────────────────
14
+
15
+ function _component(form) {
16
+ this.dom = form;
17
+ this._debounceTimer = null;
18
+
19
+ const self = this;
20
+
21
+ this._onValid = function () {
22
+ self._updateSubmitButton();
23
+ };
24
+
25
+ this._onInvalid = function () {
26
+ self._updateSubmitButton();
27
+ };
28
+
29
+ this._onSubmit = function (e) {
30
+ e.preventDefault();
31
+ self.submit();
32
+ };
33
+
34
+ this._onFill = function (e) {
35
+ if (e.detail) self.fill(e.detail);
36
+ };
37
+
38
+ this._onFormReset = function () {
39
+ self.reset();
40
+ };
41
+
42
+ this._onNativeReset = function () {
43
+ setTimeout(function () { self._resetValidation(); }, 0);
44
+ };
45
+
46
+ form.addEventListener('ln-validate:valid', this._onValid);
47
+ form.addEventListener('ln-validate:invalid', this._onInvalid);
48
+ form.addEventListener('submit', this._onSubmit);
49
+ form.addEventListener('ln-form:fill', this._onFill);
50
+ form.addEventListener('ln-form:reset', this._onFormReset);
51
+ form.addEventListener('reset', this._onNativeReset);
52
+
53
+ // Auto-submit
54
+ this._onAutoInput = null;
55
+ if (form.hasAttribute(AUTO_ATTR)) {
56
+ const debounceMs = parseInt(form.getAttribute(DEBOUNCE_ATTR)) || 0;
57
+ this._onAutoInput = function () {
58
+ if (debounceMs > 0) {
59
+ clearTimeout(self._debounceTimer);
60
+ self._debounceTimer = setTimeout(function () { self.submit(); }, debounceMs);
61
+ } else {
62
+ self.submit();
63
+ }
64
+ };
65
+ form.addEventListener('input', this._onAutoInput);
66
+ form.addEventListener('change', this._onAutoInput);
67
+ }
68
+
69
+ // Initial submit button state
70
+ this._updateSubmitButton();
71
+
72
+ return this;
73
+ }
74
+
75
+ _component.prototype._updateSubmitButton = function () {
76
+ const buttons = this.dom.querySelectorAll('button[type="submit"], input[type="submit"]');
77
+ if (!buttons.length) return;
78
+
79
+ const fields = this.dom.querySelectorAll('[' + VALIDATE_SELECTOR + ']');
80
+ let shouldDisable = false;
81
+
82
+ if (fields.length > 0) {
83
+ // Disable if any field is invalid OR if no fields have been touched yet
84
+ let anyTouched = false;
85
+ let anyInvalid = false;
86
+ for (let i = 0; i < fields.length; i++) {
87
+ const instance = fields[i][VALIDATE_ATTRIBUTE];
88
+ if (instance && instance._touched) anyTouched = true;
89
+ if (!fields[i].checkValidity()) anyInvalid = true;
90
+ }
91
+ shouldDisable = anyInvalid || !anyTouched;
92
+ }
93
+
94
+ for (let j = 0; j < buttons.length; j++) {
95
+ buttons[j].disabled = shouldDisable;
96
+ }
97
+ };
98
+
99
+ _component.prototype.fill = function (data) {
100
+ const filled = populateForm(this.dom, data);
101
+
102
+ // Trigger events so ln-validate picks up the changes.
103
+ // Mirror the same isChangeBased logic as ln-validate:
104
+ // SELECT/checkbox/radio -> 'change', everything else -> 'input'
105
+ for (let k = 0; k < filled.length; k++) {
106
+ const el = filled[k];
107
+ const isChangeBased = el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio';
108
+ el.dispatchEvent(new Event(isChangeBased ? 'change' : 'input', { bubbles: true }));
109
+ }
110
+ };
111
+
112
+ _component.prototype.submit = function () {
113
+ // Force-validate all fields
114
+ const fields = this.dom.querySelectorAll('[' + VALIDATE_SELECTOR + ']');
115
+ let allValid = true;
116
+
117
+ for (let i = 0; i < fields.length; i++) {
118
+ const instance = fields[i][VALIDATE_ATTRIBUTE];
119
+ if (instance) {
120
+ if (!instance.validate()) allValid = false;
121
+ }
122
+ }
123
+
124
+ if (!allValid) return;
125
+
126
+ const data = serializeForm(this.dom);
127
+ dispatch(this.dom, 'ln-form:submit', { data: data });
128
+ };
129
+
130
+ _component.prototype.reset = function () {
131
+ this.dom.reset();
132
+
133
+ // Mirror fill() — dispatch input/change so reactive consumers
134
+ // (ln-autoresize, ln-validate, custom listeners) re-react to the
135
+ // cleared values. dom.reset() clears .value but does NOT fire
136
+ // input/change events; without these dispatches, ln-autoresize
137
+ // keeps its previous height, etc.
138
+ //
139
+ // Order matters: this loop MUST run BEFORE _resetValidation().
140
+ // ln-validate's input handler will mark default-empty required
141
+ // fields as invalid (touched + validate); _resetValidation()
142
+ // below clears that transient state. Moving _resetValidation
143
+ // above the dispatch loop would leave fields visibly invalid.
144
+ const fields = this.dom.querySelectorAll('input, textarea, select');
145
+ for (let k = 0; k < fields.length; k++) {
146
+ const el = fields[k];
147
+ const isChangeBased = el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio';
148
+ el.dispatchEvent(new Event(isChangeBased ? 'change' : 'input', { bubbles: true }));
149
+ }
150
+
151
+ this._resetValidation();
152
+
153
+ // Notify high-level subscribers (custom controls that hold their
154
+ // own value and cannot be reset via input/change). Distinct from
155
+ // the incoming 'ln-form:reset' request event to avoid a loop.
156
+ dispatch(this.dom, 'ln-form:reset-complete', { target: this.dom });
157
+ };
158
+
159
+ _component.prototype._resetValidation = function () {
160
+ const fields = this.dom.querySelectorAll('[' + VALIDATE_SELECTOR + ']');
161
+ for (let i = 0; i < fields.length; i++) {
162
+ const instance = fields[i][VALIDATE_ATTRIBUTE];
163
+ if (instance) instance.reset();
164
+ }
165
+
166
+ this._updateSubmitButton();
167
+ };
168
+
169
+ Object.defineProperty(_component.prototype, 'isValid', {
170
+ get: function () {
171
+ const fields = this.dom.querySelectorAll('[' + VALIDATE_SELECTOR + ']');
172
+ for (let i = 0; i < fields.length; i++) {
173
+ if (!fields[i].checkValidity()) return false;
174
+ }
175
+ return true;
176
+ }
177
+ });
178
+
179
+ _component.prototype.destroy = function () {
180
+ if (!this.dom[DOM_ATTRIBUTE]) return;
181
+ this.dom.removeEventListener('ln-validate:valid', this._onValid);
182
+ this.dom.removeEventListener('ln-validate:invalid', this._onInvalid);
183
+ this.dom.removeEventListener('submit', this._onSubmit);
184
+ this.dom.removeEventListener('ln-form:fill', this._onFill);
185
+ this.dom.removeEventListener('ln-form:reset', this._onFormReset);
186
+ this.dom.removeEventListener('reset', this._onNativeReset);
187
+ if (this._onAutoInput) {
188
+ this.dom.removeEventListener('input', this._onAutoInput);
189
+ this.dom.removeEventListener('change', this._onAutoInput);
190
+ }
191
+ clearTimeout(this._debounceTimer);
192
+ dispatch(this.dom, 'ln-form:destroyed', { target: this.dom });
193
+ delete this.dom[DOM_ATTRIBUTE];
194
+ };
195
+
196
+ // ─── Init ──────────────────────────────────────────────────
197
+
198
+ registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-form');
199
+ })();
@@ -0,0 +1,89 @@
1
+ # ln-http
2
+
3
+ A zero-dependency, global **HTTP Concurrency Coordinator** that intercepts browser network operations to prevent race conditions, out-of-order responses, and duplicate submission side-effects.
4
+
5
+ It manages requests on two distinct pipelines: **Path A** (transparent GET/HEAD URL-deduplication wrapping `window.fetch`) and **Path B** (explicit key-based event-driven cancellations for POST/PUT/DELETE).
6
+
7
+ ---
8
+
9
+ ## 🧭 Philosophy & Architecture
10
+
11
+ 1. **Path A (Transparent GET/HEAD Concurrency):** Automatically intercepts global `fetch()` calls. If a GET/HEAD request to the exact same URL is already in-flight (e.g., search-as-you-type), the predecessor is instantly aborted. POST and unsafe methods are bypassed to preserve intent.
12
+ 2. **Path B (Event-Driven Keyed Concurrency):** Listens globally for `ln-http:request` events containing a distinct `key` (e.g. `reorder-list-1`). A new dispatch instantly aborts any existing in-flight request bearing the same key (any method), preventing double-submit or drag-and-drop overlaps.
13
+ 3. **Composition, Not Modification:** `ln-http` is a transport supervisor. It does not inject headers, manipulate bodies, or parse responses. It focuses entirely on socket cancellation via standard browser `AbortController` signals.
14
+
15
+ ---
16
+
17
+ ## 📦 Minimal Blueprint
18
+
19
+ ### Path A (Transparent URL-Deduplication)
20
+ Just use the standard native `fetch` API. Older identical GET requests are aborted automatically.
21
+ ```js
22
+ // Rapid keystrokes abort previous search GETs transparently
23
+ try {
24
+ const res = await fetch('/api/search?q=query');
25
+ const data = await res.json();
26
+ } catch (err) {
27
+ if (err.name === 'AbortError') return; // Swallowed abort
28
+ }
29
+ ```
30
+
31
+ ### Path B (Event-Driven Concurrency)
32
+ Dispatch an `ln-http:request` event with a unique `key` from any element.
33
+ ```js
34
+ element.dispatchEvent(new CustomEvent('ln-http:request', {
35
+ bubbles: true, // Must bubble to document!
36
+ detail: {
37
+ url: '/api/items/reorder',
38
+ method: 'POST',
39
+ body: JSON.stringify({ ids }),
40
+ key: 'items-reorder'
41
+ }
42
+ }));
43
+ ```
44
+
45
+ ---
46
+
47
+ ## 🛠️ Declarative API Contract
48
+
49
+ ### Path B Request Object (`detail`)
50
+
51
+ | Parameter | Type | Default | Description |
52
+ | :--- | :--- | :--- | :--- |
53
+ | `url` | `string` | *Required* | Target endpoint URL. |
54
+ | `method` | `string` | `'GET'` | HTTP verb. Automatically capitalized. |
55
+ | `body` | `any` | `null` | Request payload (JSON string, FormData, Blob, etc.). |
56
+ | `key` | `string` | `null` | Unique identifier to cancel previous in-flight requests under this key. |
57
+ | `signal` | `AbortSignal` | `null` | Optional external signal to compose with the internal abort controller. |
58
+
59
+ ### Global JavaScript API (`window.lnHttp`)
60
+
61
+ | Member | Type | Description |
62
+ | :--- | :--- | :--- |
63
+ | `cancel(url)` | `(url: string) => boolean` | Aborts all Path A in-flight requests matching `url`. |
64
+ | `cancelByKey(key)` | `(key: string) => boolean` | Aborts the Path B in-flight request matching `key`. |
65
+ | `cancelAll()` | `() => void` | Aborts all active in-flight requests (both paths). |
66
+ | `inflight` | `getter` | Returns snapshot of active requests: `{ url, method }` or `{ key }`. |
67
+ | `destroy()` | `() => void` | Clears all pending requests, removes event listeners, restores native `fetch`. |
68
+
69
+ ---
70
+
71
+ ## ⚡ DOM Events (Path B response lifecycle)
72
+
73
+ Both events bubble from the element that dispatched the original `'ln-http:request'`.
74
+
75
+ ### `ln-http:response`
76
+ Fired when `fetch` resolves. The consumer must branch on `ok`/`status` and parse the raw body.
77
+ - `detail`: `{ ok: boolean, status: number, response: Response }`
78
+
79
+ ### `ln-http:error`
80
+ Fired when network-level failures reject the fetch promise (excluding aborts).
81
+ - `detail`: `{ ok: false, status: 0, error: Error }`
82
+
83
+ ---
84
+
85
+ ## ⚠️ Common Pitfalls
86
+
87
+ - **Forgetting `bubbles: true`:** Path B listens on the `document` level. Events dispatched without `bubbles: true` will never reach the service and fail silently.
88
+ - **Ignoring `AbortError`:** Canceled Path A GET promises reject with an `AbortError`. Presenters must explicitly check and catch this error to avoid logging false failures.
89
+ - **Accessing response body twice:** `response` in the `ln-http:response` detail is a native `Response` stream. It can only be parsed (e.g., `.json()`, `.text()`) once.
@@ -0,0 +1 @@
1
+ (function(){"use strict";function d(s,e,r){s.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:r||{}}))}const h={};function g(s,e){h[s]=e}function b(s){return h[s]||{ingress:e=>e,egress:e=>e}}typeof window<"u"&&(window.lnCore=window.lnCore||{},window.lnCore.registerDataMapper=g,window.lnCore.getDataMapper=b),(function(){if(window.lnHttp)return;const s=window.fetch.bind(window),e=new Map,r=new Map;function m(t){return typeof t=="string"?t:t instanceof URL?t.href:t instanceof Request?t.url:String(t)}function E(t,n){return n&&n.method?String(n.method).toUpperCase():t instanceof Request?t.method.toUpperCase():"GET"}function y(t,n){return n+" "+t}function _(t){return t==="GET"||t==="HEAD"}function p(t,n){n=n||{};const i=m(t),f=E(t,n),o=y(i,f);_(f)&&e.has(o)&&(e.get(o).abort(),e.delete(o));const a=new AbortController,c=n.signal;c&&(c.aborted?a.abort(c.reason):c.addEventListener("abort",function(){a.abort(c.reason)},{once:!0}));const u=Object.assign({},n,{signal:a.signal});return e.set(o,a),s(t,u).finally(function(){e.get(o)===a&&e.delete(o)})}p.toString=function(){return"function fetch() { [ln-http wrapped] }"},window.fetch=p;function w(t){const n=t.detail||{};if(!n.url)return;const i=t.target,f=(n.method||(n.body?"POST":"GET")).toUpperCase(),o=n.key;o&&r.has(o)&&(r.get(o).abort(),r.delete(o));const a=new AbortController,c=n.signal;c&&(c.aborted?a.abort(c.reason):c.addEventListener("abort",function(){a.abort(c.reason)},{once:!0})),o&&r.set(o,a);const u={method:f,signal:a.signal};n.body!==void 0&&(u.body=n.body),window.fetch(n.url,u).then(function(l){o&&r.get(o)===a&&r.delete(o),d(i,"ln-http:response",{ok:l.ok,status:l.status,response:l})}).catch(function(l){o&&r.get(o)===a&&r.delete(o),!(l&&l.name==="AbortError")&&d(i,"ln-http:error",{ok:!1,status:0,error:l})})}document.addEventListener("ln-http:request",w),window.lnHttp={cancel:function(t){let n=!1;return e.forEach(function(i,f){f.endsWith(" "+t)&&(i.abort(),e.delete(f),n=!0)}),n},cancelByKey:function(t){return r.has(t)?(r.get(t).abort(),r.delete(t),!0):!1},cancelAll:function(){e.forEach(function(t){t.abort()}),e.clear(),r.forEach(function(t){t.abort()}),r.clear()},get inflight(){const t=[];return e.forEach(function(n,i){const f=i.indexOf(" ");t.push({method:i.slice(0,f),url:i.slice(f+1)})}),r.forEach(function(n,i){t.push({key:i})}),t},destroy:function(){window.lnHttp.cancelAll(),document.removeEventListener("ln-http:request",w),window.fetch=s,delete window.lnHttp}}})()})();
@@ -0,0 +1,219 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // ln-http — Transparent fetch interceptor + explicit-key dedup
3
+ // ═══════════════════════════════════════════════════════════════
4
+ //
5
+ // On load, this module does two things:
6
+ //
7
+ // PATH A — fetch wrapping (transparent, GET/HEAD only).
8
+ // Wraps window.fetch. All fetch calls in the page route through
9
+ // this wrapper. Components keep calling fetch() natively — they
10
+ // do not import or know about ln-http.
11
+ // 1. Tracks in-flight requests in _inflight, keyed by URL + method.
12
+ // 2. For idempotent methods (GET, HEAD): if a new request lands
13
+ // on the same key while the previous is still in flight, the
14
+ // previous is aborted. Only the latest GET to a given URL wins.
15
+ // 3. For non-idempotent methods (POST, PUT, PATCH, DELETE, …):
16
+ // NO auto-cancel. Two simultaneous POSTs both run.
17
+ // 4. Combines a consumer-provided AbortSignal with the wrapper's
18
+ // own controller.
19
+ //
20
+ // PATH B — event API (explicit key, any method, opt-in).
21
+ // Listens for `ln-http:request` at document level. Consumer
22
+ // dispatches:
23
+ // el.dispatchEvent(new CustomEvent('ln-http:request', {
24
+ // bubbles: true,
25
+ // detail: { url, method, body, signal, key }
26
+ // }));
27
+ // If `detail.key` is present and a previous request with the same
28
+ // key is in flight, the previous is aborted regardless of method.
29
+ // Use case: drag-reorder POSTs — each drag fires a POST to
30
+ // /api/reorder; only the latest order should reach the server.
31
+ // Without `key`, the dispatch is a one-shot (no dedup beyond
32
+ // whatever Path A does for GET/HEAD).
33
+ // Response dispatched on the original target element:
34
+ // ln-http:response { ok, status, response }
35
+ // ln-http:error { ok: false, status: 0, error }
36
+ //
37
+ // The two paths coexist. A GET dispatched via Path B with
38
+ // `detail.key: 'foo'` lives in BOTH _inflight (Path A, URL-keyed)
39
+ // and _keyed (Path B, key-keyed). Aborts from either side are
40
+ // idempotent and cooperate cleanly.
41
+ //
42
+ // Public API (window.lnHttp):
43
+ // .cancel(url) — abort any Path A in-flight whose URL matches.
44
+ // .cancelByKey(key) — abort the Path B in-flight with this key.
45
+ // .cancelAll() — abort every in-flight (both paths).
46
+ // .inflight — getter; returns Array<{ url, method, key? }>
47
+ // covering both paths, for debugging.
48
+ // .destroy() — restore the original fetch, remove the
49
+ // document listener, clear both queues.
50
+ // Used in dev hot-reload and tests.
51
+
52
+ import { dispatch } from '../../ln-core';
53
+
54
+ (function () {
55
+ if (window.lnHttp) return; // double-load guard
56
+
57
+ const _origFetch = window.fetch.bind(window);
58
+ const _inflight = new Map(); // "METHOD URL" → AbortController (Path A)
59
+ const _keyed = new Map(); // consumer key → AbortController (Path B)
60
+
61
+ // ─── helpers ───────────────────────────────────────────────────
62
+
63
+ // Accept string | URL | Request → return absolute URL string.
64
+ function _extractUrl(resource) {
65
+ if (typeof resource === 'string') return resource;
66
+ if (resource instanceof URL) return resource.href;
67
+ if (resource instanceof Request) return resource.url;
68
+ return String(resource); // last-resort coercion
69
+ }
70
+
71
+ // Extract method from options OR Request, default GET, uppercased.
72
+ function _extractMethod(resource, options) {
73
+ if (options && options.method) return String(options.method).toUpperCase();
74
+ if (resource instanceof Request) return resource.method.toUpperCase();
75
+ return 'GET';
76
+ }
77
+
78
+ function _key(url, method) { return method + ' ' + url; }
79
+ function _isIdempotent(method) { return method === 'GET' || method === 'HEAD'; }
80
+
81
+ // ─── Path A: window.fetch wrapper ─────────────────────────────
82
+
83
+ function _wrappedFetch(resource, options) {
84
+ options = options || {};
85
+
86
+ const url = _extractUrl(resource);
87
+ const method = _extractMethod(resource, options);
88
+ const key = _key(url, method);
89
+
90
+ // Idempotent dedup — abort previous in-flight on same key.
91
+ if (_isIdempotent(method) && _inflight.has(key)) {
92
+ _inflight.get(key).abort();
93
+ _inflight.delete(key);
94
+ }
95
+
96
+ // Combine consumer signal with wrapper controller.
97
+ const controller = new AbortController();
98
+ const consumerSignal = options.signal;
99
+ if (consumerSignal) {
100
+ if (consumerSignal.aborted) controller.abort(consumerSignal.reason);
101
+ else consumerSignal.addEventListener('abort', function () {
102
+ controller.abort(consumerSignal.reason);
103
+ }, { once: true });
104
+ }
105
+
106
+ const merged = Object.assign({}, options, { signal: controller.signal });
107
+
108
+ _inflight.set(key, controller);
109
+
110
+ return _origFetch(resource, merged).finally(function () {
111
+ // Only clear if THIS controller is still the one in the map.
112
+ if (_inflight.get(key) === controller) _inflight.delete(key);
113
+ });
114
+ }
115
+
116
+ _wrappedFetch.toString = function () { return 'function fetch() { [ln-http wrapped] }'; };
117
+ window.fetch = _wrappedFetch;
118
+
119
+ // ─── Path B: ln-http:request event listener ───────────────────
120
+
121
+ function _onRequest(e) {
122
+ const opts = e.detail || {};
123
+ if (!opts.url) return;
124
+
125
+ const target = e.target;
126
+ const method = (opts.method || (opts.body ? 'POST' : 'GET')).toUpperCase();
127
+ const userKey = opts.key;
128
+
129
+ // Explicit-key dedup — abort previous on same key, any method.
130
+ if (userKey && _keyed.has(userKey)) {
131
+ _keyed.get(userKey).abort();
132
+ _keyed.delete(userKey);
133
+ }
134
+
135
+ // Combine consumer signal (if any) with our own controller.
136
+ const controller = new AbortController();
137
+ const consumerSignal = opts.signal;
138
+ if (consumerSignal) {
139
+ if (consumerSignal.aborted) controller.abort(consumerSignal.reason);
140
+ else consumerSignal.addEventListener('abort', function () {
141
+ controller.abort(consumerSignal.reason);
142
+ }, { once: true });
143
+ }
144
+
145
+ if (userKey) _keyed.set(userKey, controller);
146
+
147
+ const fetchOptions = { method: method, signal: controller.signal };
148
+ if (opts.body !== undefined) fetchOptions.body = opts.body;
149
+
150
+ // fetch() here IS the wrapped fetch — Path A still applies
151
+ // for GET/HEAD on top of Path B's explicit-key dedup. Aborts
152
+ // from either side are idempotent.
153
+ window.fetch(opts.url, fetchOptions)
154
+ .then(function (response) {
155
+ if (userKey && _keyed.get(userKey) === controller) _keyed.delete(userKey);
156
+ dispatch(target, 'ln-http:response', {
157
+ ok: response.ok,
158
+ status: response.status,
159
+ response: response
160
+ });
161
+ })
162
+ .catch(function (err) {
163
+ if (userKey && _keyed.get(userKey) === controller) _keyed.delete(userKey);
164
+ if (err && err.name === 'AbortError') return; // silent on abort
165
+ dispatch(target, 'ln-http:error', {
166
+ ok: false,
167
+ status: 0,
168
+ error: err
169
+ });
170
+ });
171
+ }
172
+
173
+ document.addEventListener('ln-http:request', _onRequest);
174
+
175
+ // ─── Public API ───────────────────────────────────────────────
176
+
177
+ window.lnHttp = {
178
+ cancel: function (url) {
179
+ let any = false;
180
+ _inflight.forEach(function (controller, key) {
181
+ if (key.endsWith(' ' + url)) {
182
+ controller.abort();
183
+ _inflight.delete(key);
184
+ any = true;
185
+ }
186
+ });
187
+ return any;
188
+ },
189
+ cancelByKey: function (userKey) {
190
+ if (!_keyed.has(userKey)) return false;
191
+ _keyed.get(userKey).abort();
192
+ _keyed.delete(userKey);
193
+ return true;
194
+ },
195
+ cancelAll: function () {
196
+ _inflight.forEach(function (c) { c.abort(); });
197
+ _inflight.clear();
198
+ _keyed.forEach(function (c) { c.abort(); });
199
+ _keyed.clear();
200
+ },
201
+ get inflight() {
202
+ const list = [];
203
+ _inflight.forEach(function (_c, key) {
204
+ const sp = key.indexOf(' ');
205
+ list.push({ method: key.slice(0, sp), url: key.slice(sp + 1) });
206
+ });
207
+ _keyed.forEach(function (_c, userKey) {
208
+ list.push({ key: userKey });
209
+ });
210
+ return list;
211
+ },
212
+ destroy: function () {
213
+ window.lnHttp.cancelAll();
214
+ document.removeEventListener('ln-http:request', _onRequest);
215
+ window.fetch = _origFetch;
216
+ delete window.lnHttp;
217
+ }
218
+ };
219
+ })();