Docs Examples

Intro

  • 1 file. 1 class. ~350 lines of code
  • No build tools required
  • Based on native web components
  • Composition oriented
  • Event delegation by default
  • Boring

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 HTML.

class MyGreeting extends Tonic {
  //
  // The render function can return a template-literal of HTML,
  // it can also include other components.
  //
  render () {
    return this.html`<div>Hello, World.</div>`
  }
}

The html tag for your component will match its class 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 be CamelCased (starting with an uppercase letter). For exmaple, 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>
  <head>
    <script src="index.js"></script>
  </head>

  <body>
    <my-greeting></my-greeting>
  </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>

The render function can also be async or even 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 the 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, 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, use foo-bar='30' to get this.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 compoent 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 accross 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] })

State

this.state is just a plain-old javascript object that will persist across re-renders. A component that uses state requires an id property.

//
// Update a component's state
//
this.state = {
  ...this.state,
  color: 'red'
}))

//
// Reset a component's state
//
this.state = { color: 'red' }

Setting 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.

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 properties of an element:

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')

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 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.

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 method 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 styleaheet to your compoonent.

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 styleaheet 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>`
  }
}

CSP

Tonic is Content Security Policy friendly. 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:;">

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'

APIs

METHODS IMPLEMENTED BY THE COMPONENT DEVELOPER

Name Description
render() Required, should return a template literal of HTML. There is no need to call this directly, the browser will call it. Can be sync, async or async generator.
stylesheet() Optional, Should return a string of css to be added to a style tag in the component (ideal for components that use a shadow dom).
static stylesheet() Optional, Should return a string of css to be lazily added to a style tag in the head (ideal for custom elements with no shadow dom).
styles() Optional, 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.

STATIC METHODS

Method Description
add(Class, String?) Register a class as a new custom-tag and provide options for it. You can pass an optional string that is the HTML tagName for the custom component.
escape(String) Escapes HTML characters from a string (based on he).
unsafeRawString(String) Insert raw text in html`...`. Be careful with calling unsafeRawString on untrusted text like user input as that is an XSS attack vector.
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.
html`...` Interpolated HTML string (use as a tagged template). Provides...
1. Pass object references as properties.
2. Spread operator (ie <a ...${object}></a>) which turns ojbects into html properties.
3. Automatic string escaping.
4. Render NamedNodeMap, HTMLElement, HTMLCollection, or NodeList as html (ie <a>${span}</a>).
dispatch(String, Object?) Dispatch a custom event from the component with an optional detail. A parent component can listen for the event by calling this.addEventListener(String, this) and implementing the event handler method.

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.