Skip to content
This repository has been archived by the owner on Jul 25, 2019. It is now read-only.

lyst/baustein

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

baustein

Build Status:

Baustein is a component library that we use at Lyst to build the front-end of our website. It has some similarities with other component-based frameworks such as React or FruitMachine, but grew very organically out of the specific needs we had at Lyst.

Docs

The Basics

The simplest use case for Baustein is to progressively enhance some server-rendered HTML, so let's look at how we can do this.

Imagine we have a webpage with the following server-rendered HTML.

<div is="my-component">Hello!</div>

This HTML by itself will do nothing, even with Baustein loaded on the page. For anything to happen we need to do two things, register a component with the name my-component (denoted by the is attribute), and then initialise Baustein.

Baustein keeps an internal registry of available components and to add a component to this registry you use baustein.register(name, implementation).

import baustein from 'baustein';

// register our component
baustein.register('my-component', {

    init() {
        console.log('Hello from my-component!');
    }

});

// initialise baustein
baustein.init();

The call to baustein.init() will cause Baustein to parse the DOM looking for any components, initialising them as it finds them, and then to listen for any changes to the DOM so it can check to see if new components have been added. baustein.init() should only be called once all your components are registered, and should only be called once.

This example shows how to register a component and to initialise Baustein, but logging things to the console isn't very interesting. Let's look at how we can respond to DOM events.

Handling DOM Events

To show how to listen to and respond to DOM events let's create a counter button component that shows how many times it's been clicked.

First we'll start with some HTML:

<button is="counter-button">Clicked <span>0</span> times</button>

Now lets register our component.

baustein.register('counter-button', {

    init() {
        this.count = 0;
    },

    setupEvents(add) {
        add('click', this.onClick);
    },
    
    onClick(event) {
        this.count++;
        this.el.querySelector('span').textContent = this.count;
    }

});

This component uses two Baustein API methods, init and setupEvents. init is called when a component is created and is a good place to initialise any instance properties. setupEvents is also called when a component is created and is passed a function that can be used to bind events. This function accepts either two or three arguments:

setupEvents(add) {

    // this will listen for clicks anywhere within the component
    add('click', this.onClick);
    
    // this will only listen for clicks that happen within the <span> element inside the component
    add('click', 'span', this.onSpanClick);
}

In this above example onClick will receive just the event object as it's only argument, but onSpanClick will receive the event object as it's first argument and the <span> element as it's second argument.

Note that the handler function does not need to be bound with .bind(this) or anything similar as baustein will always call the function with the instance of the component as the this value.

To illustrate event handling further let's create a slightly more complicated component:

<ul is="highlightable-list">
    <li>John</li>
    <li>Paul</li>
    <li>George</li>
    <li>Ringo</li>
</li>
baustein.register('highlightable-list', {

    setupEvents(add) {
        add('click', 'li', this.onItemClick);
    },
    
    onItemClick(event, clickedItem) {
        const items = Array.from(this.el.querySelectorAll('li'));
        items.forEach(item => item.classList.toggle('active', item === clickedItem));
    }

});

This component listens for click events on any <li> element within it, and when it receives one it adds a class of active to that <li> and removes it from any others.

Global Events

Global events are ones that happen outside of a component, for example resize and scroll events, which typically happen on the window. Listening for these events is done in a slightly different way, using the setGlobalHandler(event, handler) method. A good place to start listening for these kinds of events is onInsert which is called whenever a component is inserted into the DOM.

onInsert() {
    this.setGlobalHandler('resize', this.onResize);
},

onRemove() {
    this.releaseGlobalHandler('resize', this.onResize);
},

onResize() {
    this.el.style.width = `${window.innerWidth}px`;
}

Due to the performance implications of dispatching high frequency events like scroll to components it is important that a component only listens for these events when it really needs to. In the above example we "release" the event handler when the component is removed using the onRemove function, which is called whenever a component is removed from the DOM.

Using Options

Components can take options which are passed using attributes. To show how this works lets extend our previous highlightable-list component to take the "active" class as an option.

<ul is="highlightable-list" active-class="highlighted">
    <li>John</li>
    <li>Paul</li>
    <li>George</li>
    <li>Ringo</li>
</li>
baustein.register('highlightable-list', {

    setupEvents(add) {
        add('click', 'li', this.onItemClick);
    },
    
    onItemClick(event, clickedItem) {
        const items = Array.from(this.el.querySelectorAll('li'));
        const {activeClass} = this.options;
        items.forEach(item => item.classList.toggle(activeClass, item === clickedItem));
    }

});

As you can see we can extract the options from this.options, which will be an object where the keys are camel-cased versions of the attribute names and the values are the attribute values. The values are run through JSON.parse() which means that they can be numbers, booleans, lists, or even objects. If parsing as JSON fails then the value will be left as-is.

Providing Default Options

It is possible to provide default options by implementing defaultOptions on the component. If provided then this must be either an object, or a function that returns an object. To extend our highlightable-list component to have a default value for activeClass we can do:

baustein.register('highlightable-list', {

    defaultOptions: {
        activeClass: 'active'
    },

    setupEvents(add) {
        add('click', 'li', this.onItemClick);
    },
    
    onItemClick(event, clickedItem) {
        const items = Array.from(this.el.querySelectorAll('li'));
        const {activeClass} = this.options;
        items.forEach(item => item.classList.toggle(activeClass, item === clickedItem));
    }

});

Changing options

Options can be changed at any time using the updateOptions(options) method. This method will merge the new options with the old ones and if they have changed in any way call onOptionsChange(), passing it the old options. So for example if we wanted to support changing the activeClass option in the highlightable-list component:

baustein.register('highlightable-list', {

    defaultOptions: {
        activeClass: 'active'
    },

    setupEvents(add) {
        add('click', 'li', this.onItemClick);
    },
    
    onOptionsChange(oldOptions) {
        const items = Array.from(this.el.querySelectorAll('li'));
        const {activeClass} = this.options;
        items.forEach(item => {
            const isActive = item.classList.has(oldOptions.activeClass);
            item.classList.toggle(activeClass, isActive);
            item.classList.remove(oldOptions.activeClass);
        });
    },
    
    onItemClick(event, clickedItem) {
        const items = Array.from(this.el.querySelectorAll('li'));
        const {activeClass} = this.options;
        items.forEach(item => item.classList.toggle(activeClass, item === clickedItem));
    }

});

updateOptions will mostly be called by parent components wanting to change the state of a child, but there is nothing to stop a component from updating it's own options.

Creating components programmatically

Although progressively enhancing server-rendered HTML is a big use case of Baustein it is also possible to create components programmatically. The baustein.register() function returns a constructor function which can be used to create a new instance of the component.

const HighlightableList = baustein.register('highlightable-list', {
    // implementation omitted
});

// create a new instance of the component
const list = new HighlightableList({
    activeClass: 'highlighted'
});

// append it to the <body>
list.appendTo(document.body);

The above code creates a new instance of the component, passing options via an object to the constructor, and then appends the component to the <body>. However if you inspect the <body> you would see something like this:

<div is="highlightable-list" data-component-id="1"></div>

This is obviously nothing like the HTML we were using before for this component, but so far the component doesn't know anything about how to render itself. Let's change that.

Components can implement a template function, which should return an HTML string. Implementing the render function for highlightable-list might look like this:

baustein.register('highlightable-list', {

    tagName: 'ul',

    template() {
        return `
            <ul is="highlightable-list">
                ${this.options.items.map(item => `<li>${item}</li>`).join('')}
            </ul>
        `;
    },

    defaultOptions() {
        return {
            activeClass: 'active',
            items: []
        };
    },

    setupEvents(add) {
        add('click', 'li', this.onItemClick);
    },
    
    onItemClick(event, clickedItem) {
        const items = Array.from(this.el.querySelectorAll('li'));
        const {activeClass} = this.options;
        items.forEach(item => item.classList.toggle(activeClass, item === clickedItem));
    }

});

We did three things here, first we added a property called tagName which tells Baustein what type of element to create as the root node of the component. Then we implemented the template function, which builds and returns some HTML, and finally we changed defaultOptions to be a function as now it contain an items property which defaults to an empty list. The reason we need to use a function now is that Baustein doesn't deep copy the options from defaultOptions, which means in this case if we implemented defaultOptions as a property then all component instances would share the same default value for items.

Now we can create our component passing some items:

// create a new instance of the component
const list = new HightlightableList({
    items: ['John', 'Paul', 'George', 'Ringo']
});

// append it to the <body>
list.appendTo(document.body);

Now if we inspect the body we should see something like our original HTML.

<ul is="highlightable-list" data-component-id="2">
    <li>John</li>
    <li>Paul</li>
    <li>George</li>
    <li>Ringo</li>
</li>

Working with templates

Building HTML strings manually in functions is not a very scalable way to write HTML, but since all Baustein requires is a function that returns an HTML string you are free to use whatever templating language you want. At Lyst we use Jinja2 templates in our Django backend and then use jinja-to-js to compile these templates into JS functions, meaning we can share the same templates between client and server.

The Render Context

As shown from the previous examples the template function is called with the component instance as the this value, but it is also passed a render context object as its first argument. The render context starts off as being whatever is returned by the getInitialRenderContext() method, or {} if this method is not implemented. It can then be changed by using setRenderContext(context) or replaceRenderContext(context), with the former updating the render context with the passed object and the latter completely replacing it. In both cases if the resulting render context changes in any way render() is called, which in turn calls the template function passing it the new render context. The current render context can be retreived at any time using getRenderContext(), but note that this function returns a copy of the current render context, so mutating will not change the underlying components context. To update the context you must use either setRenderContext(context) or replaceRenderContext(context).

As an example:

baustein.register('todo-items', {

    template(context) {
        return `
            <div is="todo-items">
                <form>
                    <input name="todo-input">
                    <input type="submit" value="add todo">
                </form>
                <button>Clear all</button>
                <ul>
                    ${context.todos.map(todo => `<li>${todo}</li>`).join('')}
                </ul>
            </div>
        `;
    },
    
    setupEvents(add) {
        add('submit', 'form', this.addTodo);
        add('click', 'button', this.clearAll);
    },
    
    getInitialRenderContext() {
        return {
            todos: []
        };
    },
    
    addTodo(event) {
        event.preventDefault();

        const todo = this.el.querySelector('input').value;
        const context = this.getRenderContext();
        
        context.todos.push(todo);

        // this will cause a render as we changed the context
        this.setRenderContext(context);
    },
    
    clearAll() {
        // this will cause a render only if the `todos` list was not already empty
        this.replaceRenderContext({
            todos: []
        });
    }

});

Render context vs. options

Although these two things may seem similar they are intended for different things. Think of a components options as the public API to change a components state (e.g. an object id) and the render context as the full data structure needed to render the template (e.g. data fetched from an API).

Custom events

Components will often need to communicate with each other and in the case of child to parent communication emitting custom events is the preferred way of implementing this. Components can emit events to their parents using the emit(eventName, eventData) function and can listen to events from their children in the same way as they do for DOM events. This is best explained with an example:

<ul is="hightable-list">
    <li is="highlightable-list-item">John</li>
    <li is="highlightable-list-item">Paul</li>
    <li is="highlightable-list-item">George</li>
    <li is="highlightable-list-item">Ringo</li>
</ul>
baustein.register('highlightable-list', {

    setupEvents(add) {
        add('item-selected', 'highlightable-list-item', this.onItemSelected);
    },
    
    onItemSelected(event) {
        this.findComponents('highlightable-list-item').forEach(item => {
            item.setHighlighted(item === event.target);
        }); 
    }

});

baustein.register('highlightable-list-item', {

    setupEvents(add) {
        add('click', this.onClick);
    },
    
    onClick() {
        this.emit('item-selected');
    },
    
    setHighlighted(highlighted) {
        this.el.classList.toggle('highlighted', highlighted);
    }

});

As well as showing how custom events can be emitted this example also introduced the findComponents(componentName) method, which can be used to find child components of a certain type.

Stopping propagation of custom events

Custom events propagate in the same was native DOM event, moving up the tree. If you want to stop an event propagating any further up the tree then you can call stopPropagation() on the event and it will not be dispatched to any components further up the tree.

Testing

Coming soon...