Reputation: 204
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
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
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