Focus Manager Node Plugin: Accessible Menu Button
+ ++This example illustrates how to use the Focus Manager Node Plugin, +Event's delegation support and +mouseenter event, along with +the Overlay widget and Node's support for the +WAI-ARIA Roles and States to +create an accessible menu button. +
Setting Up the HTML
+
+For a menu button, start with an <a> element whose
+href attribute is set to the id of an <div>
+that wraps a list of <input> elements.
+Therefore, without JavaScript and CSS, the menu button is simply an in-page
+link to a set of additional buttons.
+
<a id="button-1" href="#menu-1"><span><span>Move To</span></span></a> <div id="menu-1"> <ul> <li><input type="button" name="button-1" value="Inbox"></li> <li><input type="button" name="button-2" value="Archive"></li> <li><input type="button" name="button-3" value="Trash"></li> </ul> </div>
<a id="button-1" href="#menu-1"><span><span>Move To</span></span></a> +<div id="menu-1"> + <ul> + <li><input type="button" name="button-1" value="Inbox"></li> + <li><input type="button" name="button-2" value="Archive"></li> + <li><input type="button" name="button-3" value="Trash"></li> + </ul> +</div>
Progressive Enhancement
+
+The markup above will be transformed using both CSS and JavaScript. To account
+for the scenario where the user has CSS enabled in their browser but JavaScript
+is disabled, the CSS used to style the menu button will be loaded via JavaScript
+using the YUI instance's built-in Loader.
+Additionally, a small block of JavaScript will be placed in the
+<head> used to temporarily hide the markup
+while the JavaScript and CSS are in the process of loading to prevent the user
+from seeing a flash unstyled content.
+
YUI({ base:"../../build/", timeout: 10000, modules: { "menubuttoncss": { type: "css", fullpath: "assets/menubutton.css" }, "menubuttonjs": { type: "js", fullpath: "assets/menubutton.js", requires: ["menubuttoncss", "node-focusmanager", "node-event-simulate", "overlay"] } } }).use("menubuttonjs", function(Y, result) { // The callback supplied to use() will be executed regardless of // whether the operation was successful or not. The second parameter // is a result object that has the status of the operation. We can // use this to try to recover from failures or timeouts. if (!result.success) { Y.log("Load failure: " + result.msg, "warn", "Example"); // Show the menu button HTML if loading the JavaScript resources // failed, that way the original unskinned menu button // will be visible so that the user can interact with the menu // either way. document.documentElement.className = ""; } });
YUI({ + base:"../../build/", + timeout: 10000, + modules: { + "menubuttoncss": { + type: "css", + fullpath: "assets/menubutton.css" + }, + "menubuttonjs": { + type: "js", + fullpath: "assets/menubutton.js", + requires: ["menubuttoncss", "node-focusmanager", "node-event-simulate", "overlay"] + } + } + +}).use("menubuttonjs", function(Y, result) { + + // The callback supplied to use() will be executed regardless of + // whether the operation was successful or not. The second parameter + // is a result object that has the status of the operation. We can + // use this to try to recover from failures or timeouts. + + if (!result.success) { + + Y.log("Load failure: " + result.msg, "warn", "Example"); + + // Show the menu button HTML if loading the JavaScript resources + // failed, that way the original unskinned menu button + // will be visible so that the user can interact with the menu + // either way. + + document.documentElement.className = ""; + + } + +});
ARIA Support
++Through the use of CSS and JavaScript the HTML for the menu button can be +transformed into something that looks and behaves like a desktop menu button, +but users of screen readers won't perceive it as an atomic widget, but rather +simply as a set of HTML elements. However, through the application +of the +WAI-ARIA Roles and States, it is +possible to improve the semantics of the markup such that users of screen +readers perceive it as a menu button control. +
+ + +Keyboard Functionality
+
+The keyboard functionality for the button's menu will be provided by the Focus
+Manager Node Plugin. The Focus Manager's
+descendants
+attribute is set to a value of "input", so that only one menuitem in the
+button's menu is in the browser's default tab flow. This allows users
+navigating via the keyboard to use the tab key to quickly move into and out of
+the menu. Once the menu has focus, the user can move focus among each menuitem
+using the up and down arrows keys, as defined by the value of the
+keys
+attribute. The
+focusClass
+attribute is used to apply a class of yui-menuitem-active to
+the parent <li> of each
+<input> when it is focused, making it easy to style the
+menuitem's focused state in each of the
+A-Grade browsers.
+
+
YUI().use("*", function (Y) { var menuButton = Y.one("#button-1"), menu; var initMenu = function () { menu = new Y.Overlay({ contentBox: "#menu-1", visible: false, tabIndex: null }); menu.render(); Y.one("#menu-1").setStyle("display", ""); var boundingBox = menu.get("boundingBox"), contentBox = menu.get("contentBox"); boundingBox.addClass("yui-buttonmenu"); contentBox.addClass("yui-buttonmenu-content"); // Append a decorator element to the bounding box to render the shadow. boundingBox.append('<div class="yui-menu-shadow"></div>'); // Apply the ARIA roles, states and properties to the menu. boundingBox.setAttrs({ role: "menu", "aria-labelledby": menuButton.get("id") }); boundingBox.all("input").set("role", "menuitem"); // For NVDA: Add the role of "presentation" to each LI // element to prevent NVDA from announcing the // "listitem" role. boundingBox.all("div,ul,li").set("role", "presentation"); // Use the FocusManager Node Plugin to manage the focusability // of each menuitem in the menu. contentBox.plug(Y.Plugin.NodeFocusManager, { descendants: "input", keys: { next: "down:40", // Down arrow previous: "down:38" }, // Up arrow focusClass: { className: "yui-menuitem-active", fn: function (node) { return node.get("parentNode"); } }, circular: true }); // Subscribe to the change event for the "focused" attribute // to listen for when the menu initially gains focus, and // when the menu has lost focus completely. contentBox.focusManager.after("focusedChange", function (event) { if (!event.newVal) { // The menu has lost focus // Set the "activeDescendant" attribute to 0 when the // menu is hidden so that the user can tab from the // button to the first item in the menu the next time // the menu is made visible. this.set("activeDescendant", 0); } }); // Hide the button's menu if the user presses the escape key // while focused either on the button or its menu. Y.on("key", function () { menu.hide(); menuButton.focus(); }, [menuButton, boundingBox] ,"down:27"); if (Y.UA.ie === 6) { // Set the width and height of the menu's bounding box - // this is necessary for IE 6 so that the CSS for the // shadow element can simply set the shadow's width and // height to 100% to ensure that dimensions of the shadow // are always sync'd to the that of its parent menu. menu.on("visibleChange", function (event) { if (event.newVal) { boundingBox.setStyles({ height: "", width: "" }); boundingBox.setStyles({ height: (boundingBox.get("offsetHeight") + "px"), width: (boundingBox.get("offsetWidth") + "px") }); } }); } menu.after("visibleChange", function (event) { var bVisible = event.newVal; // Focus the first item when the menu is made visible // to allow users to navigate the menu via the keyboard if (bVisible) { // Need to set focus via a timer for Webkit and Opera Y.Lang.later(0, contentBox.focusManager, contentBox.focusManager.focus); } boundingBox.set("aria-hidden", (!bVisible)); }); // Hide the menu when one of menu items is clicked. boundingBox.delegate("click", function (event) { alert("You clicked " + this.query("input").get("value")); contentBox.focusManager.blur(); menu.hide(); }, "li"); // Focus each menuitem as the user moves the mouse over // the menu. boundingBox.delegate("mouseenter", function (event) { var focusManager = contentBox.focusManager; if (focusManager.get("focused")) { focusManager.focus(this.query("input")); } }, "li"); // Hide the menu if the user clicks outside of it or if the // user doesn't click on the button boundingBox.get("ownerDocument").on("mousedown", function (event) { var oTarget = event.target; if (!oTarget.compareTo(menuButton) && !menuButton.contains(oTarget) && !oTarget.compareTo(boundingBox) && !boundingBox.contains(oTarget)) { menu.hide(); } }); }; menuButton.addClass("yui-menubutton"); // Hide the list until it is transformed into a menu Y.one("#menu-1").setStyle("display", "none"); // Remove the "yui-loading" class from the documentElement // now that the necessary YUI dependencies are loaded and the // menu button has been skinned. menuButton.get("ownerDocument").get("documentElement").removeClass("yui-loading"); // Apply the ARIA roles, states and properties to the anchor. menuButton.setAttrs({ role: "button", "aria-haspopup": true }); // Remove the "href" attribute from the anchor element to // prevent JAWS and NVDA from reading the value of the "href" // attribute when the anchor is focused. if ((Y.UA.gecko || Y.UA.ie) && navigator.userAgent.indexOf("Windows") > -1) { menuButton.removeAttribute("href"); // Since the anchor's "href" attribute has been removed, the // element will not fire the click event in Firefox when the // user presses the enter key. To fix this, dispatch the // "click" event to the anchor when the user presses the // enter key. Y.on("key", function (event) { menuButton.simulate("click"); }, menuButton, "down:13"); } // Set the "tabIndex" attribute of the anchor element to 0 to // place it in the browser's default tab flow. This is // necessary since 1) anchor elements are not in the default // tab flow in Opera and 2) removing the "href" attribute // prevents the anchor from firing its "click" event // in Firefox. menuButton.set("tabIndex", 0); var showMenu = function (event) { // For performance: Defer the creation of the menu until // the first time the button is clicked. if (!menu) { initMenu(); } if (!menu.get("visible")) { menu.set("align", { node: menuButton, points: [Y.WidgetPositionExt.TL, Y.WidgetPositionExt.BL] }); menu.show(); } // Prevent the anchor element from being focused // when the users mouses down on it. event.preventDefault(); }; // Bind both a "mousedown" and "click" event listener to // ensure the button's menu can be invoked using both the // mouse and the keyboard. menuButton.on("mousedown", showMenu); menuButton.on("click", showMenu); });
YUI().use("*", function (Y) { + + var menuButton = Y.one("#button-1"), + menu; + + + var initMenu = function () { + + menu = new Y.Overlay({ + contentBox: "#menu-1", + visible: false, + tabIndex: null + }); + + menu.render(); + + + Y.one("#menu-1").setStyle("display", ""); + + var boundingBox = menu.get("boundingBox"), + contentBox = menu.get("contentBox"); + + boundingBox.addClass("yui-buttonmenu"); + contentBox.addClass("yui-buttonmenu-content"); + + + // Append a decorator element to the bounding box to render the shadow. + + boundingBox.append('<div class="yui-menu-shadow"></div>'); + + + // Apply the ARIA roles, states and properties to the menu. + + boundingBox.setAttrs({ + role: "menu", + "aria-labelledby": menuButton.get("id") + }); + + boundingBox.all("input").set("role", "menuitem"); + + + // For NVDA: Add the role of "presentation" to each LI + // element to prevent NVDA from announcing the + // "listitem" role. + + boundingBox.all("div,ul,li").set("role", "presentation"); + + + // Use the FocusManager Node Plugin to manage the focusability + // of each menuitem in the menu. + + contentBox.plug(Y.Plugin.NodeFocusManager, { + + descendants: "input", + keys: { next: "down:40", // Down arrow + previous: "down:38" }, // Up arrow + focusClass: { + className: "yui-menuitem-active", + fn: function (node) { + return node.get("parentNode"); + } + }, + circular: true + + }); + + + // Subscribe to the change event for the "focused" attribute + // to listen for when the menu initially gains focus, and + // when the menu has lost focus completely. + + contentBox.focusManager.after("focusedChange", function (event) { + + if (!event.newVal) { // The menu has lost focus + + // Set the "activeDescendant" attribute to 0 when the + // menu is hidden so that the user can tab from the + // button to the first item in the menu the next time + // the menu is made visible. + + this.set("activeDescendant", 0); + + } + + }); + + + // Hide the button's menu if the user presses the escape key + // while focused either on the button or its menu. + + Y.on("key", function () { + + menu.hide(); + menuButton.focus(); + + }, [menuButton, boundingBox] ,"down:27"); + + + if (Y.UA.ie === 6) { + + // Set the width and height of the menu's bounding box - + // this is necessary for IE 6 so that the CSS for the + // shadow element can simply set the shadow's width and + // height to 100% to ensure that dimensions of the shadow + // are always sync'd to the that of its parent menu. + + menu.on("visibleChange", function (event) { + + if (event.newVal) { + + boundingBox.setStyles({ height: "", width: "" }); + + boundingBox.setStyles({ + height: (boundingBox.get("offsetHeight") + "px"), + width: (boundingBox.get("offsetWidth") + "px") }); + + } + + }); + + } + + + menu.after("visibleChange", function (event) { + + var bVisible = event.newVal; + + // Focus the first item when the menu is made visible + // to allow users to navigate the menu via the keyboard + + if (bVisible) { + + // Need to set focus via a timer for Webkit and Opera + Y.Lang.later(0, contentBox.focusManager, contentBox.focusManager.focus); + + } + + boundingBox.set("aria-hidden", (!bVisible)); + + }); + + + // Hide the menu when one of menu items is clicked. + + boundingBox.delegate("click", function (event) { + + alert("You clicked " + this.query("input").get("value")); + + contentBox.focusManager.blur(); + menu.hide(); + + }, "li"); + + + // Focus each menuitem as the user moves the mouse over + // the menu. + + boundingBox.delegate("mouseenter", function (event) { + + var focusManager = contentBox.focusManager; + + if (focusManager.get("focused")) { + focusManager.focus(this.query("input")); + } + + }, "li"); + + + // Hide the menu if the user clicks outside of it or if the + // user doesn't click on the button + + boundingBox.get("ownerDocument").on("mousedown", function (event) { + + var oTarget = event.target; + + if (!oTarget.compareTo(menuButton) && + !menuButton.contains(oTarget) && + !oTarget.compareTo(boundingBox) && + !boundingBox.contains(oTarget)) { + + menu.hide(); + + } + + }); + + }; + + + menuButton.addClass("yui-menubutton"); + + + // Hide the list until it is transformed into a menu + + Y.one("#menu-1").setStyle("display", "none"); + + + // Remove the "yui-loading" class from the documentElement + // now that the necessary YUI dependencies are loaded and the + // menu button has been skinned. + + menuButton.get("ownerDocument").get("documentElement").removeClass("yui-loading"); + + + // Apply the ARIA roles, states and properties to the anchor. + + menuButton.setAttrs({ + role: "button", + "aria-haspopup": true + }); + + + // Remove the "href" attribute from the anchor element to + // prevent JAWS and NVDA from reading the value of the "href" + // attribute when the anchor is focused. + + if ((Y.UA.gecko || Y.UA.ie) && navigator.userAgent.indexOf("Windows") > -1) { + + menuButton.removeAttribute("href"); + + // Since the anchor's "href" attribute has been removed, the + // element will not fire the click event in Firefox when the + // user presses the enter key. To fix this, dispatch the + // "click" event to the anchor when the user presses the + // enter key. + + Y.on("key", function (event) { + + menuButton.simulate("click"); + + }, menuButton, "down:13"); + + } + + + // Set the "tabIndex" attribute of the anchor element to 0 to + // place it in the browser's default tab flow. This is + // necessary since 1) anchor elements are not in the default + // tab flow in Opera and 2) removing the "href" attribute + // prevents the anchor from firing its "click" event + // in Firefox. + + menuButton.set("tabIndex", 0); + + + var showMenu = function (event) { + + // For performance: Defer the creation of the menu until + // the first time the button is clicked. + + if (!menu) { + initMenu(); + } + + + if (!menu.get("visible")) { + + menu.set("align", { + node: menuButton, + points: [Y.WidgetPositionExt.TL, Y.WidgetPositionExt.BL] + }); + + menu.show(); + + } + + // Prevent the anchor element from being focused + // when the users mouses down on it. + event.preventDefault(); + + }; + + + // Bind both a "mousedown" and "click" event listener to + // ensure the button's menu can be invoked using both the + // mouse and the keyboard. + + menuButton.on("mousedown", showMenu); + menuButton.on("click", showMenu); + +});

