@ -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; |
@ -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; |
@ -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); |
@ -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; |
@ -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; |
@ -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"> | |||
<x-counter count="2"></x-counter> | |||
</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> |
@ -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>"; |
@ -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; | |||
} |
@ -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; |
@ -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; |
@ -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; |
@ -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 | |||
}; | |||
}; |
@ -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 }; |