YUI 3.x Home -

YUI Library Examples: Focus Manager Node Plugin: Accessible Menu Button

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.

Move To

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.

  1. <a id="button-1" href="#menu-1"><span><span>Move To</span></span></a>
  2. <div id="menu-1">
  3. <ul>
  4. <li><input type="button" name="button-1" value="Inbox"></li>
  5. <li><input type="button" name="button-2" value="Archive"></li>
  6. <li><input type="button" name="button-3" value="Trash"></li>
  7. </ul>
  8. </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.

  1. YUI({
  2. base:"../../build/",
  3. timeout: 10000,
  4. modules: {
  5. "menubuttoncss": {
  6. type: "css",
  7. fullpath: "assets/menubutton.css"
  8. },
  9. "menubuttonjs": {
  10. type: "js",
  11. fullpath: "assets/menubutton.js",
  12. requires: ["menubuttoncss", "node-focusmanager", "node-event-simulate", "overlay"]
  13. }
  14. }
  15.  
  16. }).use("menubuttonjs", function(Y, result) {
  17.  
  18. // The callback supplied to use() will be executed regardless of
  19. // whether the operation was successful or not. The second parameter
  20. // is a result object that has the status of the operation. We can
  21. // use this to try to recover from failures or timeouts.
  22.  
  23. if (!result.success) {
  24.  
  25. Y.log("Load failure: " + result.msg, "warn", "Example");
  26.  
  27. // Show the menu button HTML if loading the JavaScript resources
  28. // failed, that way the original unskinned menu button
  29. // will be visible so that the user can interact with the menu
  30. // either way.
  31.  
  32. document.documentElement.className = "";
  33.  
  34. }
  35.  
  36. });
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.

  1. YUI().use("*", function (Y) {
  2.  
  3. var menuButton = Y.one("#button-1"),
  4. menu;
  5.  
  6.  
  7. var initMenu = function () {
  8.  
  9. menu = new Y.Overlay({
  10. contentBox: "#menu-1",
  11. visible: false,
  12. tabIndex: null
  13. });
  14.  
  15. menu.render();
  16.  
  17.  
  18. Y.one("#menu-1").setStyle("display", "");
  19.  
  20. var boundingBox = menu.get("boundingBox"),
  21. contentBox = menu.get("contentBox");
  22.  
  23. boundingBox.addClass("yui-buttonmenu");
  24. contentBox.addClass("yui-buttonmenu-content");
  25.  
  26.  
  27. // Append a decorator element to the bounding box to render the shadow.
  28.  
  29. boundingBox.append('<div class="yui-menu-shadow"></div>');
  30.  
  31.  
  32. // Apply the ARIA roles, states and properties to the menu.
  33.  
  34. boundingBox.setAttrs({
  35. role: "menu",
  36. "aria-labelledby": menuButton.get("id")
  37. });
  38.  
  39. boundingBox.all("input").set("role", "menuitem");
  40.  
  41.  
  42. // For NVDA: Add the role of "presentation" to each LI
  43. // element to prevent NVDA from announcing the
  44. // "listitem" role.
  45.  
  46. boundingBox.all("div,ul,li").set("role", "presentation");
  47.  
  48.  
  49. // Use the FocusManager Node Plugin to manage the focusability
  50. // of each menuitem in the menu.
  51.  
  52. contentBox.plug(Y.Plugin.NodeFocusManager, {
  53.  
  54. descendants: "input",
  55. keys: { next: "down:40", // Down arrow
  56. previous: "down:38" }, // Up arrow
  57. focusClass: {
  58. className: "yui-menuitem-active",
  59. fn: function (node) {
  60. return node.get("parentNode");
  61. }
  62. },
  63. circular: true
  64.  
  65. });
  66.  
  67.  
  68. // Subscribe to the change event for the "focused" attribute
  69. // to listen for when the menu initially gains focus, and
  70. // when the menu has lost focus completely.
  71.  
  72. contentBox.focusManager.after("focusedChange", function (event) {
  73.  
  74. if (!event.newVal) { // The menu has lost focus
  75.  
  76. // Set the "activeDescendant" attribute to 0 when the
  77. // menu is hidden so that the user can tab from the
  78. // button to the first item in the menu the next time
  79. // the menu is made visible.
  80.  
  81. this.set("activeDescendant", 0);
  82.  
  83. }
  84.  
  85. });
  86.  
  87.  
  88. // Hide the button's menu if the user presses the escape key
  89. // while focused either on the button or its menu.
  90.  
  91. Y.on("key", function () {
  92.  
  93. menu.hide();
  94. menuButton.focus();
  95.  
  96. }, [menuButton, boundingBox] ,"down:27");
  97.  
  98.  
  99. if (Y.UA.ie === 6) {
  100.  
  101. // Set the width and height of the menu's bounding box -
  102. // this is necessary for IE 6 so that the CSS for the
  103. // shadow element can simply set the shadow's width and
  104. // height to 100% to ensure that dimensions of the shadow
  105. // are always sync'd to the that of its parent menu.
  106.  
  107. menu.on("visibleChange", function (event) {
  108.  
  109. if (event.newVal) {
  110.  
  111. boundingBox.setStyles({ height: "", width: "" });
  112.  
  113. boundingBox.setStyles({
  114. height: (boundingBox.get("offsetHeight") + "px"),
  115. width: (boundingBox.get("offsetWidth") + "px") });
  116.  
  117. }
  118.  
  119. });
  120.  
  121. }
  122.  
  123.  
  124. menu.after("visibleChange", function (event) {
  125.  
  126. var bVisible = event.newVal;
  127.  
  128. // Focus the first item when the menu is made visible
  129. // to allow users to navigate the menu via the keyboard
  130.  
  131. if (bVisible) {
  132.  
  133. // Need to set focus via a timer for Webkit and Opera
  134. Y.Lang.later(0, contentBox.focusManager, contentBox.focusManager.focus);
  135.  
  136. }
  137.  
  138. boundingBox.set("aria-hidden", (!bVisible));
  139.  
  140. });
  141.  
  142.  
  143. // Hide the menu when one of menu items is clicked.
  144.  
  145. boundingBox.delegate("click", function (event) {
  146.  
  147. alert("You clicked " + this.query("input").get("value"));
  148.  
  149. contentBox.focusManager.blur();
  150. menu.hide();
  151.  
  152. }, "li");
  153.  
  154.  
  155. // Focus each menuitem as the user moves the mouse over
  156. // the menu.
  157.  
  158. boundingBox.delegate("mouseenter", function (event) {
  159.  
  160. var focusManager = contentBox.focusManager;
  161.  
  162. if (focusManager.get("focused")) {
  163. focusManager.focus(this.query("input"));
  164. }
  165.  
  166. }, "li");
  167.  
  168.  
  169. // Hide the menu if the user clicks outside of it or if the
  170. // user doesn't click on the button
  171.  
  172. boundingBox.get("ownerDocument").on("mousedown", function (event) {
  173.  
  174. var oTarget = event.target;
  175.  
  176. if (!oTarget.compareTo(menuButton) &&
  177. !menuButton.contains(oTarget) &&
  178. !oTarget.compareTo(boundingBox) &&
  179. !boundingBox.contains(oTarget)) {
  180.  
  181. menu.hide();
  182.  
  183. }
  184.  
  185. });
  186.  
  187. };
  188.  
  189.  
  190. menuButton.addClass("yui-menubutton");
  191.  
  192.  
  193. // Hide the list until it is transformed into a menu
  194.  
  195. Y.one("#menu-1").setStyle("display", "none");
  196.  
  197.  
  198. // Remove the "yui-loading" class from the documentElement
  199. // now that the necessary YUI dependencies are loaded and the
  200. // menu button has been skinned.
  201.  
  202. menuButton.get("ownerDocument").get("documentElement").removeClass("yui-loading");
  203.  
  204.  
  205. // Apply the ARIA roles, states and properties to the anchor.
  206.  
  207. menuButton.setAttrs({
  208. role: "button",
  209. "aria-haspopup": true
  210. });
  211.  
  212.  
  213. // Remove the "href" attribute from the anchor element to
  214. // prevent JAWS and NVDA from reading the value of the "href"
  215. // attribute when the anchor is focused.
  216.  
  217. if ((Y.UA.gecko || Y.UA.ie) && navigator.userAgent.indexOf("Windows") > -1) {
  218.  
  219. menuButton.removeAttribute("href");
  220.  
  221. // Since the anchor's "href" attribute has been removed, the
  222. // element will not fire the click event in Firefox when the
  223. // user presses the enter key. To fix this, dispatch the
  224. // "click" event to the anchor when the user presses the
  225. // enter key.
  226.  
  227. Y.on("key", function (event) {
  228.  
  229. menuButton.simulate("click");
  230.  
  231. }, menuButton, "down:13");
  232.  
  233. }
  234.  
  235.  
  236. // Set the "tabIndex" attribute of the anchor element to 0 to
  237. // place it in the browser's default tab flow. This is
  238. // necessary since 1) anchor elements are not in the default
  239. // tab flow in Opera and 2) removing the "href" attribute
  240. // prevents the anchor from firing its "click" event
  241. // in Firefox.
  242.  
  243. menuButton.set("tabIndex", 0);
  244.  
  245.  
  246. var showMenu = function (event) {
  247.  
  248. // For performance: Defer the creation of the menu until
  249. // the first time the button is clicked.
  250.  
  251. if (!menu) {
  252. initMenu();
  253. }
  254.  
  255.  
  256. if (!menu.get("visible")) {
  257.  
  258. menu.set("align", {
  259. node: menuButton,
  260. points: [Y.WidgetPositionExt.TL, Y.WidgetPositionExt.BL]
  261. });
  262.  
  263. menu.show();
  264.  
  265. }
  266.  
  267. // Prevent the anchor element from being focused
  268. // when the users mouses down on it.
  269. event.preventDefault();
  270.  
  271. };
  272.  
  273.  
  274. // Bind both a "mousedown" and "click" event listener to
  275. // ensure the button's menu can be invoked using both the
  276. // mouse and the keyboard.
  277.  
  278. menuButton.on("mousedown", showMenu);
  279. menuButton.on("click", showMenu);
  280.  
  281. });
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);
 
});

Copyright © 2009 Yahoo! Inc. All rights reserved.

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