htmlcsstemplatesdynamicmustache

How can I achieve something similar to Angular's ngStyle, but with Mustache?


I received the following question on the issue tracker of the Mustache website:

Like angular, ngStyle, is there any way we can add dynamic style

The question refers to the following notation, which only works in Angular templates (see Angular documentation):

<div [ngStyle]="{'background-color': section.background}"></div>
<!-- or -->
<div [ngStyle]="section.computedStyle"></div>

By "works", I mean that if the context of the above template snippet has a section object with a background property with the value '#e33', the first line of the snippet will render like this:

<div style="background-color: #e33;"></div>

Moreover, if the section object is later replaced by {background: '#d44'}, the above <div> within the live document will be automatically updated with the new background color.

The asker is not using Angular, but Mustache, and would like to know how to achieve a similar effect (if possible).


Solution

  • Answering the question breaks down into two parts:

    1. finding a notation that will interpolate a style attribute in a piece of HTML based on an object in the template context;
    2. finding a way to update the live document when the object containing the style changes.

    Interpolating a style attribute in HTML

    As mentioned in the question, Angular's ngStyle supports two slightly different notations. The first notation explicitly extracts the value of each CSS attribute from the context:

    <div [ngStyle]="{'background-color': section.background}"></div>
    

    This variant is straightforward to port to an equivalent Mustache notation:

    <div style="background-color: {{section.background}};"></div>
    

    The second notation interpolates any number of CSS attributes, based on all properties of an object in the template context:

    <div [ngStyle]="section.computedStyle"></div>
    

    This notation can be ported, too, but it is more involved. The most portable but inconvenient way is by using a partial. We first have to define a second template that lists the CSS attributes we want to support, let's call it styleAttribute:

    {{! we hide line breaks by moving them inside the tags }}
    {{#background-color
    }}background-color: {{background-color
    }}; {{/background-color
    }}{{#font-family
    }}font-family: {{font-family
    }}; {{/font-family
    }}{{! and so forth }}
    

    With this additional template at our disposal, we can use it to port Angular's notation as follows:

    <div style="{{#section.computedStyle}}{{>styleAttribute}}{{/section.computedStyle}}"></div>
    

    You can see the above notation in action by copy-pasting the following savestate into the load/store box of the playground:

    {"data":{"text":"{\n    section: {\n        computedStyle: {\n            /* feel welcome to edit this (supported \n               attributes are background-color,\n               font-family and font-weight) */\n            'background-color': '#e33',\n            'font-family': 'Georgia'\n        }\n    }\n}"},"templates":[{"name":"main","text":"<div style=\"{{#section.computedStyle}}{{>styleAttribute}}{{/section.computedStyle}}\"></div>"},{"name":"styleAttribute","text":"{{#background-color\n}}background-color: {{background-color\n}}; {{/background-color\n}}{{#font-family\n}}font-family: {{font-family\n}}; {{/font-family\n}}{{#font-weight\n}}font-weight: {{font-weight\n}}; {{/font-weight\n}}{{! and so forth }}"}]}
    

    Of course, it would be better if we did not need to list all CSS attributes that we want to support. More convenient, but less portable, is to write a function in the host programming language that can do the conversion, and then use it in the template as a lambda (you have to scroll up a bit from that link). In this case, the host language would most likely be JavaScript, which means that we can rely on the this binding in order to obtain the CSS attributes in the function. The function itself looks like this:

    function asStyleAttribute() {
        var attributeText = '';
        for (var cssAttr in this) {
            attributeText += `${cssAttr}: ${this[cssAttr]}; `;
        }
        return attributeText;
    }
    

    This function will support all possible CSS attributes out of the box, no need to list them. However, we still have to jump a few more hoops to ensure that asStyleAttribute can run as a lambda with this pointing to the object with the actual style data. We do this by creating a wrapper:

    function CssAttributes(attributes) {
        for (var attr in attributes) {
            this[attr] = attributes[attr];
        }
    }
    // add our asStyleAttributes function as a method to the wrapper
    CssAttributes.prototype.asStyleAttribute = asStyleAttribute;
    

    Instead of storing style data in the template context as plain objects, we always put them in this wrapper:

    section.computedStyle = new CssAttributes({
        'background-color': '#e33'
    });
    

    Now, with all of these preparations, we can finally write this in the template:

    <div style="{{section.computedStyle.asStyleAttribute}}"></div>
    

    Again, the following savestate will demonstrate this notation in the playground:

    {"data":{"text":"{\n    section: {\n        /* the code looks a bit different here\n           because of the way the playground works */\n        computedStyle: {\n            asStyleAttribute() {\n                var attributeText = '';\n                for (var cssAttr in this) {\n                    if (cssAttr === 'asStyleAttribute') continue;\n                    attributeText += `${cssAttr}: ${this[cssAttr]}; `;\n                }\n                return attributeText;\n            },\n            /* feel welcome to edit this (you\n               can use any attributes you want) */\n            'background-color': '#e33',\n            'font-family': 'Georgia'\n        }\n    }\n}"},"templates":[{"name":"main","text":"<div style=\"{{section.computedStyle.asStyleAttribute}}\"></div>"}]}
    

    More elegant ways to do this would require nonstandard extensions to the Mustache language. I will mention a few that have been discussed:

    Updating the live document

    Mustache alone cannot update the live document, because it is both unaware of the meaning of HTML and of any future changes to the data. When you render a template, the output is just a raw string, and as far as Mustache is concerned, its job is done.

    However, you can add dynamic updates from the outside, by involving another piece of software that is aware of these things. Such pieces of software are called client-side application frameworks, and I know one that allows you to use Mustache templates: Backbone.

    The principle is quite simple: you wrap the data in a Model and the template in a View. Backbone gives you tools to easily update a view when a model changes.

    It is beyond the scope of this answer to explain how Backbone works, but I will demonstrate the principles in a snippet:

    // Our template. There could be more than one.
    var template = _.mustache(`
    <button type=button>Change it</button>
    <button type=reset>Reset</button>
    <div style="{{sectionStyle.asStyleAttribute}}">
        Watch this!
    </div>
    `);
    
    // Our wrapper from before.
    function CssAttributes(attributes) {
        for (var attr in attributes) {
            this[attr] = attributes[attr];
        }
    }
    CssAttributes.prototype.asStyleAttribute = function() {
        var attributeText = '';
        for (var cssAttr in this) {
            attributeText += `${cssAttr}: ${this[cssAttr]}; `;
        }
        return attributeText;
    };
    
    // A model. We will add the sectionStyle to it.
    var model = new Backbone.Model({
        sectionStyle: null
    });
    
    // A view class. Most of the magic will happen here.
    var DemoView = Backbone.View.extend({
        // This view uses our template from above.
        template: template,
        
        // Tell the view what to do when a button is clicked.
        events: {
            'click button[type=button]': 'changeIt',
            'click button[type=reset]': 'resetIt'
        },
        
        // Setup when the view is instantiated.
        initialize() {
            this.render();
            // This line enables the dynamic updates.
            this.listenTo(this.model, 'change', this.render);
        },
        
        // How the view uses the template. The contents
        // of the model become the context of the template.
        render() {
            this.$el.html(this.template(this.model.attributes));
            return this;
        },
        
        // Event handlers for the buttons.
        changeIt() {
            this.model.set('sectionStyle', new CssAttributes({
                'font-weight': 'bold',
                'text-decoration': 'underline',
                'background-color': '#e33',
                'border': '2px solid black'
            }));
        },
        resetIt() {
            this.model.unset('sectionStyle');
        }
    });
    
    // To use a view class, we create an instance
    // and attach it to the DOM.
    var theView = new DemoView({model: model});
    theView.$el.appendTo('body');
    div {
        width: auto;
        padding: 1em;
        margin: 1em;
    }
    <script src="https://cdn.jsdelivr.net/npm/underscore@1.13.6/underscore-umd-min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/backbone@1.6.0/backbone-min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/wontache@0.2.0/mustache-umd.min.js"></script>

    For more advanced and more realistic example code, you can view the source of the playground. It was built using Mustache and Backbone, too, although the scripting was done in CoffeeScript.