@ecopages/scripts-injector 0.1.2 → 0.1.3

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 CHANGED
@@ -22,16 +22,75 @@ The ScriptInjectorProps type defines the properties that can be used to control
22
22
 
23
23
  `scripts`: This property should be a comma-separated string of scripts to be loaded. For example, `<script-injector scripts="script1.js,script2.js"></script-injector>` will load script1.js and script2.js.
24
24
 
25
+ ## Latest Features
26
+
27
+ ### Full Event Replay with Property Preservation
28
+
29
+ For all interaction events, the Scripts Injector intercepts the initial event, prevents the default action, loads the required script, and then **replays the event** with all original properties preserved. This ensures that user actions are never lost, even if they happen before the script is fully loaded.
30
+
31
+ - **Click events**: Use native `.click()` for best compatibility with links and forms
32
+ - **Mouse events**: Preserve `clientX`, `clientY`, `screenX`, `screenY`, `button`, `buttons`, and modifier keys
33
+ - **Keyboard events**: Preserve `key`, `code`, `location`, `repeat`, and modifier keys
34
+ - **Focus events**: Preserve `relatedTarget`
35
+ - **Touch events**: Preserve `touches`, `targetTouches`, `changedTouches`, and modifier keys
36
+
37
+ ### Race Condition Prevention
38
+
39
+ Multiple script injectors requesting the same script will coordinate to prevent duplicate loading. A static registry tracks scripts currently being loaded, ensuring only one network request is made even when multiple injectors trigger simultaneously.
40
+
41
+ ### Load Reason Tracking
42
+
43
+ After loading, the element receives a `data-load-reason` attribute indicating what triggered the load:
44
+
45
+ - `idle` - Loaded via `on:idle`
46
+ - `visible` - Loaded via `on:visible`
47
+ - `interaction:click`, `interaction:mouseenter`, etc. - Loaded via the specific interaction event
48
+
49
+ ### Error Tracking
50
+
51
+ If any scripts fail to load, the element receives a `data-error` attribute containing the comma-separated list of failed script URLs. The `DATA_LOADED` event also includes a `failedScripts` array in its detail.
52
+
53
+ ```tsx
54
+ // Listen for load completion with error info
55
+ document.addEventListener('data-loaded', (event) => {
56
+ const { loadedScripts, failedScripts } = event.detail;
57
+ if (failedScripts.length > 0) {
58
+ console.warn('Some scripts failed to load:', failedScripts);
59
+ }
60
+ });
61
+ ```
62
+
25
63
  ## Typical usage
26
64
 
27
65
  This passage provides a standard use case for the custom element. The script is designed to load when the user interacts with it, either through mouse entry or a focus event. If multiple script injectors are present with the same script, only the first one will execute the load. This is because once a script is loaded, all script injectors are notified and subsequently remove the script from their loading responsibilities.
28
66
 
29
67
  ```tsx
30
- <script-injector on:interaction="mouseenter,focusin" scripts={["path/to/my/element"]}>
31
- <lit-counter class="lit-counter" count={8}></lit-counter>
68
+ <script-injector on:interaction="mouseenter,focusin" scripts={['path/to/my/element']}>
69
+ <lit-counter class="lit-counter" count={8}></lit-counter>
32
70
  </script-injector>
33
71
  ```
34
72
 
73
+ ## Supported Events
74
+
75
+ The `on:interaction` prop supports a wide range of DOM events, as defined in `InteractionEvent`.
76
+
77
+ - **Mouse:** `click`, `dblclick`, `mousedown`, `mouseup`, `mouseenter`, `mouseleave`, `mousemove`, `mouseover`, `mouseout`
78
+ - **Touch:** `touchstart`, `touchend`, `touchmove`, `touchcancel`
79
+ - **Focus:** `focus`, `blur`, `focusin`, `focusout`
80
+ - **Keyboard:** `keydown`, `keypress`, `keyup`
81
+ - **Form:** `input`, `change`, `submit`
82
+ - **UI:** `scroll`, `resize`
83
+
84
+ ## Data Attributes
85
+
86
+ After script loading completes, the following attributes are set on the element:
87
+
88
+ | Attribute | Description |
89
+ | ------------------ | ---------------------------------------------------------------------------------- |
90
+ | `data-loaded` | Present when all scripts have been loaded (or attempted) |
91
+ | `data-load-reason` | The trigger that caused scripts to load (`idle`, `visible`, `interaction:<event>`) |
92
+ | `data-error` | Comma-separated list of script URLs that failed to load (only if errors occurred) |
93
+
35
94
  ## Inspiration
36
95
 
37
96
  This is undeniably heavily influenced by [11ty/is-land](https://github.com/11ty/is-land), [Astro island](https://docs.astro.build/en/concepts/islands/) and the "component island" concept, originally introduced by Etsy's frontend architect, Katie Sylor-Miller.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ecopages/scripts-injector",
3
3
  "main": "scripts-injector.js",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "type": "module",
6
6
  "types": "types.d.ts",
7
7
  "repository": {
@@ -1 +1 @@
1
- var B;((k)=>k.DATA_LOADED="data-loaded")(B||={});var C=["on:visible","on:idle","on:interaction"];class w extends HTMLElement{intersectionObserver=null;scriptsToLoad=[];registeredEvents=[];conditionsMap={"on:visible":this.onVisible.bind(this),"on:idle":this.onIdle.bind(this),"on:interaction":this.onInteraction.bind(this)};constructor(){super();this.loadScripts=this.loadScripts.bind(this),this.listenToDataLoaded=this.listenToDataLoaded.bind(this)}connectedCallback(){let h=this.getAttribute("scripts");this.scriptsToLoad=h?h.split(",").map((k)=>k.trim()).filter(Boolean):[],document.addEventListener("data-loaded",this.listenToDataLoaded),this.applyConditions()}disconnectedCallback(){if(document.removeEventListener("data-loaded",this.listenToDataLoaded),this.unregisterEvents(),this.intersectionObserver)this.intersectionObserver.disconnect(),this.intersectionObserver=null}notifyInjectors(){document.dispatchEvent(new CustomEvent("data-loaded",{detail:{loadedScripts:this.scriptsToLoad}}))}applyConditions(){let h=Object.keys(this.conditionsMap);for(let k of h)if(this.hasAttribute(k))this.conditionsMap[k]()}onVisible(){this.setupIntersectionObserver()}onIdle(){this.loadScripts()}onInteraction(){let h=this.getAttribute("on:interaction");if(!h)return;for(let k of h.split(",")){let q=k.trim();if(q)this.addEventListener(q,this.loadScripts),this.registeredEvents.push({type:q,listener:this.loadScripts})}}listenToDataLoaded(h){if(this.hasAttribute("data-loaded"))return;let{loadedScripts:k}=h.detail;if(this.scriptsToLoad=this.scriptsToLoad.filter((q)=>!k.includes(q)),this.scriptsToLoad.length===0)this.setAttribute("data-loaded",""),this.unregisterEvents()}unregisterEvents(){this.intersectionObserver?.disconnect();for(let{type:h,listener:k}of this.registeredEvents)this.removeEventListener(h,k);this.registeredEvents=[]}loadScripts(){if(this.hasAttribute("data-loaded"))return;try{for(let h of this.scriptsToLoad)if(!this.isScriptLoaded(h))this.loadScript(h)}catch(h){console.error("[scripts-injector] Error loading scripts:",h)}finally{this.setAttribute("data-loaded",""),this.unregisterEvents(),this.notifyInjectors()}}isScriptLoaded(h){return document.querySelector(`script[src="${h}"]`)!==null}loadScript(h){let k=document.createElement("script");k.src=h,k.type="module",k.async=!0,k.onerror=(q)=>{console.error(`[scripts-injector] Failed to load script: ${h}`,q)},document.head.appendChild(k)}setupIntersectionObserver(){let h=this.getAttribute("on:visible"),q={rootMargin:h&&h!==""&&h!=="true"?h:"50px 0px",threshold:0.1};this.intersectionObserver=new IntersectionObserver((x)=>{for(let z of x)if(z.isIntersecting)this.loadScripts()},q),this.intersectionObserver.observe(this)}}if(!customElements.get("scripts-injector"))customElements.define("scripts-injector",w);export{C as conditions,w as ScriptsInjector,B as ScriptInjectorEvents};
1
+ var Q=new Set,Z=new Set(["click","dblclick","mousedown","mouseup","mouseenter","mouseleave","mousemove","mouseover","mouseout"]),$=new Set(["keydown","keypress","keyup"]),k=new Set(["focus","blur","focusin","focusout"]),B=new Set(["touchstart","touchend","touchmove","touchcancel"]),D;((z)=>z.DATA_LOADED="data-loaded")(D||={});var H=["on:visible","on:idle","on:interaction"];class X extends HTMLElement{intersectionObserver=null;scriptsToLoad=[];failedScripts=[];registeredEvents=[];conditionsMap={"on:visible":this.onVisible.bind(this),"on:idle":this.onIdle.bind(this),"on:interaction":this.onInteraction.bind(this)};constructor(){super();this.loadScripts=this.loadScripts.bind(this),this.listenToDataLoaded=this.listenToDataLoaded.bind(this)}connectedCallback(){let q=this.getAttribute("scripts");this.scriptsToLoad=q?q.split(",").map((z)=>z.trim()).filter(Boolean):[],document.addEventListener("data-loaded",this.listenToDataLoaded),this.applyConditions()}disconnectedCallback(){if(document.removeEventListener("data-loaded",this.listenToDataLoaded),this.unregisterEvents(),this.intersectionObserver)this.intersectionObserver.disconnect(),this.intersectionObserver=null}notifyInjectors(){document.dispatchEvent(new CustomEvent("data-loaded",{detail:{loadedScripts:this.scriptsToLoad,failedScripts:this.failedScripts}}))}applyConditions(){let q=Object.keys(this.conditionsMap);for(let z of q)if(this.hasAttribute(z))this.conditionsMap[z]()}onVisible(){this.setupIntersectionObserver()}onIdle(){queueMicrotask(()=>this.loadScripts("idle"))}onInteraction(){let q=this.getAttribute("on:interaction");if(!q)return;for(let z of q.split(",")){let J=z.trim();if(J){let G=async(P)=>{await this.handleInteraction(P)};this.addEventListener(J,G),this.registeredEvents.push({type:J,listener:G})}}}async handleInteraction(q){if(q.stopImmediatePropagation(),q.preventDefault(),await this.loadScripts(`interaction:${q.type}`),q.target===this)return;if(q.type==="click"&&q.target instanceof HTMLElement){q.target.click();return}let z=this.cloneEvent(q);if(z)q.target?.dispatchEvent(z)}cloneEvent(q){let z={bubbles:q.bubbles,cancelable:q.cancelable,composed:q.composed};if(Z.has(q.type)&&q instanceof MouseEvent)return new MouseEvent(q.type,{...z,screenX:q.screenX,screenY:q.screenY,clientX:q.clientX,clientY:q.clientY,button:q.button,buttons:q.buttons,ctrlKey:q.ctrlKey,shiftKey:q.shiftKey,altKey:q.altKey,metaKey:q.metaKey,relatedTarget:q.relatedTarget});if($.has(q.type)&&q instanceof KeyboardEvent)return new KeyboardEvent(q.type,{...z,key:q.key,code:q.code,location:q.location,repeat:q.repeat,isComposing:q.isComposing,ctrlKey:q.ctrlKey,shiftKey:q.shiftKey,altKey:q.altKey,metaKey:q.metaKey});if(k.has(q.type)&&q instanceof FocusEvent)return new FocusEvent(q.type,{...z,relatedTarget:q.relatedTarget});if(B.has(q.type)&&q instanceof TouchEvent)return new TouchEvent(q.type,{...z,touches:Array.from(q.touches),targetTouches:Array.from(q.targetTouches),changedTouches:Array.from(q.changedTouches),ctrlKey:q.ctrlKey,shiftKey:q.shiftKey,altKey:q.altKey,metaKey:q.metaKey});return new Event(q.type,z)}listenToDataLoaded(q){if(this.hasAttribute("data-loaded"))return;let{loadedScripts:z}=q.detail;if(this.scriptsToLoad=this.scriptsToLoad.filter((J)=>!z.includes(J)),this.scriptsToLoad.length===0)this.setAttribute("data-loaded",""),this.unregisterEvents()}unregisterEvents(){this.intersectionObserver?.disconnect();for(let{type:q,listener:z}of this.registeredEvents)this.removeEventListener(q,z);this.registeredEvents=[]}async loadScripts(q="unknown"){if(this.hasAttribute("data-loaded"))return;this.failedScripts=[];let z=[];for(let G of this.scriptsToLoad)if(!this.isScriptLoaded(G)&&!Q.has(G))Q.add(G),z.push({script:G,promise:this.loadScript(G)});let J=await Promise.allSettled(z.map((G)=>G.promise));try{for(let G=0;G<J.length;G++)if(J[G].status==="rejected")this.failedScripts.push(z[G].script)}finally{for(let{script:G}of z)Q.delete(G)}if(this.setAttribute("data-loaded",""),this.setAttribute("data-load-reason",q),this.failedScripts.length>0)this.setAttribute("data-error",this.failedScripts.join(","));this.unregisterEvents(),this.notifyInjectors()}isScriptLoaded(q){return document.querySelector(`script[src="${q}"]`)!==null}loadScript(q){return new Promise((z,J)=>{if(this.isScriptLoaded(q)){z();return}let G=document.createElement("script");G.src=q,G.type="module",G.async=!0,G.onload=()=>z(),G.onerror=(P)=>{console.error(`[scripts-injector] Failed to load script: ${q}`,P),J(P)},document.head.appendChild(G)})}setupIntersectionObserver(){let q=this.getAttribute("on:visible"),J={rootMargin:q&&q!==""&&q!=="true"?q:"50px 0px",threshold:0.1};this.intersectionObserver=new IntersectionObserver((G)=>{for(let P of G)if(P.isIntersecting)this.loadScripts("visible")},J),this.intersectionObserver.observe(this)}}if(typeof window<"u"&&!customElements.get("scripts-injector"))customElements.define("scripts-injector",X);export{H as conditions,X as ScriptsInjector,D as ScriptInjectorEvents};
package/types.d.ts CHANGED
@@ -1,42 +1,81 @@
1
1
  import type { ScriptsInjector } from './scripts-injector';
2
2
  import { ScriptInjectorEvents, type conditions } from './scripts-injector';
3
3
 
4
- export type OnDataLoadedEvent = CustomEvent<{ loadedScripts: string[] }>;
4
+ export type OnDataLoadedEvent = CustomEvent<{
5
+ /** Array of script URLs that were successfully loaded or already existed */
6
+ loadedScripts: string[];
7
+ /** Array of script URLs that failed to load */
8
+ failedScripts: string[];
9
+ }>;
5
10
 
6
11
  export type Conditions = (typeof conditions)[number];
7
12
 
8
13
  declare global {
9
- interface HTMLElementTagNameMap {
10
- 'scripts-injector': ScriptsInjector;
11
- }
12
- namespace JSX {
13
- interface IntrinsicElements {
14
- 'scripts-injector': HtmlTag & ScriptInjectorProps;
15
- }
16
- }
17
- interface HTMLElementEventMap {
18
- [ScriptInjectorEvents.DATA_LOADED]: OnDataLoadedEvent;
19
- }
14
+ interface HTMLElementTagNameMap {
15
+ 'scripts-injector': ScriptsInjector;
16
+ }
17
+ namespace JSX {
18
+ interface IntrinsicElements {
19
+ 'scripts-injector': HtmlTag & ScriptInjectorProps;
20
+ }
21
+ }
22
+ interface HTMLElementEventMap {
23
+ [ScriptInjectorEvents.DATA_LOADED]: OnDataLoadedEvent;
24
+ }
20
25
  }
21
26
 
27
+ export type InteractionEvent =
28
+ | 'click'
29
+ | 'dblclick'
30
+ | 'mousedown'
31
+ | 'mouseup'
32
+ | 'mouseenter'
33
+ | 'mouseleave'
34
+ | 'mousemove'
35
+ | 'mouseover'
36
+ | 'mouseout'
37
+ | 'touchstart'
38
+ | 'touchend'
39
+ | 'touchmove'
40
+ | 'touchcancel'
41
+ | 'keydown'
42
+ | 'keypress'
43
+ | 'keyup'
44
+ | 'focus'
45
+ | 'blur'
46
+ | 'focusin'
47
+ | 'focusout'
48
+ | 'input'
49
+ | 'change'
50
+ | 'submit'
51
+ | 'scroll'
52
+ | 'resize';
53
+
54
+ export type InteractionEventsString =
55
+ | InteractionEvent
56
+ | `${InteractionEvent},${InteractionEvent}`
57
+ | `${InteractionEvent},${InteractionEvent},${InteractionEvent}`
58
+ | (string & {});
59
+
22
60
  export declare type ScriptInjectorProps = {
23
- /**
24
- * @description Load the script once the dom is ready
25
- * @example <script-injector on:idle></script-injector>
26
- */
27
- 'on:idle'?: boolean;
28
- /**
29
- * @description Load the script based on a series of events
30
- * @example <script-injector on:interaction="mouseenter, focusin"></script-injector>
31
- */
32
- 'on:interaction'?: 'touchstart,click' | 'mouseenter,focusin';
33
- /**
34
- * @description Import a script to be loaded when the observer detects the element is in the viewport
35
- * @example <script-injector on:visible="50px 1px"></script-injector>
36
- */
37
- 'on:visible'?: string | boolean;
38
- /**
39
- * A list of scripts to be loaded, comma separated.
40
- */
41
- scripts: string;
61
+ /**
62
+ * @description Load the script once the dom is ready
63
+ * @example <script-injector on:idle></script-injector>
64
+ */
65
+ 'on:idle'?: boolean;
66
+ /**
67
+ * @description Load the script based on a series of events.
68
+ * Accepts a comma-separated list of event names.
69
+ * @example <script-injector on:interaction="mouseenter,focusin"></script-injector>
70
+ */
71
+ 'on:interaction'?: InteractionEventsString;
72
+ /**
73
+ * @description Import a script to be loaded when the observer detects the element is in the viewport
74
+ * @example <script-injector on:visible="50px 1px"></script-injector>
75
+ */
76
+ 'on:visible'?: string | boolean;
77
+ /**
78
+ * A list of scripts to be loaded, comma separated.
79
+ */
80
+ scripts: string;
42
81
  };