Docs Examples

Intro

Minimalist, Stable Component Framework.

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, use foo-bar='30' to get this.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="&quot;ok&quot;">
    <div a="testing" b="2.2" foo-bar="&amp;quot;ok&amp;quot;">
    </div>
  </spread-component>

  <div a="testing" b="2.2" foo-bar="&quot;ok&quot;">
  </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:

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 component is added.
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.