@ecopages/scripts-injector 0.1.1 → 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 +61 -2
- package/package.json +11 -3
- package/scripts-injector.js +1 -1
- package/types.d.ts +70 -31
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={[
|
|
31
|
-
|
|
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,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/scripts-injector",
|
|
3
3
|
"main": "scripts-injector.js",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "types.d.ts",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/ecopages/scripts-injector"
|
|
10
|
+
},
|
|
7
11
|
"exports": {
|
|
8
12
|
"./package.json": {
|
|
9
13
|
"import": "./package.json"
|
|
@@ -15,10 +19,14 @@
|
|
|
15
19
|
"./types": "./types.d.ts"
|
|
16
20
|
},
|
|
17
21
|
"scripts": {
|
|
18
|
-
"build:lib": "rm -rf dist && bun build --entry scripts-injector.ts --outdir . --minify"
|
|
22
|
+
"build:lib": "rm -rf dist && bun build --entry scripts-injector.ts --outdir . --minify",
|
|
23
|
+
"test": "vitest",
|
|
24
|
+
"test:run": "vitest run"
|
|
19
25
|
},
|
|
20
26
|
"devDependencies": {
|
|
21
|
-
"@types/bun": "latest"
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"@vitest/browser-playwright": "^4.0.16",
|
|
29
|
+
"vitest": "^4.0.16"
|
|
22
30
|
},
|
|
23
31
|
"peerDependencies": {
|
|
24
32
|
"typescript": "^5.0.0"
|
package/scripts-injector.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
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<{
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
};
|