Docs Examples

Intro

Minimal Component Framework

  • 1 file. 1 class. ~350 lines of code
  • 100% native web
  • No build tools required
  • Composition oriented
  • No new template languages
  • Event delegation by default

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>

Properties

Props are properties that are passed to the component in the form of html attributes. For example...

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! 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 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, 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`
      <spread-component ...${o}>
      </spread-component>

      <div ...${o}>
      </div>
    `
  }
}

The above compoent renders the following output.

<my-component>
  <another-component a="testing" b="2.2" foo-bar="ok">
    <div a="testing" b="2.2" foo-bar="ok">
    </div>
  </another-component>

  <div a="testing" b="2.2" foo-bar="ok">
  </div>
</my-component>

Updating properties

Virtual DOMs are not proven to imrpove performance, but have proved to increase complexity. So, Tonic does not use them. Instead, 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.

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

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 `
      <div class="parent">
        <another-component>
          ${this.children}
        </another-component>
      </div>
    `
  }
}

Tonic.add(ParentComponent)

class ChildComponent extends Tonic {
  render () {
    return `
      <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>

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:

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.

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)

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 `<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 `<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 `<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).
raw(String) Insert raw text in html`...`. Useful when calling super.render() or otherwise delegating to render() of another component. Be careful with calling raw 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.
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). 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>).

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.