@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.
- package/README.md +177 -0
- package/js/COMPONENTS.md +1102 -0
- package/js/index.js +41 -0
- package/js/ln-accordion/README.md +137 -0
- package/js/ln-accordion/ln-accordion.js +1 -0
- package/js/ln-accordion/src/ln-accordion.js +41 -0
- package/js/ln-ajax/README.md +91 -0
- package/js/ln-ajax/ln-ajax.js +1 -0
- package/js/ln-ajax/src/ln-ajax.js +277 -0
- package/js/ln-api-connector/README.md +150 -0
- package/js/ln-api-connector/ln-api-connector.js +1 -0
- package/js/ln-api-connector/src/ln-api-connector.js +265 -0
- package/js/ln-autoresize/README.md +80 -0
- package/js/ln-autoresize/ln-autoresize.js +1 -0
- package/js/ln-autoresize/src/ln-autoresize.js +47 -0
- package/js/ln-autosave/README.md +92 -0
- package/js/ln-autosave/ln-autosave.js +1 -0
- package/js/ln-autosave/src/ln-autosave.js +147 -0
- package/js/ln-circular-progress/README.md +161 -0
- package/js/ln-circular-progress/ln-circular-progress.js +1 -0
- package/js/ln-circular-progress/src/ln-circular-progress.js +133 -0
- package/js/ln-confirm/README.md +86 -0
- package/js/ln-confirm/_ln-confirm.scss +13 -0
- package/js/ln-confirm/ln-confirm.js +1 -0
- package/js/ln-confirm/src/ln-confirm.js +131 -0
- package/js/ln-core/crypto.js +83 -0
- package/js/ln-core/helpers.js +411 -0
- package/js/ln-core/index.js +5 -0
- package/js/ln-core/persist.js +71 -0
- package/js/ln-core/positioning.js +207 -0
- package/js/ln-core/reactive.js +74 -0
- package/js/ln-couchdb-connector/README.md +156 -0
- package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -0
- package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +348 -0
- package/js/ln-data-coordinator/README.md +165 -0
- package/js/ln-data-coordinator/ln-data-coordinator.js +1 -0
- package/js/ln-data-coordinator/src/ln-data-coordinator.js +249 -0
- package/js/ln-data-store/README.md +94 -0
- package/js/ln-data-store/ln-data-store.js +1 -0
- package/js/ln-data-store/src/ln-data-store.js +699 -0
- package/js/ln-data-table/README.md +110 -0
- package/js/ln-data-table/ln-data-table.js +1 -0
- package/js/ln-data-table/ln-data-table.scss +10 -0
- package/js/ln-data-table/src/ln-data-table.js +1103 -0
- package/js/ln-date/README.md +151 -0
- package/js/ln-date/ln-date.js +1 -0
- package/js/ln-date/src/ln-date.js +442 -0
- package/js/ln-dropdown/README.md +117 -0
- package/js/ln-dropdown/ln-dropdown.js +1 -0
- package/js/ln-dropdown/ln-dropdown.scss +15 -0
- package/js/ln-dropdown/src/ln-dropdown.js +174 -0
- package/js/ln-external-links/README.md +341 -0
- package/js/ln-external-links/ln-external-links.js +1 -0
- package/js/ln-external-links/src/ln-external-links.js +116 -0
- package/js/ln-filter/README.md +99 -0
- package/js/ln-filter/ln-filter.js +1 -0
- package/js/ln-filter/ln-filter.scss +7 -0
- package/js/ln-filter/src/ln-filter.js +404 -0
- package/js/ln-form/README.md +101 -0
- package/js/ln-form/ln-form.js +1 -0
- package/js/ln-form/src/ln-form.js +199 -0
- package/js/ln-http/README.md +89 -0
- package/js/ln-http/ln-http.js +1 -0
- package/js/ln-http/src/ln-http.js +219 -0
- package/js/ln-icons/README.md +88 -0
- package/js/ln-icons/ln-icons.js +1 -0
- package/js/ln-icons/src/ln-icons.js +169 -0
- package/js/ln-link/README.md +303 -0
- package/js/ln-link/ln-link.js +1 -0
- package/js/ln-link/src/ln-link.js +196 -0
- package/js/ln-modal/README.md +154 -0
- package/js/ln-modal/ln-modal.js +1 -0
- package/js/ln-modal/ln-modal.scss +11 -0
- package/js/ln-modal/src/ln-modal.js +201 -0
- package/js/ln-nav/README.md +70 -0
- package/js/ln-nav/ln-nav.js +1 -0
- package/js/ln-nav/src/ln-nav.js +177 -0
- package/js/ln-number/README.md +122 -0
- package/js/ln-number/ln-number.js +1 -0
- package/js/ln-number/src/ln-number.js +302 -0
- package/js/ln-popover/README.md +127 -0
- package/js/ln-popover/ln-popover.js +1 -0
- package/js/ln-popover/src/ln-popover.js +288 -0
- package/js/ln-progress/README.md +442 -0
- package/js/ln-progress/ln-progress.js +1 -0
- package/js/ln-progress/src/ln-progress.js +150 -0
- package/js/ln-search/README.md +83 -0
- package/js/ln-search/ln-search.js +1 -0
- package/js/ln-search/ln-search.scss +7 -0
- package/js/ln-search/src/ln-search.js +114 -0
- package/js/ln-sortable/README.md +95 -0
- package/js/ln-sortable/ln-sortable.js +1 -0
- package/js/ln-sortable/src/ln-sortable.js +203 -0
- package/js/ln-table/README.md +101 -0
- package/js/ln-table/ln-table-sort.js +1 -0
- package/js/ln-table/ln-table.js +1 -0
- package/js/ln-table/ln-table.scss +11 -0
- package/js/ln-table/src/ln-table-sort.js +168 -0
- package/js/ln-table/src/ln-table.js +473 -0
- package/js/ln-tabs/README.md +137 -0
- package/js/ln-tabs/ln-tabs.js +1 -0
- package/js/ln-tabs/src/ln-tabs.js +171 -0
- package/js/ln-time/README.md +81 -0
- package/js/ln-time/ln-time.js +1 -0
- package/js/ln-time/src/ln-time.js +192 -0
- package/js/ln-toast/README.md +122 -0
- package/js/ln-toast/ln-toast.js +15 -0
- package/js/ln-toast/src/ln-toast.js +210 -0
- package/js/ln-toast/template.html +14 -0
- package/js/ln-toggle/README.md +137 -0
- package/js/ln-toggle/ln-toggle.js +1 -0
- package/js/ln-toggle/src/ln-toggle.js +139 -0
- package/js/ln-tooltip/README.md +58 -0
- package/js/ln-tooltip/ln-tooltip.js +1 -0
- package/js/ln-tooltip/ln-tooltip.scss +9 -0
- package/js/ln-tooltip/src/ln-tooltip.js +169 -0
- package/js/ln-translations/README.md +96 -0
- package/js/ln-translations/ln-translations.js +1 -0
- package/js/ln-translations/src/ln-translations.js +275 -0
- package/js/ln-upload/README.md +180 -0
- package/js/ln-upload/ln-upload.js +1 -0
- package/js/ln-upload/ln-upload.scss +20 -0
- package/js/ln-upload/src/ln-upload.js +407 -0
- package/js/ln-validate/README.md +108 -0
- package/js/ln-validate/ln-validate.js +1 -0
- package/js/ln-validate/src/ln-validate.js +160 -0
- package/package.json +55 -0
- package/scss/base/_global.scss +83 -0
- package/scss/base/_reset.scss +17 -0
- package/scss/base/_typography.scss +125 -0
- package/scss/components/_accordion.scss +34 -0
- package/scss/components/_ajax.scss +15 -0
- package/scss/components/_alert.scss +5 -0
- package/scss/components/_app-shell.scss +15 -0
- package/scss/components/_avatar.scss +6 -0
- package/scss/components/_breadcrumbs.scss +33 -0
- package/scss/components/_button.scss +20 -0
- package/scss/components/_card.scss +10 -0
- package/scss/components/_chip.scss +5 -0
- package/scss/components/_circular-progress.scss +29 -0
- package/scss/components/_confirm.scss +5 -0
- package/scss/components/_data-table.scss +83 -0
- package/scss/components/_dropdown.scss +25 -0
- package/scss/components/_empty-state.scss +22 -0
- package/scss/components/_form.scss +100 -0
- package/scss/components/_layout.scss +8 -0
- package/scss/components/_link.scss +11 -0
- package/scss/components/_ln-table.scss +60 -0
- package/scss/components/_loader.scss +6 -0
- package/scss/components/_modal.scss +20 -0
- package/scss/components/_nav.scss +9 -0
- package/scss/components/_page-header.scss +10 -0
- package/scss/components/_popover.scss +10 -0
- package/scss/components/_progress.scss +17 -0
- package/scss/components/_prose.scss +5 -0
- package/scss/components/_scrollbar.scss +32 -0
- package/scss/components/_sections.scss +12 -0
- package/scss/components/_sidebar.scss +5 -0
- package/scss/components/_stat-card.scss +5 -0
- package/scss/components/_status-badge.scss +4 -0
- package/scss/components/_stepper.scss +5 -0
- package/scss/components/_table.scss +19 -0
- package/scss/components/_tabs.scss +21 -0
- package/scss/components/_timeline.scss +14 -0
- package/scss/components/_toast.scss +41 -0
- package/scss/components/_toggle.scss +81 -0
- package/scss/components/_tooltip.scss +18 -0
- package/scss/components/_translations.scss +111 -0
- package/scss/components/_upload.scss +51 -0
- package/scss/config/_breakpoints.scss +72 -0
- package/scss/config/_density.scss +117 -0
- package/scss/config/_icons.scss +37 -0
- package/scss/config/_mixins.scss +13 -0
- package/scss/config/_theme.scss +216 -0
- package/scss/config/_tokens.scss +419 -0
- package/scss/config/mixins/_accordion.scss +52 -0
- package/scss/config/mixins/_ajax.scss +39 -0
- package/scss/config/mixins/_alert.scss +82 -0
- package/scss/config/mixins/_app-shell.scss +312 -0
- package/scss/config/mixins/_avatar.scss +109 -0
- package/scss/config/mixins/_borders.scss +36 -0
- package/scss/config/mixins/_breadcrumbs.scss +72 -0
- package/scss/config/mixins/_breakpoints.scss +62 -0
- package/scss/config/mixins/_btn.scss +179 -0
- package/scss/config/mixins/_card.scss +338 -0
- package/scss/config/mixins/_chip.scss +66 -0
- package/scss/config/mixins/_circular-progress.scss +71 -0
- package/scss/config/mixins/_collapsible.scss +24 -0
- package/scss/config/mixins/_colors.scss +46 -0
- package/scss/config/mixins/_confirm.scss +31 -0
- package/scss/config/mixins/_data-table.scss +346 -0
- package/scss/config/mixins/_display.scss +32 -0
- package/scss/config/mixins/_dropdown.scss +143 -0
- package/scss/config/mixins/_empty-state.scss +30 -0
- package/scss/config/mixins/_focus.scss +55 -0
- package/scss/config/mixins/_footer.scss +42 -0
- package/scss/config/mixins/_form.scss +601 -0
- package/scss/config/mixins/_index.scss +58 -0
- package/scss/config/mixins/_interaction.scss +15 -0
- package/scss/config/mixins/_kbd.scss +22 -0
- package/scss/config/mixins/_layout.scss +117 -0
- package/scss/config/mixins/_link.scss +55 -0
- package/scss/config/mixins/_ln-table.scss +420 -0
- package/scss/config/mixins/_loader.scss +26 -0
- package/scss/config/mixins/_modal.scss +66 -0
- package/scss/config/mixins/_motion.scss +19 -0
- package/scss/config/mixins/_nav.scss +273 -0
- package/scss/config/mixins/_page-header.scss +69 -0
- package/scss/config/mixins/_popover.scss +25 -0
- package/scss/config/mixins/_position.scss +32 -0
- package/scss/config/mixins/_progress.scss +56 -0
- package/scss/config/mixins/_prose.scss +127 -0
- package/scss/config/mixins/_shadows.scss +8 -0
- package/scss/config/mixins/_sidebar.scss +95 -0
- package/scss/config/mixins/_sizing.scss +6 -0
- package/scss/config/mixins/_spacing.scss +19 -0
- package/scss/config/mixins/_stat-card.scss +68 -0
- package/scss/config/mixins/_status-badge.scss +83 -0
- package/scss/config/mixins/_stepper.scss +78 -0
- package/scss/config/mixins/_table.scss +215 -0
- package/scss/config/mixins/_tabs.scss +64 -0
- package/scss/config/mixins/_timeline.scss +69 -0
- package/scss/config/mixins/_toast.scss +148 -0
- package/scss/config/mixins/_tooltip.scss +111 -0
- package/scss/config/mixins/_transitions.scss +10 -0
- package/scss/config/mixins/_translations.scss +124 -0
- package/scss/config/mixins/_typography.scss +57 -0
- package/scss/config/mixins/_upload.scss +168 -0
- package/scss/ln-ashlar.scss +62 -0
- package/scss/tabler-icons.txt +5039 -0
- package/scss/utilities/_animations.scss +83 -0
- 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
|
+
})();
|