Browse Source

Initial commit

master
Nicolas Petton 2 years ago
commit
166d21d77b
No known key found for this signature in database GPG Key ID: E8BCD7866AFCF978
14 changed files with 516 additions and 0 deletions
  1. +13
    -0
      .eslintrc.json
  2. +1
    -0
      .gitignore
  3. +5
    -0
      README.md
  4. +12
    -0
      examples/counter.js
  5. +17
    -0
      examples/counter2.js
  6. +63
    -0
      examples/index.html
  7. +6
    -0
      examples/index.js
  8. +18
    -0
      examples/style.css
  9. +15
    -0
      package.json
  10. +57
    -0
      src/HookRegistry.js
  11. +186
    -0
      src/HtmlCanvas.js
  12. +83
    -0
      src/component.js
  13. +34
    -0
      src/hooks.js
  14. +6
    -0
      src/index.js

+ 13
- 0
.eslintrc.json View File

@ -0,0 +1,13 @@
{
"env": {
"es6": true,
"browser": true
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"quotes": [2, "double"]
}
}

+ 1
- 0
.gitignore View File

@ -0,0 +1 @@
node_modules

+ 5
- 0
README.md View File

@ -0,0 +1,5 @@
# Functional web components & HtmlCanvas
## Functional components
## HtmlCanvas

+ 12
- 0
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;

+ 17
- 0
examples/counter2.js View File

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

+ 63
- 0
examples/index.html View File

@ -0,0 +1,63 @@
<!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>
</body>
</html>

+ 6
- 0
examples/index.js View File

@ -0,0 +1,6 @@
import counter from "./counter.js";
import counter2 from "./counter2.js";
document.body.querySelector("#counter").innerHTML = "<x-counter></x-counter>";
document.body.querySelector("#counter-attr").innerHTML =
"<x-counter2 count=\"0\"></x-counter2>";

+ 18
- 0
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;
}

+ 15
- 0
package.json View File

@ -0,0 +1,15 @@
{
"name": "x",
"version": "1.0.0",
"description": "Functional web components & HtmlCanvas",
"main": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://gitea.petton.fr/nico/x.git"
},
"author": "Nicolas Petton <nico@petton.fr>",
"license": "MIT"
}

+ 57
- 0
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) {
this._hooks.push(f);
hook = f;
}
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
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;

+ 83
- 0
src/component.js View File

@ -0,0 +1,83 @@
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) => {
class Component extends HTMLElement {
constructor(props) {
super();
this.attachShadow({ mode: "open" });
this._props = 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() {
this._withHookRegistry(() => {
this._empty();
let html = new HtmlCanvas(this.shadowRoot);
html.render(renderer(this._props));
});
}
_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;

+ 34
- 0
src/hooks.js View File

@ -0,0 +1,34 @@
import HookRegistry from "./HookRegistry.js";
export const useState = initialState => {
return HookRegistry.current.use(
(() => {
let state = initialState;
let component = HookRegistry.current.component;
return () => {
const handler = {
get state() {
return state;
},
setState(value) {
state = value;
component.update();
}
};
return [handler.state, handler.setState.bind(handler)];
};
})()
);
};
export const useAttribute = name => {
let component = HookRegistry.current.component;
return [
component.getAttribute(name),
component.setAttribute.bind(component, name),
component.removeAttribute.bind(component, name)
];
};

+ 6
- 0
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