- JavaScript 99.2%
- HTML 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| examples | ||
| images | ||
| test | ||
| LICENSE | ||
| README.md | ||
| test.html | ||
| test.js | ||
| triad.js | ||
A toolset for creating web apps
Features
- Composable views
- using the full power of javascript
- Modular code
- the javascript code can be split and organized in multiple files and folders
- Progressive asset loading
- loading additional files can be delayed until needed
Examples
You can find the example apps "news", "gallery" and "todo" in the /examples directory.
The tools
The tools consist of three small helper functions, that wrap around already existing javascript functionality.
e
This is a helper function that uses the native javascript functionality of creating elements. The power lies in the fact that it accepts a child (or list of children) as an argument. This enables expressing a tree of html elements, as a tree of function calls.
withFiles
Enables programatically loading additional javascript and css files, and executes a callback only when all files have been loaded.
With { defer: true } as a third argument, the files are loaded the same way, but the callback is returned instead of executed. This lets a script define a function together with the files it needs:
lib.parse = withFiles(['reader.js'], function (bytes) {
return lib.reader(bytes).parse();
}, { defer: true });
A script that is loaded with withFiles only counts as loaded once the files of its own withFiles calls have loaded, so whoever loaded it can call lib.parse right away.
When a file fails to load, the callback is called once the other files have loaded, with an Error that names the file; otherwise it is called with no argument. The failed file leaves the page, so a later call tries it again. A script fails to load, too, when a file of its own withFiles calls does:
withFiles(['list.js'], function (err) {
if (err) {
return showOffline();
}
renderList();
});
A call with a file still to load dispatches triad:loading on window when it starts, and triad:loaded once they have all loaded, or failed to, after the callback, or with { defer: true } in place of calling it. A call with nothing to wait for dispatches neither, so re-rendering a view stays silent:
addEventListener('triad:loading', function () {
indicator.hidden = false;
});
addEventListener('triad:loaded', function () {
indicator.hidden = true;
});
dispatch
A small wrapper to make it more convenient to dispatch custom events.
makeView - the missing link
Every non-trivial application built with triad.js needs to re-render parts of the page in response to user interaction or data changes. The pattern for doing this is a placeholder element that can be swapped out on each render, combined with lazy file loading. makeView implements that pattern.
Creating a web app
Generating html
Here is how to generate basic html, with the e function:
e('div', e('h1', 'Hello World!'));
Results in:
<div>
<h1>Hello World!</h1>
</div>
And further:
e('div', [
e('h1', 'Lorem Ipsum'),
e('p', 'Lorem ipsum dolor sit amet'),
e('ul', [e('li', 'a'), e('li', 'b'), e('li', 'c')]),
e(
'div',
e('button', 'Click me', {
onclick: function () {
alert('Button clicked');
}
})
)
]);
Results in:
<div>
<h1>Lorem Ipsum</h1>
<p>Lorem ipsum dolor sit amet</p>
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
<div>
<button>Click me</button>
</div>
</div>
A complete working example, consisting of two files:
index.html
<html>
<body>
<div id="app"></div>
<script src="triad.js"></script>
<script
src="app.js"
onload="document.getElementById('app').appendChild(app.main().render())"
></script>
</body>
</html>
app.js
(function ({ e }, app) {
app.main = function () {
return {
render: function () {
return e('div', [
e('h1', 'Lorem Ipsum'),
e('p', 'Lorem ipsum dolor sit amet'),
e('ul', [e('li', 'a'), e('li', 'b'), e('li', 'c')]),
e(
'div',
e('button', 'Click me', {
onclick: function () {
alert('Button clicked');
}
})
)
]);
}
};
};
})(triad, app);
Running the app locally
Simply type in file:/// in your web browser, and select the index.html file.
Leveraging javascript
When eis used to generate the html, all the power of abstraction and composability in javascript, can be used. It's now possible, for example, to split the view into multiple smaller parts that can be re-used, and build on top of each other:
(function ({ e }, app) {
app.main = function () {
var renderListItem = function (content) {
return e('li', content);
},
list = e('ul', ['a', 'b', 'c'].map(renderListItem)),
renderFooter = function (buttonLabel) {
return e(
'div',
e('button', buttonLabel, {
onclick: function () {
alert('Button clicked');
}
})
);
},
ingress = e('p', 'Lorem ipsum dolor sit amet');
return {
render: function () {
return e('div', [
e('h1', 'Lorem Ipsum'),
ingress,
list,
renderFooter('Click me')
]);
}
};
};
})(triad, app);
Adding interactivity
In order for the user to interact with and change the page, the view needs to be modified to enable also re-rendering:
(function ({ e }, app) {
app.main = function () {
var renderListItem = function (content) {
return e('li', content);
},
list = e('ul', ['a', 'b', 'c'].map(renderListItem)),
renderFooter = function (buttonLabel) {
return e(
'div',
e('button', buttonLabel, {
onclick: function () {
alert('Button clicked');
}
})
);
},
ingress = e('p', 'Lorem ipsum dolor sit amet'),
el = e('div', ''),
view = function () {
return e('div', [
e('h1', 'Lorem Ipsum'),
ingress,
list,
renderFooter('Click me')
]);
};
return {
render: function () {
var newEl = view();
el.replaceWith(newEl);
el = newEl;
return el;
}
};
};
})(triad, app);
The variable el is a reference to the current element, allowing it to be replaced. With this, it's possible to add user interactivity that changes the page:
(function ({ e }, app) {
app.main = function () {
var renderListItem = function (content) {
return e('li', content);
},
list = e(
'ul',
['a', 'b', 'c'].map(function (item) {
return renderListItem(item);
})
),
showList = false,
render = function () {
var newEl = view();
el.replaceWith(newEl);
el = newEl;
return el;
},
renderFooter = function (buttonLabel) {
return e(
'div',
e('button', buttonLabel, {
onclick: function () {
showList = !showList;
render();
}
})
);
},
ingress = e('p', 'Lorem ipsum dolor sit amet'),
el = e('div', ''),
view = function () {
return e('div', [
e('h1', 'Lorem Ipsum'),
ingress,
showList && list,
renderFooter(`${showList ? 'Hide' : 'Show'} list`)
]);
};
return { render };
};
})(triad, app);
Using multiple files
Moving the list component into a separete file list.js
(function ({ e }, app) {
app.list = function () {
var renderListItem = function (content) {
return e('li', content);
};
return {
render: function () {
return e('ul', ['a', 'b', 'c'].map(renderListItem));
}
};
};
})(triad, app);
Using the withFiles function to load list.js, and then render the list, in app.js:
(function ({ e, withFiles }, app) {
app.main = function () {
var showList = false,
render = function () {
var replaceElement = function () {
var newEl = view();
el.replaceWith(newEl);
el = newEl;
};
if (showList) {
withFiles(['list.js'], replaceElement);
} else {
replaceElement();
}
return el;
},
renderFooter = function (buttonLabel) {
return e(
'div',
e('button', buttonLabel, {
onclick: function () {
showList = !showList;
render();
}
})
);
},
ingress = e('p', 'Lorem ipsum dolor sit amet'),
el = e('div', ''),
view = function () {
return e('div', [
e('h1', 'Lorem Ipsum'),
ingress,
showList && app.list().render(),
renderFooter(`${showList ? 'Hide' : 'Show'} list`)
]);
};
return { render };
};
})(triad, app);
Note that the file list.js is not loaded in the browser, until the user clicks the button and the list is rendered.
Creating one more file: listItem.js:
(function ({ e }, app) {
app.listItem = function (content) {
var view = function () {
return e('li', content);
};
return {
render: view
};
};
})(triad, app);
And modifying list.js to use this new file:
(function ({ e, withFiles }, app) {
app.list = function () {
var renderListItem = function (content) {
return app.listItem(content).render();
},
el = e('ul', ''),
view = function () {
return e('ul', ['a', 'b', 'c'].map(renderListItem));
},
render = function () {
var replaceElement = function () {
var newEl = view();
el.replaceWith(newEl);
el = newEl;
};
withFiles(['listItem.js'], replaceElement);
return el;
};
return { render };
};
})(triad, app);
Both app.js and list.js now contain the same rendering logic. By using makeView, this duplication disappears:
(function ({ e, makeView, withFiles }, app) {
app.main = function () {
var showList = false,
renderFooter = function (buttonLabel) {
return e(
'div',
e('button', buttonLabel, {
onclick: function () {
showList = !showList;
render();
}
})
);
},
ingress = e('p', 'Lorem ipsum dolor sit amet'),
view = makeView(function () {
return e('div', [
e('h1', 'Lorem Ipsum'),
ingress,
showList && app.list().render(),
renderFooter(`${showList ? 'Hide' : 'Show'} list`)
]);
}),
render = function () {
return view.render(showList && ['list.js']);
};
return { render };
};
})(triad, app);
Using it also in list.js:
(function ({ e, makeView }, app) {
app.list = function () {
var renderListItem = function (content) {
return app.listItem(content).render();
},
view = makeView(function () {
return e('ul', ['a', 'b', 'c'].map(renderListItem));
});
return {
render: function () {
return view.render(['listItem.js']);
}
};
};
})(triad, app);
Adding another file listItem.css
div#app {
li.list-item {
list-style-type: none;
margin: 0.5em;
padding: 0.5em;
border: 1px solid black;
width: 5em;
border-radius: 0.5em;
background: pink;
text-align: center;
}
}
And using it in listItem.js:
(function ({ e, makeView }, app) {
app.listItem = function (content) {
var view = makeView(function () {
return e('li', content, { className: 'list-item' });
});
return {
render: function () {
return view.render(['listItem.css']);
}
};
};
})(triad, app);
Communicating between components
A component passes data down to its children by rendering them with it. In listItem.js, the item gets a prefix as an argument of render, and dispatches an event upwards when it is clicked:
(function ({ e, makeView }, app) {
app.listItem = function (content) {
var view = makeView(function (prefix) {
return e('li', prefix + content, {
className: 'list-item',
onclick: function () {
view.dispatch('app:listItem:remove', content);
}
});
});
return {
render: function (prefix) {
return view.render(['listItem.css'], prefix);
}
};
};
})(triad, app);
list.js keeps the prefix that the user has entered, and renders the items again with it. It listens on its own element for the event from its items, and removes the item that was clicked. The items get a view of their own, itemsView, so the input and the button are left as they are:
(function ({ e, makeView }, app) {
app.list = function () {
var items = ['a', 'b', 'c'],
input = '',
prefix = '',
renderListItem = function (content) {
return app.listItem(content).render(prefix);
},
itemsView = makeView(function () {
var el = e('ul', items.map(renderListItem));
el.addEventListener('app:listItem:remove', function (event) {
items = items.filter(function (item) {
return item !== event.payload;
});
itemsView.render();
});
return el;
}),
view = makeView(function () {
return e('div', [
itemsView.render(),
e('input', '', {
onchange: function (e) {
input = e.target.value;
}
}),
e('button', 'Add prefix', {
onclick: function () {
prefix = input;
itemsView.render();
}
})
]);
});
return {
render: function () {
return view.render(['listItem.js']);
}
};
};
})(triad, app);
Note that in the browser DOM tree, only the ul element is changed.
This concludes the demonstration of the three tools, and how they can be used to create a web app. The final version of this example can be found under examples/lorem_ipsum — try it live.
Other more elaborate examples are also available under /examples.
Patterns for larger applications
Stable sub-views
As an app grows, re-rendering the whole tree on every change starts to cost: every child component is created again, and the parts of the page that did not change are rebuilt anyway. Instead, give each region of the page its own makeView, created once in the component's closure, and let each event re-render only the regions it changes.
Here is an app.main for a mail app. Selecting a message changes the message list and the message pane, and selecting a folder changes the folder list and the message list, but neither changes the rest of the page:
(function ({ e, makeView }, app) {
app.main = function () {
var folder = 'inbox',
selectedId = null,
folderListView = makeView(function () {
return app.folderList().render({ folder: folder });
}),
messageListView = makeView(function () {
return app.messageList().render({ folder: folder, selectedId: selectedId });
}),
messageView = makeView(function () {
return app.message().render({ selectedId: selectedId });
}),
view = makeView(function () {
var el = e('div', [
app.header().render(),
folderListView.render(['folderList.js']),
messageListView.render(['messageList.js']),
messageView.render(['message.js'])
]);
el.addEventListener('app:folder:select', function (event) {
folder = event.payload;
// The message pane still shows the selected message
folderListView.render();
messageListView.render();
});
el.addEventListener('app:message:select', function (event) {
selectedId = event.payload;
// The folder list does not show the selected message
messageListView.render();
messageView.render();
});
return el;
});
return {
render: function () {
return view.render(['header.js']);
}
};
};
})(triad, app);
Each sub-view is created once and keeps its place in the page between renders, so the top-level view renders once and each sub-view swaps out only its own element. There is no diffing: you state what changed, because you wrote the code that changed it.
Component communication
Within a tree, data goes down as arguments to render, and events go up by bubbling from the element of the component that dispatches them, to the nearest ancestor that listens on its own element:
flowchart TD
main["main"]
list["messageList<br/>listens on its own element"]
item["messageItem"]
main -->|"render(data)"| list
list -->|"render(data)"| item
item -.->|"view.dispatch(name, payload)<br/>bubbles up"| list
Across trees, the event bubbles up past every ancestor of the dispatching component to window, where a component in another branch listens with its view's on function, which stops listening once the view's element has left the page:
flowchart TD
window(("window"))
b1[ ]:::hidden
b2[ ]:::hidden
main["main"]
list["messageList"]
item["messageItem"]
sidebar["sidebar"]
status["statusBar<br/>view.on(name, handler)"]
window -.- b1
b1 -.-|"↑ view.dispatch(name, payload)<br/>bubbles up"| b2
b2 -.- item
window --- main
main -->|"render(data)"| list
main -->|"render(data)"| sidebar
list -->|"render(data)"| item
sidebar -->|"render(data)"| status
window -.->|"event"| status
classDef hidden stroke:none,fill:none
Listening on window also lets a component deep in the tree render itself again on its own, without rendering the large subtree above it again.
Tests
The tests run in the browser — run them live.



