Wednesday, March 19, 2008

An Ajax Button with an Overlay Div and Wait Indicator

I needed an Ajax button that disables the web page and displays a spinner (a.k.a. wait indicator, working indicator, wait icon, hourglass) while working. The disabling is done by superimposing a div over the entire visible part of the page that has a 50% alpha blending value.


This example uses existing Wicket 1.3.0, but is probably backwards compatible. It's been tested on Firefox and Internet Explorer.


The code was inspired by the Veil component by Igor Vaynberg in wicketstuff-minis. My goal was to make this as minimal as possible to provide a skeleton you can build upon. Igor's component is more general and more capable, but I needed a point solution.


First, an example of how to use it:


HomePage.java


package wicket.quickstart;


import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.model.Model;
import org.apache.wicket.model.PropertyModel;


public class HomePage extends WebPage
{
private static final long serialVersionUID = 1L;

private int counter;

public HomePage()
{
final Label counterLabel = new Label("counter", new PropertyModel(this,
"counter"));
counterLabel.setOutputMarkupId(true);
add(counterLabel);

final Form myform = new Form("myform");
myform.add(new MyAjaxButton("button", new Model("Test"), "mybody")
{
private static final long serialVersionUID = 1L;

@Override
protected void onSubmit(AjaxRequestTarget target, Form form)
{
try
{
// Add some delay to demonstrate the effect
Thread.sleep(1000);
}
catch (InterruptedException e)
{
// Ignore
}
// Update the counter label
counter++;
target.addComponent(counterLabel);
}
});
add(myform);
}

public int getCounter()
{
return counter;
}
}

HomePage.html


<?xml version="1.0"?>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:wicket="http://wicket.sourceforge.net/" xml:lang="en"
lang="en">
<head>
<title>Wicket Quickstart Archetype Homepage</title>
</head>

<body id="mybody">
<strong>Wicket Quickstart Archetype Homepage</strong>

<p>Counter:
<span wicket:id="counter">counter value</span>
</p>

<form wicket:id="myform">
<input type="submit" wicket:id="button" />
</form>
</body>
</html>

Now the code for MyAjaxButton. This is the class that implements the mask:


MyAjaxButton.java


package wicket.quickstart;

import org.apache.wicket.ResourceReference;
import org.apache.wicket.ajax.IAjaxCallDecorator;
import org.apache.wicket.ajax.markup.html.form.AjaxButton;
import org.apache.wicket.behavior.HeaderContributor;
import org.apache.wicket.markup.html.resources.JavascriptResourceReference;
import org.apache.wicket.model.IModel;

/**
* Superclass for all Ajax buttons that display a mask over the whole
* page and a spinner on top of that.
*
* @author jsinai
*/
public abstract class MyAjaxButton extends AjaxButton
{
private static final ResourceReference JS = new JavascriptResourceReference(
MyAjaxButton.class, "MyAjaxButton.js");
private static final ResourceReference CSS = new ResourceReference(
MyAjaxButton.class, "MyAjaxButton.css");

private final String markupId;

public MyAjaxButton(String id, IModel m, String markupId)
{
super(id, null);
this.setModel(m);
this.markupId = markupId;
add(HeaderContributor.forJavaScript(JS));
add(HeaderContributor.forCss(CSS));
}

/**
* The call decorator is what displays the mask and spinner
*/
@Override
protected IAjaxCallDecorator getAjaxCallDecorator()
{
return new IAjaxCallDecorator()
{
private static final long serialVersionUID = 1L;

public CharSequence decorateScript(CharSequence script)
{
return "Mask.show('" + markupId + "');" + script;
}
public CharSequence decorateOnFailureScript(CharSequence script)
{
return "Mask.hide('" + markupId + "');" + script;
}
public CharSequence decorateOnSuccessScript(CharSequence script)
{
return "Mask.hide('" + markupId + "');" + script;
}
};
}
}

The Javascript for the button implements the masking behavior:


MyAjaxButton.js


/**
Inspired by the Veil component by Igor Vaynberg in wicketstuff-minis
http://wicketstuff.org/confluence/display/STUFFWIKI/wicketstuff-minis
wicketstuff-minis is released under the Apache 2 License
http://apache.org/licenses/LICENSE-2.0.html
*/
Mask = { };

/**
Shows a mask and a spinner over the element with the specified id
*/
Mask.show = function(targetId)
{
var target=document.getElementById(targetId);
var mask=document.createElement("div");
mask.innerHTML="&nbsp;";
mask.className="wicket-mask";
mask.style.cursor="not-allowed";
mask.style.zIndex="5000";
mask.id="wicket_mask_"+targetId;
document.body.appendChild(mask);
Mask.offsetMask(mask);

var spinner=document.createElement("div");
spinner.innerHTML="&nbsp;";
spinner.className="wicket-spinner";
spinner.style.cursor="not-allowed";
spinner.style.zIndex="6000";
spinner.id="wicket_spinner_"+targetId;
document.body.appendChild(spinner);
Mask.centerSpinner(spinner);
}

/**
Hides the mask and spinner
*/
Mask.hide = function(targetId)
{
var mask=document.getElementById("wicket_mask_"+targetId);
if (mask!=null) {
mask.style.display="none";
document.body.removeChild(mask);
}
var spinner=document.getElementById("wicket_spinner_"+targetId);
if (spinner!=null) {
spinner.style.display="none";
document.body.removeChild(spinner);
}
}

/**
* Places the spinner at the center of the viewport.
*/
Mask.centerSpinner = function(spinner)
{
var width = document.body.clientWidth;
var height = document.body.clientHeight;

var offsetX = document.body.scrollLeft;
var offsetY = document.body.scrollTop;

var left = (width / 2) - 24 + offsetX;
var top = (height / 2) - 24 + offsetY;

spinner.style.left = left + "px";
spinner.style.top = top + "px";
}

/**
* Offsets the mask to the scroll position.
*/
Mask.offsetMask = function(mask)
{
var offsetX = document.body.scrollLeft;
var offsetY = document.body.scrollTop;

mask.style.left = offsetX + "px";
mask.style.top = offsetY + "px";
}

The CSS determines the alpha blending and size of the mask, and which spinner we are using:


MyAjaxButton.css


/**
Inspired by the Veil component by Igor Vaynberg in wicketstuff-minis
http://wicketstuff.org/confluence/display/STUFFWIKI/wicketstuff-minis
wicketstuff-minis is released under the Apache 2 License
http://apache.org/licenses/LICENSE-2.0.html
*/
div.wicket-mask {
position:absolute;top:0;left:0;
width:100%;height:100%;
background:rgb(230,230,230);
opacity:.50;
filter:alpha(opacity=50);
-moz-opacity:0.5;
text-decoration:none;
}
div.wicket-spinner {
position:absolute;
background-image:url(/app/resources/org.apache.wicket.ajax.AbstractDefaultAjaxBehavior/indicator.gif);
background-repeat: no-repeat;
width: 16;
height: 16;
background-color: transparent;
}

8 comments:

bhaskar karambelkar said...

Great Post,
One minor issue, you are using a hard coded markup ID for veiling. Instead you can pass in a Component to the Constructor. and in the Constructor do
component.setOutputMarkupId(true);

and in the getIAjaxCallDecorator use component.getMarkupId();

That way you work completely with Wicket, without having to hard code markup IDs in your HTML.

Julian Sinai said...

Bhaskar,

Thanks for your comment. In my example, though, the component being veiled is not a Wicket component, i.e. it does not have a Wicket ID. It's the body tag that has the html ID attribute set. Your suggestion is a good one for components with wicket IDs.

James D said...

Thanks for a great example. Displaying the spinner along with blocking further user input during the server-side work was exactly what we needed to do.
When running the example application, I could not get the spinner to show. In the example, the spinner is specified in CSS as a background image. In case someone else has the same problem, I was able to get the spinner to show by using a regular img tag. In MyAjaxButton.js, I changed the line that reads: spinner.innerHTML=" "; to:

spinner.innerHTML="<img src='resources/org.apache.wicket.ajax.AbstractDefaultAjaxBehavior/indicator.gif'>";

and the spinner shows up OK. (sorry, hard to show code in the post - < is the less than symbol and > is the greater than symbol.)
Again, thanks for a very helpful article.

nino said...

Hi fine post, I realized I where half through something similar when I decided to copy paste..

One thing though... IT's not compatible with FF, it's some of the js commands are ie only. this script should work:

/**
Inspired by the Veil component by Igor Vaynberg in wicketstuff-minis
http://wicketstuff.org/confluence/display/STUFFWIKI/wicketstuff-minis
wicketstuff-minis is released under the Apache 2 License
http://apache.org/licenses/LICENSE-2.0.html
*/
Mask = { };

/**
Shows a mask and a spinner over the element with the specified id
*/
Mask.show = function(targetId)
{
var target=document.getElementById(targetId);
var mask=document.createElement("div");
mask.innerHTML=" ";
mask.className="wicket-mask";
mask.style.cursor="not-allowed";
mask.style.zIndex="5000";
mask.id="wicket_mask_"+targetId;
document.body.appendChild(mask);
Mask.offsetMask(mask);

var spinner=document.createElement("div");
spinner.innerHTML="Please wait loading "<"img src='../../resources/org.apache.wicket.ajax.AbstractDefaultAjaxBehavior/indicator.gif'>";
spinner.className="wicket-spinner";
spinner.style.cursor="not-allowed";
spinner.style.zIndex="6000";
spinner.id="wicket_spinner_"+targetId;
document.body.appendChild(spinner);
Mask.centerSpinner(spinner);
}

/**
Hides the mask and spinner
*/
Mask.hide = function(targetId)
{
var mask=document.getElementById("wicket_mask_"+targetId);
if (mask!=null) {
mask.style.display="none";
document.body.removeChild(mask);
}
var spinner=document.getElementById("wicket_spinner_"+targetId);
if (spinner!=null) {
spinner.style.display="none";
document.body.removeChild(spinner);
}
}

/**
* Places the spinner at the center of the viewport.
*/
Mask.centerSpinner = function(spinner)
{
var width = document.body.clientWidth;
var height =window.innerHeight;
// var height = document.body.clientHeight;

var offsetX = document.body.scrollLeft;
var offsetY = Mask.findYOffSet();

var left = (width / 2) - 24 + offsetX;
var top = (height / 2) - 24 + offsetY;

spinner.style.left = left + "px";
spinner.style.top = top + "px";
}
Mask.findYOffSet= function(){
var offset=0;
if(window.pageYOffset>0)
{
offset=window.pageYOffset;
}
if(document.body.scrollTop>0)
{
offset=document.body.scrollTop;
}
return offset;


}

/**
* Offsets the mask to the scroll position.
*/
Mask.offsetMask = function(mask)
{
var offsetX = document.body.scrollLeft;
var offsetY = Mask.findYOffSet();

mask.style.left = offsetX + "px";
mask.style.top = offsetY + "px";
}



my blog : ninomartinez.wordpress.com

And please remember to remove " from "<" at image, I had to insert it inorder to post..

nino said...

And now ones that compatible with all:


/**
Inspired by the Veil component by Igor Vaynberg in wicketstuff-minis
http://wicketstuff.org/confluence/display/STUFFWIKI/wicketstuff-minis
wicketstuff-minis is released under the Apache 2 License
http://apache.org/licenses/LICENSE-2.0.html
*/
Mask = { };

/**
Shows a mask and a spinner over the element with the specified id
*/
Mask.show = function(targetId)
{
var target=document.getElementById(targetId);
var mask=document.createElement("div");
mask.innerHTML=" ";
mask.className="wicket-mask";
mask.style.cursor="not-allowed";
mask.style.zIndex="5000";
mask.id="wicket_mask_"+targetId;
document.body.appendChild(mask);
Mask.offsetMask(mask);

var spinner=document.createElement("div");
spinner.innerHTML="Please wait loading "<"img src='../../resources/org.apache.wicket.ajax.AbstractDefaultAjaxBehavior/indicator.gif'>";
spinner.className="wicket-spinner";
spinner.style.cursor="not-allowed";
spinner.style.zIndex="6000";
spinner.id="wicket_spinner_"+targetId;
document.body.appendChild(spinner);
Mask.centerSpinner(spinner);
}

/**
Hides the mask and spinner
*/
Mask.hide = function(targetId)
{
var mask=document.getElementById("wicket_mask_"+targetId);
if (mask!=null) {
mask.style.display="none";
document.body.removeChild(mask);
}
var spinner=document.getElementById("wicket_spinner_"+targetId);
if (spinner!=null) {
spinner.style.display="none";
document.body.removeChild(spinner);
}
}

/**
* Places the spinner at the center of the viewport.
*/
Mask.centerSpinner = function(spinner)
{
var width = Mask.findWidth();
var height =Mask.findHeight();
// var height = document.body.clientHeight;

var offsetX = document.body.scrollLeft;
var offsetY = Mask.findYOffSet();

var left = (width / 2) - 24 + offsetX;
var top = (height / 2) - 24 + offsetY;

spinner.style.left = left + "px";
spinner.style.top = top + "px";
}


Mask.findHeight = function(){
var myHeight = 0;
if( typeof( window.innerWidth ) == 'number' ) {
//Non-IE
myWidth = window.innerWidth;
myHeight = window.innerHeight;
} else if( document.documentElement && document.documentElement.clientHeight ) {
//IE 6+ in 'standards compliant mode'
myHeight = document.documentElement.clientHeight;
} else if( document.body && document.body.clientHeight) {
//IE 4 compatible
myHeight = document.body.clientHeight;
}
return myHeight;
}
Mask.findWidth = function(){
var myWidth = 0;
if( typeof( window.innerWidth ) == 'number' ) {
//Non-IE
myWidth = window.innerWidth;
} else if( document.documentElement && document.documentElement.clientWidth ) {
//IE 6+ in 'standards compliant mode'
myWidth = document.documentElement.clientWidth;
} else if( document.body && document.body.clientWidth ) {
//IE 4 compatible
myWidth = document.body.clientWidth;
}
return myWidth;
}

Mask.findYOffSet = function() {

if (typeof window.pageYOffset == 'number') {

Mask.findYOffSet = function() {
return window.pageYOffset;
};

} else if ((typeof document.compatMode == 'string') &&
(document.compatMode.indexOf('CSS') >= 0) &&
(document.documentElement) &&
(typeof document.documentElement.scrollTop == 'number')) {

Mask.findYOffSet = function() {
return document.documentElement.scrollTop;
};

} else if ((document.body) &&
(typeof document.body.scrollTop == 'number')) {

Mask.findYOffSet = function() {
return document.body.scrollTop;
}

} else {

Mask.findYOffSet = function() {
return NaN;
};

}

return Mask.findYOffSet();
}

Mask.findXOffSet = function() {

if (typeof window.pageXOffset == 'number') {

Mask.findXOffSet = function() {
return window.pageXOffset;
};

} else if ((typeof document.compatMode == 'string') &&
(document.compatMode.indexOf('CSS') >= 0) &&
(document.documentElement) &&
(typeof document.documentElement.scrollLeft == 'number')) {

Mask.findXOffSet = function() {
return document.documentElement.scrollLeft;
};

} else if ((document.body) &&
(typeof document.body.scrollLeft == 'number')) {

Mask.findXOffSet = function() {
return document.body.scrollLeft;
}

} else {

Mask.findXOffSet = function() {
return NaN;
};

}

return Mask.findXOffSet();
}


/**
* Offsets the mask to the scroll position.
*/
Mask.offsetMask = function(mask)
{
var offsetX = Mask.findXOffSet();
var offsetY = Mask.findYOffSet();

mask.style.left = offsetX + "px";
mask.style.top = offsetY + "px";
}


However, I cant get the background to display correctly (it's like 100px height and on in the top) in ie, spinner works just fine..

Again remember to remove " at img tag before using

nino said...

okay the thing regarding ie where that I had to set body height to 100%.. then its working.

James Selvakumar said...

Though this idea was posted before a couple of years, most of it still holds water (I'm using it :) )

I still use the original javascript file mentioned in this post with the exception of just adding the "img" tag during the spinner creation. And it works fine.

Since Wicket has evolved over the days, the Wicket source file might have to be modified to a small extent.

Overall this a great solution and thanks a lot for posting this.

Nishant Neeraj said...

Great. Thanks. This was helpful.