Browse Source

Add x

master
Nicolas Petton 1 year ago
parent
commit
d4a7aa8605
No known key found for this signature in database GPG Key ID: E8BCD7866AFCF978
13 changed files with 638 additions and 0 deletions
  1. +13
    -0
      x/examples/composite.js
  2. +12
    -0
      x/examples/counter.js
  3. +14
    -0
      x/examples/counter2.js
  4. +18
    -0
      x/examples/damienComp.js
  5. +26
    -0
      x/examples/fullname.js
  6. +108
    -0
      x/examples/index.html
  7. +17
    -0
      x/examples/index.js
  8. +18
    -0
      x/examples/style.css
  9. +57
    -0
      x/src/HookRegistry.js
  10. +186
    -0
      x/src/HtmlCanvas.js
  11. +101
    -0
      x/src/component.js
  12. +62
    -0
      x/src/hooks.js
  13. +6
    -0
      x/src/index.js

+ 13
- 0
x/examples/composite.js View File

@ -0,0 +1,13 @@
import component from "../src/component.js";
import counter from "./counter.js";
const wrapper = component("x-wrapper", ({ children = [] } = {}) => html => {
html.h3("Parent component with children");
html.render(children);
});
const composite = component("x-composite", () => html =>
html.render(wrapper({ children: [counter(), counter()] }))
);
export default composite;

+ 12
- 0
x/examples/counter.js View File

@ -0,0 +1,12 @@
import component from "../src/component.js";
import { useState, useAttribute } from "../src/hooks.js";
let counter = component("x-counter", ({ count = 0 } = {}) => html => {
let [currentCount, setCount] = useState(count);
html.h2(currentCount);
html.button({ click: () => setCount(currentCount + 1) }, "++");
html.button({ click: () => setCount(currentCount - 1) }, "--");
});
export default counter;

+ 14
- 0
x/examples/counter2.js View File

@ -0,0 +1,14 @@
import component from "../src/component.js";
import { useState, useAttribute } from "../src/hooks.js";
let counter = ({ initialCount = 0 } = {}) => html => {
let [count, setCount] = useAttribute("count", initialCount);
html.h2(count);
html.button({ click: () => setCount(Number(count) + 1) }, "++");
html.button({ click: () => setCount(Number(count) - 1) }, "--");
};
counter.observedAttributes = ["count"];
export default component("x-counter2", counter);

+ 18
- 0
x/examples/damienComp.js View File

@ -0,0 +1,18 @@
import component from "../src/component.js";
import counter2 from "./counter2.js";
const damienComp = component("x-damien-comp", () => {
const count = counter2();
return html => {
html.render(count);
html.render(
html.input({
value: count.count,
change: evt => (count.count = evt.target.value)
})
);
};
});
export default damienComp;

+ 26
- 0
x/examples/fullname.js View File

@ -0,0 +1,26 @@
import component from "../src/component.js";
import { useState, useAttribute } from "../src/hooks.js";
let fullname = component(
"x-fullname",
({ firstName = "John", lastName = "Doe" } = {}) => html => {
let [currentFirstName, setFirstName] = useState(firstName);
let [currentLastName, setLastName] = useState(lastName);
html.h3(`Your fullname is ${currentFirstName} ${currentLastName}`);
html.form(
html.input({
placeholder: "First name",
value: currentFirstName,
change: ({ target }) => setFirstName(target.value)
}),
html.input({
placeholder: "Last name",
value: currentLastName,
change: ({ target }) => setLastName(target.value)
})
);
}
);
export default fullname;

+ 108
- 0
x/examples/index.html View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html>
<head>
<title>X components</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="./style.css" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/styles/github.min.css" type="text/css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/languages/javascript.min.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
</head>
<body>
<script type="module" src="./index.js"></script>
<h1>X — Functional component examples</h1>
<h2>Stateful counter</h2>
<p>The famous counter example is implemented as follows in X.</p>
<p>It is declared as a function of the initial state, state being maintained between re-renders using the <code>useState</code> hook.</p>
<pre><code class="javascript">
let counter = component("x-counter", ({count = 0} = {}) => html => {
let [currentCount, setCount] = useState(count);
html.h2(currentCount);
html.button({ click: () => setCount(currentCount + 1) }, "++");
html.button({ click: () => setCount(currentCount - 1) }, "--");
});
</code></pre>
<div id="counter"></div>
<h2>Counter with attributes</h2>
It can be useful to setup components by using attributes:
<pre><code class="html">
&lt;x-counter count="2"&gt;&lt;/x-counter&gt;
</code></pre>
<p>This is the implementation of a counter component that uses a <code>count</code> attribute:</p>
<pre><code class="javascript">
let counter = () => html => {
let [count, setCount] = useAttribute("count");
html.h2(count);
html.button({ click: () => setCount(Number(count) + 1) }, "++");
html.button({ click: () => setCount(Number(count) - 1) }, "--");
};
counter.observedAttributes = ['count'];
component('x-counter2', counter);
</code></pre>
<p>Note that the attribute is accessed using the <code>useAttribute</code> hook.</p>
<div id="counter-attr"></div>
<h2>Forms & Inputs</h2>
<p>Here is a simple form example:</p>
<pre><code>
let fullname = component(
"x-fullname",
({ firstName = "John", lastName = "Doe" } = {}) => html => {
let [currentFirstName, setFirstName] = useState(firstName);
let [currentLastName, setLastName] = useState(lastName);
html.h3(`Your fullname is ${currentFirstName} ${currentLastName}`);
html.form(
html.input({
value: currentFirstName,
change: ({ target }) => setFirstName(target.value)
}),
html.input({
value: currentLastName,
change: ({ target }) => setLastName(target.value)
})
);
}
);
</pre></code>
<div id="fullname"></div>
<h2>Component composition</h2>
<pre><code>
const wrapper = component("x-wrapper", ({ children = [] } = {}) => html => {
html.h3("Parent component with children");
html.render(children);
});
const composite = component("x-composite", () => html =>
html.render(wrapper({ children: [counter(), counter()] }))
);
</pre></code>
<div id="composite"></div>
<h2>Damien example</h2>
<div id="damien-comp"></div>
</body>
</html>

+ 17
- 0
x/examples/index.js View File

@ -0,0 +1,17 @@
import "./counter.js";
import "./counter2.js";
import "./fullname.js";
import "./composite.js";
import "./damienComp.js";
document.body.querySelector("#counter").innerHTML = "<x-counter></x-counter>";
document.body.querySelector("#counter-attr").innerHTML =
"<x-counter2 count=\"0\"></x-counter2>";
document.body.querySelector("#fullname").innerHTML =
"<x-fullname></x-fullname>";
document.body.querySelector("#composite").innerHTML =
"<x-composite></x-composite>";
document.body.querySelector("#damien-comp").innerHTML =
"<x-damien-comp></x-damien-comp>";

+ 18
- 0
x/examples/style.css View File

@ -0,0 +1,18 @@
body {
max-width: 800px;
margin: 20px auto;
font-family: "Trebuchet MS", Helvetica, sans-serif;
color: #333;
}
h1 {
font-size: 32px;
border-bottom: 1px solid;
}
code {
padding: 3px;
background: #eee;
color: #d14;
}

+ 57
- 0
x/src/HookRegistry.js View File

@ -0,0 +1,57 @@
const registries = new WeakMap();
class HookRegistry {
constructor(component) {
this._component = component;
this._hookIndex = 0;
this._hooks = [];
}
get component() {
return this._component;
}
resetHookIndex() {
this._hookIndex = 0;
}
use(f) {
let hook = this._hooks[this._hookIndex];
if (!hook) {
hook = f();
this._hooks.push(hook);
}
this._hookIndex++;
return hook();
}
static get current() {
return this._current;
}
static setCurrent(component) {
this._current = this._hookRegistryFor(component);
this._current.resetHookIndex();
}
static clearCurrent() {
this._current = null;
}
static unregister(component) {
if (registries.has(component)) {
registries.delete(component);
}
}
static _hookRegistryFor(component) {
if (!registries.has(component)) {
registries.set(component, new this(component));
}
return registries.get(component);
}
}
export default HookRegistry;

+ 186
- 0
x/src/HtmlCanvas.js View File

@ -0,0 +1,186 @@
let tags = (
"a abbr acronym address area article aside audio b bdi bdo big " +
"blockquote body br button canvas caption cite code col colgroup command " +
"datalist dd del details dfn div dl dt em embed fieldset figcaption figure " +
"footer form frame frameset h1 h2 h3 h4 h5 h6 hr head header hgroup html i " +
"iframe img input ins kbd keygen label legend li link map mark meta meter " +
"nav noscript object ol optgroup option output p param pre progress q rp rt " +
"ruby samp script section select slot small source span strong style sub " +
"summary sup table tbody td textarea tfoot th thead time title tr track tt " +
"ul var video wbr"
).split(" ");
let attributes = "href for id media rel src style title type".split(" ");
let omitSymbol = {};
let events = (
"blur focus focusin focusout load resize scroll unload " +
"click dblclick mousedown mouseup mousemove mouseover " +
"mouseout mouseenter mouseleave change input select submit " +
"keydown keypress keyup error dragstart dragenter dragover dragleave drop dragend"
).split(" ");
class HtmlCanvas {
constructor(root) {
this.root = new TagBrush({ element: root });
}
tag(tagName, children) {
let tagBrush = new TagBrush({ tag: tagName, children: children });
this.root.appendBrush(tagBrush);
return tagBrush;
}
omit() {
return omitSymbol;
}
render() {
this.root.render(...arguments);
}
}
tags.forEach(function(tagName) {
HtmlCanvas.prototype[tagName] = function() {
let args = Array.prototype.slice.call(arguments);
return this.tag(tagName, args);
};
});
class TagBrush {
constructor({ tag, element, children } = {}) {
this.element = element ? element : this.createElement(tag);
if (!this.element) {
throw new Error("TagBrush requires an element");
}
if (children) {
this.append(children);
}
}
createElement(tagName) {
if (tagName) {
return document.createElement(tagName);
} else {
return document.createDocumentFragment();
}
}
append(object) {
if (object.appendToBrush) {
object.appendToBrush(this);
return;
}
// Assume attributes
if (typeof object === "object") {
this._attr(object);
return;
}
throw new Error("Unsupported data type");
}
appendChild(child) {
if (this.element.canHaveChildren !== false) {
this.element.appendChild(child);
} else {
this.element.text = this.element.text + child.innerHTML;
}
}
appendBrush(aTagBrush) {
this.appendChild(aTagBrush.element);
}
appendString(string) {
this.appendChild(document.createTextNode(string));
}
appendFunction(fn) {
fn(new HtmlCanvas(this));
}
appendElement(element) {
this.appendChild(element);
}
render(...args) {
this.append(args);
}
appendToBrush(aTagBrush) {
aTagBrush.appendBrush(this);
}
addEventListener(type, handler) {
this.element.addEventListener(type, handler);
}
setAttribute(key, value) {
// Omit attribute if value is omit
if (value === omitSymbol) {
return;
}
this.element.setAttribute(key, value);
}
_attr(object) {
for (let key in object) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
// Attach functions
if (typeof object[key] === "function") {
this.addEventListener(key, object[key]);
} else if (key === "klass") {
this.element.className = object[key];
} else {
this.setAttribute(key, object[key]);
}
}
}
return this;
}
}
events.forEach(function(eventType) {
TagBrush.prototype[eventType] = function(callback) {
return this.addEventListener(eventType, callback);
};
});
attributes.forEach(function(attributeName) {
TagBrush.prototype[attributeName] = function(value) {
return this.setAttribute(attributeName, value);
};
});
omitSymbol.appendToBrush = brush => {};
String.prototype.appendToBrush = function(brush) {
brush.appendString(this);
};
Function.prototype.appendToBrush = function(brush) {
brush.appendFunction(this);
};
Number.prototype.appendToBrush = function(brush) {
this.toString().appendToBrush(brush);
};
Array.prototype.appendToBrush = function(brush) {
let length = this.length;
for (let i = length - 1; i >= 0; i--) {
brush.append(this[length - i - 1]);
}
};
HTMLElement.prototype.appendToBrush = function(brush) {
brush.appendElement(this);
};
export default HtmlCanvas;

+ 101
- 0
x/src/component.js View File

@ -0,0 +1,101 @@
import HtmlCanvas from "./HtmlCanvas.js";
import HookRegistry from "./HookRegistry.js";
import { useState } from "./hooks.js";
const attrsToProps = (component, attrs) => {
attrs.forEach(attr => {
Object.defineProperty(component, attr, {
get() {
return component.getAttribute(attr);
},
set(v) {
component.setAttribute(attr, v);
}
});
});
};
const component = (name, renderer, style) => {
class Component extends HTMLElement {
constructor(props) {
super();
this.attachShadow({ mode: "open" });
this._props = props;
this._renderable = renderer(this._props);
attrsToProps(this, renderer.observedAttributes || []);
}
attributeChangedCallback(name, oldVal, newVal) {
this.update();
}
connectedCallback() {
// `connectedCallback` is called the first time before any of its custom
// element children are upgraded. Any <child-element> is only an HTMLElement
// when connectedCallback is called.
setTimeout(this.update.bind(this));
}
disconnectedCallback() {
HookRegistry.unregister(this);
}
update() {
if (this._preventUpdates) {
return;
}
this._withHookRegistry(() => {
this._empty();
let html = new HtmlCanvas(this.shadowRoot);
html.render(this._renderable);
if (style) {
html.style(style);
}
});
}
withoutUpdating(f) {
try {
this._preventUpdates = true;
f();
} finally {
this._preventUpdates = false;
}
}
_withHookRegistry(f) {
try {
HookRegistry.setCurrent(this);
f();
} finally {
HookRegistry.clearCurrent();
}
}
_empty() {
this._emptyElement(this.shadowRoot);
this._emptyElement(this);
}
_emptyElement(element) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
static get observedAttributes() {
return renderer.observedAttributes;
}
}
window.customElements.define(name, Component);
return props => new Component(props);
};
export default component;

+ 62
- 0
x/src/hooks.js View File

@ -0,0 +1,62 @@
import HookRegistry from "./HookRegistry.js";
export const useCallback = f => {
return HookRegistry.current.use(() => {
f();
return () => {};
});
};
export const useState = initialState => {
return HookRegistry.current.use(() => {
let state = initialState;
let component = HookRegistry.current.component;
return () => {
return [
state,
value => {
state = value;
component.update();
}
];
};
});
};
export const useAttribute = (name, initialValue) => {
let component = HookRegistry.current.component;
useCallback(() => {
if (initialValue !== undefined) {
component.withoutUpdating(() => {
component.setAttribute(name, initialValue);
});
}
});
return [
component.getAttribute(name),
component.setAttribute.bind(component, name),
component.removeAttribute.bind(component, name)
];
};
export const useAsync = promise => {
const [pending, setPending] = useState(true);
const [value, setValue] = useState(null);
const [error, setError] = useState(null);
useCallback(() => {
promise
.then(setValue)
.catch(setError)
.finally(() => setPending(false));
});
return {
value,
error,
pending
};
};

+ 6
- 0
x/src/index.js View File

@ -0,0 +1,6 @@
import component from "./component.js";
import HtmlCanvas from "./HtmlCanvas.js";
import { useState, useAttribute } from "./hooks.js";
export default component;
export { useState, useAttribute, HtmlCanvas };

Loading…
Cancel
Save