Merge pull request #2 from bwindels/bwindels/tree
Template & data-binding infrastructure for web ui
This commit is contained in:
commit
83613f49c9
9 changed files with 321 additions and 69 deletions
|
@ -1,4 +1,4 @@
|
||||||
import * as html from "./html.js";
|
import {tag} from "./html.js";
|
||||||
|
|
||||||
class UIView {
|
class UIView {
|
||||||
mount() {}
|
mount() {}
|
||||||
|
@ -47,7 +47,7 @@ export default class ListView {
|
||||||
}
|
}
|
||||||
|
|
||||||
mount() {
|
mount() {
|
||||||
this._root = html.ul({className: "ListView"});
|
this._root = tag.ul({className: "ListView"});
|
||||||
this._loadList();
|
this._loadList();
|
||||||
if (this._onItemClick) {
|
if (this._onItemClick) {
|
||||||
this._root.addEventListener("click", this._onClick);
|
this._root.addEventListener("click", this._onClick);
|
||||||
|
|
|
@ -1,29 +1,12 @@
|
||||||
import { li } from "./html.js";
|
import TemplateView from "./TemplateView.js";
|
||||||
|
|
||||||
export default class RoomTile {
|
export default class RoomTile extends TemplateView {
|
||||||
constructor(viewModel) {
|
render(t) {
|
||||||
this._viewModel = viewModel;
|
return t.li(vm => vm.name);
|
||||||
this._root = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
mount() {
|
|
||||||
this._root = li(null, this._viewModel.name);
|
|
||||||
return this._root;
|
|
||||||
}
|
|
||||||
|
|
||||||
unmount() {
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
// no data-binding yet
|
|
||||||
this._root.innerText = this._viewModel.name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// called from ListView
|
||||||
clicked() {
|
clicked() {
|
||||||
this._viewModel.open();
|
this.viewModel.open();
|
||||||
}
|
|
||||||
|
|
||||||
root() {
|
|
||||||
return this._root;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import TimelineTile from "./TimelineTile.js";
|
import TimelineTile from "./TimelineTile.js";
|
||||||
import ListView from "./ListView.js";
|
import ListView from "./ListView.js";
|
||||||
import * as html from "./html.js";
|
import {tag} from "./html.js";
|
||||||
|
import GapView from "./timeline/GapView.js";
|
||||||
|
|
||||||
export default class RoomView {
|
export default class RoomView {
|
||||||
constructor(viewModel) {
|
constructor(viewModel) {
|
||||||
|
@ -13,13 +14,15 @@ export default class RoomView {
|
||||||
|
|
||||||
mount() {
|
mount() {
|
||||||
this._viewModel.on("change", this._onViewModelUpdate);
|
this._viewModel.on("change", this._onViewModelUpdate);
|
||||||
this._nameLabel = html.h2(null, this._viewModel.name);
|
this._nameLabel = tag.h2(null, this._viewModel.name);
|
||||||
this._errorLabel = html.div({className: "RoomView_error"});
|
this._errorLabel = tag.div({className: "RoomView_error"});
|
||||||
|
|
||||||
this._timelineList = new ListView({}, entry => new TimelineTile(entry));
|
this._timelineList = new ListView({}, entry => {
|
||||||
|
return entry.shape === "gap" ? new GapView(entry) : new TimelineTile(entry);
|
||||||
|
});
|
||||||
this._timelineList.mount();
|
this._timelineList.mount();
|
||||||
|
|
||||||
this._root = html.div({className: "RoomView"}, [
|
this._root = tag.div({className: "RoomView"}, [
|
||||||
this._nameLabel,
|
this._nameLabel,
|
||||||
this._errorLabel,
|
this._errorLabel,
|
||||||
this._timelineList.root()
|
this._timelineList.root()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import ListView from "./ListView.js";
|
import ListView from "./ListView.js";
|
||||||
import RoomTile from "./RoomTile.js";
|
import RoomTile from "./RoomTile.js";
|
||||||
import RoomView from "./RoomView.js";
|
import RoomView from "./RoomView.js";
|
||||||
import { div } from "./html.js";
|
import {tag} from "./html.js";
|
||||||
|
|
||||||
export default class SessionView {
|
export default class SessionView {
|
||||||
constructor(viewModel) {
|
constructor(viewModel) {
|
||||||
|
@ -19,7 +19,7 @@ export default class SessionView {
|
||||||
mount() {
|
mount() {
|
||||||
this._viewModel.on("change", this._onViewModelChange);
|
this._viewModel.on("change", this._onViewModelChange);
|
||||||
|
|
||||||
this._root = div({className: "SessionView"});
|
this._root = tag.div({className: "SessionView"});
|
||||||
this._roomList = new ListView(
|
this._roomList = new ListView(
|
||||||
{
|
{
|
||||||
list: this._viewModel.roomList,
|
list: this._viewModel.roomList,
|
||||||
|
|
231
src/ui/web/Template.js
Normal file
231
src/ui/web/Template.js
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
import { setAttribute, text, TAG_NAMES } from "./html.js";
|
||||||
|
|
||||||
|
|
||||||
|
function classNames(obj, value) {
|
||||||
|
return Object.entries(obj).reduce((cn, [name, enabled]) => {
|
||||||
|
if (typeof enabled === "function") {
|
||||||
|
enabled = enabled(value);
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
return (cn.length ? " " : "") + name;
|
||||||
|
} else {
|
||||||
|
return cn;
|
||||||
|
}
|
||||||
|
}, "");
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
Bindable template. Renders once, and allows bindings for given nodes. If you need
|
||||||
|
to change the structure on a condition, use a subtemplate (if)
|
||||||
|
|
||||||
|
supports
|
||||||
|
- event handlers (attribute fn value with name that starts with on)
|
||||||
|
- one way binding of attributes (other attribute fn value)
|
||||||
|
- one way binding of text values (child fn value)
|
||||||
|
- refs to get dom nodes
|
||||||
|
- className binding returning object with className => enabled map
|
||||||
|
missing:
|
||||||
|
- create views
|
||||||
|
*/
|
||||||
|
export default class Template {
|
||||||
|
constructor(value, render) {
|
||||||
|
this._value = value;
|
||||||
|
this._eventListeners = null;
|
||||||
|
this._bindings = null;
|
||||||
|
this._subTemplates = null;
|
||||||
|
this._root = render(this, this._value);
|
||||||
|
this._attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
root() {
|
||||||
|
return this._root;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(value) {
|
||||||
|
this._value = value;
|
||||||
|
if (this._bindings) {
|
||||||
|
for (const binding of this._bindings) {
|
||||||
|
binding();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._subTemplates) {
|
||||||
|
for (const sub of this._subTemplates) {
|
||||||
|
sub.update(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._eventListeners) {
|
||||||
|
for (let {node, name, fn} of this._eventListeners) {
|
||||||
|
node.removeEventListener(name, fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._subTemplates) {
|
||||||
|
for (const sub of this._subTemplates) {
|
||||||
|
sub.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_attach() {
|
||||||
|
if (this._eventListeners) {
|
||||||
|
for (let {node, name, fn} of this._eventListeners) {
|
||||||
|
node.addEventListener(name, fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addEventListener(node, name, fn) {
|
||||||
|
if (!this._eventListeners) {
|
||||||
|
this._eventListeners = [];
|
||||||
|
}
|
||||||
|
this._eventListeners.push({node, name, fn});
|
||||||
|
}
|
||||||
|
|
||||||
|
_addBinding(bindingFn) {
|
||||||
|
if (!this._bindings) {
|
||||||
|
this._bindings = [];
|
||||||
|
}
|
||||||
|
this._bindings.push(bindingFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addSubTemplate(t) {
|
||||||
|
if (!this._subTemplates) {
|
||||||
|
this._subTemplates = [];
|
||||||
|
}
|
||||||
|
this._subTemplates.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
_addAttributeBinding(node, name, fn) {
|
||||||
|
let prevValue = undefined;
|
||||||
|
const binding = () => {
|
||||||
|
const newValue = fn(this._value);
|
||||||
|
if (prevValue !== newValue) {
|
||||||
|
prevValue = newValue;
|
||||||
|
setAttribute(node, name, newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._addBinding(binding);
|
||||||
|
binding();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addClassNamesBinding(node, obj) {
|
||||||
|
this._addAttributeBinding(node, "className", value => classNames(obj, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
_addTextBinding(fn) {
|
||||||
|
const initialValue = fn(this._value);
|
||||||
|
const node = text(initialValue);
|
||||||
|
let prevValue = initialValue;
|
||||||
|
const binding = () => {
|
||||||
|
const newValue = fn(this._value);
|
||||||
|
if (prevValue !== newValue) {
|
||||||
|
prevValue = newValue;
|
||||||
|
node.textContent = newValue+"";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._addBinding(binding);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
el(name, attributes, children) {
|
||||||
|
if (attributes) {
|
||||||
|
// valid attributes is only object that is not a DOM node
|
||||||
|
// anything else (string, fn, array, dom node) is presumed
|
||||||
|
// to be children with no attributes passed
|
||||||
|
if (typeof attributes !== "object" || !!attributes.nodeType || Array.isArray(attributes)) {
|
||||||
|
children = attributes;
|
||||||
|
attributes = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = document.createElement(name);
|
||||||
|
|
||||||
|
if (attributes) {
|
||||||
|
this._setNodeAttributes(node, attributes);
|
||||||
|
}
|
||||||
|
if (children) {
|
||||||
|
this._setNodeChildren(node, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setNodeAttributes(node, attributes) {
|
||||||
|
for(let [key, value] of Object.entries(attributes)) {
|
||||||
|
const isFn = typeof value === "function";
|
||||||
|
// binding for className as object of className => enabled
|
||||||
|
if (key === "className" && typeof value === "object" && value !== null) {
|
||||||
|
this._addClassNamesBinding(node, value);
|
||||||
|
} else if (key.startsWith("on") && key.length > 2 && isFn) {
|
||||||
|
const eventName = key.substr(2, 1).toLowerCase() + key.substr(3);
|
||||||
|
const handler = value;
|
||||||
|
this._addEventListener(node, eventName, handler);
|
||||||
|
} else if (isFn) {
|
||||||
|
this._addAttributeBinding(node, key, value);
|
||||||
|
} else {
|
||||||
|
setAttribute(node, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setNodeChildren(node, children) {
|
||||||
|
if (!Array.isArray(children)) {
|
||||||
|
children = [children];
|
||||||
|
}
|
||||||
|
for (let child of children) {
|
||||||
|
if (typeof child === "function") {
|
||||||
|
child = this._addTextBinding(child);
|
||||||
|
} else if (!child.nodeType) {
|
||||||
|
// not a DOM node, turn into text
|
||||||
|
child = text(child);
|
||||||
|
}
|
||||||
|
node.appendChild(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addReplaceNodeBinding(fn, renderNode) {
|
||||||
|
let prevValue = fn(this._value);
|
||||||
|
let node = renderNode(null);
|
||||||
|
|
||||||
|
const binding = () => {
|
||||||
|
const newValue = fn(this._value);
|
||||||
|
if (prevValue !== newValue) {
|
||||||
|
prevValue = newValue;
|
||||||
|
const newNode = renderNode(node);
|
||||||
|
if (node.parentElement) {
|
||||||
|
node.parentElement.replaceChild(newNode, node);
|
||||||
|
}
|
||||||
|
node = newNode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._addBinding(binding);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// creates a conditional subtemplate
|
||||||
|
if(fn, render) {
|
||||||
|
const boolFn = value => !!fn(value);
|
||||||
|
return this._addReplaceNodeBinding(boolFn, (prevNode) => {
|
||||||
|
if (prevNode && prevNode.nodeType !== Node.COMMENT_NODE) {
|
||||||
|
const templateIdx = this._subTemplates.findIndex(t => t.root() === prevNode);
|
||||||
|
const [template] = this._subTemplates.splice(templateIdx, 1);
|
||||||
|
template.dispose();
|
||||||
|
}
|
||||||
|
if (boolFn(this._value)) {
|
||||||
|
const template = new Template(this._value, render);
|
||||||
|
this._addSubTemplate(template);
|
||||||
|
return template.root();
|
||||||
|
} else {
|
||||||
|
return document.createComment("if placeholder");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of TAG_NAMES) {
|
||||||
|
Template.prototype[tag] = function(attributes, children) {
|
||||||
|
return this.el(tag, attributes, children);
|
||||||
|
};
|
||||||
|
}
|
30
src/ui/web/TemplateView.js
Normal file
30
src/ui/web/TemplateView.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import Template from "./Template.js";
|
||||||
|
|
||||||
|
export default class TemplateView {
|
||||||
|
constructor(value) {
|
||||||
|
this.viewModel = value;
|
||||||
|
this._template = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
throw new Error("render not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this._template = new Template(this.viewModel, (t, value) => this.render(t, value));
|
||||||
|
return this.root();
|
||||||
|
}
|
||||||
|
|
||||||
|
root() {
|
||||||
|
return this._template.root();
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount() {
|
||||||
|
this._template.dispose();
|
||||||
|
this._template = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(value) {
|
||||||
|
this._template.update(value);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import * as html from "./html.js";
|
import {tag} from "./html.js";
|
||||||
|
|
||||||
export default class TimelineTile {
|
export default class TimelineTile {
|
||||||
constructor(tileVM) {
|
constructor(tileVM) {
|
||||||
|
@ -24,19 +24,10 @@ export default class TimelineTile {
|
||||||
function renderTile(tile) {
|
function renderTile(tile) {
|
||||||
switch (tile.shape) {
|
switch (tile.shape) {
|
||||||
case "message":
|
case "message":
|
||||||
return html.li(null, [html.strong(null, tile.internalId+" "), tile.label]);
|
return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]);
|
||||||
case "gap": {
|
|
||||||
const button = html.button(null, (tile.isUp ? "🠝" : "🠟") + " fill gap");
|
|
||||||
const handler = () => {
|
|
||||||
tile.fill();
|
|
||||||
button.removeEventListener("click", handler);
|
|
||||||
};
|
|
||||||
button.addEventListener("click", handler);
|
|
||||||
return html.li(null, [html.strong(null, tile.internalId+" "), button]);
|
|
||||||
}
|
|
||||||
case "announcement":
|
case "announcement":
|
||||||
return html.li(null, [html.strong(null, tile.internalId+" "), tile.label]);
|
return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]);
|
||||||
default:
|
default:
|
||||||
return html.li(null, [html.strong(null, tile.internalId+" "), "unknown tile shape: " + tile.shape]);
|
return tag.li(null, [tag.strong(null, tile.internalId+" "), "unknown tile shape: " + tile.shape]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
|
// DOM helper functions
|
||||||
|
|
||||||
export function setAttribute(el, name, value) {
|
export function setAttribute(el, name, value) {
|
||||||
if (name === "className") {
|
if (name === "className") {
|
||||||
name = "class";
|
name = "class";
|
||||||
}
|
}
|
||||||
el.setAttribute(name, value);
|
if (value === false) {
|
||||||
|
el.removeAttribute(name);
|
||||||
|
} else {
|
||||||
|
if (value === true) {
|
||||||
|
value = name;
|
||||||
|
}
|
||||||
|
el.setAttribute(name, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function el(elementName, attrs, children) {
|
export function el(elementName, attrs, children) {
|
||||||
|
@ -16,9 +25,8 @@ export function el(elementName, attrs, children) {
|
||||||
if (!Array.isArray(children)) {
|
if (!Array.isArray(children)) {
|
||||||
children = [children];
|
children = [children];
|
||||||
}
|
}
|
||||||
// TODO: use fragment here?
|
|
||||||
for (let c of children) {
|
for (let c of children) {
|
||||||
if (typeof c === "string") {
|
if (!c.nodeType) {
|
||||||
c = text(c);
|
c = text(c);
|
||||||
}
|
}
|
||||||
e.appendChild(c);
|
e.appendChild(c);
|
||||||
|
@ -31,24 +39,15 @@ export function text(str) {
|
||||||
return document.createTextNode(str);
|
return document.createTextNode(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ol(... params) { return el("ol", ... params); }
|
export const TAG_NAMES = [
|
||||||
export function ul(... params) { return el("ul", ... params); }
|
"ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
export function li(... params) { return el("li", ... params); }
|
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
|
||||||
export function div(... params) { return el("div", ... params); }
|
"pre", "button"];
|
||||||
export function h1(... params) { return el("h1", ... params); }
|
|
||||||
export function h2(... params) { return el("h2", ... params); }
|
export const tag = {};
|
||||||
export function h3(... params) { return el("h3", ... params); }
|
|
||||||
export function h4(... params) { return el("h4", ... params); }
|
for (const tagName of TAG_NAMES) {
|
||||||
export function h5(... params) { return el("h5", ... params); }
|
tag[tagName] = function(attributes, children) {
|
||||||
export function h6(... params) { return el("h6", ... params); }
|
return el(tagName, attributes, children);
|
||||||
export function p(... params) { return el("p", ... params); }
|
}
|
||||||
export function strong(... params) { return el("strong", ... params); }
|
}
|
||||||
export function em(... params) { return el("em", ... params); }
|
|
||||||
export function span(... params) { return el("span", ... params); }
|
|
||||||
export function img(... params) { return el("img", ... params); }
|
|
||||||
export function section(... params) { return el("section", ... params); }
|
|
||||||
export function main(... params) { return el("main", ... params); }
|
|
||||||
export function article(... params) { return el("article", ... params); }
|
|
||||||
export function aside(... params) { return el("aside", ... params); }
|
|
||||||
export function pre(... params) { return el("pre", ... params); }
|
|
||||||
export function button(... params) { return el("button", ... params); }
|
|
||||||
|
|
15
src/ui/web/timeline/GapView.js
Normal file
15
src/ui/web/timeline/GapView.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import TemplateView from "../TemplateView.js";
|
||||||
|
|
||||||
|
export default class GapView extends TemplateView {
|
||||||
|
render(t, vm) {
|
||||||
|
const className = {
|
||||||
|
gap: true,
|
||||||
|
isLoading: vm => vm.isLoading
|
||||||
|
};
|
||||||
|
const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding
|
||||||
|
return t.li({className}, [
|
||||||
|
t.button({onClick: () => this.viewModel.fill(), disabled: vm => vm.isLoading}, label),
|
||||||
|
t.if(vm => vm.error, t => t.strong(vm => vm.error))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue