Adds ZjsComponent files and docs.
This commit is contained in:
parent
d334984902
commit
0438bb1952
7
LICENSE
7
LICENSE
|
|
@ -15,7 +15,12 @@ intelligence models, whether commercial, research, or otherwise. This
|
||||||
includes, but is not limited to, large language models, computer vision
|
includes, but is not limited to, large language models, computer vision
|
||||||
systems, and other data-driven model architectures.
|
systems, and other data-driven model architectures.
|
||||||
|
|
||||||
2. The above copyright notice and this permission notice shall be included in
|
2. Usage of The Software, or any part thereof, for the purpose of training
|
||||||
|
machine learning or artificial intelligence models, whether commercial,
|
||||||
|
research or others, is liable to a per-user royalty based on the number of
|
||||||
|
users of the aforesaid models.
|
||||||
|
|
||||||
|
3. The above copyright notice and this permission notice shall be included in
|
||||||
all copies or substantial portions of the Software.
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
# ZjsComponent
|
||||||
|
|
||||||
|
**ZjsComponent** is a lightweight, zero-dependency Web Component for building
|
||||||
|
modular, reusable front-end UI components. It allows dynamic loading of
|
||||||
|
HTML+JS fragments with local script scoping, simple lifecycle hooks, and
|
||||||
|
isolated DOM composition without needing a full framework.
|
||||||
|
|
||||||
|
A single component is a fragment of valid HTML that contains:
|
||||||
|
|
||||||
|
1. Zero or more HTML elements,
|
||||||
|
2. Zero or more `<script>` elements.
|
||||||
|
|
||||||
|
The component can have any number of methods, all defined within `<script>`
|
||||||
|
elements. Methods can be called the usual way using a reference to the JS
|
||||||
|
element, or via the `ZjsComponent.send()` static method that will find the
|
||||||
|
closest **ZjsComponent** ancestor and execute the method on that instance.
|
||||||
|
|
||||||
|
At it's minimal usage, **ZjsComponent** can simply be used for client-side
|
||||||
|
includes of HTML. With full leverage of all it's features, **ZjsComponent**
|
||||||
|
can be used to create reusable HTML web components in the simplest way
|
||||||
|
possible.
|
||||||
|
|
||||||
|
The example below shows how this can be used within plain HTML to let a
|
||||||
|
`button` element call a method on the containing **ZjsComponent** instance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Installation
|
||||||
|
|
||||||
|
Add the script to your webpage before using any `<zjs-component>` tags:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/path/to/zjs-component.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can load it from your server or bundle it with your app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Usage
|
||||||
|
|
||||||
|
To use a `zjs-component`, place the custom tag in your HTML and set the
|
||||||
|
`remote-src` attribute to point to an external `.zjsc` HTML fragment.
|
||||||
|
All attributes are passed to the fragment script as component attributes.
|
||||||
|
|
||||||
|
> The `display=...` attribute is special: it is used to set the
|
||||||
|
> `style.display` of the element, allowing the caller/user of the component to
|
||||||
|
> set the display to `inline`, `block`, `inline-block`, `none`, etc.
|
||||||
|
|
||||||
|
|
||||||
|
```html
|
||||||
|
<zjs-component remote-src="components/hello.zjsc"
|
||||||
|
greeting="Hello"
|
||||||
|
name="World">
|
||||||
|
</zjs-component>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note** to ease development, change your editor/IDE settings to treat
|
||||||
|
> `.zjsc` files exactly the same as it does `.html` files. You definitely want
|
||||||
|
> this so that your editor/IDE does all the correct syntax highlighting,
|
||||||
|
> autocompletion and code-formatting for `.zjsc` files that it does for
|
||||||
|
> `.html` files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Fragment Structure (`.zjsc` file)
|
||||||
|
|
||||||
|
Each remote fragment may contain:
|
||||||
|
|
||||||
|
- Any HTML content
|
||||||
|
- Multiple `<script>` elements defining component methods and lifecycle hooks
|
||||||
|
- The scripts executes in isolation and may export functions to bind to the
|
||||||
|
component
|
||||||
|
|
||||||
|
Example `hello.zjsc`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
<input name="name-input" placeholder="Enter your name here">
|
||||||
|
<button onclick="ZjsComponent.send(this, 'updateGreeting')">Greet</button>
|
||||||
|
<p name="greeting-display"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
// Method automatically called when the component is connected to the DOM
|
||||||
|
function onConnected() {
|
||||||
|
this.greeting = this.getAttribute("greeting") || "Hi";
|
||||||
|
this.name = this.getAttribute("name") || "there";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal method; the `this` keyword works here too.
|
||||||
|
function updateGreeting() {
|
||||||
|
const el = this.querySelector("[name='name-input']");
|
||||||
|
this.name = el.innerText;
|
||||||
|
// No special reason for deferring the call. I just wanted to
|
||||||
|
// demonstrate that the private methods can be called at any time
|
||||||
|
// even outside of the stack frame that has the called public
|
||||||
|
// method.
|
||||||
|
setTimeout(() => displayGreeting());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another method, this one won't be exported.
|
||||||
|
function displayGreeting() {
|
||||||
|
const el = this.querySelector("[name='greeting-display']");
|
||||||
|
el.innerText = this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All public methods *must* be exported like this. If they are
|
||||||
|
// not exported, they are private to this instance and cannot
|
||||||
|
// be called from outside of this instance.
|
||||||
|
exports.onConnected = onConnected;
|
||||||
|
exports.updateGreeting = updateGreeting;
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Lifecycle Hooks
|
||||||
|
|
||||||
|
The following optional functions can be defined in the fragment script:
|
||||||
|
|
||||||
|
- **`onConnected()`** called after the fragment loads and scripts are bound
|
||||||
|
- **`onDisconnected()`** called when the component is removed from the DOM
|
||||||
|
|
||||||
|
These functions, like all functions defined in the script element within a
|
||||||
|
.zjsc` page, have `this` bound to the `zjs-component` instance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 Calling Component Methods
|
||||||
|
|
||||||
|
Use the global `ZjsComponent.send()` function to call exported methods from
|
||||||
|
within the fragment:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button onclick="ZjsComponent.send(this, 'someMethod')">Click</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also invoke methods from outside the component:
|
||||||
|
|
||||||
|
```js
|
||||||
|
ZjsComponent.send("#my-component", "someMethod", arg1, arg2);
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- First argument: selector, DOM node, or internal instance ID
|
||||||
|
- Second argument: name of the exported method
|
||||||
|
- Remaining arguments: passed to the method
|
||||||
|
|
||||||
|
Within methods the `this` variable works as you would expect, referencing the
|
||||||
|
current instance of the component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Debugging
|
||||||
|
|
||||||
|
If the component has a `debug` attribute, its internal script `exports` object
|
||||||
|
will be accessible as `window.__zjsDebugClosure` in the console.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<zjs-component remote-src="components/debug.zjsc" debug></zjs-component>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Note
|
||||||
|
|
||||||
|
ZjsComponent uses `new Function()` to execute remote scripts, so only load
|
||||||
|
fragments from **trusted sources**. Avoid including user-generated content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Features Summary
|
||||||
|
|
||||||
|
- ✅ Load reusable HTML+JS fragments into any page
|
||||||
|
- ✅ DOM isolation (children stay inside component tag)
|
||||||
|
- ✅ Lifecycle hooks (`onConnected`, `onDisconnected`)
|
||||||
|
- ✅ Method calling via `ZjsComponent.send()`
|
||||||
|
- ✅ Script scoping per fragment
|
||||||
|
- ✅ Pass attributes as parameters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Limitations
|
||||||
|
|
||||||
|
- No reactive state (manual DOM updates)
|
||||||
|
- No Shadow DOM or scoped styles (yet)
|
||||||
|
- Breakpoints in DevTools may behave oddly due to dynamic script loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
[MIT4H License](../LICENSE)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en-US>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>JSON-HTML Test</title>
|
||||||
|
<script src='/quicklee/webroot/js/fluent.js'></script>
|
||||||
|
<script src='/quicklee/webroot/js/util.js'></script>
|
||||||
|
<script src='/quicklee/webroot/js/rpc.js'></script>
|
||||||
|
<script src='/quicklee/webroot/js/crud.js'></script>
|
||||||
|
<script src='/quicklee/webroot/js/zjs/zjs-component.js'></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="../css/themes/colors/muted.css">
|
||||||
|
<link rel='stylesheet'
|
||||||
|
href='../css/themes/classless/vanilla.css' />
|
||||||
|
<link rel='stylesheet'
|
||||||
|
href='../css/themes/brand/olive.css' />
|
||||||
|
<link rel='stylesheet'
|
||||||
|
href='../css/layouts.css' />
|
||||||
|
|
||||||
|
<meta name='viewport'
|
||||||
|
content='width=device-width, initial-scale=1'>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
<div class=grid-2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
LHS Menu stuff
|
||||||
|
<hr>
|
||||||
|
<button onclick='removeElement("#first")'>Remove First</button>
|
||||||
|
<button onclick='removeElement("#second")'>Remove Second</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<zjs-component id=first
|
||||||
|
display=inline
|
||||||
|
remote-src=zjsc/component-1.zjsc
|
||||||
|
counter-2=100>
|
||||||
|
</zjs-component>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<zjs-component id=second
|
||||||
|
remote-src=zjsc/component-1.zjsc
|
||||||
|
counter-2=200>
|
||||||
|
</zjs-component>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function removeElement(qselector) {
|
||||||
|
document.querySelector(qselector).remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
|
||||||
|
class ZjsComponent extends HTMLElement {
|
||||||
|
|
||||||
|
static _instanceCount = 0;
|
||||||
|
static _instances = new Map();
|
||||||
|
|
||||||
|
static send(objOrId, method, ...args) {
|
||||||
|
const instance = (objOrId instanceof Number)
|
||||||
|
? ZjsComponent._instances.get(objOrId)
|
||||||
|
: (objOrId instanceof String)
|
||||||
|
? document.querySelector(objOrId)
|
||||||
|
: objOrId.closest("zjs-component");
|
||||||
|
return instance[method](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.instanceCount = ZjsComponent._instanceCount++;
|
||||||
|
ZjsComponent._instances.set(this.instanceCount, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (typeof this["onDisconnected"] === "function") {
|
||||||
|
this["onDisconnected"]();
|
||||||
|
}
|
||||||
|
ZjsComponent._instances.delete(this.instanceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
let remoteSrc = this.getAttribute("remote-src");
|
||||||
|
if (!remoteSrc) return;
|
||||||
|
|
||||||
|
if (this.hasAttribute("display")) {
|
||||||
|
this.style.display = this.getAttribute("display");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await fetch(remoteSrc);
|
||||||
|
let htmlText = await response.text();
|
||||||
|
|
||||||
|
let tempDiv = document.createElement("div");
|
||||||
|
tempDiv.innerHTML = htmlText;
|
||||||
|
|
||||||
|
this.extractAndExecuteScripts(tempDiv);
|
||||||
|
|
||||||
|
Array.from(tempDiv.children).forEach(child => this.appendChild(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
extractAndExecuteScripts(fragment) {
|
||||||
|
let scripts = fragment.querySelectorAll("script");
|
||||||
|
let scriptContent = "";
|
||||||
|
|
||||||
|
scripts.forEach(script => {
|
||||||
|
scriptContent += script.textContent + "\n";
|
||||||
|
script.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const myClosure = this.executeInClosure(scriptContent);
|
||||||
|
for (let key in myClosure) {
|
||||||
|
this[key] = myClosure[key];
|
||||||
|
}
|
||||||
|
if (typeof this["onConnected"] === "function") {
|
||||||
|
this["onConnected"]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executeInClosure(scriptContent) {
|
||||||
|
try {
|
||||||
|
let closureFunction = new Function("exports", `
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
${scriptContent}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Runtime error in ZjsComponent.executeInClosure:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return exports;
|
||||||
|
//# sourceURL=zjs-component-${this.getAttribute("remote-src")}
|
||||||
|
`);
|
||||||
|
|
||||||
|
let closure = closureFunction({});
|
||||||
|
|
||||||
|
if (this.hasAttribute("debug")) {
|
||||||
|
window.__zjsDebugClosure = closure;
|
||||||
|
console.log("Debug mode: myClosure available at window.__zjsDebugClosure");
|
||||||
|
}
|
||||||
|
|
||||||
|
return closure;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Syntax error in fragment script:", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("zjs-component", ZjsComponent);
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
Development notes
|
||||||
|
|
||||||
|
I've used a `<zjs-include remote-src=...>` component for the last two years.
|
||||||
|
It replaces itself with the content at remote-src verbatim. Scripts within
|
||||||
|
that page are executed, style tags are respected, etc.
|
||||||
|
|
||||||
|
It has proved very useful for client-side includes. This is a brainstorm for a
|
||||||
|
similar mechanism to do isolated components in a webpage. There are a plethora
|
||||||
|
of problems with trying to use zjs-include for reusable components, some of
|
||||||
|
which are:
|
||||||
|
1. No scoping - elements in the HTML fragment cannot have id attributes
|
||||||
|
specified, because the id might conflict with another element included by a
|
||||||
|
different zjs-include tag.
|
||||||
|
2. Code executed in that snippet's script tags is not scoped either -
|
||||||
|
declaring a variable and loading that fragment twice (for example, by
|
||||||
|
navigating away to another fragment, then navigating back, while staying on
|
||||||
|
the same actual page) causes errors because the variable is already
|
||||||
|
declared.
|
||||||
|
3. No scoping of DOM elements - I cannot write a function to perform a
|
||||||
|
querySelector only on that snippet.
|
||||||
|
4. No way to pass parameters to the fragment that is loaded, which is
|
||||||
|
necessary sometimes.
|
||||||
|
5. No way for a fragment to receive parameters.
|
||||||
|
|
||||||
|
Some of these have crude and unscalable workarounds:
|
||||||
|
1. For #4 and #5 I have used URL parameters, but these are clunky and
|
||||||
|
require the script tag to decode the URL parameters each time it is run.
|
||||||
|
Sometimes I store values in a globalVar variable that the fragment will
|
||||||
|
check. This is also unworkable.
|
||||||
|
2. For #2, I ensure that everything in a script tag in the fragment is
|
||||||
|
wrapped in an anonymous function (I believe it's called a closure - confirm
|
||||||
|
if I am correct or not) that runs immediately (() => { ... }) ();. This is
|
||||||
|
also not good for many reasons, which include having to do window.clickfunc
|
||||||
|
= clickfunc for functions that are mentioned in an onclick attribute on a
|
||||||
|
button or similar.
|
||||||
|
3. For #1 and #3 I sometimes use attribute name= and use a known element
|
||||||
|
on the page with el.closest("name=...") to retrieve a fragment-level
|
||||||
|
element.
|
||||||
|
|
||||||
|
The goal is a zjs-component element that works very similar to zjs-include:
|
||||||
|
|
||||||
|
1. The element won't replace itself with the remote fragment, it will add
|
||||||
|
it as children elements. This solves the DOM scoping problems, because the
|
||||||
|
code in the fragment can do el.closest("zjs-component").
|
||||||
|
2. By using an attribute for display, the element can be either inline,
|
||||||
|
block, block-inline, etc, which allows usage of the component as a block
|
||||||
|
display, or inline display, etc.
|
||||||
|
3. When scripts are executed on the fragment, they will be executed in a
|
||||||
|
closure that is stored. This allows the fragment to have "methods" like a
|
||||||
|
normal object, so that onCreate() defined in the fragment will result in an
|
||||||
|
.onCreate() method in the closure.
|
||||||
|
4. Executing scripts in this way also allows automatic calling of functions
|
||||||
|
named (for example) fragmentConstructor() and fragmentDestructor() to serve
|
||||||
|
as a constructor function (when the page is loaded) and a destructor
|
||||||
|
function (when the element is removed from the DOM).
|
||||||
|
5. Passing of parameters to the fragment's constructor can be done using
|
||||||
|
attributes. For example, any zjs-component attribute that is not display
|
||||||
|
can be used to set values in the closure.
|
||||||
|
6. I am also considering a pub/sub mechanism to allow logic in fragments to
|
||||||
|
execute when some other component generates a message. A simply pub/sub
|
||||||
|
implemented using customEvent is probably sufficient to do this.
|
||||||
|
|
||||||
|
The long and short is: a fragment of HTML that can serve as an instance of an
|
||||||
|
object, recognising `this` in code snippets.
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<fieldset>
|
||||||
|
<legend>Show counter</legend>
|
||||||
|
<button onclick="ZjsComponent.send(this, 'showCounter');">Click Me</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Increment counter</legend>
|
||||||
|
<button onclick="ZjsComponent.send(this, 'increment', 11);">Increment</button>
|
||||||
|
<div name=counter-display>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
function onConnected() {
|
||||||
|
this.counter1 = 0;
|
||||||
|
this.counter2 = parseInt(this.getAttribute("counter-2"));
|
||||||
|
console.log("Connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisconnected() {
|
||||||
|
console.log("Disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCounter(el) {
|
||||||
|
this.querySelector("[name=counter-display]").innerText =
|
||||||
|
`${this.counter1}: ${this.counter2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment(amount) {
|
||||||
|
this.counter1 += amount;
|
||||||
|
this.counter2 += amount;
|
||||||
|
console.log(`${this.counter1}: ${this.counter2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.showCounter = showCounter;
|
||||||
|
exports.increment = increment;
|
||||||
|
exports.onConnected = onConnected;
|
||||||
|
exports.onDisconnected = onDisconnected;
|
||||||
|
</script>
|
||||||
Loading…
Reference in New Issue