A low profile component framework.
- 1 file. 1 class. ~350 lines of code.
- No build tools required.
- Native web components.
- Ideal for JAM stacks.
- Identical on client & server.
- Composition oriented.
- Event delegation by default
- Lots of examples.
Getting Started
Building a component with Tonic starts by creating a function or a class. The class should have at least one method named render which returns a template literal of HTML.
import Tonic from '@socketsupply/tonic'
class MyGreeting extends Tonic {
render () {
return this.html`<div>Hello, World.</div>`
}
}
or
function MyGreeting () {
return this.html`
<div>Hello, World.</div>
`
}
The HTML tag for your component will match the class or function name.
Note: Tonic is a thin wrapper around
web components
. Web components require a name with two or more parts. So your class name should beCamelCased
(starting with an uppercase letter). For example,MyGreeting
becomes<my-greeting></my-greeting>
.
Next, register your component with Tonic.add(ClassName)
.
Tonic.add(MyGreeting)
After adding your Javascript to your HTML, you can use your component anywhere.
<html>
<body>
<my-greeting></my-greeting>
<script src="index.js"></script>
</body>
</html>
Note: Custom tags (in all browsers) require a closing tag (even if they have no children). Tonic doesn't add any "magic" to change how this works.
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>
A component (or its render function) may be an async
or an async generator
.
class GithubUrls extends Tonic {
async * render () {
yield this.html`<p>Loading...</p>`
const res = await fetch('https://api.github.com/')
const urls = await res.json()
return this.html`
<pre>
${JSON.stringify(urls, 2, 2)}
</pre>
`
}
}
Properties
Props
are properties that are passed to the component in the form of HTML
attributes. For example...
class MyApp extends Tonic {
render () {
return this.html`
<my-greeting message="Hello, World">
</my-greeting>
`
}
}
Properties added to a component appear on this.props
object.
Tonic has no templating language, it uses HTML! But since HTML only
understands string values, we need some help to pass more complex
values to a component, and for that we use this.html
.
const foo = {
hi: 'Hello, world',
bye: 'Goodbye, and thanks for all the fish'
}
class MyApp extends Tonic {
render () {
return this.html`
<my-greeting messages="${foo}">
</my-greeting>
`
}
}
class MyGreeting extends Tonic {
render () {
return this.html`
<h1>${this.props.messages.hi}</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 MyComponent extends Tonic {
render () {
const o = {
a: 'testing',
b: 2.2,
fooBar: 'ok'
}
return this.html`
<some-component ...${o}>
</some-component>
<div ...${o}>
</div>
`
}
}
The above component renders the following output.
<my-component>
<some-component a="testing" b="2.2" foo-bar="ok">
<div a="testing" b="2.2" foo-bar="ok">
</div>
</some-component>
<div a="testing" b="2.2" foo-bar="ok">
</div>
</my-component>
Updating properties
There is no evidence that Virtual DOMs improve performance across a broad set
of use cases, but it's certain that they greatly increase complexity. Tonic doesn't
use them. Instead, we recommend incremental updates
. Simply put, you re-render a
component when you think the time is right. The rule of thumb is to only re-render
what is absolutely needed.
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] })
Component State
this.state
is a plain-old javascript object. Its value will be persisted if
the component is re-rendered. Any element that has an id
attribute can use
state, and any component that uses state must have an id
property.
//
// Update a component's state
//
this.state.color = 'red'
//
// Reset a component's state
//
this.state = { color: 'red' }
<my-app id="my-app"></my-app>
<!-- always set a unique ID if you have multiple elems. -->
<my-downloader id="download-chrome" app="chrome"></my-downloader>
<my-downloader id="download-firefox" app="firefox"></my-downloader>
Setting the state will not cause a component to re-render. This way you can make incremental updates. Components can be updated independently. And rendering only happens only when necessary.
Remember to clean up! States are just a set of key-value pairs on the Tonic
object. So if you create temporary components that use state, clean up their
state after you delete them. For example, if a list of a component with thousands
of temporary child elements all uses state, I should delete their state after
they get destroyed, delete Tonic._states[someRandomId]
.
Composition
Nesting
With Tonic
you can nest templates from other functions or methods.
class MyPage {
renderHeader () {
return this.html`<h1>Header</h1>`
}
render () {
return this.html`
${this.renderHeader()}
<main>My page</main>
`
}
}
This means you can break up your render() {}
method into multiple
methods or re-usable functions.
Conditionals
If you want to do conditional rendering you can use if statements.
const LoginPage {
render () {
let message = 'Please Log in'
if (this.props.user) {
message = this.html`<div>Welcome ${this.props.user.name}</div>`
}
return this.html`<div class="message">${message}</div>`
}
}
Children
Once you add components, they can be nested any way you want. The
property this.children
will get this component's child elements
so that you can read, mutate or wrap them.
class ParentComponent extends Tonic {
render () {
return this.html`
<div class="parent">
<another-component>
${this.children}
</another-component>
</div>
`
}
}
Tonic.add(ParentComponent)
class ChildComponent extends Tonic {
render () {
return this.html`
<div class="child">
${this.props.value}
</div>
`
}
}
Tonic.add(ChildComponent)
Input HTML
<parent-component>
<child-component value="hello world"></child-component>
</parent-component>
Output HTML
<parent-component>
<div class="parent">
<another-component>
<child-component>
<div class="child">hello world</div>
</child-component>
</another-component>
</div>
</parent-component>
Repeating templates
You can embed an array of template results using this.html
class TodoList extends Tonic {
render () {
const todos = this.state.todos
const lis = []
for (const todo of todos) {
lis.push(this.html`<li>${todo.value}</li>`)
}
return this.html`<ul>${lis}</ul>`
}
}
By using an array of template results, tonic will render your repeating templates for you.
Events
There are two kinds of events. Lifecycle Events
and Interaction Events
.
Tonic uses the regular web component lifecycle events but improves on them,
see the API section for more details.
Tonic helps you capture interaction events without turning your html into property spaghetti. It also helps you organize and optimize it.
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) {
// ...
}
willConnect () {
// The component will connect.
}
connected () {
// The component has rendered.
}
disconnected () {
// The component has disconnected.
}
updated () {
// The component has re-rendered.
}
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 this.html`<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 the 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 that checks if the element matches the selector, and if not, tries to find the closest match.
Tonic.match(el, 'selector')
You can attach an event handler in any component, for example here
we attach an event handler in a ParentElement
component that handles
clicks from DOM elements in ChildElement
.
Example
class ChildElement extends Tonic {
render () {
return this.html`
<span data-event="click-me" data-bar="true">Click Me</span>
`
}
}
class ParentElement extends Tonic {
click (e) {
const el = Tonic.match(e.target, '[data-event]')
if (el.dataset.event === 'click-me') {
console.log(el.dataset.bar)
}
}
render () {
return this.html`
<child-element>
</child-element>
`
}
}
The event object has an 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.
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.state.number = n
this.reRender()
}
render () {
const n = this.state.number
return this.html`
<div>
The number is ${n}
</div>
`
}
}
After the component is created, the method myMethod
can be called.
document.getElementById('foo').myMethod(42)
Styling
Tonic supports multiple approaches to safely styling components.
Option 1. Inline styles
Inline styles are a security risk. Tonic provides the styles()
method so you
can inline styles safely. Tonic will apply the style properties when the render()
method is called.
class MyGreeting extends Tonic {
styles () {
return {
a: {
color: this.props.fg,
fontSize: '30px'
},
b: {
backgroundColor: this.props.bg,
padding: '10px'
}
}
}
render () {
return this.html`<div styles="a b">${this.children}</div>`
}
}
<my-greeting fg="white" bg="red">Hello, World</my-greeting>
Option 2. Dynamic Stylesheets
The stylesheet()
method will add a stylesheet to your component.
class MyGreeting extends Tonic {
stylesheet () {
return `
my-greeting div {
display: ${this.props.display};
}
`
}
render () {
return this.html`<div></div>`
}
}
Option 3. Static Stylesheets
The static stylesheet()
method will add a stylesheet to the document,
but only once.
class MyGreeting extends Tonic {
static stylesheet () {
return `
my-greeting div {
border: 1px dotted #666;
}
`
}
render () {
return this.html`<div></div>`
}
}
Server Side Rendering.
Tonic components are exactly the same on the server. You don't need
any build tools or any special treatment. Just use the tonic-ssr
module (it mocks-up a few dom apis that tonic needs).
Check out the code for this site
for a real life example.
CSP
Tonic is Content Security Policy
friendly. This is a good introduction to
CSP
s 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:;">
For Tonic
to work with a CSP, you need to set the nonce
property. For
example, given the above policy you would add the following to your javascript...
Tonic.nonce = 'c213ef6'