Drupal 7: Load content nodes into a modal overlay using AJAX

January 19, 2012 by jason

For one of our current projects (Aiko) we are building a site influenced by the Bloomberg L.P., company site, by Frog Design and the website, Pinterest. The design dictates that most of the site content is presented using a dropdown menu approach. Normally, a dropdown functions best when all of the content is already loaded on the page, and just relies on javascript to animate the display and hiding of each dropdown element.  For this design, it is pretty obvious that requiring all of the overlay content to load along with the initial page is excessive for both load times, as well as complicated for the CMS organization and structure.

AJAX to the rescue.  By loading each of the individual content nodes "on demand" we can cut the unnecessary burden of loading everything up front, and only load the content that is actually requested by the visitor.  Now the question becomes... how do we best do this in Drupal 7?

My solution involves the following approach:

  1. Add a class selector to all links that are meant to open in the overlay (class="overlay").
  2. Use jQuery to find all of these links in the document, and attach the click behavior of loading href into the desired overlay element via ajax, animate the effects, and return false to avoid firing the actual link to a separate page.
  3. Normally the link destination would load the entire page all over again within the overlay element, thus creating an infinite nesting problem (as well as breaking the design).  This is avoided by asking drupal to load these special "overlay" designated links in a different html.tpl.php and page.tpl.php so we can control which parts of the surrounding page are loaded into the overlay.  This is done using the theme's template.php preprocess functions and altering the href sent through ajax.
  4. We also want the url to correctly reflect which overlay page is currently open, and allow direct linking to a specific overlay.  This is handled with jQuery/javascript and dynamic updating of the page hashtag.  Additionally, when a page is first loaded an existing hash is parsed to determine what to display immediately.
  5. Lastly, each overlay may contain a slideshow of content.  If so, we also include this additional information in the hash to also allow for direct linking.

The following modules were used to implement this solution:
menu attributes: to add the class designation to selected menu links

CODE

mytheme/template.php
<?php
function mytheme_preprocess_page(&$vars) {
  if ( isset($_GET['overlay']) && $_GET['overlay'] == 'true' ) {
        $vars['theme_hook_suggestions'][] = 'page__overlay';
  }
}
function mytheme_preprocess_html(&$vars) {
  if ( isset($_GET['overlay']) && $_GET['overlay'] == 'true' ) {
        $vars['theme_hook_suggestions'][] = 'html__overlay';
  }
}
?>
mytheme/script.js
// JavaScript Document

jQuery(document).ready(function () {

  // set overlay-wrap to be hidden from view
  jQuery('#overlay-wrap').css('height', '0px');

  // find all overlay links and add overlay functionality
  jQuery('a.overlay').each( function() {
    jQuery(this).click( function() {
      var href=jQuery(this).attr('href');      // page url to load
      var name=jQuery(this).attr('name');      // new page name
      var rel =jQuery(this).attr('rel');       // new page group
      updateHash(name);
      loadPage(href);
      updateActive(href, rel);
      return false; // important to keep the page from loading in a new window
    });
  });

  // If hashtag exists, load the appropriate page and slide on page load.
  if(hash = parseHash()) {
    if(hash['page']) {
      var href = jQuery('a[name="'+hash['page']+'"]').attr('href');
      var rel = jQuery('a[name="'+hash['page']+'"]').attr('rel');
      var slide = hash['slide'];
      loadPage(href, rel, slide);
      updateActive(href, rel);
    }
  }

});

/* UTILITY FUNCTIONS FOR OVERLAY MANAGEMENT */

// function to update which link is active for styling purposes
function updateActive(href, rel) {
  // remove active from all links
  jQuery('a.active').removeClass('active');
  // add active to all links with same destination
  jQuery('a[href= "'+href+'"]').addClass('active');
  // add active to main level link regardless of destination
  jQuery('#main-menu a[name="'+rel+'"]').addClass('active');
}

// function to update the Hash after the content has changed
function updateHash(page, slide) {
  var list = 'page='+page;                     // add page hash
  if (slide) list += '&slide='+slide;          // add slide hash if exists
  window.location.hash = list;                 // update hash
}

// function to load the content via ajax and animate the overlay
function loadPage(href, rel, slide) {
  // fade out current content
  jQuery('#overlay').stop(true,true).fadeTo('fast', 0, function() {
    // determine if overlay is already open, if not animate it immediately
    if (!jQuery('#overlay-wrap').height()) {
      jQuery('#overlay-wrap').stop(true,true).animate({ height: 200 }, 'slow');
    }
/** 
*   NOTE: the additional '?overlay=true' is added to trigger the new 
*   templates in the template.php file
**/
    jQuery('#overlay').load( href+'?overlay=true', function () {
      if (slide) jQuery('#'+slide).click();  // cycle to correct slide
      // animate the height to fit the new content (within callback after load)
      jQuery('#overlay-wrap').stop(true,true).animate({ 
        height: jQuery('#overlay').height()+10 
      }, 'fast', function() { 
        jQuery('#overlay').stop(true,true).fadeTo('fast', 1.0); // fade in
      });
    });
  });
}

// function to extract data from the existing hash
function parseHash() {
  hash = window.location.hash.substring(1);   // load hash string
  if (!hash) return false;                    // return false if no hash
  var varlist= hash.split("&");               // break hash into variables
  for (i=0; i < varlist.length; i++) {        // cycle through variables
    var vars = varlist[i].split("=");         // split variable name from value
    varlist[vars[0]] = vars[1];               // assign variable value to array
                                              // indexed by variable name
  }
  return varlist;                             // return variable array
}

// function to close the overlay
function closePage() {
  jQuery('#overlay').stop(true,true).fadeTo('fast', 0, function() {
    jQuery('#overlay-wrap').stop(true,true).animate({
      height: 0
    }, 'fast', function() {
      jQuery('#overlay').html('');
      jQuery('a.active').removeClass('active');
      window.location.hash = '';
    });
  });
}
mytheme/html--overlay.tpl.php
<div id="overlay-close"></div>
<?php print $page; ?>
<script type="text/javascript">
  // close button
  jQuery('#overlay-close').click(function() { closePage() });
</script>
mytheme/page--overlay.tpl.php
<div class="overlay-content">
  <?php print render($page['content']); ?>
</div>
mytheme/page.tpl.php (add this code where you want the modal overlay to be located)
<div id="overlay-wrap"><div id="overlay"></div></div>

Comments

Thanks for sharing your solution.  I recently did something similar but with about twice as much code. FYI,  you can also avoid needing the Menu Attributes module by adding a preprocess function that adds the necessary classes to your menu items. 

@Ken: Thanks for the suggestion.  In my case, the overlay links are not on all menu links, so there needs to be more selective control than using a preprocess function would allow.  If you are going for all menu links, then you don't even need a preprocess function as you can make the selector just hit every link within the menu structure.

I have been searching for a way to achieve this for some time, thank you for sharing!

Exactly what I was looking for Thx!

Jason, I would to thank you a million times. Thank you very much for sharing your idea!

Keep up that good work!

Thanks a lot for this !

I'm now trying to make this work generating my links within views.

I'm rewriting my links using "Output this field as a link", but couldn't find how to add a "name" attribute to make dynamic path to work.

Any idea ? Thanks again !

I can't get this to work. Should all files go in the root of the theme including script.js?

Sorry for the delayed response.  I hope you've got your problems sorted out by now, but if not here are some answers:

@jm: the name attribute is just a placeholder to differentiate this link from the other overlay links and to describe the page in the url in a friendly way.  You could probably make use of the "title" attribute to do the same thing.  Just swap out all uses of the name attribute with the title attribute.

@Peter: This will depend on your theme and how you have implemented it.  The template.php file is always in the theme root folder (as far as I have seen).  The various "tpl.php" files can either be in the theme root or optionally in a folder called "templates".  Place them wherever your theme keeps the other files of that type.  The javascript file can be located pretty much anywhere as long as it is added to your theme in the ".info" file.  Normally the "scripts.js" file is located in the theme root or a subfolder named "js", but since you manually add it with the callout in the theme.info file, you can really decide wherever makes sense for your theme.

Thank You !

Echoing everyone else's sentiments: THANK YOU! Wonderfully executed.

One note: Perhaps I missed it somewhere in your explanation, but it took me a while to figure out that I had to add (something like) this to my theme's page.tpl.php in order to get the content to actually show up: 

<div id="overlay-wrap">     <div id="overlay" class="clearfix">&nbsp;</div> </div>

@Derek, yes thank you for clarifying.  I will add that to the post to make sure no one else has to learn that the hard way. 

I see it's probably a good solution for my website. I created views gallery and would like to open node content when user click an image but it not clear for me how to do it in views. Is it possible?

So, I created a link in my views, now it works, thank you! Could you give me a clue what to include in hash in case I'd like to group my images in overlay like lightbox does? If it's possible, of course.

I was trying to accomplish something very similar and I ended up discovering this excellent approach to the matter.

One question related to the manipulations of the hastag for reflecting which overlay page is currently opened: Have you ever considered how the HTML5 History API would be inserted in your approach? 

@ink p., Interesting idea.  I have yet to use the History API, but a quick look at how it works makes me think it would be simple to implement.  Basically you would want to replace each appearance of:

window.location.hash = list;

with:

history.pushState(null, null, URI);

where the URI variable is set to the new URI with hashtag included.  Then you would need to add an event binding for the popstate event:

jQuery(window).bind('popstate', function() {
  if(hash = parseHash()) {
    if(hash['page']) {
      var href = jQuery('a[name="'+hash['page']+'"]').attr('href');
      var rel = jQuery('a[name="'+hash['page']+'"]').attr('rel');
      var slide = hash['slide'];
      loadPage(href, rel, slide);
      updateActive(href, rel);
    }
  }
});

There might be some other details to work out, but I think that's the bulk of it.

Nice approach. I am however confused about which overlay you are aiming to use here (default Drupal overlay? Jquery UI?). After a quick try with your code, my ccontent is properly loaded through AJAX, but within the page, not in an overlay. What am i missing? 

thanks

@Alioso: As written, the content is being dynamically loaded into the '#overlay' div wherever you have placed it within your page template.  Because of the many diverse ways of displaying modal content, this tutorial does not really handle the functionality of the modal, so it would be up to you to handle the style sheets and javascript to give your modal the proper functionality and appearance.  In my live example the modal is more of a sliding drop down tray as part of the site menu structure.  It is probably more common to employ a traditional modal with an absolutely positioned top layer.  It would also be possible to combine this ajax content loading with an off-the-shelf modal plugin like lightbox.

Thank you so much, this is working like a charm. I have 1 issue though, for a specific case. I am opening a /node/add/content_type in a modal this way, but when someone uses the image upload it refreshes, jumps out of the overlay and into a blank page. It's still calling the tpl files and renders what I would want in the modal, it's just not staying in the modal. It is loading the url of /node/add/content_type?overlay=true

Any thoughts on that?

Thanks!

@Jeff: This method relies on the idea that we can intercept all links that we want to load in the overlay, and instead of updating the browser location, force the destination to be AJAX loaded into the overlay.  It sounds to me like the image upload field in your content type has a built-in page redirect, which is not refreshing the ajax loaded region, but rather the whole page.  In order to make this work it sounds like you will have to determine what javascript is running when the upload is fired and either override it or modify it to update only the overlay region.

@Jason - I figured it out. It's as simple as adding <?php print $scripts; ?> to the html--overlay.tpl.php file. The Ajax functions for file uploads didn't exist, thus it broke itself. It works now.

Thanks for the help.

Hi,

This example was great. I applied it on my portfolio and added a loading animation to it, but there's a problem. After you load a few pages the page freezes, like the cache is getting out of hand on your browser or serverside. What way is there to basically flush out the content before the next round comes in?

@ Rcls: This approach is using a standard jQuery Ajax request and simply overwriting the existing content with the new content.  There should be no cache overload going on here.  I have tested this code extensively and never experienced this problem.  Do you get any errors in the console inspector?  That's where I always start my debugging.

Hey,

I just wanna thank you for the debugging stuff. It helped me find the root of this problem which was the GET method was pulling out the headers and all from the page, which meant that the preprocess_page HTML was not loading and I found out that it was because I had $variables, not $vars on my function. Fixed now and loading works as intended. Thank you!

To stop the page from scrolling to the top after closePage(), change:

window.location.hash = '';

to:

window.location.hash = 'none';

Hi Jason. thank you so much. your instruction was help me alot. so. there are some problems. i'm using drupal7x then, when i load a content page via your code, some jquery plugin did not load through( like CKEditor or my custom jquerys..)

when debug with chrome, i checked in header tag, and no jquery script tag has been loaded...

my english is not good very much, hope you can understand the problem i'm facing.

p/s: i have 4 months experience with drupal, and 2 weeks with jquery (start when i read your instruction :D to know how to use your code)

please help..

some things were wrong...the truth is ckeditor jquery plugin has not been loaded and my custom jquery to hide filter tip has been loaded but take no effect.

my custom jquery code:

<code>jQuery(document).ready(function () {

jQuery(".filter-wrapper").hide();

});</code>

p/s: i tested with node/add/content_type url

@thanh: In order to get any javascript files to load into the <head> of the document, you must have it included when the page loads.  Any scripts that are added along with dynamic content must have the javascript simply be inline with the new content.  It would be much easier to see your problem if you would link to your site. 

@jason: you can go into http://tantam.com.vn

i set new basic page link is load via overlay and article is normal load

check both of them and you will see poorly editor in overlay

all i do is copy all of your code then insert a line code in page.tpl.php: <?php drupal_add_js(path_to_theme().'/script.js','file');?>

hope you can solve this.

ps: the editor i'm using is wysiwyg and ckeditor 3.6.6.7568 in libraries

@thanh: The problem is that CKEditor automatically processes textareas and adds the editor interface on page load.  The "create basic content" node is then added dynamically, and the CKEditor module does not get triggered to add the interface to the added textarea.  You will need to find a way to trigger the CKEditor to process the newly added textarea after the ajax action is complete.

Here is a link to someone else's solution to this problem: http://joe-riggs.com/blog/2012/11/drupal-ckeditor-load-configuration-settings-on-dynamic-page/

FYI, you will also need to make sure that the CKEditor javascript and css files are loaded on the page as well (they are already loaded if you are on the "add article" page when you click "add basic page", but not if you are on "home" and click "add basic page" if you want to see the difference).

thank you so much.

so i discorvered if i disable html__overlay. it means i use only page--overlay template then ckeditor is loaded nearly perfect.

i read code carefully but don't know why.

and do u have any experience with ctools modal form?

if yes, the questions is: should i use it for create modal form?

i got some troubles when using ctools modal api, maybe.. it is troubles with ajax, json and drupal :(

@thanh: Sorry, I have no experience using ctools for modal forms.  There shouldn't be any problems using ajax, json, and drupal together, they are all complementary technologies.

Hi, It seemed a bit laggy, I have to wait a few seconds before modal opening. You can check it here: http://fabienlefrapper.me/cinelatin/programme (by clicking on a picture).

I have another problem, the url doesn't change properly, it displays 'page=undefined'. The content I load in the modal is a view. I'm a bit new to preprocess function and jquery, so I don't really know where does these issues come from.

Thanks ;)

By the way, thank you very much for this little tutorial, it helped me a lot !

Thank you for this tutorial it was very useful for me!

@Fabien you must edit the js code and change it in the the corresponding line for something like this:  var name=jQuery(this).attr('title');

Change 'name' with 'title', then edit your view and give the "title" the value you want. Read the previous comments!

 

@Kundu Thank you very much, I'm gonna try it right now. I read all the comments, but I didn't really understand how to figure out. 

So, it worked. But I still have some problems, @Kundu, do you have a link I can check to see how it works on your installation ? 

Mine seems really laggy. I think that it comes from the content I'm trying to load into the modal (some pictures and text), but maybe I can speed it up via the .js file ?

Thanks again

@Fabien, glad you got the url updating solved. I just checked your site linked above. It looks like it is currently in a non-functioning state. There are multiple jquery plugin files that are returning 404 errors followed by a breaking error which stops the javascript from finishing loading.

With regard to the load speed feeling laggy, it all depends on the server response to an ajax call requesting the overlay content. They way I built it should actually open the overlay immediately and then only resize it to fit the content after the ajax call is complete. If you feel that your server response is slower than you would like I would suggest adding a "spinner" or other loading graphic temporarily within the overlay that is then removed when you inject the content.

Unfortunately the original customer site that this was created for has since redesigned their website and so it no longer exists as an example to show you.

Thank you very much Jason for answering that quickly. My code is a bit dirty, I'm gonna clean it first. 

Then, I'll try to understand why the content is that long to load

what if I need to close modal clicking just outside modal, not close button

@mavo: using the existing markup in this solution you could style #overlay-close to be a full screen backdrop that is z-indexed behind #overlay-content, either with a semi-transparent background as traditional modals do, or however you want it to appear. You can also add a document binding to the keyup event so that the escape key will close the modal too, which I have found to be good practice.

This comment thread has been getting nothing but spam for a few months now so I am going to close the comments. If you have any questions or feedback about this post please use the generic contact form in the header.

Thanks!