Using web components with composite controls in DotVVM

|
Publikováno:

One of the scenarios in which the new way of building controls in DotVVM – the composite controls – really excel, is when used in combination with Web components.

Web components is a great concept which allows to define custom HTML elements and define how they’ll look in the page thanks to attaching the Shadow DOM (HTML that will be rendered but stands aside of the page DOM).

Web component with its shadow DOM in Dev Tools

In the picture above, you can see a custom element <fluent-button>. This component can have its own attributes (e. g. current-value), and can produce the Shadow DOM which will be rendered. However, in the main page DOM, there is still just one element <fluent-button> instead of the <button> element and the <span> elements inside.

There are several component libraries based on Web Components – for example Material Design, vaadin, GitHub elements, and more. Recently, Microsoft announced porting their Fluent UI into Web Components. We were quite passionate about that, however the project now looks a bit abandoned and there haven’t been many new commits recently – it seems that Microsoft is focusing on the React version of the controls.


Data-binding to web components

It is quite easy to use Web Components in DotVVM projects since they are using only the primitives offered by HTML and DOM manipulation.

For example, to render a Progress component from Fluent UI web components, you can use the following snippet. You can use data-binding to set attribute values, and it will just work:

<fluent-progress min="0" max="100" value="{value: Progress}"></fluent-progress>

The values of HTML attributes are always treated as strings, but DotVVM will do a conversion of numeric and date-time values for you automatically – you don’t need to do anything special.

Two-way binding

A more interesting situation will occur when you need to make the data-binding working both ways. By default, HTML doesn’t let you know when a value of attribute changes. Even the default HTML elements only have events to notify about changes on some of their attributes. For example, the <input> element triggers the onchange event when its value is changed, and it applies only to the situation when the user makes the change. If you change the value attribute programmatically, the onchange event is not going to be triggered.

When you use data-binding on a HTML attribute, DotVVM will generate something like this:

<fluent-progress data-bind="attr: { value: Progress }" ... />

This Knockout JS binding handler sets a subscription on the Progress property in the viewmodel, and whenever its value changes, it will update the HTML attribute value. It doesn’t try to do the binding the opposite way since there are no good events for that.

If you are binding specifically the value attribute and the element has the onchange event, you may use the Knockout JS value binding which will cover the way back.

If you are working with a component library, there may be some generic mechanism to handle changes of attributes. For example, the Fluent UI Web Components have the Observable class that can do this. The easiest way to approach this is to write your own Knockout binding handler which will work similar to the attr binding but will take care of the way back:

import { Observable } from '@microsoft/fast-element';

export function init() {
    ko.bindingHandlers["fast-bind"] = {
        init(element, valueAccessor, allBindings, viewModel, bindingContext) {
            const notifier = Observable.getNotifier(element);
            const value = ko.unwrap(valueAccessor()) || {};
            ko.utils.objectForEach(value,
                function (attrName, attrValue) {
                    notifier.subscribe({
                        handleChange(source, propertyName) {
                            const expr = (valueAccessor() || {})[attrName];
                            if (ko.isWritableObservable(expr)) {
                                expr(source[propertyName]);
                            }
                        }
                    },
                    attrName);
                });
        },
        update(element, valueAccessor, allBindings, viewModel, bindingContext) {
            const value = ko.unwrap(valueAccessor()) || {};
            ko.utils.objectForEach(value,
                function (attrName, attrValue) {
                    attrValue = ko.utils.unwrapObservable(attrValue);
                    element[attrName] = attrValue;
                });
        }
    }
}

The Knockout binding handler has two methods – init and update.

The init method is called as soon as the HTML element appears in the page. In most cases, it’s when the page is loaded, but remember that elements may appear in a page later – for example in SPAs, or when you add an item in a collection bound to Repeater – in that moment, a new elements appear in the page. The purpose of this method is mainly to hook up on various events and set up handlers for them.

The update method is called right after init, and also whenever the binding value is updated. In this method, you should read the value from the viewmodel, and apply it on the HTML element.

In our case, the binding value may be an object with several properties – we are trying to make the binding similar to attr:

<fluent-progress data-bind="fast-bind: { value: Progress }" ... />

You can see that in the init method we’ll get a notifier object for the element (this object is a part of Fluent UI library). Then, we are evaluating valueAccessor() which is a function which will give us the value of the binding: { value: Progress }

After we have it, we’ll go through all keys in the object and register a handler for the attribute changes on the notifier object. If any of the attributes will be changed, we’ll look in the corresponding property in the binding value, and if we’ll find a writeable observable there, we’ll set the new value in it. This last check is important as the expression doesn’t have to be writeable – e. g. {value: ItemsProcessed / ItemsTotal} would be a valid DotVVM binding, but it is not possible to update these two properties if you know only the result. In such case, the ko.isWritableObservable will return false and we’ll do nothing.

The update method just goes through all the properties in the object, reads the values of individual observables and sets them to the attributes. This will register handlers on all observables automatically – Knockout JS tracks which observables you had to evaluate in your update handler, and will call update when any of these observables will change.

Collections of elements

Some web components contain child elements. This is often used when you work with collections. In DotVVM, there is the Repeater control which can render elements based on items in a viewmodel collection, which is exactly what we need here. You can use WrapperTagName property to tell Repeater what element should be rendered around the items (by default it’s <div>).

<fluent-select>
    <fluent-option value="1">One</fluent-option>
    <fluent-option value="2">Two</fluent-option>
    <fluent-option value="3">Three</fluent-option>
    <fluent-option value="4">Four</fluent-option>
    <fluent-option value="5">Five</fluent-option>
</fluent-select>

<dot:Repeater DataSource="{value: Items}" 
              WrapperTagName="fluent-select">
    <fluent-option value="{value: Value}">{{value: Name}}</fluent-option>
</dot:Repeater>

These two elements will have the same outcome.


Composite control as a wrapper

Now when we dealt with the primitive things, let’s make our life simpler and provide strongly-typed wrappers for our web components. This is not difficult to do, and it will give you a more precise IntelliSense. Also, you’ll be able to hide a lot of logic inside the controls, which will help to get rid of duplicate code.

Rendering the fast-bind binding handler

Instead of rendering the data-bind attribute with the correct binding handler manually, we can create an attached property which can be set to any control even if the control doesn’t define them. You’ve probably met attached properties when using validation – Validation.Enabled=false is an attached property defined in the Validation class in the framework. You can apply this property on any HTML element and on most DotVVM controls (those which render exactly one HTML element).

In our case we’ll need something more – since you need to specify both name of the attribute and its value, and there can be several of them on the same element, we need property group. A property group is a dictionary of properties with the same prefix. You’ve seen them on the RouteLink control where you can specify for example Param-Id and Param-Title properties which specify the values for the Id and Title route parameters.

We’ll need to define an attached property group which will be used like this:

<fluent:Select Fast.Bind-value="{value: SelectedValue}" ... />

The code where such property group is defined, can look like this:

[ContainsDotvvmProperties]
public class Fast
{

    [AttachedProperty(typeof(object))]
    [PropertyGroup("Bind-")]
    public static DotvvmPropertyGroup BindGroupDescriptor =
        DelegateActionPropertyGroup<object>.Register<Fast>("Bind-", "Bind", AddBindProperty);

    private static void AddBindProperty(IHtmlWriter writer, IDotvvmRequestContext context, DotvvmPropertyGroup group, DotvvmControl control, IEnumerable<DotvvmProperty> properties)
    {
        var bindingGroup = new KnockoutBindingGroup();
        foreach (var prop in properties)
        {
            bindingGroup.Add(prop.Name.Split(':')[1], control, prop);
        }
        writer.AddKnockoutDataBind("fast-bind", bindingGroup);
    }

}

The class must be marked with ContainsDotvvmProperties attribute – DotVVM needs it to be able to discover the property declaration.

The property is a static field of type DotvvmPropertyGroup – it is a descriptor object which contains the metadata of the property – its name, its prefix in the markup, and a handler that will be called when rendering a control with such property.

The property is marked with AttachedProperty so it can be used on any control or HTML element.

The AddBindProperty method is called when such element is rendered. We are creating a KnockoutBindingGroup object (an object which represents { key: value, key2: value2… } dictionary in Knockout data-binding. We’ll add all properties in the collection, and call writer.AddKnockoutDataBind. It will merge the binding group with other data-bind attributes that are already specified on the control, so they’ll be rendered as one attribute.

Wrappers

In the previous blog posts (Part 1, Part 2), we’ve shown how to define composite controls. You can use the SetProperty overload which accepts the DotvvmProperty object – this way you can use the property group descriptor and ask for a specific property name for the target attribute.

[ControlMarkupOptions(AllowContent = true)]
public class NumberField : CompositeControl
{
    public static DotvvmControl GetContents(
        HtmlCapability htmlCapability,
        ValueOrBinding<double>? value
    )
    {
        return new HtmlGenericControl("fluent-number-field", htmlCapability)
            .SetProperty(Fast.BindGroupDescriptor.GetDotvvmProperty("value"), value);
    }

    protected override void OnPreRender(IDotvvmRequestContext context)
    {
        context.ResourceManager.AddRequiredResource(Constants.FluentUIResourceName);
        base.OnPreRender(context);
    }
}

In the PreRender method, the control just requests the resource with the binding handler. The resource should be configured so they are loaded after DotVVM and Knockout JS.


Fluent UI wrappers

Occasionally, we are getting asked when we’ll eventually publish the wrappers for Fluent UI Web Components. We are still considering this although we think that the package is not of much use. The components are quite limited in their features, and the repo is not updated very frequently so it is unsure whether the controls will be production ready at some point. Currently, there are only basic components, and even things like calendar or date picker are not in a working state.

If you have a use case for the Fluent UI Web Components, or have been using some other library based on web components, let us know about your experience. If there is demand for some specific library, we will be happy to build a component package for that.

Tomáš Herceg
Tomáš Herceg

BIO: 

I am the CEO of RIGANTI, small software development company located in Prague, Czech Republic.

I am a Microsoft Regional Director and Microsoft Most Valuable Professional.

I am the author of DotVVM, an open source .NET-based web framework which lets you build Line-of-Business applications easily and without writing thousands lines of Javascript code.

Ostatní články z kategorie: DotVVM Blog