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. +
+ ++ + + View example in new window. + + +
+ + +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
:hoverand:focuspseudo classes on +<a>elements. And while IE 7 supports:hoveron all + elements, it only supports:focuspseudo 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
:hoverand:focuspseudo 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:focuspseudo 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 +:hoverpseudo class can be used to show submenus in response to a +mouseoverevent, 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
:hoverand:focusas 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:
+
<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>
<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.
+
// 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…</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 }); });
// 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…</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 }); + +});

