YUI 3.x Home -

YUI Library Examples: MenuNav Node Plugin: Adding Submenus On The Fly

MenuNav Node Plugin: Adding Submenus On The Fly

This example demonstrates how to use the IO Utility to add submenus to a menu built using the MenuNav Node Plugin.

Design Goal

This menu will be created using the Progressive Enhancement design pattern, so that the accessibility of the menu can be tailored based on the capabilities of the user's browser. The goal is to design a menu that satisfies each of the following use cases:

Browser Grade Technologies User Experience
C HTML The user is using a browser for which CSS and JavaScript are being withheld.
A HTML + CSS The user is using an A-Grade browser, but has chosen to disable JavaScript.
A HTML + CSS + JavaScript The user is using an A-Grade browser with CSS and JavaScript enabled.
A HTML + CSS + JavaScript + ARIA The user is using an ARIA-capable, A-Grade browser with CSS and JavaScript enabled.

The MenuNav Node Plugin helps support most of the these use cases out of the box. By using an established, semantic, list-based pattern for markup, the core, C-grade experience is easily cemented using the MenuNav Node Plugin. Using JavaScript, the MenuNav Node Plugin implements established mouse and keyboard interaction patterns to deliver a user experience that is both familiar and easy to use, as well as support for the WAI-ARIA Roles and States, making it easy to satisfy the last two use cases. The second is the only use case that is not handled out of the box when using the MenuNav Node Plugin.

One common solution to making a menuing system work when CSS is enabled, but JavaScript is disabled is to leverage the :hover and :focus pseudo classes to provide support for both the mouse and the keyboard. However, there are a couple of problems with this approach:

Inconsistent Browser Support
IE 6 only supports the :hover and :focus pseudo classes on <a> elements. And while IE 7 supports :hover on all elements, it only supports :focus pseudo class on <a> elements. This solution won't work if the goal is to provide a consistent user experience across all of the A-grade browsers when JavaScript is disabled.
Poor User Experience
Even if the :hover and :focus pseudo classes were supported consistently across all A-grade browsers, it would be a solution that would work, but wouldn't work well. Use of the :focus pseudo class to enable keyboard support for a menu results in an unfamiliar, potentially cumbersome experience for keyboard users. Having a menu appear in response to its label simply being focused isn't an established interaction pattern for menus on the desktop, and implementing that pattern could result in menus that popup unexpectedly, and as a result, have the potential to get in the user's way. While use of the :hover pseudo class can be used to show submenus in response to a mouseover event, it doesn't allow the user to move diagonally from a label to its corresponding submenu — an established interaction pattern that greatly improves a menu's usability.
Bloats Code
Relying on :hover and :focus as an intermediate solution when JavaScript is disabled adds bloat to a menu's CSS. And relying on these pseudo classes would also likely mean additional code on the server to detect IE, so that submenu HTML that is inaccessible to IE users with JavaScript disabled is not delivered to the browser.

As the functionality for displaying submenus cannot be implemented in CSS to work consistently and well in all A-grade browsers, then that functionality is better implemented using JavaScript. And if submenus are only accessible if JavaScript is enabled, then it is best to only add the HTML for submenus via JavaScript. Adding submenus via JavaScript has the additional advantage of speeding up the initial load time of a page.

Approach

The approach for this menu will be to create horizontal top navigation that, when JavaScript is enabled, is enhanced into split buttons. The content of each submenu is functionality that is accessible via the page linked from the anchor of each submenu's label. Each submenu is purely sugar — a faster means of accessing functionality that is accessible via another path.

Setting Up the HTML

Start by providing the markup for the root horizontal menu, following the pattern outlined in the Split Button Top Nav example, minus the application of the yui-splitbuttonnav class to the menu's bounding box, the markup for the submenus, and the <a href="…" class="yui-menu-toggle"> elements inside each label that toggle each submenu's display. Include the MenuNav Node Plugin CSS in the <head> so that menu is styled even if JS is disabled. The following illustrates what the initial menu markup:

  1. <div id="productsandservices" class="yui-menu yui-menu-horizontal">
  2. <div class="yui-menu-content">
  3. <ul>
  4. <li>
  5. <span id="answers-label" class="yui-menu-label">
  6. <a href="http://answers.yahoo.com">Answers</a>
  7. </span>
  8. </li>
  9. <li>
  10. <span id="flickr-label" class="yui-menu-label">
  11. <a href="http://www.flickr.com">Flickr</a>
  12. </span>
  13. </li>
  14. <li>
  15. <span id="mobile-label" class="yui-menu-label">
  16. <a href="http://mobile.yahoo.com">Mobile</a>
  17. </span>
  18. </li>
  19. <li>
  20. <span id="upcoming-label" class="yui-menu-label">
  21. <a href="http://upcoming.yahoo.com/">Upcoming</a>
  22. </span>
  23. </li>
  24. <li>
  25. <span id="forgood-label" class="yui-menu-label">
  26. <a href="http://forgood.yahoo.com/index.html">Yahoo! for Good</a>
  27. </span>
  28. </li>
  29. </ul>
  30. </div>
  31. </div>
<div id="productsandservices" class="yui-menu yui-menu-horizontal">
    <div class="yui-menu-content">
        <ul>
            <li>
                <span id="answers-label" class="yui-menu-label">
                    <a href="http://answers.yahoo.com">Answers</a>
                </span>
            </li>
            <li>
                <span id="flickr-label" class="yui-menu-label">
                    <a href="http://www.flickr.com">Flickr</a>
                </span>
            </li>
            <li>
                <span id="mobile-label" class="yui-menu-label">
                    <a href="http://mobile.yahoo.com">Mobile</a>
                </span>
            </li>
            <li>
                <span id="upcoming-label" class="yui-menu-label">
                    <a href="http://upcoming.yahoo.com/">Upcoming</a>
                </span>
            </li>
            <li>
                <span id="forgood-label" class="yui-menu-label">
                    <a href="http://forgood.yahoo.com/index.html">Yahoo! for Good</a>
                </span>
            </li>
        </ul>
    </div>
</div>

Setting Up the script

With the core markup for the menu in place, JavaScript will be responsible for transforming the simple horizontal menu into top navigation rendered like split buttons. The script will appended a submenu toggle to each menu label as well as add the yui-splitbuttonnav class to the menu's bounding box. Each submenu's label will be responsible for creating its corresponding submenu the first time its display is requested by the user. The content of each submenu is fetched asynchronously using Y.io.

  1. // Call the "use" method, passing in "node-menunav". This will load the
  2. // script and CSS for the MenuNav Node Plugin and all of the required
  3. // dependencies.
  4.  
  5. YUI({base:"../../build/", timeout: 10000}).use("node-menunav", "io", function(Y) {
  6.  
  7. var applyARIA = function (menu) {
  8.  
  9. var oMenuLabel,
  10. oMenuToggle,
  11. sID;
  12.  
  13. menu.set("role", "menu");
  14.  
  15. oMenuLabel = menu.previous();
  16. oMenuToggle = oMenuLabel.one(".yui-menu-toggle");
  17.  
  18. if (oMenuToggle) {
  19. oMenuLabel = oMenuToggle;
  20. }
  21.  
  22. sID = Y.stamp(oMenuLabel);
  23.  
  24. if (!oMenuLabel.get("id")) {
  25. oMenuLabel.set("id", sID);
  26. }
  27.  
  28. menu.set("aria-labelledby", sID);
  29.  
  30. menu.all("ul,li,.yui-menu-content").set("role", "presentation");
  31.  
  32. menu.all(".yui-menuitem-content").set("role", "menuitem");
  33.  
  34. };
  35.  
  36.  
  37. var onIOComplete = function (transactionID, response, submenuNode) {
  38.  
  39. var sHTML = response.responseText;
  40.  
  41. submenuNode.one(".yui-menu-content").set("innerHTML", sHTML);
  42. submenuNode.one("ul").addClass("first-of-type");
  43.  
  44. applyARIA(submenuNode);
  45.  
  46. // Need to set the width of the submenu to "" to clear it, then to nothing
  47. // (or the offsetWidth for IE < 8) so that the width of the submenu is
  48. // rendered correctly, otherwise the width will be rendered at the width
  49. // before the new content for the submenu was loaded.
  50.  
  51. submenuNode.setStyle("width", "");
  52.  
  53. if (Y.UA.ie && Y.UA.ie < 8) {
  54. submenuNode.setStyle("width", (submenuNode.get("offsetWidth") + "px"));
  55. }
  56.  
  57.  
  58. var oAnchor = submenuNode.one("a");
  59.  
  60. if (oAnchor) {
  61. oAnchor.focus();
  62. }
  63.  
  64. };
  65.  
  66.  
  67. var addSubmenu = function (event, submenuIdBase) {
  68.  
  69. var sSubmenuId = submenuIdBase + "-options",
  70. bIsKeyDown = (event.type === "keydown"),
  71. nKeyCode = event.keyCode,
  72. sURI;
  73.  
  74.  
  75. if ((bIsKeyDown && nKeyCode === 40) ||
  76. (event.target.hasClass("yui-menu-toggle") &&
  77. (event.type === "mousedown" || (bIsKeyDown && nKeyCode === 13)))) {
  78.  
  79. // Build the bounding box and content box for the submenu and fill
  80. // the content box with a "Loading..." message so that the user
  81. // knows the submenu's content is in the process of loading.
  82.  
  83. this.get("parentNode").append('<div id="' + sSubmenuId + '" class="yui-menu yui-menu-hidden"><div class="yui-menu-content"><p>Loading&#8230;</p></div></div>');
  84.  
  85.  
  86. // Use Y.io to fetch the content of the submenu
  87.  
  88. sURI = "assets/submenus.php?menu=" + sSubmenuId;
  89.  
  90. Y.io(sURI, { on: { complete: onIOComplete }, arguments: Y.one(("#" + sSubmenuId)) });
  91.  
  92.  
  93. // Detach event listeners so that this code runs only once
  94.  
  95. this.detach("mousedown", addSubmenu);
  96. this.detach("keydown", addSubmenu);
  97.  
  98. }
  99.  
  100. };
  101.  
  102.  
  103. // Retrieve the Node instance representing the root menu
  104. // (<div id="productsandservices">)
  105.  
  106. var menu = Y.one("#productsandservices");
  107.  
  108. menu.addClass("yui-splitbuttonnav");
  109.  
  110.  
  111. var oSubmenuToggles = {
  112. answers: { label: "Answers Options", url: "#answers-options" },
  113. flickr: { label: "Flickr Options", url: "#flickr-options" },
  114. mobile: { label: "Mobile Options", url: "#mobile-options" },
  115. upcoming: { label: "Upcoming Options", url: "#upcoming-options" },
  116. forgood: { label: "Yahoo! for Good Options", url: "#forgood-options" }
  117. },
  118.  
  119. sKey,
  120. oToggleData,
  121. oSubmenuToggle;
  122.  
  123.  
  124. // Add the menu toggle to each menu label
  125.  
  126. menu.all(".yui-menu-label").each(function(node) {
  127.  
  128. sKey = node.get("id").split("-")[0];
  129.  
  130. oToggleData = oSubmenuToggles[sKey];
  131.  
  132. oSubmenuToggle = Y.Node.create('<a class="yui-menu-toggle">' + oToggleData.label + '</a>');
  133.  
  134. // Need to set the "href" attribute via the "set" method as opposed to
  135. // including it in the string passed to "Y.Node.create" to work around a
  136. // bug in IE. The MenuNav Node Plugin code examines the "href" attribute
  137. // of all <A>s in a menu. To do this, the MenuNav Node Plugin retrieves
  138. // the value of the "href" attribute by passing "2" as a second argument
  139. // to the "getAttribute" method. This is necessary for IE in order to get
  140. // the value of the "href" attribute exactly as it was set in script or in
  141. // the source document, as opposed to a fully qualified path. (See
  142. // http://msdn.microsoft.com/en-gb/library/ms536429(VS.85).aspx for
  143. // more info.) However, when the "href" attribute is set inline via the
  144. // string passed to "Y.Node.create", calls to "getAttribute('href', 2)"
  145. // will STILL return a fully qualified URL rather than the value of the
  146. // "href" attribute exactly as it was set in script.
  147.  
  148. oSubmenuToggle.set("href", oToggleData.url);
  149.  
  150.  
  151. // Add a "mousedown" and "keydown" listener to each menu label that
  152. // will build the submenu the first time the users requests it.
  153.  
  154. node.on("mousedown", addSubmenu, node, sKey);
  155. node.on("keydown", addSubmenu, node, sKey);
  156.  
  157. node.appendChild(oSubmenuToggle);
  158.  
  159. });
  160.  
  161.  
  162. // Call the "plug" method passing in a reference to the
  163. // MenuNav Node Plugin.
  164.  
  165. menu.plug(Y.Plugin.NodeMenuNav, { autoSubmenuDisplay: false, mouseOutHideDelay: 0 });
  166.  
  167. });
//  Call the "use" method, passing in "node-menunav".  This will load the
//  script and CSS for the MenuNav Node Plugin and all of the required
//  dependencies.
 
YUI({base:"../../build/", timeout: 10000}).use("node-menunav", "io", function(Y) {
 
    var applyARIA = function (menu) {
 
        var oMenuLabel,
            oMenuToggle,
            sID;
 
        menu.set("role", "menu");
 
        oMenuLabel = menu.previous();
        oMenuToggle = oMenuLabel.one(".yui-menu-toggle");
 
        if (oMenuToggle) {
            oMenuLabel = oMenuToggle;
        }
 
        sID = Y.stamp(oMenuLabel);
 
        if (!oMenuLabel.get("id")) {
            oMenuLabel.set("id", sID);
        }
 
        menu.set("aria-labelledby", sID);
 
        menu.all("ul,li,.yui-menu-content").set("role", "presentation");
 
        menu.all(".yui-menuitem-content").set("role", "menuitem");
 
    };
 
 
    var onIOComplete = function (transactionID, response, submenuNode) {
 
        var sHTML = response.responseText;
 
        submenuNode.one(".yui-menu-content").set("innerHTML", sHTML);
        submenuNode.one("ul").addClass("first-of-type");
 
        applyARIA(submenuNode);
 
        //  Need to set the width of the submenu to "" to clear it, then to nothing
        //  (or the offsetWidth for IE < 8) so that the width of the submenu is
        //  rendered correctly, otherwise the width will be rendered at the width
        //  before the new content for the submenu was loaded.
 
        submenuNode.setStyle("width", "");
 
        if (Y.UA.ie && Y.UA.ie < 8) {
            submenuNode.setStyle("width", (submenuNode.get("offsetWidth") + "px"));
        }
 
 
        var oAnchor = submenuNode.one("a");
 
        if (oAnchor) {
            oAnchor.focus();
        }
 
    };
 
 
    var addSubmenu = function (event, submenuIdBase) {
 
        var sSubmenuId = submenuIdBase + "-options",
            bIsKeyDown = (event.type === "keydown"),
            nKeyCode = event.keyCode,
            sURI;
 
 
        if ((bIsKeyDown && nKeyCode === 40) ||
            (event.target.hasClass("yui-menu-toggle") &&
            (event.type === "mousedown" || (bIsKeyDown && nKeyCode === 13)))) {
 
            //  Build the bounding box and content box for the submenu and fill
            //  the content box with a "Loading..." message so that the user
            //  knows the submenu's content is in the process of loading.
 
            this.get("parentNode").append('<div id="' + sSubmenuId + '" class="yui-menu yui-menu-hidden"><div class="yui-menu-content"><p>Loading&#8230;</p></div></div>');
 
 
            //  Use Y.io to fetch the content of the submenu
 
            sURI = "assets/submenus.php?menu=" + sSubmenuId;
 
            Y.io(sURI, { on: { complete: onIOComplete }, arguments: Y.one(("#" + sSubmenuId)) });
 
 
            //  Detach event listeners so that this code runs only once
 
            this.detach("mousedown", addSubmenu);
            this.detach("keydown", addSubmenu);
 
        }
 
    };
 
 
    //  Retrieve the Node instance representing the root menu
    //  (<div id="productsandservices">)
 
    var menu = Y.one("#productsandservices");
 
    menu.addClass("yui-splitbuttonnav");
 
 
    var oSubmenuToggles = {
            answers: { label: "Answers Options", url: "#answers-options" },
            flickr: { label: "Flickr Options", url: "#flickr-options" },
            mobile: { label: "Mobile Options", url: "#mobile-options" },
            upcoming: { label: "Upcoming Options", url: "#upcoming-options" },
            forgood: { label: "Yahoo! for Good Options", url: "#forgood-options" }
        },
 
        sKey,
        oToggleData,
        oSubmenuToggle;
 
 
    //  Add the menu toggle to each menu label
 
    menu.all(".yui-menu-label").each(function(node) {
 
        sKey = node.get("id").split("-")[0];
 
        oToggleData = oSubmenuToggles[sKey];
 
        oSubmenuToggle = Y.Node.create('<a class="yui-menu-toggle">' + oToggleData.label + '</a>');
 
        //  Need to set the "href" attribute via the "set" method as opposed to
        //  including it in the string passed to "Y.Node.create" to work around a
        //  bug in IE.  The MenuNav Node Plugin code examines the "href" attribute
        //  of all <A>s in a menu.  To do this, the MenuNav Node Plugin retrieves
        //  the value of the "href" attribute by passing "2" as a second argument
        //  to the "getAttribute" method.  This is necessary for IE in order to get
        //  the value of the "href" attribute exactly as it was set in script or in
        //  the source document, as opposed to a fully qualified path.  (See
        //  http://msdn.microsoft.com/en-gb/library/ms536429(VS.85).aspx for
        //  more info.)  However, when the "href" attribute is set inline via the
        //  string passed to "Y.Node.create", calls to "getAttribute('href', 2)"
        //  will STILL return a fully qualified URL rather than the value of the
        //  "href" attribute exactly as it was set in script.
 
        oSubmenuToggle.set("href", oToggleData.url);
 
 
        //  Add a "mousedown" and "keydown" listener to each menu label that
        //  will build the submenu the first time the users requests it.
 
        node.on("mousedown", addSubmenu, node, sKey);
        node.on("keydown", addSubmenu, node, sKey);
 
        node.appendChild(oSubmenuToggle);
 
    });
 
 
    //  Call the "plug" method passing in a reference to the
    //  MenuNav Node Plugin.
 
    menu.plug(Y.Plugin.NodeMenuNav, { autoSubmenuDisplay: false, mouseOutHideDelay: 0 });
 
});
Note: In keeping with the Exceptional Performance team's recommendation, the script block used to instantiate the menu will be placed at the bottom of the page. This not only improves performance, it helps ensure that the DOM subtree of the element representing the root menu (<div id="productsandservices">) is ready to be scripted.

Copyright © 2009 Yahoo! Inc. All rights reserved.

Privacy Policy - Terms of Service - Copyright Policy - Job Openings