Docs Examples

Intro

Tonic is a very small JavaScript class for building components — without React.

Core Library Examples

Features

  • Tonic is tiny. Less than 200 lines of code.
  • 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 include
  // other components as well. It can also return a dom node, we'll talk about
  // that later.
  //
  render () {
    return `<div>Hello, World.</div>`
  }
}

The name of your class will determine the html tag name for your component. A class named Greeting will become <greeting></greeting>. Camel cased class names will create hyphenated tag names, ie MyGreeting will become <my-greeting></my-greeting>.


Next, you need to tell the browser about your new class and it will create a custom HTML tag for it. Your top most component should generally be added after the DOM is ready. Now any time the component's tag appears in your html, an instance of your class will be created.

Tonic.add(MyGreeting)

After adding your Javascript to your HTML, you can start to use your component. And remember, all custom tags require a closing tag.

<html>
  <head>
    <script src="index.js"></script>
  </head>

  <body>
    <my-greeting></my-greeting>
  </body>
</html>

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 potential for components is that the complexity of a solution can be hidden for the programmer who uses it. They can then focus more on the value it produces and the properties they want to pass it.

2. Properties

Properties can be used by a component to help it decide how it should appear or how it should behave. In this case, message is our property and Hello, World is our porperty 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>
    `
  }
}

There is no templating language in Tonic. It's just HTML — a limitation of HTML is that it only understands string values. So if we want to pass more complex property values to a component, Tonic can help. Notice the string we would normally return is prefixed with this.html. This just helps Tonic to understand your html better.

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[0].greeting}</h1>
    `
  }
}

A note about property names. A prop 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.

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.

All component methods are private by default. No one else can access them. But in some cases you want to provide public access to a method.

The constructor is a special method that is called once each time an instance of your component is created. Here you can add a method to the root element of your component that calls a method from the component.

class MyComponent extends Tonic {
  constructor (node) {
    super(node)

    this.root.myMethod = n => this.myMethod(n)
  }

  myMethod (n) {
    this.root.innerHTML = `The number is ${n}`
  }
}

Once the component has been created, the exampleMethod method can be called on it.

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

The value returned from the stylesheet() function will be attached to a style element in the head of the document if and when the component is used (lazily). Since the value is css, you can use any css-in-js library you want and it will be easy to inspect and override from another stylesheet.

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

Any classes you add should be prefixed so that they don't collide with any class names which already exist in the document's stylesheet. We use this convention...

library--component-name--class-name

Approach 2. Inline styles

Sometimes you want to use inline-styles. If your component has a styles() method that returns an object and the styles="..." attribute is found on an html tag, Tonic will try to apply the matching style properties when the render() method is called. Note that the styles are applied through Javascript in a CSP-friendly way.

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>

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:

Tonic also provides a helper function which checks if the element matches the selector, and if not, tries to find the closest match.

Tonic.matches(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 only a few 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, Object) Register a class as a new custom-tag and provide optional options for it.
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.
stylesheet() Returns a string of css to be lazily added to a style tag in the head.
styles() Returns an object that represents inline-styles. 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() Returns HTML to be parsed or a dom node that will overwrite. There is usually no need to call this directly, prefer componentInstance.reRender({ ... }).
html`...` Tidy up an HTML string (use as a tagged template).

"LIFECYCLE" INSTANCE METHODS

Method Description
constructor(props) 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. A constructor will receive an argument of props and must call super(props).
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.