Rico Suter's blog.
 


I’m currently working on an SPA (single-page application) which uses the Aurelia JavaScript client framework and Bootstrap UI CSS framework. In the application I need to show various custom dialogs. This is why I implemented a reusable dialog service class which simplifies the creation of Bootstrap based dialogs. In this article I’d like to share and explain the code of this dialog service and the required helper classes. The sample code is written in TypeScript but it can easily be converted to ES6 JavaScript.

Implement the dialog service and base classes

First we need to implement the dialog service - the most complicated class of the required components.

The dialog service has three responsibilities:

  • Dynamically instantiate a the custom dialog view component
  • Modify the the CSS classes of the document’s body element (the Bootstrap ‘modal-open’ CSS class)
  • Add and remove the Bootstrap backdrop DIV element

Let’s have a look at the implementation of the dialog service:

dialog-service.ts:

import { inject, Container, CompositionEngine, Controller, ViewSlot } from "aurelia-framework";
import { DialogBase } from './dialog-base';

@inject(CompositionEngine, Container)
export class DialogService {
    constructor(
        private compositionEngine: CompositionEngine,
        private container: Container) {

    }

    show<TDialog extends DialogBase>(viewModel: string, model?: any, 
        onCreated?: (dialog: TDialog) => void): Promise<TDialog> {

        var dialogDiv = document.createElement("div");
        var backdropDiv = document.createElement("div");
        backdropDiv.setAttribute("class", "modal-backdrop fade in");

        document.body.appendChild(dialogDiv);
        document.body.appendChild(backdropDiv);

        var instruction = {
            model: model,
            viewModel: viewModel,
            container: this.container,
            bindingContext: null,
            viewResources: null,
            viewSlot: new ViewSlot(dialogDiv, true)
        };

        return this.compositionEngine.compose(instruction).then((controller: Controller) => {
            document.body.classList.toggle("modal-open");

            var dialog = controller.view.bindingContext as TDialog;
            if (onCreated)
                onCreated(dialog);

            return this.waitForClose<TDialog>(dialog, controller, dialogDiv, backdropDiv);
        });
    }

    private waitForClose<TDialog extends DialogBase>(dialog: TDialog, controller: Controller, 
        dialogDiv: HTMLDivElement, backdropDiv: HTMLDivElement): Promise<TDialog> {

        return new Promise<TDialog>((resolve, reject) => {
            dialog.element.addEventListener("close", () => {
                document.body.classList.toggle("modal-open");

                dialogDiv.remove();
                backdropDiv.remove();

                controller.view.unbind();
                controller.view.removeNodes(); 

                resolve(dialog);
            });
        });
    }
}

The first step of the show() method creates the backdrop DIV with the required Bootstrap classes and adds it to the document’s body element.

Then the dialog container DIV is created and added to the body element.

In next step the service employs the CompositionEngine to dynamically instantiate the dialog view in the previously created dialog container DIV. For this, the method needs to know the full path to the view model’s JavaScript module.

Finally the method creates a promise which resolves when the close event of the dialog event is triggered. The close event is a custom event which is implemented and raised in the DialogBase class which will be shown next. Finally, when the promise is resolved, the service reverts the DOM changes and destroys the dialog view.

The DialogBase is the base class of our custom dialog’s view model. The class just implements a close() method which triggers the close event.

dialog-base.ts

import { inject } from "aurelia-framework";

@inject(Element)
export class DialogBase {
    constructor(public element: Element) {

    }

    close() {
        var event = new CustomEvent("close");
        this.element.dispatchEvent(event);
    }
}

To simplify the implementation of a custom dialog, I implemented the Dialog component which provides the bs-dialog tag. This tag is used as root tag of our custom dialog’s view. It renders the required DIVs and CSS classes for a Bootstrap dialog and provides properties to control the appereance of the dialog:

dialog.ts:

import { bindable, inject, customElement, View } from "aurelia-framework";
import { DialogBase } from "./dialog-base";

@inject(Element)
@customElement("bs-dialog")
export class Dialog {
    @bindable
    title = "";

    @bindable
    showCloseButton = true;

    @bindable
    closeOnBackdrop = true;

    dialog: DialogBase;

    bind(view: any, myView: View) {
        this.dialog = myView.bindingContext as DialogBase;
    }

    checkDismissClick(event: Event) {
        if (this.closeOnBackdrop && event.srcElement.getAttribute("class").indexOf("modal fade in") !== -1)
            this.dialog.close();
    }
}

dialog.html

<template>
    <div class="modal fade in" click.trigger="checkDismissClick($event)" 
          style="display: block" tabindex="-1" role="dialog">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <button if.bind="showCloseButton" type="button" class="close" 
                            click.trigger="dialog.close()" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">&times;</span>
                    </button>
                    <h4 class="modal-title">${title}</h4>
                </div>
                <slot></slot>
            </div>
        </div>
    </div>
</template>

Implement a custom dialog

Now we can implement our own custom dialog. Let’s implement an alert dialog:

alert-dialog.ts

import { inject, bindable } from "aurelia-framework";
import { DialogBase } from "./dialog-base";

@inject(Element)
export class AlertDialog extends DialogBase {
    @bindable
    message;

    activated(params: { message: string }) {
        this.message = params.message; 
    }
}

The dialog view model class has to inherit from DialogBase and define the injection type Element which is required by the DialogBase class. The activated() method is called by the Aurelia framework after the view has been created. The activated() method also receives the parameters which are passed to the dialog.

Next, we implement the dialog’s HTML code:

alert-dialog.html

<template>
    <require from="./dialog"></require>

    <bs-dialog title="Hello World!">
        <div class="modal-body">
            ${message}
        </div>
        <div class="modal-footer">
            <button click.trigger="close()">Close</button>
        </div>
    </bs-dialog>
</template>

As you can see, I implemented a new component which defines the bs-dialog tag as root element. We can then set the title, body and footer via a property and DIV tags. Because the view model inherits from DialogBase we can directly bind to the close() method in the button tag.

To show this dialog, just inject the DialogService into your component and call the show() method:

import { inject } from "aurelia-framework";
import { DialogService } from "./dialog-service";
import { AlertDialog } from "./alert-dialog";

@inject(DialogService)
export class Dialogs {  
    constructor(private dialogService: DialogService) {

    }

    showAlert(message: string) {
        return this.dialogService.show<AlertDialog>("path/to/custom-dialog", { message: message });
    }
}

Custom parameters can be provided via the show() method’s second argument. The parameters are passed to the activated() method. The third parameter of the show() method is an optional callback which is triggered when the dialog has been loaded into the DOM. The show() method returns a promise which resolves when the user closes the dialog.

Sample on GitHub

I created a GitHub repository with a sample project: AureliaBootstrapDialogs

Just clone the project and run the following commands in the directory:

npm install aurelia-cli -g
npm install
aurelia run --watch

What do you think of this implementation? Do you see ways to improve it? Does someone know a way to use a view model type instead of a string-based path to the view model?



Discussion