Intro
Features
- Tonic is about 250 lines of code in total.
- One-way data binding. Pipe data through connected components.
- Mix in your own Routers, Reducers, Validators, etc.
- React-like component composition.
- Prefer Javascript template literals to weird template languages.
- Prefer event delegation over individual handlers.
1. Getting Started
Building a component with Tonic starts by creating a Javascript Class. The class should have at least one method named render which usually returns a string of HTML.
class MyGreeting extends Tonic {
//
// The render function can return a template-literal of HTML,
// it can also include other components.
//
render () {
return `<div>Hello, World.</div>`
}
}
The name of your class will determine the html tag name for your component. A
Camel cased class names will create hyphenated tag names, ie MyGreeting will
become <my-greeting></my-greeting>. Web components require that you have two
part names.
Next, register your component with Tonic.add(ClassName).
Tonic.add(MyGreeting)
After adding your Javascript to your HTML, you can start to use your component.
<html>
<head>
<script src="index.js"></script>
</head>
<body>
<my-greeting></my-greeting>
</body>
</html>
Note: Unrelated to Tonic, custom tags (in all browsers) require a closing tag (even if they have no children).
When the component is rendered by the browser, the result of your render function will be inserted into the component tag.
<html>
<head>
<script src="index.js"></script>
</head>
<body>
<my-greeting>
<div>Hello, World.</div>
</my-greeting>
</body>
</html>
2. Properties
Properties are used by a component to help it decide how it should appear or
how it should behave. Properties are read only. In this case, message is our
property and Hello, World is our property value.
class MyApp extends Tonic {
render () {
return `
<my-greeting message='Hello, World'></my-greeting>
`
}
}
Properties added to a component appear on the this.props object.
class MyGreeting extends Tonic {
render () {
return `
<h1>${this.props.message}</h1>
`
}
}
Tonic has no templating language, it uses HTML! Remember, HTML only understands
string values. If we want to pass more complex values to a component, prefix the
string returned by the render function with this.html.
const data = { greeting: 'hello, world' }
class MyApp extends Tonic {
render () {
return this.html`
<my-component title=${data}></my-component>
`
}
}
Now this.props has a reference to the data object.
class MyComponent extends Tonic {
render () {
return `
<h1>${this.props.data.greeting}</h1>
`
}
}
Note: A property named
fooBar='30'will become lowercased (as per the HTML spec). If you want the property name to be camel cased when added to the props object, usefoo-bar='30'to getthis.props.fooBar.
You can use the "spread" operator to expand object literals into html properties.
class FooBar extends Tonic {
render () {
const o = {
a: 'testing',
b: 2.2,
fooBar: '"ok"'
}
return this.html`
<spread-component ...${o}>
</spread-component>
<div ...${o}>
</div>
`
}
}
The above compoent renders the following output.
<foo-bar>
<spread-component a="testing" b="2.2" foo-bar=""ok"">
<div a="testing" b="2.2" foo-bar="&quot;ok&quot;">
</div>
</spread-component>
<div a="testing" b="2.2" foo-bar=""ok"">
</div>
</foo-bar>
Updating properties
If you want to manually update a component, you should think of your document's hierarchy and where in it the update should take place. It's better to update a component higher up in the hierarchy and let the data cascade downward to child components.
To manually update a component you can use the .reRender() method. This method
receives either an object or a function. For example...
// Update a component's properties
this.reRender(props => ({
...props,
color: 'red'
}))
// Reset a component's properties
this.reRender({ color: 'red' })
// Re-render a component with its existing properties
this.reRender()
The .reRender() method can also be called directly on a component.
document.getElementById('parent').reRender({ data: [1,2,3, ...9999] })
3. Methods
A method is a function of a component. It can help to organize the internal logic of a component.
The constructor is a special method that is called once each time an instance of your component is created.
class MyComponent extends Tonic {
constructor () {
super()
// ...
}
myMethod (n) {
this.innerHTML = `The number is ${n}`
}
}
After the component is created, the method myMethod method can be called.
document.getElementById('foo').myMethod(42)
4. Styling
Components should ship with as little CSS as possible and try to inherit whenever possible from the document's stylesheets. Tonic supports two approaches to styling components.
Approach 1. Inline styles
It is a security risk to add inline styles from html. A CSP policy will usually
prevent this. Use the styles() method to inline styles safely. Tonic will
apply the style properties when the render() method is called.
class MyGreeting extends Tonic {
styles () {
return {
fontStyle: {
color: this.props.fg,
fontSize: '30px'
},
background: {
backgroundColor: this.props.bg,
padding: '10px'
}
}
}
render () {
return `<div styles="fontStyle background">${this.children}</div>`
}
}
<my-greeting fg="white" bg="red">Hello, World</my-greeting>
Approach 2. Dynamic Stylesheets
Use the stylesheet() function to inline a stylesheet into the document where
the component is rendered. Since the value is css, you can use any css-in-js
library.
class MyGreeting extends Tonic {
stylesheet () {
return `
my-greeting div {
display: inline-block;
border: 1px dotted #666;
line-height: 90px;
}
my-greeting .tonic--my-greeting--show {
display: flex;
}
`
}
render () {
return `<div></div>`
}
}
5. Events
Tonic helps you capture events that happen when someone interacts with your component. It also helps you organize that code.
class Example extends Tonic {
//
// You can listen to any DOM event that happens in your component
// by creating a method with the corresponding name. The method will
// receive the plain old Javascript event object.
//
mouseover (e) {
// ...
}
change (e) {
// ...
}
click (e) {
//
// You may want to check which element in the component was actually
// clicked. You can also check the `e.path` attribute to see what was
// clicked (helpful when handling clicks on top of SVGs).
//
if (!e.target.matches('.parent')) return
// ...
}
render () {
return `<div></div>`
}
}
The convention of most frameworks is to attach individual event listeners,
such as onClick={myHandler()} or click=myHandler. In the case where
you have a table with 2000 rows, this would create 2000 individual listeners.
Tonic prefers the event delegation pattern. With event delegation, we attach a single event listener and watch for interactions on the child elements of a component. With this approach, fewer listeners are created and we do not need to rebind them when the DOM is re-created.
Each event handler method will receive the plain old Javascript event object.
This object contains a target property, the exact element that was clicked.
The path property is an array of elements containing the exact hierarchy.
Some helpful native DOM APIs for testing properties of an element:
Element.matches(String)tests if an element matches a selectorElement.closest(String)finds the closest ancestor from the element that matches the given selector
Tonic also provides a helper function which checks if the element matches the selector, and if not, tries to find the closest match.
Tonic.match(el, 'selector')
Here, when a particular element inside a child component is clicked, we intercept the click event and pass along some data to the parent component.
Example
class Child extends Tonic {
click (e) {
e.detail.bar = true
}
render () {
return `<div class="foo">Click Me</div>`
}
}
class Parent extends Tonic {
click (e) {
if (e.target.matches('.foo')) {
console.log(e.detail.bar)
}
}
render () {
return `<child></child>`
}
}
The event object has a Event.stopPropagation() method that is useful for
preventing an event from bubbling up to parent components. You may also be
interested in the Event.preventDefault() method.
6. State
Props are received by the parent and should never be changed by the component
that receives them. A component can however change its state. Each instance of
a component has state object, this.state. This is just a plain-old javascript
object. this.setState() can receive a value or a function.
// Update a component's state
this.setState(state => ({
...state,
color: 'red'
}))
// Reset a component's state
this.setState({ color: 'red' })
.setState() will not cause a component to re-render. The reasoning behind this
is that the state can be updated independently, as needed — rendering
happens only when changes to the representation of the component are required.
7. Composition
You may want to move the children of a component inside some additional layout
when the render() function is executed. The this.children property is helpful
for this. This is not a "special" member of the props object like React
(which is unintuitive), it's a member of the class instance.
class Parent extends Tonic {
render () {
return `
<div class="parent">
<another-component>
${this.children}
</another-component>
</div>
`
}
}
Tonic.add(Parent)
class Child extends Tonic {
render () {
return `
<div class="child">
${this.props.value}
</div>
`
}
}
Tonic.add(Child)
Input HTML
<parent>
<child value="hello world"></child>
</parent>
Output HTML
<parent>
<div class="parent">
<another-component>
<child>
<div class="child">hello world</div>
</child>
</another-component>
</div>
</parent>
8. Performance
If you have lots of structure, but minimal changes, you could pre-render your
layout to create a reusable node and pass it to the render method. This
structure could also come from a <template> tag which my also improve
performance.
class AnotherThing extends Tonic {
constructor (node) {
super(node)
const template = document.createElement('template')
template.appendChild(document.createElement('span'))
this.template = template.content
}
//
// Render will automatically deep-clone this node for you.
//
render () {
return this.template
}
}
9. CSP
CSP stands for Content Security Policy. It's important to add one of these
to your app or website if you do anything beyond pure html. This is a good
introduction to CSPs if you're not already familiar with how they work.
This is an example policy, it's quite liberal, in a real app you would want these rules to be more specific.
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
font-src 'self' https:;
img-src 'self' https: data:;
style-src 'self' 'nonce-123' https:;
script-src 'self' 'nonce-123';
connect-src 'self' https:;">
In order to allow Tonic to execute properly when using a CSP, you might need
to set the Tonic.nonce property. For example, given the above policy you would
add the following to your javascript...
Tonic.nonce = '123'
Note that 123 is a placeholder, this should be an actual nonce.
APIs
STATIC METHODS
| Method | Description |
|---|---|
add(Class) |
Register a class as a new custom-tag and provide options for it. |
init(root?) |
Initialize all components (optionally starating at a root node in the DOM). This is called automatically when an |
escape(String) |
Escapes HTML characters from a string (based on he). |
sanitize(Object) |
Escapes all the strings found in an object literal. |
match(Node, Selector) |
Match the given node against a selector or any matching parent of the given node. This is useful when trying to locate a node from the actual node that was interacted with. |
INSTANCE METHODS
| Method | Description |
|---|---|
reRender(Object | Function) |
Set the properties of a component instance. Can also take a function which will receive the current props as an argument. |
getProps() |
Get the properties of a component instance. |
setState(Object | Function) |
Set the state of a component instance. Can also take a function which will receive the current props as an argument. |
| html`...` | Interpolated HTML string (use as a tagged template). |
INSTANCE METHODS IMPLEMENTED BY THE DEVELOPER
| Name | Description |
|---|---|
stylesheet() |
Should return a string of css to be lazily added to a style tag in the head. |
styles() |
Should return an object that represents inline-styles to be applied to the component. Styles are applied by adding a keys from the object to the styles attribute of an html tag in the render function, for example styles="key1 key2". Each object's key-value pair are added to the element's style object. |
render() |
Required, should return HTML or nodes to be parsed or a dom node that will overwrite. There is usually no need to call this directly, prefer foo.reRender({ ... }). This function can be async or an async generator. |
INSTANCE PROPERTIES
| Name | Description |
|---|---|
elements |
An array of the original child elements of the component. |
nodes |
An array of the original child nodes of the component. |
props |
An object that contains the properties that were passed to the component. |
state |
A plain-old JSON object that contains the state of the component. |
"LIFECYCLE" INSTANCE METHODS
| Method | Description |
|---|---|
constructor(object) |
An instance of the element is created or upgraded. Useful for initializing state, setting up event listeners, or creating shadow dom. See the spec for restrictions on what you can do in the constructor. The constructor's arguments must be forwarded by calling super(object). |
willConnect() |
Called prior to the element being inserted into the DOM. Useful for updating configuration, state and preparing for the render. |
connected() |
Called every time the element is inserted into the DOM. Useful for running setup code, such as fetching resources or rendering. Generally, you should try to delay work until this time. |
disconnected() |
Called every time the element is removed from the DOM. Useful for running clean up code. |
updated(oldProps) |
Called after reRender() is called. This method is not called on the initial render. |