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/RoomTile.js b/src/ui/web/RoomTile.js index 4beb6f1a..a68049ab 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; + this.viewModel.open(); } } diff --git a/src/ui/web/RoomView.js b/src/ui/web/RoomView.js index d26cf5df..35523c9f 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 {tag} from "./html.js"; +import GapView from "./timeline/GapView.js"; export default class RoomView { constructor(viewModel) { @@ -13,13 +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 => 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"}, [ + 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/Template.js b/src/ui/web/Template.js new file mode 100644 index 00000000..de47a6f0 --- /dev/null +++ b/src/ui/web/Template.js @@ -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); + }; +} diff --git a/src/ui/web/TemplateView.js b/src/ui/web/TemplateView.js new file mode 100644 index 00000000..96d1a500 --- /dev/null +++ b/src/ui/web/TemplateView.js @@ -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); + } +} diff --git a/src/ui/web/TimelineTile.js b/src/ui/web/TimelineTile.js index 0e583a37..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,19 +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]); - 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]); - } + 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 ebf57091..604090f2 100644 --- a/src/ui/web/html.js +++ b/src/ui/web/html.js @@ -1,8 +1,17 @@ +// DOM helper functions + 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) { @@ -16,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); @@ -31,24 +39,15 @@ export function text(str) { return document.createTextNode(str); } -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_NAMES = [ + "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", + "p", "strong", "em", "span", "img", "section", "main", "article", "aside", + "pre", "button"]; + +export const tag = {}; + +for (const tagName of TAG_NAMES) { + tag[tagName] = function(attributes, children) { + return el(tagName, attributes, children); + } +} diff --git a/src/ui/web/timeline/GapView.js b/src/ui/web/timeline/GapView.js new file mode 100644 index 00000000..258cc167 --- /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, + 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)) + ]); + } +}