From c7163a055434c9a9c7f15642c134928e236fe39d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 15 Mar 2019 20:36:04 +0100 Subject: [PATCH 01/13] wip of template/databinding --- src/ui/web/tree.js | 120 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/ui/web/tree.js diff --git a/src/ui/web/tree.js b/src/ui/web/tree.js new file mode 100644 index 00000000..6e3ce694 --- /dev/null +++ b/src/ui/web/tree.js @@ -0,0 +1,120 @@ +import { setAttribute, addChildren, text } from "./html.js"; + +function renderTree() {} + + +const tree = renderTree(vm, t => { + return t.div({onClick: () => this._clicked(), className: "errorLabel"}, [ + vm => vm.label, + t.span({className: vm => {{fatal: !!vm.fatal}}}, [vm => vm.error]) + ]); +}); + +tree.root +tree.detach() +tree.updateBindings(vm); + + + + +class Tree { + constructor(value, render) { + this._ctx = new TreeContext(value); + this._root = render(this._ctx); + } + + ref(name) { + return this._ctx._refs[name]; + } + + detach() { + for (let {node, name, fn} of this._ctx._eventListeners) { + node.removeEventListener(name, fn); + } + } +} + +class TreeContext { + constructor(value) { + this._value = value; + this._refs = {}; + this._eventListeners = []; + this._bindings = []; + } + + _addRef(name, node) { + this._refs[name] = node; + } + + _addEventListener(node, name, fn) { + node.addEventListener(name, fn); + this._eventListeners.push({node, event, fn}); + } + + _setAttributeBinding(node, name, fn) { + let prevValue = undefined; + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + setAttribute(node, name, newValue); + } + }; + this._bindings.push(binding); + binding(); + } + + _setTextBinding(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._bindings.push(binding); + } + + el(name, attributes, children) { + const node = document.createElement(name); + for(let [key, value] of Object.entries(attributes)) { + const isFn = typeof value === "function"; + 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); + } + } + + addChildren(node, children); + return node; + } + + ol(... params) { return this.el("ol", ... params); } + ul(... params) { return this.el("ul", ... params); } + li(... params) { return this.el("li", ... params); } + div(... params) { return this.el("div", ... params); } + h1(... params) { return this.el("h1", ... params); } + h2(... params) { return this.el("h2", ... params); } + h3(... params) { return this.el("h3", ... params); } + h4(... params) { return this.el("h4", ... params); } + h5(... params) { return this.el("h5", ... params); } + h6(... params) { return this.el("h6", ... params); } + p(... params) { return this.el("p", ... params); } + strong(... params) { return this.el("strong", ... params); } + em(... params) { return this.el("em", ... params); } + span(... params) { return this.el("span", ... params); } + img(... params) { return this.el("img", ... params); } + section(... params) { return this.el("section", ... params); } + main(... params) { return this.el("main", ... params); } + article(... params) { return this.el("article", ... params); } + aside(... params) { return this.el("aside", ... params); } + pre(... params) { return this.el("pre", ... params); } +} From eb2eb291d3271b76227ba611e69549807990df24 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 13 Jun 2019 00:41:45 +0200 Subject: [PATCH 02/13] more work on databinding and templating --- src/ui/web/RoomTile.js | 27 ++----- src/ui/web/Template.js | 151 +++++++++++++++++++++++++++++++++++++ src/ui/web/TemplateView.js | 29 +++++++ src/ui/web/html.js | 5 ++ src/ui/web/tree.js | 120 ----------------------------- 5 files changed, 190 insertions(+), 142 deletions(-) create mode 100644 src/ui/web/Template.js create mode 100644 src/ui/web/TemplateView.js delete mode 100644 src/ui/web/tree.js diff --git a/src/ui/web/RoomTile.js b/src/ui/web/RoomTile.js index 4beb6f1a..a72f6745 100644 --- a/src/ui/web/RoomTile.js +++ b/src/ui/web/RoomTile.js @@ -1,29 +1,12 @@ -import { li } from "./html.js"; +import TemplateView from "./TemplateView.js"; -export default class RoomTile { - constructor(viewModel) { - this._viewModel = viewModel; - 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; +export default class RoomTile extends TemplateView { + render(t) { + return t.li(vm => vm.name); } + // called from ListView clicked() { this._viewModel.open(); } - - root() { - return this._root; - } } diff --git a/src/ui/web/Template.js b/src/ui/web/Template.js new file mode 100644 index 00000000..3150f172 --- /dev/null +++ b/src/ui/web/Template.js @@ -0,0 +1,151 @@ +import { setAttribute, text, TAG_NAMES } from "./html.js"; + +// const template = new Template(vm, t => { +// return t.div({onClick: () => this._clicked(), className: "errorLabel"}, [ +// vm => vm.label, +// t.span({className: vm => t.className({fatal: !!vm.fatal})}, [vm => vm.error]) +// ]); +// }); + +/* + 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._refs = {}; + this._eventListeners = []; + this._bindings = []; + this._render = render; + } + + className(obj) { + Object.entries(obj).filter(([, value]) => value).map(([key]) => key).join(" "); + } + + root() { + if (!this._root) { + this._root = this._render(this, this._value); + } + return this._root; + } + + ref(name) { + return this._refs[name]; + } + + update(value) { + this._value = value; + for (const binding of this._bindings) { + binding(); + } + } + + detach() { + for (let {node, name, fn} of this._eventListeners) { + node.removeEventListener(name, fn); + } + } + + attach() { + for (let {node, name, fn} of this._eventListeners) { + node.addEventListener(name, fn); + } + } + + _addRef(name, node) { + this._refs[name] = node; + } + + _addEventListener(node, name, fn) { + this._eventListeners.push({node, name, fn}); + } + + _setAttributeBinding(node, name, fn) { + let prevValue = undefined; + const binding = () => { + const newValue = fn(this._value); + if (prevValue !== newValue) { + prevValue = newValue; + setAttribute(node, name, newValue); + } + }; + this._bindings.push(binding); + binding(); + } + + _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._bindings.push(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 === Node.ELEMENT_NODE || Array.isArray(attributes)) { + children = attributes; + attributes = null; + } + } + + const node = document.createElement(name); + + if (attributes) { + for(let [key, value] of Object.entries(attributes)) { + const isFn = typeof value === "function"; + if (key === "ref") { + this._refs[value] = node; + } 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); + } + } + } + + if (children) { + if (!Array.isArray(children)) { + children = [children]; + } + for (let child of children) { + if (typeof child === "string") { + child = text(child); + } else if (typeof c === "function") { + child = this._addTextBinding(child); + } + node.appendChild(child); + } + } + + return node; + } +} + +for (const tag of TAG_NAMES) { + Template.prototype[tag] = function(...params) { + this.el(tag, ... params); + }; +} diff --git a/src/ui/web/TemplateView.js b/src/ui/web/TemplateView.js new file mode 100644 index 00000000..f0a16cf0 --- /dev/null +++ b/src/ui/web/TemplateView.js @@ -0,0 +1,29 @@ +import Template from "./Template.js"; + +export default class TemplateView { + constructor(value) { + this._template = new Template(value, (t, value) => this.render(t, value)); + } + + render() { + throw new Error("render not implemented"); + } + + mount() { + const root = this._template.root(); + this._template.attach(); + return root; + } + + root() { + return this._template.root(); + } + + unmount() { + this._template.detach(); + } + + update(value) { + this._template.update(value); + } +} diff --git a/src/ui/web/html.js b/src/ui/web/html.js index ebf57091..e6b8a194 100644 --- a/src/ui/web/html.js +++ b/src/ui/web/html.js @@ -31,6 +31,11 @@ export function text(str) { return document.createTextNode(str); } +export const TAG_NAMES = [ + "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", + "p", "strong", "em", "span", "img", "section", "main", "article", "aside", + "pre", "button"]; + export function ol(... params) { return el("ol", ... params); } export function ul(... params) { return el("ul", ... params); } export function li(... params) { return el("li", ... params); } diff --git a/src/ui/web/tree.js b/src/ui/web/tree.js deleted file mode 100644 index 6e3ce694..00000000 --- a/src/ui/web/tree.js +++ /dev/null @@ -1,120 +0,0 @@ -import { setAttribute, addChildren, text } from "./html.js"; - -function renderTree() {} - - -const tree = renderTree(vm, t => { - return t.div({onClick: () => this._clicked(), className: "errorLabel"}, [ - vm => vm.label, - t.span({className: vm => {{fatal: !!vm.fatal}}}, [vm => vm.error]) - ]); -}); - -tree.root -tree.detach() -tree.updateBindings(vm); - - - - -class Tree { - constructor(value, render) { - this._ctx = new TreeContext(value); - this._root = render(this._ctx); - } - - ref(name) { - return this._ctx._refs[name]; - } - - detach() { - for (let {node, name, fn} of this._ctx._eventListeners) { - node.removeEventListener(name, fn); - } - } -} - -class TreeContext { - constructor(value) { - this._value = value; - this._refs = {}; - this._eventListeners = []; - this._bindings = []; - } - - _addRef(name, node) { - this._refs[name] = node; - } - - _addEventListener(node, name, fn) { - node.addEventListener(name, fn); - this._eventListeners.push({node, event, fn}); - } - - _setAttributeBinding(node, name, fn) { - let prevValue = undefined; - const binding = () => { - const newValue = fn(this._value); - if (prevValue !== newValue) { - prevValue = newValue; - setAttribute(node, name, newValue); - } - }; - this._bindings.push(binding); - binding(); - } - - _setTextBinding(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._bindings.push(binding); - } - - el(name, attributes, children) { - const node = document.createElement(name); - for(let [key, value] of Object.entries(attributes)) { - const isFn = typeof value === "function"; - 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); - } - } - - addChildren(node, children); - return node; - } - - ol(... params) { return this.el("ol", ... params); } - ul(... params) { return this.el("ul", ... params); } - li(... params) { return this.el("li", ... params); } - div(... params) { return this.el("div", ... params); } - h1(... params) { return this.el("h1", ... params); } - h2(... params) { return this.el("h2", ... params); } - h3(... params) { return this.el("h3", ... params); } - h4(... params) { return this.el("h4", ... params); } - h5(... params) { return this.el("h5", ... params); } - h6(... params) { return this.el("h6", ... params); } - p(... params) { return this.el("p", ... params); } - strong(... params) { return this.el("strong", ... params); } - em(... params) { return this.el("em", ... params); } - span(... params) { return this.el("span", ... params); } - img(... params) { return this.el("img", ... params); } - section(... params) { return this.el("section", ... params); } - main(... params) { return this.el("main", ... params); } - article(... params) { return this.el("article", ... params); } - aside(... params) { return this.el("aside", ... params); } - pre(... params) { return this.el("pre", ... params); } -} From 553bda2837c289d3da598fdd51987a7f06f5e7da Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 22:40:18 +0200 Subject: [PATCH 03/13] support bool values for attribute --- src/ui/web/html.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/web/html.js b/src/ui/web/html.js index e6b8a194..99a151f0 100644 --- a/src/ui/web/html.js +++ b/src/ui/web/html.js @@ -2,7 +2,14 @@ export function setAttribute(el, name, value) { if (name === "className") { 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) { From 773b4ed94112666aabb9446ba55b661802f9e2c3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 22:41:50 +0200 Subject: [PATCH 04/13] remove support for refs not really needed, as render functions work with DOM nodes that can be easily stored as a side-effect of the render fn --- src/ui/web/Template.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/ui/web/Template.js b/src/ui/web/Template.js index 3150f172..3d0d47b5 100644 --- a/src/ui/web/Template.js +++ b/src/ui/web/Template.js @@ -20,7 +20,6 @@ import { setAttribute, text, TAG_NAMES } from "./html.js"; export default class Template { constructor(value, render) { this._value = value; - this._refs = {}; this._eventListeners = []; this._bindings = []; this._render = render; @@ -37,10 +36,6 @@ export default class Template { return this._root; } - ref(name) { - return this._refs[name]; - } - update(value) { this._value = value; for (const binding of this._bindings) { @@ -60,8 +55,6 @@ export default class Template { } } - _addRef(name, node) { - this._refs[name] = node; } _addEventListener(node, name, fn) { @@ -112,9 +105,7 @@ export default class Template { if (attributes) { for(let [key, value] of Object.entries(attributes)) { const isFn = typeof value === "function"; - if (key === "ref") { - this._refs[value] = node; - } else if (key.startsWith("on") && key.length > 2 && isFn) { + 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); From 0503b48c9839b90fa34c91292f6b1cdec104251b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 22:43:31 +0200 Subject: [PATCH 05/13] support conditional subtemplate & optimizations --- src/ui/web/Template.js | 131 +++++++++++++++++++++++++++++++---------- 1 file changed, 99 insertions(+), 32 deletions(-) diff --git a/src/ui/web/Template.js b/src/ui/web/Template.js index 3d0d47b5..5b275996 100644 --- a/src/ui/web/Template.js +++ b/src/ui/web/Template.js @@ -1,12 +1,5 @@ import { setAttribute, text, TAG_NAMES } from "./html.js"; -// const template = new Template(vm, t => { -// return t.div({onClick: () => this._clicked(), className: "errorLabel"}, [ -// vm => vm.label, -// t.span({className: vm => t.className({fatal: !!vm.fatal})}, [vm => vm.error]) -// ]); -// }); - /* supports - event handlers (attribute fn value with name that starts with on) @@ -20,48 +13,79 @@ import { setAttribute, text, TAG_NAMES } from "./html.js"; export default class Template { constructor(value, render) { this._value = value; - this._eventListeners = []; - this._bindings = []; - this._render = render; + this._eventListeners = null; + this._bindings = null; + this._subTemplates = null; + this._root = render(this, this._value); + this._attach(); } - className(obj) { + // TODO: obj needs to support bindings + classes(obj) { Object.entries(obj).filter(([, value]) => value).map(([key]) => key).join(" "); } root() { - if (!this._root) { - this._root = this._render(this, this._value); - } return this._root; } update(value) { this._value = value; - for (const binding of this._bindings) { - binding(); + if (this._bindings) { + for (const binding of this._bindings) { + binding(); + } + } + if (this._subTemplates) { + for (const sub of this._subTemplates) { + sub.update(value); + } } } - detach() { - for (let {node, name, fn} of this._eventListeners) { - node.removeEventListener(name, fn); + 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() { - for (let {node, name, fn} of this._eventListeners) { - node.addEventListener(name, fn); + _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}); } - _setAttributeBinding(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); @@ -70,7 +94,7 @@ export default class Template { setAttribute(node, name, newValue); } }; - this._bindings.push(binding); + this._addBinding(binding); binding(); } @@ -85,7 +109,8 @@ export default class Template { node.textContent = newValue+""; } }; - this._bindings.push(binding); + + this._addBinding(binding); return node; } @@ -94,7 +119,7 @@ export default class Template { // 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 === Node.ELEMENT_NODE || Array.isArray(attributes)) { + if (typeof attributes !== "object" || !!attributes.nodeType || Array.isArray(attributes)) { children = attributes; attributes = null; } @@ -122,10 +147,11 @@ export default class Template { children = [children]; } for (let child of children) { - if (typeof child === "string") { - child = text(child); - } else if (typeof c === "function") { + 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); } @@ -133,10 +159,51 @@ export default class Template { return node; } + + _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(...params) { - this.el(tag, ... params); + return this.el(tag, ... params); }; } + + + From eba92399e34eff696b27c65659b64f04a00c530a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 22:45:13 +0200 Subject: [PATCH 06/13] update TemplateView with changes --- src/ui/web/TemplateView.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/web/TemplateView.js b/src/ui/web/TemplateView.js index f0a16cf0..96d1a500 100644 --- a/src/ui/web/TemplateView.js +++ b/src/ui/web/TemplateView.js @@ -2,7 +2,8 @@ import Template from "./Template.js"; export default class TemplateView { constructor(value) { - this._template = new Template(value, (t, value) => this.render(t, value)); + this.viewModel = value; + this._template = null; } render() { @@ -10,9 +11,8 @@ export default class TemplateView { } mount() { - const root = this._template.root(); - this._template.attach(); - return root; + this._template = new Template(this.viewModel, (t, value) => this.render(t, value)); + return this.root(); } root() { @@ -20,7 +20,8 @@ export default class TemplateView { } unmount() { - this._template.detach(); + this._template.dispose(); + this._template = null; } update(value) { From 69be5012e8175d13b043e3502020697680575760 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 22:54:51 +0200 Subject: [PATCH 07/13] templateview exposes viewModel prop --- src/ui/web/RoomTile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/web/RoomTile.js b/src/ui/web/RoomTile.js index a72f6745..a68049ab 100644 --- a/src/ui/web/RoomTile.js +++ b/src/ui/web/RoomTile.js @@ -7,6 +7,6 @@ export default class RoomTile extends TemplateView { // called from ListView clicked() { - this._viewModel.open(); + this.viewModel.open(); } } From f9038e2af95801ea0aec6c35e002d0d3f8a28d3e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 22:55:07 +0200 Subject: [PATCH 08/13] dedicated template view for gaps --- src/ui/web/RoomView.js | 5 ++++- src/ui/web/timeline/GapView.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/ui/web/timeline/GapView.js diff --git a/src/ui/web/RoomView.js b/src/ui/web/RoomView.js index d26cf5df..ca293a2a 100644 --- a/src/ui/web/RoomView.js +++ b/src/ui/web/RoomView.js @@ -1,6 +1,7 @@ import TimelineTile from "./TimelineTile.js"; import ListView from "./ListView.js"; import * as html from "./html.js"; +import GapView from "./timeline/GapView.js"; export default class RoomView { constructor(viewModel) { @@ -16,7 +17,9 @@ export default class RoomView { this._nameLabel = html.h2(null, this._viewModel.name); this._errorLabel = html.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._root = html.div({className: "RoomView"}, [ diff --git a/src/ui/web/timeline/GapView.js b/src/ui/web/timeline/GapView.js new file mode 100644 index 00000000..ef1c8df1 --- /dev/null +++ b/src/ui/web/timeline/GapView.js @@ -0,0 +1,15 @@ +import TemplateView from "../TemplateView.js"; + +export default class GapView extends TemplateView { + render(t, vm) { + const className = { + gap: true, + loading: 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)) + ]); + } +} From 0a6c50b3bb70899fa3095d973b79085399f0d912 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 23:08:41 +0200 Subject: [PATCH 09/13] support bindings for className object --- src/ui/web/Template.js | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/ui/web/Template.js b/src/ui/web/Template.js index 5b275996..3bf08e03 100644 --- a/src/ui/web/Template.js +++ b/src/ui/web/Template.js @@ -1,5 +1,18 @@ 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; + } + }, ""); +} /* supports - event handlers (attribute fn value with name that starts with on) @@ -20,11 +33,6 @@ export default class Template { this._attach(); } - // TODO: obj needs to support bindings - classes(obj) { - Object.entries(obj).filter(([, value]) => value).map(([key]) => key).join(" "); - } - root() { return this._root; } @@ -98,6 +106,10 @@ export default class Template { binding(); } + _addClassNamesBinding(node, obj) { + this._addAttributeBinding(node, "className", value => classNames(obj, value)); + } + _addTextBinding(fn) { const initialValue = fn(this._value); const node = text(initialValue); @@ -130,7 +142,10 @@ export default class Template { if (attributes) { for(let [key, value] of Object.entries(attributes)) { const isFn = typeof value === "function"; - if (key.startsWith("on") && key.length > 2 && isFn) { + // 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); @@ -204,6 +219,3 @@ for (const tag of TAG_NAMES) { return this.el(tag, ... params); }; } - - - From 5feca5e0f418bba86e78044266d502e43deaa0e7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 23:44:31 +0200 Subject: [PATCH 10/13] gap has its own view now --- src/ui/web/TimelineTile.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/ui/web/TimelineTile.js b/src/ui/web/TimelineTile.js index 0e583a37..c121ea14 100644 --- a/src/ui/web/TimelineTile.js +++ b/src/ui/web/TimelineTile.js @@ -25,15 +25,6 @@ function renderTile(tile) { switch (tile.shape) { case "message": return html.li(null, [html.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": return html.li(null, [html.strong(null, tile.internalId+" "), tile.label]); default: From 468af4755bf361cde688ae56b268fe406141fcee Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 23:46:18 +0200 Subject: [PATCH 11/13] cleanup html utils --- src/ui/web/ListView.js | 4 ++-- src/ui/web/RoomView.js | 8 ++++---- src/ui/web/SessionView.js | 4 ++-- src/ui/web/TimelineTile.js | 8 ++++---- src/ui/web/html.js | 33 ++++++++++----------------------- 5 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/ui/web/ListView.js b/src/ui/web/ListView.js index 2fb377d4..f12651a3 100644 --- a/src/ui/web/ListView.js +++ b/src/ui/web/ListView.js @@ -1,4 +1,4 @@ -import * as html from "./html.js"; +import {tag} from "./html.js"; class UIView { mount() {} @@ -47,7 +47,7 @@ export default class ListView { } mount() { - this._root = html.ul({className: "ListView"}); + this._root = tag.ul({className: "ListView"}); this._loadList(); if (this._onItemClick) { this._root.addEventListener("click", this._onClick); diff --git a/src/ui/web/RoomView.js b/src/ui/web/RoomView.js index ca293a2a..35523c9f 100644 --- a/src/ui/web/RoomView.js +++ b/src/ui/web/RoomView.js @@ -1,6 +1,6 @@ import TimelineTile from "./TimelineTile.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 { @@ -14,15 +14,15 @@ export default class RoomView { mount() { this._viewModel.on("change", this._onViewModelUpdate); - this._nameLabel = html.h2(null, this._viewModel.name); - this._errorLabel = html.div({className: "RoomView_error"}); + this._nameLabel = tag.h2(null, this._viewModel.name); + this._errorLabel = tag.div({className: "RoomView_error"}); this._timelineList = new ListView({}, entry => { return entry.shape === "gap" ? new GapView(entry) : new TimelineTile(entry); }); this._timelineList.mount(); - this._root = html.div({className: "RoomView"}, [ + this._root = tag.div({className: "RoomView"}, [ this._nameLabel, this._errorLabel, this._timelineList.root() diff --git a/src/ui/web/SessionView.js b/src/ui/web/SessionView.js index 98373a86..ba92f80b 100644 --- a/src/ui/web/SessionView.js +++ b/src/ui/web/SessionView.js @@ -1,7 +1,7 @@ import ListView from "./ListView.js"; import RoomTile from "./RoomTile.js"; import RoomView from "./RoomView.js"; -import { div } from "./html.js"; +import {tag} from "./html.js"; export default class SessionView { constructor(viewModel) { @@ -19,7 +19,7 @@ export default class SessionView { mount() { this._viewModel.on("change", this._onViewModelChange); - this._root = div({className: "SessionView"}); + this._root = tag.div({className: "SessionView"}); this._roomList = new ListView( { list: this._viewModel.roomList, diff --git a/src/ui/web/TimelineTile.js b/src/ui/web/TimelineTile.js index c121ea14..8ee411d2 100644 --- a/src/ui/web/TimelineTile.js +++ b/src/ui/web/TimelineTile.js @@ -1,4 +1,4 @@ -import * as html from "./html.js"; +import {tag} from "./html.js"; export default class TimelineTile { constructor(tileVM) { @@ -24,10 +24,10 @@ export default class TimelineTile { function renderTile(tile) { switch (tile.shape) { 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 "announcement": - return html.li(null, [html.strong(null, tile.internalId+" "), tile.label]); + return tag.li(null, [tag.strong(null, tile.internalId+" "), tile.label]); 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]); } } diff --git a/src/ui/web/html.js b/src/ui/web/html.js index 99a151f0..604090f2 100644 --- a/src/ui/web/html.js +++ b/src/ui/web/html.js @@ -1,3 +1,5 @@ +// DOM helper functions + export function setAttribute(el, name, value) { if (name === "className") { name = "class"; @@ -23,9 +25,8 @@ export function el(elementName, attrs, children) { if (!Array.isArray(children)) { children = [children]; } - // TODO: use fragment here? for (let c of children) { - if (typeof c === "string") { + if (!c.nodeType) { c = text(c); } e.appendChild(c); @@ -43,24 +44,10 @@ export const TAG_NAMES = [ "p", "strong", "em", "span", "img", "section", "main", "article", "aside", "pre", "button"]; -export function ol(... params) { return el("ol", ... params); } -export function ul(... params) { return el("ul", ... params); } -export function li(... params) { return el("li", ... params); } -export function div(... params) { return el("div", ... params); } -export function h1(... params) { return el("h1", ... params); } -export function h2(... params) { return el("h2", ... params); } -export function h3(... params) { return el("h3", ... params); } -export function h4(... params) { return el("h4", ... params); } -export function h5(... params) { return el("h5", ... params); } -export function h6(... params) { return el("h6", ... params); } -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); } +export const tag = {}; + +for (const tagName of TAG_NAMES) { + tag[tagName] = function(attributes, children) { + return el(tagName, attributes, children); + } +} From bec7720c42cdb2994ed338421d7b2591dfd4366a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 23:46:31 +0200 Subject: [PATCH 12/13] cleanup template code --- src/ui/web/Template.js | 74 ++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/src/ui/web/Template.js b/src/ui/web/Template.js index 3bf08e03..de47a6f0 100644 --- a/src/ui/web/Template.js +++ b/src/ui/web/Template.js @@ -13,7 +13,10 @@ function classNames(obj, value) { } }, ""); } -/* +/** + 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) @@ -138,43 +141,50 @@ export default class Template { } const node = document.createElement(name); - + if (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); - } - } + this._setNodeAttributes(node, attributes); } - if (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); - } + 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); @@ -215,7 +225,7 @@ export default class Template { } for (const tag of TAG_NAMES) { - Template.prototype[tag] = function(...params) { - return this.el(tag, ... params); + Template.prototype[tag] = function(attributes, children) { + return this.el(tag, attributes, children); }; } From ad92356b400c499a606b68a7e6026748bb463723 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 14 Jun 2019 23:46:47 +0200 Subject: [PATCH 13/13] css class same name as prop --- src/ui/web/timeline/GapView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/web/timeline/GapView.js b/src/ui/web/timeline/GapView.js index ef1c8df1..258cc167 100644 --- a/src/ui/web/timeline/GapView.js +++ b/src/ui/web/timeline/GapView.js @@ -4,7 +4,7 @@ export default class GapView extends TemplateView { render(t, vm) { const className = { gap: true, - loading: vm => vm.isLoading + isLoading: vm => vm.isLoading }; const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding return t.li({className}, [