Adam McKee
Adam McKee

Reputation: 204

Polymer paper-dialog: how can I know when an injected dialog is ready to be toggled?

In my application, in response to user input I inject a paper-dialog containing a scrollable area (paper-dialog-scrollable) into the DOM as the last child of the body. I inject it when it's called for, because for a few different reasons I find it impractical to include the dialog in the page just in case the user decides to activate it. I could go into those reasons but I don't think it would be productive.

I inject the dialog like this:

var fragment = "<paper-dialog id='mydialog' ...><paper-dialog-scrollable ...>...";
$('body').append(fragment);
var dialog = $('#mydialog').get(0);

The first thing I found out is that if I try to immediately activate the dialog by calling dialog.toggle(), the dialog does appear on Chrome, but on Firefox, I get an error in the console:

TypeError: dialog.toggle is not a function

I believe this difference is related to the need for more polyfilling on Firefox than on Chrome. The next thing I tried was to activate the dialog with this code:

Polymer.Base.async(function(){ dialog.toggle(); }, 1);

With this change, the toggle() method is there when I try to call it, and the dialog appears.

When testing on Chrome, the next problem I encountered is that if the paper-dialog contains a scrollable portion (paper-dialog-scrollable), the scrollable portion will have zero height if I activate the dialog "too soon" after injecting it. This happens because of a "fit" class on the "scrollable" div which is the sole child of the paper-dialog-scrollable element. I verified this by manually removing the "fit" class in Chrome Developer Tools & seeing that the dialog was then properly displayed.

In the code for paper-dialog-scrollable, I found this:

attached: function()
{
    this.classList.add('no-padding');
    // Set itself to the overlay sizing target
    this.dialogElement.sizingTarget = this.scrollTarget;
    // If the host is sized, fit the scrollable area to the container.
    // Otherwise let it be its natural size.
    requestAnimationFrame(function() {
        if (this.offsetHeight > 0) {
            // this happens when I toggle "too quickly"
            this.$.scrollable.classList.add('fit');
        }
        this._scroll();
    }.bind(this));
}

If I wait longer before toggling the dialog:

Polymer.Base.async(function(){ dialog.toggle(); }, 100);

.. then the "fit" class is absent & the scrollable portion of the dialog is properly displayed. However, this is no solution because it may be necessary to wait longer (or not as long), depending on how fast the machine is, the current load, etc. I need the dialog to work reliably without waiting any longer than necessary before toggling it. Is there some event I can listen for that will fire when it's safe to toggle the dialog? Also, does anyone have an idea about the paper-dialog-scrollable code that applies the "fit" class? Maybe there's some way I can prevent this class name from being applied in the first place (besides making the user wait longer than truly necessary)?

Upvotes: 1

Views: 1266

Answers (2)

Adam McKee
Adam McKee

Reputation: 204

About The "WebComponentsReady" Event

The WebComponentsReady event suggested by Ryan White sounds like it could help, but it fires exactly once on a page, after all imports on the initially loaded page have loaded and all custom elements on the page have been upgraded. In my testing, I found it will not fire again after that. Since the Web Components polyfill (required on Firefox for example) loads the imports and performs the element upgrades asynchronously, it's necessary to wait for WebComponentsReady before using any components or custom elements that are present in the initial page. If the initial page does import web components but does not contain custom elements, the WebComponentsReady event still signals that the loading of the imports has completed and they are ready to use.

In my situation, the initially loaded page imports no web components and contains no custom elements. I don't want to load all the components that could possible be required, or instantiate all the dialogs the user could possibly ask for. Instead, I want to load web components and create custom elements as needed. In the next sections I'll share what I've learned about dynamically injecting web components (and custom elements that use them).

Injecting a Web Component and Waiting For It To Load

This is pretty straightforward.

var util = {};

///////////////////////////////////////////////////////////////////////////////////////////////////
// util.listenOnce(elem, type, listener, useCapture)
//     Return a promise for an event of type <type> raised on <elem>.
///////////////////////////////////////////////////////////////////////////////////////////////////

util.listenOnce = function(elem, type, useCapture)
{
    var deferred = $.Deferred();
    var promise = deferred.promise();
    var callback = function()
    {
        deferred.resolve.apply(deferred, arguments);
        elem.removeEventListener(type, callback, useCapture);
    };
    elem.addEventListener(type, callback, useCapture);
    return promise;
};

///////////////////////////////////////////////////////////////////////////////////////////////////
// util.urlNormalize(url)
//    If <url> is a site-local URL, return a full URL version of it, otherwise return <url> as-is.
///////////////////////////////////////////////////////////////////////////////////////////////////

util.urlNormalize = function(url)
{
    // already a full URL -> return as-is
    if ((url.indexOf('http:') == 0) || (url.indexOf('https:') == 0)) return url;

    var path;
    if (url[0] == '/')
    {
        path = url;
    }
    else
    {
        path = window.location.pathname;
        if (path.charAt(path.length - 1) != '/') path += '/';
        path += url;
    }
    return window.location.protocol + '//' + window.location.hostname + path;
};

///////////////////////////////////////////////////////////////////////////////////////////////////
// util.addImport(url)
//     Add an HTML import to the DOM, returning a promise.
//     It's OK to call this multiple times with the same url.
///////////////////////////////////////////////////////////////////////////////////////////////////

{
    var completeUrls = [];

    util.addImport = function(url)
    {
        // already loaded this import?
        if (completeUrls.indexOf(url) >= 0)
        {
            return $.Deferred().resolve().promise();
        }

        // find the link element for this import
        var urlFull = util.urlNormalize(url);
        var links = $('head > link');
        var link;
        links.each(function(){
            if ((this.rel == 'import') && (this.href == urlFull))
            {
                link = this;
                return false;
            }
        });

        // create the <link> element if necessary, and watch for the 'load' event
        var loaded;
        if (link)
        {
            loaded = util.listenOnce(link, 'load');
        }
        else
        {
            // create a <link> element
            link = document.createElement('link');
            link.rel = 'import';
            link.href = url;

            // on load, update completeUrls
            loaded = util.listenOnce(link, 'load');
            loaded.then(function() { completeUrls.push(url); });

            // append the <link> element to the head
            var head = document.getElementsByTagName('head')[0];
            head.appendChild(link);
        }
        return loaded;
    };
}

Since util.addImport() returns a promise, it's easy to wait for multiple imports to be loaded:

///////////////////////////////////////////////////////////////////////////////////////////////////
// util.addImports(urls)
//     Add multiple HTML imports to the DOM, returning a promise.
///////////////////////////////////////////////////////////////////////////////////////////////////

util.addImports =
function(urls)
{
    var promises = urls.map(function(url){
        return util.addImport(url);
    });
    return $.when.apply($, promises);
};

E.g.

util.addImports(['//this/component.html', '//that/component.html']).then(function(){
    // go ahead and do stuff that requires the loaded components
});

Injecting a Custom Element and Waiting To Interact With It

If all required component imports have loaded, and you inject an element as I was doing..

var fragment = "<paper-dialog id='mydialog' ...><paper-dialog-scrollable ...>...";
$('body').append(fragment);
var dialog = $('#mydialog').get(0);

.. then on a browser like Chrome that natively supports web components, the upgrade of dialog to a paper-dialog will happen synchronously, and we immediately have a paper-dialog to interact with. However, on a browser like Firefox that requires polyfilling, the upgrade is asynchronous and an immediate attempt to invoke dialog.toggle() will fail:

TypeError: dialog.toggle is not a function

As I found out, if I just give the polyfill a chance to work, I then have an upgraded element to interact with:

Polymer.Base.async(function() { dialog.toggle(); }, 1);

Simply waiting for the upgrade to happen before interacting with the element seems to be OK most of the time. However, in the case of paper-dialog-scrollable, the fact that it's been upgraded does not mean it's OK to proceed with toggling its parent dialog. The reason for this is actually in the code for paper-dialog-scrollable.attached() which I included in my question.

Here it is again:

attached: function()
{
    this.classList.add('no-padding');
    // Set itself to the overlay sizing target
    this.dialogElement.sizingTarget = this.scrollTarget;
    // If the host is sized, fit the scrollable area to the container.
    // Otherwise let it be its natural size.
    requestAnimationFrame(function() {
        if (this.offsetHeight > 0) {
            this.$.scrollable.classList.add('fit');
        }
        this._scroll();
    }.bind(this));
}

When I tried to toggle() the parent paper-dialog too soon after the upgrade, the "fit" class would be applied to the div#scrollable container, which caused the scrollable region to be collapsed. As we see in paper-dialog-scrollable.attached(), it does not immediately test this.offsetHeight > 0, but it actually uses requestAnimationFrame() to wait until just before the next repaint to perform this test. When I invoke dialog.toggle() only ~1ms after the upgrade, this causes the dialog to be visible, and therefore the contents of the scrollable region have a non-zero height. However, when I waited 100ms before toggling like this:

Polymer.Base.async(function() { dialog.toggle(); }, 100);

.. then the requestAnimationFrame() callback installed by paper-dialog-scrollable.attached() had a chance to run, and since the dialog was still not activated at that time, it found the paper-dialog-scrollable element to not be sized and therefore did not apply the "fit" class (allowing the scrollable region to "be its natural size").

Of course, I want to toggle my dialog as soon as possible after the decision has been made to not apply the "fit" class, and I can do that by installing my own attached() handler for the paper-dialog-scrollable object. My attached() handler invokes the normal paper-dialog-scrollable.attached() handler, and then does:

requestAnimationFrame(function() { dialog.toggle(); });

Since requestAnimationFrame() callbacks are executed in order just before the next repaint, I toggle() the dialog immediately after it's safe to do so. To enact this solution, I used Polymer.Base.create() instead of constructing a markup fragment string for the entire dialog & getting jQuery to inject that. Using Polymer.Base.create() immediately gives you an upgraded element, which is kind of nice. I also think the function is nicer to read and maintain than the previous version that manipulated blobs of text.

However, there seems to be another solution that doesn't require my own paper-dialog-scrollable.attached() handler:

Polymer.Base.async(function(){
    requestAnimationFrame(function(){
        dialog.toggle();
    });
}, 1);

Perhaps this solution is better because it's more general, but I'm less confident that it always works.

New Dialog Creation Code

util.dialog = function(options)
{
    // provide default options
    var defaults =
    {
        imports: [],
        id: 'cms-dialog',
        classes: '',
        title: '',
        content: '',
        scrollable: false,
        dismissButtonLabel: 'Cancel',
        dismissButtonFn: null,
        confirmButtonLabel: 'OK',
        confirmButtonFn: null
    };
    options = $.extend({}, defaults, options);
    options.classes += ' cms-dialog';

    // make a list of required components
    var imports = options.imports;
    var polymerRoot = '//cdn.rawgit.com/download/polymer-cdn/1.2.3/lib/';
    imports.push(polymerRoot + 'neon-animation/animations/scale-up-animation.html');
    imports.push(polymerRoot + 'neon-animation/animations/fade-out-animation.html');
    imports.push(polymerRoot + 'paper-dialog/paper-dialog.html');
    imports.push(polymerRoot + 'paper-dialog-scrollable/paper-dialog-scrollable.html');
    imports.push(polymerRoot + 'paper-button/paper-button.html');

    // load required imports, then create the dialog
    util.addImports(imports).then(function(){
        // nuke any existing dialog
        $('.cms-dialog').remove();

        // create paper-dialog
        var dialogProps = {
            id: options.id,
            modal: true,
            className: options.classes,
            entryAnimation: 'scale-up-animation',
            exitAnimation: 'fade-out-animation'
        };
        var dialog = Polymer.Base.create('paper-dialog', dialogProps);

        // add title
        if (options.title)
        {
            $(dialog).append("<h2 class='title'>" + options.title + '</h2>');
        }

        // add content
        var content;
        if (options.scrollable)
        {
            var scrollableProps = {
                className: 'content'
            };
            content = Polymer.Base.create('paper-dialog-scrollable', scrollableProps);
            content.dialogElement = dialog;
            $(content.scrollTarget).append(options.content);
        }
        else
        {
            content = $("<div class='content'>" + options.content + "</div>").get(0);
        }
        $(dialog).append(content);

        // add buttons
        var dismissButton = '';
        if (options.dismissButtonLabel)
        {
            dismissButton =
                "<paper-button id='dialog-dismiss-button' class='cms-button' dialog-dismiss>" +
                    options.dismissButtonLabel +
                "</paper-button>";
        }
        var confirmButton = '';
        if (options.confirmButtonLabel)
        {
            confirmButton =
                "<paper-button id='dialog-confirm-button' class='cms-button' dialog-confirm>" +
                    options.confirmButtonLabel +
                "</paper-button>";
        }
        $(dialog).append(
            "<div class='buttons'>" +
                dismissButton +
                confirmButton +
            "</div>");

        // activate the dialog
        var toggle = function(){
            // install on-click event handlers
            if (options.dismissButtonFn)
            {
                $('#dialog-dismiss-button').on('click', options.dismissButtonFn);
            }
            if (options.confirmButtonFn)
            {
                $('#dialog-confirm-button').on('click', options.confirmButtonFn);
            }

            // run on-ready callback (if given)
            if (options.onReady) options.onReady();

            // bring up the dialog
            dialog.toggle();
        };

        // toggle when it's safe
        var attachedTarget = options.scrollable ? content : dialog;
        var attachedOrig = attachedTarget.attached;
        attachedTarget.attached = function() {
            if (attachedOrig) attachedOrig.apply(attachedTarget, arguments);
            requestAnimationFrame(toggle);
        };

        // toggle when it's safe (this also appears to work)
        //Polymer.Base.async(function() { requestAnimationFrame(toggle); }, 1);

        // add the dialog to the document
        document.body.appendChild(dialog);
    });
};

Concluding Thoughts

Although the WebComponentsReady event wasn't the answer for me, I still found Ryan White's demo helpful because it contained examples of using Polymer.Base.create(), and overriding the attached() handler, which helped me dig into the problem to find the solution.

Although it's nice to have a fix, I feel like there's a missing event here, to signal when an injected custom element and its children have been upgraded and settled down into a stable state, so it's safe to proceed with interaction.

Upvotes: 1

Ryan White
Ryan White

Reputation: 2436

Wait for the WebComponentsReady event before appending the scrollable paper dialog.

In chrome there are native html imports and custom elements, so you don't need to wait for this event, since the process is synchronous, but in firefox html imports are not native and are instead add using polyfills.

window.addEventListener('WebComponentsReady', function(e) {
  var dialogScrollable = Polymer.Base.create('paper-dialog-scrollable');
  var dialog = Polymer.Base.create('paper-dialog', { id: 'mydialog', });
  dialog.appendChild(dialogScrollable);
  document.body.appendChild(dialog);
});

Or more similar to the example you gave

window.addEventListener('WebComponentsReady', function(e) {
  var fragment = "<paper-dialog id='mydialog' ...><paper-dialog-scrollable ...>...";
  $('body').append(fragment);
  var dialog = $('#mydialog').get(0);
});

Here is a link to a jsbin demo showing that the WebComponentsReady event is fired, and the dialog is toggled. The issues with dialogScrollable are shown here too, but this should probably be a separate question on stack overflow, since it's not directly related to "how can I know when an injected dialog is ready to be toggled?"

Upvotes: 0

Related Questions