From 243f77da92a82dffb6fa578e68359ada15201d4e Mon Sep 17 00:00:00 2001 From: Jon Gunderson Date: Tue, 10 Nov 2020 02:05:19 -0600 Subject: [PATCH] Menu button examples: Improve High Contrast Support and consistency with APG Coding Practices (pull #1401) Updated three menu button examples to: * Improve Javascript coding; use key rather than keyCode in keyboard events; use a single object; use classes instead of prototypes. * Improve high contrast support and documentation. Co-authored-by: Valerie Young Co-authored-by: Matt King Co-authored-by: Carolyn MacLeod --- examples/menu-button/css/MenubuttonLinks.css | 56 --- .../menu-button/css/menu-button-actions.css | 82 ++++ .../menu-button/css/menu-button-links.css | 78 ++++ examples/menu-button/css/menuButtonAction.css | 47 --- examples/menu-button/js/MenuItemAction.js | 163 -------- .../js/MenuItemActionActivedescendant.js | 59 --- examples/menu-button/js/MenuItemLinks.js | 163 -------- examples/menu-button/js/Menubutton.js | 142 ------- examples/menu-button/js/Menubutton2.js | 135 ------ examples/menu-button/js/PopupMenuAction.js | 259 ------------ .../js/PopupMenuActionActivedescendant.js | 386 ------------------ examples/menu-button/js/PopupMenuLinks.js | 247 ----------- .../menu-button-actions-active-descendant.js | 352 ++++++++++++++++ .../menu-button/js/menu-button-actions.js | 348 ++++++++++++++++ examples/menu-button/js/menu-button-links.js | 330 +++++++++++++++ ...menu-button-actions-active-descendant.html | 83 ++-- examples/menu-button/menu-button-actions.html | 56 +-- examples/menu-button/menu-button-links.html | 64 +-- .../menu-button_actions-active-descendant.js | 29 +- test/tests/menu-button_actions.js | 30 +- test/tests/menu-button_links.js | 15 +- 21 files changed, 1356 insertions(+), 1768 deletions(-) delete mode 100644 examples/menu-button/css/MenubuttonLinks.css create mode 100644 examples/menu-button/css/menu-button-actions.css create mode 100644 examples/menu-button/css/menu-button-links.css delete mode 100644 examples/menu-button/css/menuButtonAction.css delete mode 100644 examples/menu-button/js/MenuItemAction.js delete mode 100644 examples/menu-button/js/MenuItemActionActivedescendant.js delete mode 100644 examples/menu-button/js/MenuItemLinks.js delete mode 100644 examples/menu-button/js/Menubutton.js delete mode 100644 examples/menu-button/js/Menubutton2.js delete mode 100644 examples/menu-button/js/PopupMenuAction.js delete mode 100644 examples/menu-button/js/PopupMenuActionActivedescendant.js delete mode 100644 examples/menu-button/js/PopupMenuLinks.js create mode 100644 examples/menu-button/js/menu-button-actions-active-descendant.js create mode 100644 examples/menu-button/js/menu-button-actions.js create mode 100644 examples/menu-button/js/menu-button-links.js diff --git a/examples/menu-button/css/MenubuttonLinks.css b/examples/menu-button/css/MenubuttonLinks.css deleted file mode 100644 index 70853b79d9..0000000000 --- a/examples/menu-button/css/MenubuttonLinks.css +++ /dev/null @@ -1,56 +0,0 @@ -.menu_button { - position: relative; -} - -.menu_button button { - font-size: 110%; - display: block; - padding: 0.25em; - border: inset 2px gray; - width: 12em; - text-align: center; - background-color: #eee; - text-decoration: none; - color: black; -} - -ul[role="menu"] { - margin: 0; - padding: 0; - position: absolute; - font-size: 110%; - list-style: none; - background-color: #eee; - display: none; -} - -ul[role="menu"] li { - margin: 0; - padding: 0; -} - -ul[role="menu"] a[role="menuitem"], -ul[role="menu"] a[role="menuitem"]:visited { - display: block; - text-decoration: none; - padding: 0.25em; - padding-left: 0.5em; - padding-right: 0.5em; - background-color: #e0e0e0; - width: 22em; - color: black; -} - -/* focus and hover styling */ - -a[role="button"]:focus, -a[role="button"]:hover { - border: 2px solid black; - background-color: #f8f8f8; -} - -ul[role="menu"] a[role="menuitem"]:focus, -ul[role="menu"] a[role="menuitem"]:hover { - background-color: black; - color: white; -} diff --git a/examples/menu-button/css/menu-button-actions.css b/examples/menu-button/css/menu-button-actions.css new file mode 100644 index 0000000000..391043fa38 --- /dev/null +++ b/examples/menu-button/css/menu-button-actions.css @@ -0,0 +1,82 @@ +.menu-button-actions { + margin: 0; + padding: 0; +} + +.menu-button-actions button { + margin: 0; + padding: 6px; + display: inline-block; + position: relative; + background-color: #034575; + border: 1px solid #034575; + font-size: 0.9em; + color: white; + border-radius: 5px; +} + +.menu-button-actions [role="menu"] { + display: none; + position: absolute; + margin: 0; + padding: 7px 4px; + border: 2px solid #034575; + border-radius: 5px; + background-color: #eee; +} + +.menu-button-actions [role="menuitem"], +.menu-button-actions [role="separator"] { + margin: 0; + padding: 6px; + display: block; + width: 4em; + background-color: #eee; + color: black; + border-radius: 5px; +} + +.menu-button-actions [role="separator"] { + padding-top: 3px; + background-image: url("../images/separator.svg"); + background-position: center; + background-repeat: repeat-x; +} + +.menu-button-actions button svg.down { + padding-left: 0.125em; + fill: currentColor; + stroke: currentColor; +} + +.menu-button-actions button[aria-expanded="true"] svg.down { + transform: rotate(180deg); +} + +/* focus styling */ + +.menu-button-actions button:hover, +.menu-button-actions button:focus, +.menu-button-actions button[aria-expanded="true"] { + padding: 4px; + border: 3px solid #034575; + background: #eee; + color: #222; + outline: none; + margin: 0; +} + +.menu-button-actions [role="menuitem"].focus, +.menu-button-actions [role="menuitem"]:focus { + padding: 4px; + border: 2px solid #034575; + background: #034575; + color: #fff; + outline: none; + margin: 0; +} + +input.action:focus { + outline: 2px solid #034575; + background: #def; +} diff --git a/examples/menu-button/css/menu-button-links.css b/examples/menu-button/css/menu-button-links.css new file mode 100644 index 0000000000..3ffd31cb54 --- /dev/null +++ b/examples/menu-button/css/menu-button-links.css @@ -0,0 +1,78 @@ +.menu-button-links { + margin: 0; + font-size: 110%; +} + +.menu-button-links button { + margin: 0; + padding: 6px; + display: inline-block; + position: relative; + background-color: #034575; + border: 1px solid #034575; + font-size: 0.9em; + color: white; + border-radius: 5px; +} + +.menu-button-links [role="menu"] { + margin: 0; + padding: 7px 4px; + list-style: none; + display: none; + position: absolute; + border: 2px solid #034575; + border-radius: 5px; + background-color: #eee; +} + +.menu-button-links [role="menuitem"], +.menu-button-links [role="separator"] { + margin: 0; + padding: 6px; + display: block; + width: 24em; + background-color: #eee; + border: none; + color: black; + border-radius: 5px; +} + +.menu-button-links [role="separator"] { + padding-top: 3px; + background-image: url("../images/separator.svg"); + background-position: center; + background-repeat: repeat-x; +} + +.menu-button-links button svg.down { + padding-left: 0.125em; + fill: currentColor; + stroke: currentColor; +} + +.menu-button-links button[aria-expanded="true"] svg.down { + transform: rotate(180deg); +} + +/* focus styling */ + +.menu-button-links button:hover, +.menu-button-links button:focus, +.menu-button-links button[aria-expanded="true"] { + padding: 4px; + border: 3px solid #034575; + background: #eee; + color: #222; + outline: none; + margin: 0; +} + +.menu-button-links [role="menuitem"]:focus { + padding: 4px; + border: 2px solid #034575; + background: #034575; + color: #fff; + outline: none; + margin: 0; +} diff --git a/examples/menu-button/css/menuButtonAction.css b/examples/menu-button/css/menuButtonAction.css deleted file mode 100644 index 1c8a13c41e..0000000000 --- a/examples/menu-button/css/menuButtonAction.css +++ /dev/null @@ -1,47 +0,0 @@ -.menu_button { - position: relative; -} - -.menu_button button { - font-size: 110%; -} - -ul[role="menu"] { - margin: 0; - padding: 0; - position: absolute; - font-size: 110%; - list-style: none; - background-color: #eee; - display: none; -} - -ul[role="menu"] li[role="menuitem"] { - display: block; - padding: 0.25em; - padding-left: 0.5em; - padding-right: 0.5em; - background-color: #eee; - width: 4em; -} - -/* focus and hover styling */ - -button:focus, -button:hover, -input:focus, -input:hover { - outline: 2px solid black; -} - -input:focus, -input:hover { - background-color: #eee; -} - -ul[role="menu"] li[role="menuitem"]:focus, -ul[role="menu"] li[role="menuitem"].focus, -ul[role="menu"] li[role="menuitem"]:hover { - background-color: black; - color: white; -} diff --git a/examples/menu-button/js/MenuItemAction.js b/examples/menu-button/js/MenuItemAction.js deleted file mode 100644 index 80af6074c5..0000000000 --- a/examples/menu-button/js/MenuItemAction.js +++ /dev/null @@ -1,163 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: MenuItem.js - * - * Desc: Popup Menu Menuitem widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor MenuItem - * - * @desc - * Wrapper object for a simple menu item in a popup menu - * - * @param domNode - * The DOM element node that serves as the menu item container. - * The menuObj PopupMenu is responsible for checking that it has - * requisite metadata, e.g. role="menuitem". - * - * @param menuObj - * The object that is a wrapper for the PopupMenu DOM element that - * contains the menu item DOM element. See PopupMenuAction.js - */ -var PopupMenuItem = function (domNode, popupMenuObj) { - this.domNode = domNode; - this.popupMenu = popupMenuObj; - - this.keyCode = Object.freeze({ - TAB: 9, - RETURN: 13, - ESC: 27, - SPACE: 32, - PAGEUP: 33, - PAGEDOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - }); -}; - -PopupMenuItem.prototype.init = function () { - this.domNode.tabIndex = -1; - - if (!this.domNode.getAttribute('role')) { - this.domNode.setAttribute('role', 'menuitem'); - } - - this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); - this.domNode.addEventListener('click', this.handleClick.bind(this)); - this.domNode.addEventListener('focus', this.handleFocus.bind(this)); - this.domNode.addEventListener('blur', this.handleBlur.bind(this)); - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); -}; - -/* EVENT HANDLERS */ - -PopupMenuItem.prototype.handleKeydown = function (event) { - var flag = false, - char = event.key; - - function isPrintableCharacter(str) { - return str.length === 1 && str.match(/\S/); - } - - if (event.ctrlKey || event.altKey || event.metaKey) { - return; - } - - if (event.shiftKey) { - if (isPrintableCharacter(char)) { - this.popupMenu.setFocusByFirstCharacter(this, char); - } - } else { - switch (event.keyCode) { - case this.keyCode.SPACE: - flag = true; - break; - - case this.keyCode.RETURN: - this.handleClick(event); - flag = true; - break; - - case this.keyCode.ESC: - this.popupMenu.setFocusToController(); - this.popupMenu.close(true); - flag = true; - break; - - case this.keyCode.UP: - this.popupMenu.setFocusToPreviousItem(this); - flag = true; - break; - - case this.keyCode.DOWN: - this.popupMenu.setFocusToNextItem(this); - flag = true; - break; - - case this.keyCode.HOME: - case this.keyCode.PAGEUP: - this.popupMenu.setFocusToFirstItem(); - flag = true; - break; - - case this.keyCode.END: - case this.keyCode.PAGEDOWN: - this.popupMenu.setFocusToLastItem(); - flag = true; - break; - - case this.keyCode.TAB: - this.popupMenu.setFocusToController(); - this.popupMenu.close(true); - break; - - default: - if (isPrintableCharacter(char)) { - this.popupMenu.setFocusByFirstCharacter(this, char); - } - break; - } - } - - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } -}; - -PopupMenuItem.prototype.handleClick = function (event) { - if (menuAction) { - menuAction(event); - } - this.popupMenu.setFocusToController(); - this.popupMenu.close(true); -}; - -PopupMenuItem.prototype.handleFocus = function (event) { - this.popupMenu.hasFocus = true; -}; - -PopupMenuItem.prototype.handleBlur = function (event) { - this.popupMenu.hasFocus = false; - setTimeout(this.popupMenu.close.bind(this.popupMenu, false), 300); -}; - -PopupMenuItem.prototype.handleMouseover = function (event) { - this.popupMenu.hasHover = true; - this.popupMenu.open(); -}; - -PopupMenuItem.prototype.handleMouseout = function (event) { - this.popupMenu.hasHover = false; - setTimeout(this.popupMenu.close.bind(this.popupMenu, false), 300); -}; diff --git a/examples/menu-button/js/MenuItemActionActivedescendant.js b/examples/menu-button/js/MenuItemActionActivedescendant.js deleted file mode 100644 index 72dce15880..0000000000 --- a/examples/menu-button/js/MenuItemActionActivedescendant.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: MenuItem.js - * - * Desc: Popup Menu Menuitem widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor MenuItem - * - * @desc - * Wrapper object for a simple menu item in a popup menu - * - * @param domNode - * The DOM element node that serves as the menu item container. - * The menuObj PopupMenu is responsible for checking that it has - * requisite metadata, e.g. role="menuitem". - * - * @param menuObj - * The object that is a wrapper for the PopupMenu DOM element that - * contains the menu item DOM element. See PopupMenuAction.js - */ -var MenuItem = function (domNode, menuObj) { - this.domNode = domNode; - this.menu = menuObj; -}; - -MenuItem.prototype.init = function () { - this.domNode.tabIndex = -1; - - if (!this.domNode.getAttribute('role')) { - this.domNode.setAttribute('role', 'menuitem'); - } - - this.domNode.addEventListener('click', this.handleClick.bind(this)); - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); -}; - -/* EVENT HANDLERS */ - -MenuItem.prototype.handleClick = function (event) { - this.menu.setFocusToController(); - this.menu.close(true); -}; - -MenuItem.prototype.handleMouseover = function (event) { - this.menu.hasHover = true; - this.menu.open(); -}; - -MenuItem.prototype.handleMouseout = function (event) { - this.menu.hasHover = false; - setTimeout(this.menu.close.bind(this.menu, false), 300); -}; diff --git a/examples/menu-button/js/MenuItemLinks.js b/examples/menu-button/js/MenuItemLinks.js deleted file mode 100644 index c16af95165..0000000000 --- a/examples/menu-button/js/MenuItemLinks.js +++ /dev/null @@ -1,163 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: MenuItemLinks.js - * - * Desc: Popup Menu Menuitem widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor MenuItemLinks - * - * @desc - * Wrapper object for a simple menu item in a popup menu - * - * @param domNode - * The DOM element node that serves as the menu item container. - * The menuObj PopupMenu is responsible for checking that it has - * requisite metadata, e.g. role="menuitem". - * - * @param menuObj - * The object that is a wrapper for the PopupMenu DOM element that - * contains the menu item DOM element. See PopupMenu.js - */ -var MenuItemLinks = function (domNode, menuObj) { - this.domNode = domNode; - this.menu = menuObj; - - this.keyCode = Object.freeze({ - TAB: 9, - RETURN: 13, - ESC: 27, - SPACE: 32, - PAGEUP: 33, - PAGEDOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - }); -}; - -MenuItemLinks.prototype.init = function () { - this.domNode.tabIndex = -1; - - if (!this.domNode.getAttribute('role')) { - this.domNode.setAttribute('role', 'menuitem'); - } - - this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); - this.domNode.addEventListener('click', this.handleClick.bind(this)); - this.domNode.addEventListener('focus', this.handleFocus.bind(this)); - this.domNode.addEventListener('blur', this.handleBlur.bind(this)); - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); -}; - -/* EVENT HANDLERS */ - -MenuItemLinks.prototype.handleKeydown = function (event) { - var flag = false, - char = event.key; - - function isPrintableCharacter(str) { - return str.length === 1 && str.match(/\S/); - } - - if ( - event.ctrlKey || - event.altKey || - event.metaKey || - event.keyCode === this.keyCode.SPACE || - event.keyCode === this.keyCode.RETURN - ) { - return; - } - - if (event.shiftKey) { - if (isPrintableCharacter(char)) { - this.menu.setFocusByFirstCharacter(this, char); - flag = true; - } - - if (event.keyCode === this.keyCode.TAB) { - this.menu.setFocusToController(); - this.menu.close(true); - } - } else { - switch (event.keyCode) { - case this.keyCode.ESC: - this.menu.setFocusToController(); - this.menu.close(true); - flag = true; - break; - - case this.keyCode.UP: - this.menu.setFocusToPreviousItem(this); - flag = true; - break; - - case this.keyCode.DOWN: - this.menu.setFocusToNextItem(this); - flag = true; - break; - - case this.keyCode.HOME: - case this.keyCode.PAGEUP: - this.menu.setFocusToFirstItem(); - flag = true; - break; - - case this.keyCode.END: - case this.keyCode.PAGEDOWN: - this.menu.setFocusToLastItem(); - flag = true; - break; - - case this.keyCode.TAB: - this.menu.setFocusToController(); - this.menu.close(true); - break; - - default: - if (isPrintableCharacter(char)) { - this.menu.setFocusByFirstCharacter(this, char); - } - break; - } - } - - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } -}; - -MenuItemLinks.prototype.handleClick = function (event) { - this.menu.setFocusToController(); - this.menu.close(true); -}; - -MenuItemLinks.prototype.handleFocus = function (event) { - this.menu.hasFocus = true; -}; - -MenuItemLinks.prototype.handleBlur = function (event) { - this.menu.hasFocus = false; - setTimeout(this.menu.close.bind(this.menu, false), 300); -}; - -MenuItemLinks.prototype.handleMouseover = function (event) { - this.menu.hasHover = true; - this.menu.open(); -}; - -MenuItemLinks.prototype.handleMouseout = function (event) { - this.menu.hasHover = false; - setTimeout(this.menu.close.bind(this.menu, false), 300); -}; diff --git a/examples/menu-button/js/Menubutton.js b/examples/menu-button/js/Menubutton.js deleted file mode 100644 index 3b39b933f9..0000000000 --- a/examples/menu-button/js/Menubutton.js +++ /dev/null @@ -1,142 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: Menubutton.js - * - * Desc: Menubutton widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor MenuButton - * - * @desc - * Object that configures menu item elements by setting tabIndex - * and registering itself to handle pertinent events. - * - * While menuitem elements handle many keydown events, as well as - * focus and blur events, they do not maintain any state variables, - * delegating those responsibilities to its associated menu object. - * - * Consequently, it is only necessary to create one instance of - * MenubuttonItem from within the menu object; its configure method - * can then be called on each menuitem element. - * - * @param domNode - * The DOM element node that serves as the menu item container. - * The menuObj PopupMenu is responsible for checking that it has - * requisite metadata, e.g. role="menuitem". - * - * - */ -var Menubutton = function (domNode) { - this.domNode = domNode; - this.popupMenu = false; - - this.hasFocus = false; - this.hasHover = false; - - this.keyCode = Object.freeze({ - TAB: 9, - RETURN: 13, - ESC: 27, - SPACE: 32, - PAGEUP: 33, - PAGEDOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - }); -}; - -Menubutton.prototype.init = function () { - this.domNode.setAttribute('aria-haspopup', 'true'); - - this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); - this.domNode.addEventListener('click', this.handleClick.bind(this)); - this.domNode.addEventListener('focus', this.handleFocus.bind(this)); - this.domNode.addEventListener('blur', this.handleBlur.bind(this)); - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); - - // initialize pop up menus - - var popupMenu = document.getElementById( - this.domNode.getAttribute('aria-controls') - ); - - if (popupMenu) { - if (popupMenu.getAttribute('aria-activedescendant')) { - this.popupMenu = new PopupMenuActionActivedescendant(popupMenu, this); - this.popupMenu.init(); - } else { - this.popupMenu = new PopupMenuAction(popupMenu, this); - this.popupMenu.init(); - } - } -}; - -Menubutton.prototype.handleKeydown = function (event) { - var flag = false; - - switch (event.keyCode) { - case this.keyCode.SPACE: - case this.keyCode.RETURN: - case this.keyCode.DOWN: - if (this.popupMenu) { - this.popupMenu.open(); - this.popupMenu.setFocusToFirstItem(); - } - flag = true; - break; - - case this.keyCode.UP: - if (this.popupMenu) { - this.popupMenu.open(); - this.popupMenu.setFocusToLastItem(); - flag = true; - } - break; - - default: - break; - } - - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } -}; - -Menubutton.prototype.handleClick = function (event) { - if (this.domNode.getAttribute('aria-expanded') == 'true') { - this.popupMenu.close(true); - } else { - this.popupMenu.open(); - this.popupMenu.setFocusToFirstItem(); - } -}; - -Menubutton.prototype.handleFocus = function (event) { - this.popupMenu.hasFocus = true; -}; - -Menubutton.prototype.handleBlur = function (event) { - this.popupMenu.hasFocus = false; - setTimeout(this.popupMenu.close.bind(this.popupMenu, false), 300); -}; - -Menubutton.prototype.handleMouseover = function (event) { - this.hasHover = true; - this.popupMenu.open(); -}; - -Menubutton.prototype.handleMouseout = function (event) { - this.hasHover = false; - setTimeout(this.popupMenu.close.bind(this.popupMenu, false), 300); -}; diff --git a/examples/menu-button/js/Menubutton2.js b/examples/menu-button/js/Menubutton2.js deleted file mode 100644 index 5f7a082aad..0000000000 --- a/examples/menu-button/js/Menubutton2.js +++ /dev/null @@ -1,135 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: Menubutton.js - * - * Desc: Menubutton Menuitem widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor MenubuttonItem - * - * @desc - * Object that configures menu item elements by setting tabIndex - * and registering itself to handle pertinent events. - * - * While menuitem elements handle many keydown events, as well as - * focus and blur events, they do not maintain any state variables, - * delegating those responsibilities to its associated menu object. - * - * Consequently, it is only necessary to create one instance of - * MenubuttonItem from within the menu object; its configure method - * can then be called on each menuitem element. - * - * @param domNode - * The DOM element node that serves as the menu item container. - * The menuObj PopupMenu is responsible for checking that it has - * requisite metadata, e.g. role="menuitem". - * - */ -var Menubutton = function (domNode) { - this.domNode = domNode; - this.popupMenu = false; - - this.hasFocus = false; - this.hasHover = false; - - this.keyCode = Object.freeze({ - TAB: 9, - RETURN: 13, - ESC: 27, - SPACE: 32, - PAGEUP: 33, - PAGEDOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - }); -}; - -Menubutton.prototype.init = function () { - this.domNode.setAttribute('aria-haspopup', 'true'); - - this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); - this.domNode.addEventListener('click', this.handleClick.bind(this)); - this.domNode.addEventListener('focus', this.handleFocus.bind(this)); - this.domNode.addEventListener('blur', this.handleBlur.bind(this)); - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); - - // initialize pop up menus - - var popupMenu = document.getElementById( - this.domNode.getAttribute('aria-controls') - ); - - if (popupMenu) { - this.popupMenu = new PopupMenuLinks(popupMenu, this); - this.popupMenu.init(); - } -}; - -Menubutton.prototype.handleKeydown = function (event) { - var flag = false; - - switch (event.keyCode) { - case this.keyCode.SPACE: - case this.keyCode.RETURN: - case this.keyCode.DOWN: - if (this.popupMenu) { - this.popupMenu.open(); - this.popupMenu.setFocusToFirstItem(); - } - flag = true; - break; - - case this.keyCode.UP: - if (this.popupMenu) { - this.popupMenu.open(); - this.popupMenu.setFocusToLastItem(); - flag = true; - } - break; - - default: - break; - } - - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } -}; - -Menubutton.prototype.handleClick = function (event) { - if (this.domNode.getAttribute('aria-expanded') == 'true') { - this.popupMenu.close(true); - } else { - this.popupMenu.open(); - this.popupMenu.setFocusToFirstItem(); - } -}; - -Menubutton.prototype.handleFocus = function (event) { - this.popupMenu.hasFocus = true; -}; - -Menubutton.prototype.handleBlur = function (event) { - this.popupMenu.hasFocus = false; -}; - -Menubutton.prototype.handleMouseover = function (event) { - this.hasHover = true; - this.popupMenu.open(); -}; - -Menubutton.prototype.handleMouseout = function (event) { - this.hasHover = false; - setTimeout(this.popupMenu.close.bind(this.popupMenu, false), 300); -}; diff --git a/examples/menu-button/js/PopupMenuAction.js b/examples/menu-button/js/PopupMenuAction.js deleted file mode 100644 index b660e10ff8..0000000000 --- a/examples/menu-button/js/PopupMenuAction.js +++ /dev/null @@ -1,259 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: PopupMenuAction.js - * - * Desc: Popup menu widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor PopupMenuAction - * - * @desc - * Wrapper object for a simple popup menu (without nested submenus) - * - * @param domNode - * The DOM element node that serves as the popup menu container. Each - * child element of domNode that represents a menuitem must have a - * 'role' attribute with value 'menuitem'. - * - * @param controllerObj - * The object that is a wrapper for the DOM element that controls the - * menu, e.g. a button element, with an 'aria-controls' attribute that - * references this menu's domNode. See MenuButton.js - * - * The controller object is expected to have the following properties: - * 1. domNode: The controller object's DOM element node, needed for - * retrieving positioning information. - * 2. hasHover: boolean that indicates whether the controller object's - * domNode has responded to a mouseover event with no subsequent - * mouseout event having occurred. - */ -var PopupMenuAction = function (domNode, controllerObj) { - var elementChildren, - msgPrefix = 'PopupMenu constructor argument domNode '; - - // Check whether domNode is a DOM element - if (!(domNode instanceof Element)) { - throw new TypeError(msgPrefix + 'is not a DOM Element.'); - } - - // Check whether domNode has child elements - if (domNode.childElementCount === 0) { - throw new Error(msgPrefix + 'has no element children.'); - } - - // Check whether domNode child elements are A elements - var childElement = domNode.firstElementChild; - while (childElement) { - var menuitem = childElement.firstElementChild; - if (menuitem && menuitem === 'A') { - throw new Error( - msgPrefix + 'Cannot have descendant elements are A elements.' - ); - } - childElement = childElement.nextElementSibling; - } - - this.domNode = domNode; - this.controller = controllerObj; - - this.menuitems = []; // see PopupMenu init method - this.firstChars = []; // see PopupMenu init method - - this.firstItem = null; // see PopupMenu init method - this.lastItem = null; // see PopupMenu init method - - this.hasFocus = false; // see MenuItem handleFocus, handleBlur - this.hasHover = false; // see PopupMenu handleMouseover, handleMouseout -}; - -/* - * @method PopupMenuAction.prototype.init - * - * @desc - * Add domNode event listeners for mouseover and mouseout. Traverse - * domNode children to configure each menuitem and populate menuitems - * array. Initialize firstItem and lastItem properties. - */ -PopupMenuAction.prototype.init = function () { - var childElement, - menuElement, - firstChildElement, - menuItem, - textContent, - numItems, - label; - - // Configure the domNode itself - this.domNode.tabIndex = -1; - - this.domNode.setAttribute('role', 'menu'); - - if ( - !this.domNode.getAttribute('aria-labelledby') && - !this.domNode.getAttribute('aria-label') && - !this.domNode.getAttribute('title') - ) { - label = this.controller.domNode.innerHTML; - this.domNode.setAttribute('aria-label', label); - } - - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); - - // Traverse the element children of domNode: configure each with - // menuitem role behavior and store reference in menuitems array. - var menuElements = this.domNode.getElementsByTagName('LI'); - - for (var i = 0; i < menuElements.length; i++) { - menuElement = menuElements[i]; - - if ( - !menuElement.firstElementChild && - menuElement.getAttribute('role') != 'separator' - ) { - menuItem = new PopupMenuItem(menuElement, this); - menuItem.init(); - this.menuitems.push(menuItem); - textContent = menuElement.textContent.trim(); - this.firstChars.push(textContent.substring(0, 1).toLowerCase()); - } - } - - // Use populated menuitems array to initialize firstItem and lastItem. - numItems = this.menuitems.length; - if (numItems > 0) { - this.firstItem = this.menuitems[0]; - this.lastItem = this.menuitems[numItems - 1]; - } -}; - -/* EVENT HANDLERS */ - -PopupMenuAction.prototype.handleMouseover = function (event) { - this.hasHover = true; -}; - -PopupMenuAction.prototype.handleMouseout = function (event) { - this.hasHover = false; - setTimeout(this.close.bind(this, false), 300); -}; - -/* FOCUS MANAGEMENT METHODS */ - -PopupMenuAction.prototype.setFocusToController = function (command) { - if (typeof command !== 'string') { - command = ''; - } - - if (command === 'previous') { - this.controller.menubutton.setFocusToPreviousItem(this.controller); - } else { - if (command === 'next') { - this.controller.menubutton.setFocusToNextItem(this.controller); - } else { - this.controller.domNode.focus(); - } - } -}; - -PopupMenuAction.prototype.setFocusToFirstItem = function () { - this.firstItem.domNode.focus(); -}; - -PopupMenuAction.prototype.setFocusToLastItem = function () { - this.lastItem.domNode.focus(); -}; - -PopupMenuAction.prototype.setFocusToPreviousItem = function (currentItem) { - var index; - - if (currentItem === this.firstItem) { - this.lastItem.domNode.focus(); - } else { - index = this.menuitems.indexOf(currentItem); - this.menuitems[index - 1].domNode.focus(); - } -}; - -PopupMenuAction.prototype.setFocusToNextItem = function (currentItem) { - var index; - - if (currentItem === this.lastItem) { - this.firstItem.domNode.focus(); - } else { - index = this.menuitems.indexOf(currentItem); - this.menuitems[index + 1].domNode.focus(); - } -}; - -PopupMenuAction.prototype.setFocusByFirstCharacter = function ( - currentItem, - char -) { - var start, index; - - char = char.toLowerCase(); - - // Get start index for search based on position of currentItem - start = this.menuitems.indexOf(currentItem) + 1; - if (start === this.menuitems.length) { - start = 0; - } - - // Check remaining slots in the menu - index = this.getIndexFirstChars(start, char); - - // If not found in remaining slots, check from beginning - if (index === -1) { - index = this.getIndexFirstChars(0, char); - } - - // If match was found... - if (index > -1) { - this.menuitems[index].domNode.focus(); - } -}; - -PopupMenuAction.prototype.getIndexFirstChars = function (startIndex, char) { - for (var i = startIndex; i < this.firstChars.length; i++) { - if (char === this.firstChars[i]) { - return i; - } - } - return -1; -}; - -/* MENU DISPLAY METHODS */ - -PopupMenuAction.prototype.open = function () { - // get bounding rectangle of controller object's DOM node - var rect = this.controller.domNode.getBoundingClientRect(); - - // set CSS properties - this.domNode.style.display = 'block'; - this.domNode.style.position = 'absolute'; - this.domNode.style.top = rect.height + 'px'; - this.domNode.style.left = '0px'; - - // set aria-expanded attribute - this.controller.domNode.setAttribute('aria-expanded', 'true'); -}; - -PopupMenuAction.prototype.close = function (force) { - if (typeof force !== 'boolean') { - force = false; - } - - if ( - force || - (!this.hasFocus && !this.hasHover && !this.controller.hasHover) - ) { - this.domNode.style.display = 'none'; - this.controller.domNode.removeAttribute('aria-expanded'); - } -}; diff --git a/examples/menu-button/js/PopupMenuActionActivedescendant.js b/examples/menu-button/js/PopupMenuActionActivedescendant.js deleted file mode 100644 index c31acbb9ce..0000000000 --- a/examples/menu-button/js/PopupMenuActionActivedescendant.js +++ /dev/null @@ -1,386 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: PopupMenuActionActivedescendant.js - * - * Desc: Popup menu widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor PopupMenuActionActivedescendant - * - * @desc - * Wrapper object for a simple popup menu (without nested submenus) - * - * @param domNode - * The DOM element node that serves as the popup menu container. Each - * child element of domNode that represents a menuitem must have a - * 'role' attribute with value 'menuitem'. - * - * @param controllerObj - * The object that is a wrapper for the DOM element that controls the - * menu, e.g. a button element, with an 'aria-controls' attribute that - * references this menu's domNode. See MenuButton.js - * - * The controller object is expected to have the following properties: - * 1. domNode: The controller object's DOM element node, needed for - * retrieving positioning information. - * 2. hasHover: boolean that indicates whether the controller object's - * domNode has responded to a mouseover event with no subsequent - * mouseout event having occurred. - */ -var PopupMenuActionActivedescendant = function (domNode, controllerObj) { - var elementChildren, - msgPrefix = 'PopupMenu constructor argument domNode '; - - // Check whether domNode is a DOM element - if (!(domNode instanceof Element)) { - throw new TypeError(msgPrefix + 'is not a DOM Element.'); - } - - // Check whether domNode has child elements - if (domNode.childElementCount === 0) { - throw new Error(msgPrefix + 'has no element children.'); - } - - // Check whether domNode child elements are A elements - var childElement = domNode.firstElementChild; - while (childElement) { - var menuitem = childElement.firstElementChild; - if (menuitem && menuitem === 'A') { - throw new Error( - msgPrefix + 'Cannot have descendant elements are A elements.' - ); - } - childElement = childElement.nextElementSibling; - } - - this.currentItem; - - this.domNode = domNode; - this.controller = controllerObj; - - this.menuitems = []; // see PopupMenu init method - this.firstChars = []; // see PopupMenu init method - - this.firstItem = null; // see PopupMenu init method - this.lastItem = null; // see PopupMenu init method - - this.hasFocus = false; // see MenuItem handleFocus, handleBlur - this.hasHover = false; // see PopupMenu handleMouseover, handleMouseout - - this.keyCode = Object.freeze({ - TAB: 9, - RETURN: 13, - ESC: 27, - SPACE: 32, - PAGEUP: 33, - PAGEDOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - }); -}; - -/* - * @method PopupMenuActionActivedescendant.prototype.init - * - * @desc - * Add domNode event listeners for mouseover and mouseout. Traverse - * domNode children to configure each menuitem and populate menuitems - * array. Initialize firstItem and lastItem properties. - */ -PopupMenuActionActivedescendant.prototype.init = function () { - var childElement, - menuElement, - firstChildElement, - menuItem, - textContent, - label; - - // Configure the domNode itself - this.domNode.tabIndex = 0; - - this.domNode.setAttribute('role', 'menu'); - - if ( - !this.domNode.getAttribute('aria-labelledby') && - !this.domNode.getAttribute('aria-label') && - !this.domNode.getAttribute('title') - ) { - label = this.controller.domNode.innerHTML; - this.domNode.setAttribute('aria-label', label); - } - this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); - this.domNode.addEventListener('blur', this.handleBlur.bind(this)); - - // Traverse the element children of domNode: configure each with - // menuitem role behavior and store reference in menuitems array. - var menuElements = this.domNode.getElementsByTagName('LI'); - - for (var i = 0; i < menuElements.length; i++) { - menuElement = menuElements[i]; - if ( - !menuElement.firstElementChild && - menuElement.getAttribute('role') != 'separator' - ) { - menuItem = new MenuItem(menuElement, this); - menuItem.init(); - this.menuitems.push(menuItem); - textContent = menuElement.textContent.trim(); - this.firstChars.push(textContent.substring(0, 1).toLowerCase()); - } - } - - // Use populated menuitems array to initialize firstItem and lastItem. - if (this.menuitems.length > 0) { - this.firstItem = this.menuitems[0]; - this.currentItem = this.firstItem; - this.lastItem = this.menuitems[this.menuitems.length - 1]; - } -}; -PopupMenuActionActivedescendant.prototype.handleKeydown = function (event) { - var flag = false, - char = event.key, - clickEvent; - - function isPrintableCharacter(str) { - return str.length === 1 && str.match(/\S/); - } - - if (event.ctrlKey || event.altKey || event.metaKey) { - return; - } - - if (event.shiftKey) { - if (isPrintableCharacter(char)) { - this.setFocusByFirstCharacter(char); - } - } else { - switch (event.keyCode) { - case this.keyCode.SPACE: - flag = true; - break; - - case this.keyCode.RETURN: - // Create simulated mouse event to mimic the behavior of ATs - // and let the event handler handleClick do the housekeeping. - try { - clickEvent = new MouseEvent('click', { - view: window, - bubbles: true, - cancelable: true, - }); - } catch (err) { - if (document.createEvent) { - // DOM Level 3 for IE 9+ - clickEvent = document.createEvent('MouseEvents'); - clickEvent.initEvent('click', true, true); - } - } - this.currentItem.domNode.dispatchEvent(clickEvent); - flag = true; - break; - - case this.keyCode.ESC: - this.close(true); - this.setFocusToController(); - flag = true; - break; - - case this.keyCode.UP: - this.setFocusToPreviousItem(); - flag = true; - break; - - case this.keyCode.DOWN: - this.setFocusToNextItem(); - flag = true; - break; - - case this.keyCode.HOME: - case this.keyCode.PAGEUP: - this.setFocusToFirstItem(); - flag = true; - break; - - case this.keyCode.END: - case this.keyCode.PAGEDOWN: - this.setFocusToLastItem(); - flag = true; - break; - - case this.keyCode.TAB: - this.setFocusToController(); - this.hasFocus = false; - this.close(true); - break; - - default: - if (isPrintableCharacter(char)) { - this.setFocusByFirstCharacter(char); - } - break; - } - } - - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } -}; - -/* EVENT HANDLERS */ -PopupMenuActionActivedescendant.prototype.handleBlur = function (event) { - this.close(); -}; - -PopupMenuActionActivedescendant.prototype.handleMouseover = function (event) { - this.hasHover = true; -}; - -PopupMenuActionActivedescendant.prototype.handleMouseout = function (event) { - this.hasHover = false; - setTimeout(this.close.bind(this, false), 300); -}; - -/* FOCUS MANAGEMENT METHODS */ -PopupMenuActionActivedescendant.prototype.setFocus = function (item) { - for (var i = 0; i < this.menuitems.length; i++) { - var mi = this.menuitems[i]; - mi.domNode.classList.remove('focus'); - } - item.domNode.classList.add('focus'); - this.domNode.setAttribute('aria-activedescendant', item.domNode.id); - this.currentItem = item; -}; - -PopupMenuActionActivedescendant.prototype.setFocusToFirstItem = function () { - this.setFocus(this.firstItem); -}; - -PopupMenuActionActivedescendant.prototype.setFocusToLastItem = function () { - this.setFocus(this.lastItem); -}; - -PopupMenuActionActivedescendant.prototype.setFocusToPreviousItem = function () { - var index; - - if (this.currentItem === this.firstItem) { - this.setFocusToLastItem(); - } else { - index = this.menuitems.indexOf(this.currentItem); - this.setFocus(this.menuitems[index - 1]); - } -}; - -PopupMenuActionActivedescendant.prototype.setFocusToNextItem = function () { - var index; - - if (this.currentItem === this.lastItem) { - this.setFocusToFirstItem(); - } else { - index = this.menuitems.indexOf(this.currentItem); - this.setFocus(this.menuitems[index + 1]); - } -}; - -PopupMenuActionActivedescendant.prototype.setFocusByFirstCharacter = function ( - char -) { - var start, index; - - char = char.toLowerCase(); - - // Get start index for search based on position of currentItem - start = this.menuitems.indexOf(this.currentItem) + 1; - if (start === this.menuitems.length) { - start = 0; - } - - // Check remaining slots in the menu - index = this.getIndexFirstChars(start, char); - - // If not found in remaining slots, check from beginning - if (index === -1) { - index = this.getIndexFirstChars(0, char); - } - - // If match was found... - if (index > -1) { - this.setFocus(this.menuitems[index]); - } -}; - -PopupMenuActionActivedescendant.prototype.getIndexFirstChars = function ( - startIndex, - char -) { - for (var i = startIndex; i < this.firstChars.length; i++) { - if (char === this.firstChars[i]) { - return i; - } - } - return -1; -}; - -PopupMenuActionActivedescendant.prototype.getCurrentItem = function () { - var id = this.domNode.getAttribute('aria-activedescendant'); - if (!id) { - this.domNode.setAttribute( - 'aria-activedescendant', - this.firstItem.domNode.id - ); - return this.firstItem; - } - for (var i = 0; i < this.menuitems.length; i++) { - var mi = this.menuitems[i]; - if (mi.domNode.id == id) { - return mi; - } - } - this.domNode.setAttribute('aria-activedescendant', this.firstItem.domNode.id); - return this.firstItem; -}; - -/* MENU DISPLAY METHODS */ - -PopupMenuActionActivedescendant.prototype.open = function () { - // get bounding rectangle of controller object's DOM node - var rect = this.controller.domNode.getBoundingClientRect(); - - // set CSS properties - this.domNode.style.display = 'block'; - this.domNode.style.position = 'absolute'; - this.domNode.style.top = rect.height + 'px'; - this.domNode.style.left = '0px'; - - this.hasFocus = true; - this.domNode.focus(); - - // set aria-expanded attribute - this.controller.domNode.setAttribute('aria-expanded', 'true'); -}; - -PopupMenuActionActivedescendant.prototype.close = function (force) { - if (typeof force !== 'boolean') { - force = false; - } - - if (force || (this.hasFocus && !this.hasHover && !this.controller.hasHover)) { - this.domNode.style.display = 'none'; - this.controller.domNode.removeAttribute('aria-expanded'); - } -}; - -PopupMenuActionActivedescendant.prototype.setFocusToController = function () { - this.controller.domNode.focus(); -}; diff --git a/examples/menu-button/js/PopupMenuLinks.js b/examples/menu-button/js/PopupMenuLinks.js deleted file mode 100644 index ca898dfd3c..0000000000 --- a/examples/menu-button/js/PopupMenuLinks.js +++ /dev/null @@ -1,247 +0,0 @@ -/* - * This content is licensed according to the W3C Software License at - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - * File: PopupMenuLinks.js - * - * Desc: Popup menu Links widget that implements ARIA Authoring Practices - */ - -'use strict'; - -/* - * @constructor PopupMenuLinks - * - * @desc - * Wrapper object for a simple popup menu (without nested submenus) - * - * @param domNode - * The DOM element node that serves as the popup menu container. Each - * child element of domNode that represents a menuitem must have a - * 'role' attribute with value 'menuitem'. - * - * @param controllerObj - * The object that is a wrapper for the DOM element that controls the - * menu, e.g. a button element, with an 'aria-controls' attribute that - * references this menu's domNode. See MenuButton.js - * - * The controller object is expected to have the following properties: - * 1. domNode: The controller object's DOM element node, needed for - * retrieving positioning information. - * 2. hasHover: boolean that indicates whether the controller object's - * domNode has responded to a mouseover event with no subsequent - * mouseout event having occurred. - */ -var PopupMenuLinks = function (domNode, controllerObj) { - var elementChildren, - msgPrefix = 'PopupMenuLinks constructor argument domNode '; - - // Check whether domNode is a DOM element - if (!(domNode instanceof Element)) { - throw new TypeError(msgPrefix + 'is not a DOM Element.'); - } - - // Check whether domNode has child elements - if (domNode.childElementCount === 0) { - throw new Error(msgPrefix + 'has no element children.'); - } - - // Check whether domNode descendant elements have A elements - var childElement = domNode.firstElementChild; - while (childElement) { - var menuitem = childElement.firstElementChild; - if (menuitem && menuitem.tagName !== 'A') { - throw new Error( - msgPrefix + 'has descendant elements that are not A elements.' - ); - } - childElement = childElement.nextElementSibling; - } - - this.domNode = domNode; - this.controller = controllerObj; - - this.menuitems = []; // see PopupMenuLinks init method - this.firstChars = []; // see PopupMenuLinks init method - - this.firstItem = null; // see PopupMenuLinks init method - this.lastItem = null; // see PopupMenuLinks init method - - this.hasFocus = false; // see MenuItemLinks handleFocus, handleBlur - this.hasHover = false; // see PopupMenuLinks handleMouseover, handleMouseout -}; - -/* - * @method PopupMenuLinks.prototype.init - * - * @desc - * Add domNode event listeners for mouseover and mouseout. Traverse - * domNode children to configure each menuitem and populate menuitems - * array. Initialize firstItem and lastItem properties. - */ -PopupMenuLinks.prototype.init = function () { - var childElement, menuElement, menuItem, textContent, numItems, label; - - // Configure the domNode itself - this.domNode.tabIndex = -1; - - this.domNode.setAttribute('role', 'menu'); - - if ( - !this.domNode.getAttribute('aria-labelledby') && - !this.domNode.getAttribute('aria-label') && - !this.domNode.getAttribute('title') - ) { - label = this.controller.domNode.innerHTML; - this.domNode.setAttribute('aria-label', label); - } - - this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); - this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); - - // Traverse the element children of domNode: configure each with - // menuitem role behavior and store reference in menuitems array. - childElement = this.domNode.firstElementChild; - - while (childElement) { - menuElement = childElement.firstElementChild; - - if (menuElement && menuElement.tagName === 'A') { - menuItem = new MenuItemLinks(menuElement, this); - menuItem.init(); - this.menuitems.push(menuItem); - textContent = menuElement.textContent.trim(); - this.firstChars.push(textContent.substring(0, 1).toLowerCase()); - } - childElement = childElement.nextElementSibling; - } - - // Use populated menuitems array to initialize firstItem and lastItem. - numItems = this.menuitems.length; - if (numItems > 0) { - this.firstItem = this.menuitems[0]; - this.lastItem = this.menuitems[numItems - 1]; - } -}; - -/* EVENT HANDLERS */ - -PopupMenuLinks.prototype.handleMouseover = function (event) { - this.hasHover = true; -}; - -PopupMenuLinks.prototype.handleMouseout = function (event) { - this.hasHover = false; - setTimeout(this.close.bind(this, false), 300); -}; - -/* FOCUS MANAGEMENT METHODS */ - -PopupMenuLinks.prototype.setFocusToController = function (command) { - if (typeof command !== 'string') { - command = ''; - } - - if (command === 'previous') { - this.controller.menubar.setFocusToPreviousItem(this.controller); - } else { - if (command === 'next') { - this.controller.menubar.setFocusToNextItem(this.controller); - } else { - this.controller.domNode.focus(); - } - } -}; - -PopupMenuLinks.prototype.setFocusToFirstItem = function () { - this.firstItem.domNode.focus(); -}; - -PopupMenuLinks.prototype.setFocusToLastItem = function () { - this.lastItem.domNode.focus(); -}; - -PopupMenuLinks.prototype.setFocusToPreviousItem = function (currentItem) { - var index; - - if (currentItem === this.firstItem) { - this.lastItem.domNode.focus(); - } else { - index = this.menuitems.indexOf(currentItem); - this.menuitems[index - 1].domNode.focus(); - } -}; - -PopupMenuLinks.prototype.setFocusToNextItem = function (currentItem) { - var index; - - if (currentItem === this.lastItem) { - this.firstItem.domNode.focus(); - } else { - index = this.menuitems.indexOf(currentItem); - this.menuitems[index + 1].domNode.focus(); - } -}; - -PopupMenuLinks.prototype.setFocusByFirstCharacter = function ( - currentItem, - char -) { - var start, index; - - char = char.toLowerCase(); - - // Get start index for search based on position of currentItem - start = this.menuitems.indexOf(currentItem) + 1; - if (start === this.menuitems.length) { - start = 0; - } - - // Check remaining slots in the menu - index = this.getIndexFirstChars(start, char); - - // If not found in remaining slots, check from beginning - if (index === -1) { - index = this.getIndexFirstChars(0, char); - } - - // If match was found... - if (index > -1) { - this.menuitems[index].domNode.focus(); - } -}; - -PopupMenuLinks.prototype.getIndexFirstChars = function (startIndex, char) { - for (var i = startIndex; i < this.firstChars.length; i++) { - if (char === this.firstChars[i]) { - return i; - } - } - return -1; -}; - -/* MENU DISPLAY METHODS */ - -PopupMenuLinks.prototype.open = function () { - // get position and bounding rectangle of controller object's DOM node - var rect = this.controller.domNode.getBoundingClientRect(); - - // set CSS properties - this.domNode.style.display = 'block'; - this.domNode.style.position = 'absolute'; - this.domNode.style.top = rect.height + 'px'; - this.domNode.style.left = '0px'; - - // set aria-expanded attribute - this.controller.domNode.setAttribute('aria-expanded', 'true'); -}; - -PopupMenuLinks.prototype.close = function (force) { - if ( - force || - (!this.hasFocus && !this.hasHover && !this.controller.hasHover) - ) { - this.domNode.style.display = 'none'; - this.controller.domNode.removeAttribute('aria-expanded'); - } -}; diff --git a/examples/menu-button/js/menu-button-actions-active-descendant.js b/examples/menu-button/js/menu-button-actions-active-descendant.js new file mode 100644 index 0000000000..f7c0396b18 --- /dev/null +++ b/examples/menu-button/js/menu-button-actions-active-descendant.js @@ -0,0 +1,352 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * File: menu-button-actives-active-descendant.js + * + * Desc: Creates a menu button that opens a menu of actions using aria-activedescendants + */ + +'use strict'; + +class MenuButtonActionsActiveDescendant { + constructor(domNode, performMenuAction) { + this.domNode = domNode; + this.performMenuAction = performMenuAction; + this.buttonNode = domNode.querySelector('button'); + this.menuNode = domNode.querySelector('[role="menu"]'); + this.currentMenuitem = {}; + this.menuitemNodes = []; + this.firstMenuitem = false; + this.lastMenuitem = false; + this.firstChars = []; + + this.buttonNode.addEventListener( + 'keydown', + this.onButtonKeydown.bind(this) + ); + + this.buttonNode.addEventListener('click', this.onButtonClick.bind(this)); + + this.menuNode.addEventListener('keydown', this.onMenuKeydown.bind(this)); + + var nodes = domNode.querySelectorAll('[role="menuitem"]'); + + for (var i = 0; i < nodes.length; i++) { + var menuitem = nodes[i]; + this.menuitemNodes.push(menuitem); + menuitem.tabIndex = -1; + this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase()); + + menuitem.addEventListener('click', this.onMenuitemClick.bind(this)); + + menuitem.addEventListener( + 'mouseover', + this.onMenuitemMouseover.bind(this) + ); + + if (!this.firstMenuitem) { + this.firstMenuitem = menuitem; + } + this.lastMenuitem = menuitem; + } + + domNode.addEventListener('focusin', this.onFocusin.bind(this)); + domNode.addEventListener('focusout', this.onFocusout.bind(this)); + + window.addEventListener( + 'mousedown', + this.onBackgroundMousedown.bind(this), + true + ); + } + + setFocusToMenuitem(newMenuitem) { + for (var i = 0; i < this.menuitemNodes.length; i++) { + var menuitem = this.menuitemNodes[i]; + if (menuitem === newMenuitem) { + this.currentMenuitem = newMenuitem; + menuitem.classList.add('focus'); + this.menuNode.setAttribute('aria-activedescendant', newMenuitem.id); + } else { + menuitem.classList.remove('focus'); + } + } + } + + setFocusToFirstMenuitem() { + this.setFocusToMenuitem(this.firstMenuitem); + } + + setFocusToLastMenuitem() { + this.setFocusToMenuitem(this.lastMenuitem); + } + + setFocusToPreviousMenuitem() { + var newMenuitem, index; + + if (this.currentMenuitem === this.firstMenuitem) { + newMenuitem = this.lastMenuitem; + } else { + index = this.menuitemNodes.indexOf(this.currentMenuitem); + newMenuitem = this.menuitemNodes[index - 1]; + } + + this.setFocusToMenuitem(newMenuitem); + + return newMenuitem; + } + + setFocusToNextMenuitem() { + var newMenuitem, index; + + if (this.currentMenuitem === this.lastMenuitem) { + newMenuitem = this.firstMenuitem; + } else { + index = this.menuitemNodes.indexOf(this.currentMenuitem); + newMenuitem = this.menuitemNodes[index + 1]; + } + this.setFocusToMenuitem(newMenuitem); + + return newMenuitem; + } + + setFocusByFirstCharacter(char) { + var start, index; + + if (char.length > 1) { + return; + } + + char = char.toLowerCase(); + + // Get start index for search based on position of currentItem + start = this.menuitemNodes.indexOf(this.currentMenuitem) + 1; + if (start >= this.menuitemNodes.length) { + start = 0; + } + + // Check remaining slots in the menu + index = this.firstChars.indexOf(char, start); + + // If not found in remaining slots, check from beginning + if (index === -1) { + index = this.firstChars.indexOf(char, 0); + } + + // If match was found... + if (index > -1) { + this.setFocusToMenuitem(this.menuitemNodes[index]); + } + } + + // Utilities + + getIndexFirstChars(startIndex, char) { + for (var i = startIndex; i < this.firstChars.length; i++) { + if (char === this.firstChars[i]) { + return i; + } + } + return -1; + } + + // Popup menu methods + + openPopup() { + var rect = this.menuNode.getBoundingClientRect(); + this.menuNode.style.display = 'block'; + this.buttonNode.setAttribute('aria-expanded', 'true'); + this.menuNode.focus(); + this.setFocusToFirstMenuitem(); + } + + closePopup() { + if (this.isOpen()) { + this.buttonNode.removeAttribute('aria-expanded'); + this.menuNode.setAttribute('aria-activedescendant', ''); + for (var i = 0; i < this.menuitemNodes.length; i++) { + this.menuitemNodes[i].classList.remove('focus'); + } + this.menuNode.style.display = 'none'; + this.buttonNode.focus(); + } + } + + isOpen() { + return this.buttonNode.getAttribute('aria-expanded') === 'true'; + } + + // Menu event handlers + + onFocusin(event) { + this.domNode.classList.add('focus'); + } + + onFocusout(event) { + this.domNode.classList.remove('focus'); + } + + onButtonKeydown(event) { + var tgt = event.currentTarget, + key = event.key, + flag = false; + + switch (key) { + case ' ': + case 'Enter': + case 'ArrowDown': + case 'Down': + this.openPopup(); + this.setFocusToFirstMenuitem(); + flag = true; + break; + + case 'Esc': + case 'Escape': + this.closePopup(); + flag = true; + break; + + case 'Up': + case 'ArrowUp': + this.openPopup(); + this.setFocusToLastMenuitem(); + flag = true; + break; + + default: + break; + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onButtonClick(event) { + if (this.isOpen()) { + this.closePopup(); + } else { + this.openPopup(); + } + event.stopPropagation(); + event.preventDefault(); + } + + onMenuKeydown(event) { + var tgt = event.currentTarget, + key = event.key, + flag = false; + + function isPrintableCharacter(str) { + return str.length === 1 && str.match(/\S/); + } + + if (event.ctrlKey || event.altKey || event.metaKey) { + return; + } + + if (event.shiftKey) { + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(key); + flag = true; + } + + if (event.key === 'Tab') { + this.closePopup(); + flag = true; + } + } else { + switch (key) { + case ' ': + case 'Enter': + this.closePopup(); + this.performMenuAction(this.currentMenuitem); + flag = true; + break; + + case 'Esc': + case 'Escape': + this.closePopup(); + flag = true; + break; + + case 'Up': + case 'ArrowUp': + this.setFocusToPreviousMenuitem(); + flag = true; + break; + + case 'ArrowDown': + case 'Down': + this.setFocusToNextMenuitem(); + flag = true; + break; + + case 'Home': + case 'PageUp': + this.setFocusToFirstMenuitem(); + flag = true; + break; + + case 'End': + case 'PageDown': + this.setFocusToLastMenuitem(); + flag = true; + break; + + case 'Tab': + this.closePopup(); + break; + + default: + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(key); + flag = true; + } + break; + } + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onMenuitemMouseover(event) { + var tgt = event.currentTarget; + this.setFocusToMenuitem(tgt); + } + + onMenuitemClick(event) { + var tgt = event.currentTarget; + this.closePopup(); + this.performMenuAction(tgt); + } + + onBackgroundMousedown(event) { + if (!this.domNode.contains(event.target)) { + if (this.isOpen()) { + this.closePopup(); + } + } + } +} + +// Initialize menu buttons + +window.addEventListener('load', function () { + document.getElementById('action_output').value = 'none'; + + function performMenuAction(node) { + document.getElementById('action_output').value = node.textContent.trim(); + } + + var menuButtons = document.querySelectorAll('.menu-button-actions'); + for (var i = 0; i < menuButtons.length; i++) { + new MenuButtonActionsActiveDescendant(menuButtons[i], performMenuAction); + } +}); diff --git a/examples/menu-button/js/menu-button-actions.js b/examples/menu-button/js/menu-button-actions.js new file mode 100644 index 0000000000..17fd66cecf --- /dev/null +++ b/examples/menu-button/js/menu-button-actions.js @@ -0,0 +1,348 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * File: menu-button-actions.js + * + * Desc: Creates a menu button that opens a menu of actions + */ + +'use strict'; + +class MenuButtonActions { + constructor(domNode, performMenuAction) { + this.domNode = domNode; + this.performMenuAction = performMenuAction; + this.buttonNode = domNode.querySelector('button'); + this.menuNode = domNode.querySelector('[role="menu"]'); + this.menuitemNodes = []; + this.firstMenuitem = false; + this.lastMenuitem = false; + this.firstChars = []; + + this.buttonNode.addEventListener( + 'keydown', + this.onButtonKeydown.bind(this) + ); + this.buttonNode.addEventListener('click', this.onButtonClick.bind(this)); + + var nodes = domNode.querySelectorAll('[role="menuitem"]'); + + for (var i = 0; i < nodes.length; i++) { + var menuitem = nodes[i]; + this.menuitemNodes.push(menuitem); + menuitem.tabIndex = -1; + this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase()); + + menuitem.addEventListener('keydown', this.onMenuitemKeydown.bind(this)); + + menuitem.addEventListener('click', this.onMenuitemClick.bind(this)); + + menuitem.addEventListener( + 'mouseover', + this.onMenuitemMouseover.bind(this) + ); + + if (!this.firstMenuitem) { + this.firstMenuitem = menuitem; + } + this.lastMenuitem = menuitem; + } + + domNode.addEventListener('focusin', this.onFocusin.bind(this)); + domNode.addEventListener('focusout', this.onFocusout.bind(this)); + + window.addEventListener( + 'mousedown', + this.onBackgroundMousedown.bind(this), + true + ); + } + + setFocusToMenuitem(newMenuitem) { + this.menuitemNodes.forEach(function (item) { + if (item === newMenuitem) { + item.tabIndex = 0; + newMenuitem.focus(); + } else { + item.tabIndex = -1; + } + }); + } + + setFocusToFirstMenuitem(currentMenuitem) { + this.setFocusToMenuitem(this.firstMenuitem); + } + + setFocusToLastMenuitem(currentMenuitem) { + this.setFocusToMenuitem(this.lastMenuitem); + } + + setFocusToPreviousMenuitem(currentMenuitem) { + var newMenuitem, index; + + if (currentMenuitem === this.firstMenuitem) { + newMenuitem = this.lastMenuitem; + } else { + index = this.menuitemNodes.indexOf(currentMenuitem); + newMenuitem = this.menuitemNodes[index - 1]; + } + + this.setFocusToMenuitem(newMenuitem); + + return newMenuitem; + } + + setFocusToNextMenuitem(currentMenuitem) { + var newMenuitem, index; + + if (currentMenuitem === this.lastMenuitem) { + newMenuitem = this.firstMenuitem; + } else { + index = this.menuitemNodes.indexOf(currentMenuitem); + newMenuitem = this.menuitemNodes[index + 1]; + } + this.setFocusToMenuitem(newMenuitem); + + return newMenuitem; + } + + setFocusByFirstCharacter(currentMenuitem, char) { + var start, index; + + if (char.length > 1) { + return; + } + + char = char.toLowerCase(); + + // Get start index for search based on position of currentItem + start = this.menuitemNodes.indexOf(currentMenuitem) + 1; + if (start >= this.menuitemNodes.length) { + start = 0; + } + + // Check remaining slots in the menu + index = this.firstChars.indexOf(char, start); + + // If not found in remaining slots, check from beginning + if (index === -1) { + index = this.firstChars.indexOf(char, 0); + } + + // If match was found... + if (index > -1) { + this.setFocusToMenuitem(this.menuitemNodes[index]); + } + } + + // Utilities + + getIndexFirstChars(startIndex, char) { + for (var i = startIndex; i < this.firstChars.length; i++) { + if (char === this.firstChars[i]) { + return i; + } + } + return -1; + } + + // Popup menu methods + + openPopup() { + var rect = this.menuNode.getBoundingClientRect(); + this.menuNode.style.display = 'block'; + this.buttonNode.setAttribute('aria-expanded', 'true'); + } + + closePopup() { + if (this.isOpen()) { + this.buttonNode.removeAttribute('aria-expanded'); + this.menuNode.style.display = 'none'; + } + } + + isOpen() { + return this.buttonNode.getAttribute('aria-expanded') === 'true'; + } + + // Menu event handlers + + onFocusin(event) { + this.domNode.classList.add('focus'); + } + + onFocusout(event) { + this.domNode.classList.remove('focus'); + } + + onButtonKeydown(event) { + var tgt = event.currentTarget, + key = event.key, + flag = false; + + switch (key) { + case ' ': + case 'Enter': + case 'ArrowDown': + case 'Down': + this.openPopup(); + this.setFocusToFirstMenuitem(); + flag = true; + break; + + case 'Esc': + case 'Escape': + this.closePopup(); + flag = true; + break; + + case 'Up': + case 'ArrowUp': + this.openPopup(); + this.setFocusToLastMenuitem(); + flag = true; + break; + + default: + break; + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onButtonClick(event) { + if (this.isOpen()) { + this.closePopup(); + this.buttonNode.focus(); + } else { + this.openPopup(); + this.setFocusToFirstMenuitem(); + } + + event.stopPropagation(); + event.preventDefault(); + } + + onMenuitemKeydown(event) { + var tgt = event.currentTarget, + key = event.key, + flag = false; + + function isPrintableCharacter(str) { + return str.length === 1 && str.match(/\S/); + } + + if (event.ctrlKey || event.altKey || event.metaKey) { + return; + } + + if (event.shiftKey) { + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(tgt, key); + flag = true; + } + + if (event.key === 'Tab') { + this.buttonNode.focus(); + this.closePopup(); + flag = true; + } + } else { + switch (key) { + case ' ': + case 'Enter': + this.closePopup(); + this.performMenuAction(tgt); + this.buttonNode.focus(); + flag = true; + break; + + case 'Esc': + case 'Escape': + this.closePopup(); + this.buttonNode.focus(); + flag = true; + break; + + case 'Up': + case 'ArrowUp': + this.setFocusToPreviousMenuitem(tgt); + flag = true; + break; + + case 'ArrowDown': + case 'Down': + this.setFocusToNextMenuitem(tgt); + flag = true; + break; + + case 'Home': + case 'PageUp': + this.setFocusToFirstMenuitem(); + flag = true; + break; + + case 'End': + case 'PageDown': + this.setFocusToLastMenuitem(); + flag = true; + break; + + case 'Tab': + this.closePopup(); + break; + + default: + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(tgt, key); + flag = true; + } + break; + } + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onMenuitemClick(event) { + var tgt = event.currentTarget; + this.closePopup(); + this.buttonNode.focus(); + this.performMenuAction(tgt); + } + + onMenuitemMouseover(event) { + var tgt = event.currentTarget; + tgt.focus(); + } + + onBackgroundMousedown(event) { + if (!this.domNode.contains(event.target)) { + if (this.isOpen()) { + this.closePopup(); + this.buttonNode.focus(); + } + } + } +} + +// Initialize menu buttons +window.addEventListener('load', function () { + document.getElementById('action_output').value = 'none'; + + function performMenuAction(node) { + document.getElementById('action_output').value = node.textContent.trim(); + } + + var menuButtons = document.querySelectorAll('.menu-button-actions'); + for (var i = 0; i < menuButtons.length; i++) { + new MenuButtonActions(menuButtons[i], performMenuAction); + } +}); diff --git a/examples/menu-button/js/menu-button-links.js b/examples/menu-button/js/menu-button-links.js new file mode 100644 index 0000000000..01056c5e1b --- /dev/null +++ b/examples/menu-button/js/menu-button-links.js @@ -0,0 +1,330 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * File: menu-button-links.js + * + * Desc: Creates a menu button that opens a menu of links + */ + +'use strict'; + +class MenuButtonLinks { + constructor(domNode) { + this.domNode = domNode; + this.buttonNode = domNode.querySelector('button'); + this.menuNode = domNode.querySelector('[role="menu"]'); + this.menuitemNodes = []; + this.firstMenuitem = false; + this.lastMenuitem = false; + this.firstChars = []; + + this.buttonNode.addEventListener( + 'keydown', + this.onButtonKeydown.bind(this) + ); + this.buttonNode.addEventListener('click', this.onButtonClick.bind(this)); + + var nodes = domNode.querySelectorAll('[role="menuitem"]'); + + for (var i = 0; i < nodes.length; i++) { + var menuitem = nodes[i]; + this.menuitemNodes.push(menuitem); + menuitem.tabIndex = -1; + this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase()); + + menuitem.addEventListener('keydown', this.onMenuitemKeydown.bind(this)); + + menuitem.addEventListener( + 'mouseover', + this.onMenuitemMouseover.bind(this) + ); + + if (!this.firstMenuitem) { + this.firstMenuitem = menuitem; + } + this.lastMenuitem = menuitem; + } + + domNode.addEventListener('focusin', this.onFocusin.bind(this)); + domNode.addEventListener('focusout', this.onFocusout.bind(this)); + + window.addEventListener( + 'mousedown', + this.onBackgroundMousedown.bind(this), + true + ); + } + + setFocusToMenuitem(newMenuitem) { + this.menuitemNodes.forEach(function (item) { + if (item === newMenuitem) { + item.tabIndex = 0; + newMenuitem.focus(); + } else { + item.tabIndex = -1; + } + }); + } + + setFocusToFirstMenuitem(currentMenuitem) { + this.setFocusToMenuitem(this.firstMenuitem); + } + + setFocusToLastMenuitem(currentMenuitem) { + this.setFocusToMenuitem(this.lastMenuitem); + } + + setFocusToPreviousMenuitem(currentMenuitem) { + var newMenuitem, index; + + if (currentMenuitem === this.firstMenuitem) { + newMenuitem = this.lastMenuitem; + } else { + index = this.menuitemNodes.indexOf(currentMenuitem); + newMenuitem = this.menuitemNodes[index - 1]; + } + + this.setFocusToMenuitem(newMenuitem); + + return newMenuitem; + } + + setFocusToNextMenuitem(currentMenuitem) { + var newMenuitem, index; + + if (currentMenuitem === this.lastMenuitem) { + newMenuitem = this.firstMenuitem; + } else { + index = this.menuitemNodes.indexOf(currentMenuitem); + newMenuitem = this.menuitemNodes[index + 1]; + } + this.setFocusToMenuitem(newMenuitem); + + return newMenuitem; + } + + setFocusByFirstCharacter(currentMenuitem, char) { + var start, index; + + if (char.length > 1) { + return; + } + + char = char.toLowerCase(); + + // Get start index for search based on position of currentItem + start = this.menuitemNodes.indexOf(currentMenuitem) + 1; + if (start >= this.menuitemNodes.length) { + start = 0; + } + + // Check remaining slots in the menu + index = this.firstChars.indexOf(char, start); + + // If not found in remaining slots, check from beginning + if (index === -1) { + index = this.firstChars.indexOf(char, 0); + } + + // If match was found... + if (index > -1) { + this.setFocusToMenuitem(this.menuitemNodes[index]); + } + } + + // Utilities + + getIndexFirstChars(startIndex, char) { + for (var i = startIndex; i < this.firstChars.length; i++) { + if (char === this.firstChars[i]) { + return i; + } + } + return -1; + } + + // Popup menu methods + + openPopup() { + var rect = this.menuNode.getBoundingClientRect(); + this.menuNode.style.display = 'block'; + this.buttonNode.setAttribute('aria-expanded', 'true'); + } + + closePopup() { + if (this.isOpen()) { + this.buttonNode.removeAttribute('aria-expanded'); + this.menuNode.style.display = 'none'; + } + } + + isOpen() { + return this.buttonNode.getAttribute('aria-expanded') === 'true'; + } + + // Menu event handlers + + onFocusin(event) { + this.domNode.classList.add('focus'); + } + + onFocusout(event) { + this.domNode.classList.remove('focus'); + } + + onButtonKeydown(event) { + var tgt = event.currentTarget, + key = event.key, + flag = false; + + switch (key) { + case ' ': + case 'Enter': + case 'ArrowDown': + case 'Down': + this.openPopup(); + this.setFocusToFirstMenuitem(); + flag = true; + break; + + case 'Esc': + case 'Escape': + this.closePopup(); + this.buttonNode.focus(); + flag = true; + break; + + case 'Up': + case 'ArrowUp': + this.openPopup(); + this.setFocusToLastMenuitem(); + flag = true; + break; + + default: + break; + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onButtonClick(event) { + if (this.isOpen()) { + this.closePopup(); + this.buttonNode.focus(); + } else { + this.openPopup(); + this.setFocusToFirstMenuitem(); + } + + event.stopPropagation(); + event.preventDefault(); + } + + onMenuitemKeydown(event) { + var tgt = event.currentTarget, + key = event.key, + flag = false; + + function isPrintableCharacter(str) { + return str.length === 1 && str.match(/\S/); + } + + if (event.ctrlKey || event.altKey || event.metaKey) { + return; + } + + if (event.shiftKey) { + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(tgt, key); + flag = true; + } + + if (event.key === 'Tab') { + this.buttonNode.focus(); + this.closePopup(); + flag = true; + } + } else { + switch (key) { + case ' ': + window.location.href = tgt.href; + break; + + case 'Esc': + case 'Escape': + this.closePopup(); + this.buttonNode.focus(); + flag = true; + break; + + case 'Up': + case 'ArrowUp': + this.setFocusToPreviousMenuitem(tgt); + flag = true; + break; + + case 'ArrowDown': + case 'Down': + this.setFocusToNextMenuitem(tgt); + flag = true; + break; + + case 'Home': + case 'PageUp': + this.setFocusToFirstMenuitem(); + flag = true; + break; + + case 'End': + case 'PageDown': + this.setFocusToLastMenuitem(); + flag = true; + break; + + case 'Tab': + this.closePopup(); + break; + + default: + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(tgt, key); + flag = true; + } + break; + } + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onMenuitemMouseover(event) { + var tgt = event.currentTarget; + tgt.focus(); + } + + onBackgroundMousedown(event) { + if (!this.domNode.contains(event.target)) { + if (this.isOpen()) { + this.closePopup(); + this.buttonNode.focus(); + } + } + } +} + +// Initialize menu buttons + +window.addEventListener('load', function () { + var menuButtons = document.querySelectorAll('.menu-button-links'); + for (let i = 0; i < menuButtons.length; i++) { + var menuButton = new MenuButtonLinks(menuButtons[i]); + } +}); diff --git a/examples/menu-button/menu-button-actions-active-descendant.html b/examples/menu-button/menu-button-actions-active-descendant.html index cc684ce86b..f23f216a56 100644 --- a/examples/menu-button/menu-button-actions-active-descendant.html +++ b/examples/menu-button/menu-button-actions-active-descendant.html @@ -11,10 +11,8 @@ - - - - + + +

Actions Menu Button Example Using aria-activedescendant

- This example demonstrates how the + This example demonstrates how the menu button design pattern can be used to create a button that opens an actions menu. In this example, choosing an action from the menu will cause the chosen action to be displayed in the Last Action edit box.

- This implementation + This implementation uses aria-activedescendant to manage focus in the menu.

@@ -47,46 +45,59 @@

Actions Menu Button Example Using aria-activedescendant

Example

- +
- +
+

Accessibility Features

+
    +
  1. A down arrow icon is included to help users understand that the button opens a menu.
  2. +
  3. To support operating system high contrast settings: +
      +
    • + Because transparent borders are visible on some systems with operating system high contrast settings enabled, transparency cannot be used to create a visual difference between the element that is focused an other elements. + Instead of using transparency, the focused element has a thicker border and less padding. + When an element receives focus, its border changes from 1 to 3 pixels and padding is reduced by 2 pixels. + When an element loses focus, its border changes from 3 pixels to 1 and padding is increased by 2 pixels. +
    • +
    • Because background color and text color styles can be overridden by operating system high contrast settings, a border is used to ensure the button has a visible boundary when high contrast mode is enabled.
    • +
    • + To ensure the arrow icons used to indicate the expanded or collapsed state have sufficient contrast with the background when high contrast settings invert colors, the CSS currentColor value for the fill and stroke properties of the SVG polygon element is used to synchronize the color with text content. + If specific colors are used to specify the fill and stroke properties, these colors will remain the same in high contrast mode, which could lead to insufficient contrast between the icon and the background or even make the icon invisible if its color matches the high contrast mode background. +
    • +
    +
  4. +
+
+

Keyboard Support

Menu Button

@@ -226,7 +237,7 @@

Menu Button

  • Added when the menu is open.
  • Indicates that the menu is displayed and that activating the menu button closes the menu.
  • The aria-expanded attribute is removed when the menu is closed.
  • -
  • Included to support touch devices where screen reader users can touch the menu button when the menu is displayed. Keyboard users cannot focus the menu button when the menu is open.
  • +
  • Included to support touch devices where screen reader users can touch the menu button when the menu is displayed. Keyboard users cannot focus the menu button when the menu is open.
  • @@ -294,7 +305,7 @@

    Menu

  • The IDREF value is updated when keys that move the focus indicator among menu items are pressed.
  • Enables assistive technologies to know which element the application regards as focused while DOM focus remains on the ul element with role menu.
  • - For more information about this focus management technique, see + For more information about this focus management technique, see Using aria-activedescendant to Manage Focus.
  • @@ -324,10 +335,8 @@

    Menu

    Javascript and CSS Source Code

    diff --git a/examples/menu-button/menu-button-actions.html b/examples/menu-button/menu-button-actions.html index 43d76ed0b2..6868565bc0 100644 --- a/examples/menu-button/menu-button-actions.html +++ b/examples/menu-button/menu-button-actions.html @@ -11,10 +11,8 @@ - - - - + +
    - +
    +

    Accessibility Features

    +
      +
    1. A down arrow icon is included to help users understand that the button opens a menu.
    2. +
    3. To support operating system high contrast settings: +
        +
      • + Because transparent borders are visible on some systems with operating system high contrast settings enabled, transparency cannot be used to create a visual difference between the element that is focused an other elements. + Instead of using transparency, the focused element has a thicker border and less padding. + When an element receives focus, its border changes from 1 to 3 pixels and padding is reduced by 2 pixels. + When an element loses focus, its border changes from 3 pixels to 1 and padding is increased by 2 pixels. +
      • +
      • Because background color and text color styles can be overridden by operating system high contrast settings, a border is used to ensure the button has a visible boundary when high contrast mode is enabled.
      • +
      • + To ensure the arrow icons used to indicate the expanded or collapsed state have sufficient contrast with the background when high contrast settings invert colors, the CSS currentColor value for the fill and stroke properties of the SVG polygon element is used to synchronize the color with text content. + If specific colors are used to specify the fill and stroke properties, these colors will remain the same in high contrast mode, which could lead to insufficient contrast between the icon and the background or even make the icon invisible if its color matches the high contrast mode background. +
      • +
      +
    4. +
    +
    +

    Keyboard Support

    Menu Button

    @@ -305,10 +313,8 @@

    Menu

    Javascript and CSS Source Code

    diff --git a/examples/menu-button/menu-button-links.html b/examples/menu-button/menu-button-links.html index 7e5fa8663f..2e73e4a203 100644 --- a/examples/menu-button/menu-button-links.html +++ b/examples/menu-button/menu-button-links.html @@ -12,10 +12,8 @@ - - - - + +
    +
    +

    Accessibility Features

    +
      +
    1. A down arrow icon is included to help users understand that the button opens a menu.
    2. +
    3. To support operating system high contrast settings: +
        +
      • + Because transparent borders are visible on some systems with operating system high contrast settings enabled, transparency cannot be used to create a visual difference between the element that is focused an other elements. + Instead of using transparency, the focused element has a thicker border and less padding. + When an element receives focus, its border changes from 1 to 3 pixels and padding is reduced by 2 pixels. + When an element loses focus, its border changes from 3 pixels to 1 and padding is increased by 2 pixels. +
      • +
      • Because background color and text color styles can be overridden by operating system high contrast settings, a border is used to ensure the button has a visible boundary when high contrast mode is enabled.
      • +
      • + To ensure the arrow icons used to indicate the expanded or collapsed state have sufficient contrast with the background when high contrast settings invert colors, the CSS currentColor value for the fill and stroke properties of the SVG polygon element is used to synchronize the color with text content. + If specific colors are used to specify the fill and stroke properties, these colors will remain the same in high contrast mode, which could lead to insufficient contrast between the icon and the background or even make the icon invisible if its color matches the high contrast mode background. +
      • +
      +
    4. +
    +
    +

    Keyboard Support

    Menu Button

    @@ -138,13 +152,9 @@

    Menu

    - Enter + Space
    Enter -
      -
    • Closes the menu.
    • -
    • Sets focus on the menu button
    • -
    • Activates the menu item, which is equivalent to activating the link element from which the menu item is made.
    • -
    + Activates the menu item, which is equivalent to activating the link element from which the menu item is made. @@ -335,11 +345,9 @@

    Menu

    Javascript and CSS Source Code

    -
    @@ -353,7 +361,7 @@

    HTML Source Code

    If you change the ID of either the 'ex1' div or the 'sc1' div, be sure to update the sourceCode.add function parameters. -->
    diff --git a/test/tests/menu-button_actions-active-descendant.js b/test/tests/menu-button_actions-active-descendant.js index 9bd5fd102d..3e8678a73d 100644 --- a/test/tests/menu-button_actions-active-descendant.js +++ b/test/tests/menu-button_actions-active-descendant.js @@ -29,7 +29,13 @@ const checkFocus = function (t, selector, index) { ); }; -const openMenu = async function (t) { +const scrollToAndOpenMenu = async function (t) { + // Click the "last action" box to scroll the menu into view before opening the menu and sending enter + // This prevents a bug where when you click the menu button, the menu is opened and the page scrolls down + // to reveal the menu, which places the curser over the last menu item, which sets aria-activedescendent to + // the last item in the list. + await t.context.session.findElement(By.css(ex.lastactionSelector)).click(); + await t.context.session.findElement(By.css(ex.menubuttonSelector)).click(); return t.context.session.wait( @@ -90,7 +96,7 @@ ariaTest( 'The popup should not be displayed if aria-expanded is false' ); - await openMenu(t); + await scrollToAndOpenMenu(t); await assertAttributeValues( t, @@ -120,13 +126,12 @@ ariaTest( } ); -// This test fails due to bug: https://github.com/w3c/aria-practices/issues/894 -ariaTest.failing( +ariaTest( 'tabindex="-1" on role="menu"', exampleFile, 'menu-tabindex', async (t) => { - await openMenu(t); + await scrollToAndOpenMenu(t); await assertAttributeValues(t, ex.menuSelector, 'tabindex', '-1'); } ); @@ -262,7 +267,7 @@ ariaTest('"enter" on role="menu"', exampleFile, 'menu-enter', async (t) => { // Select the FIRST item: Send ENTER to the menu while aria-activedescendant is the first item - await openMenu(t); + await scrollToAndOpenMenu(t); let itemText = await items[0].getText(); await menu.sendKeys(Key.ENTER); @@ -286,7 +291,7 @@ ariaTest('"enter" on role="menu"', exampleFile, 'menu-enter', async (t) => { // Select the SECOND item: Send ENTER to the menu while aria-activedescendant is the second item - await openMenu(t); + await scrollToAndOpenMenu(t); itemText = await items[1].getText(); await menu.sendKeys(Key.ARROW_DOWN, Key.ENTER); @@ -310,7 +315,7 @@ ariaTest('"enter" on role="menu"', exampleFile, 'menu-enter', async (t) => { // Select the THIRD item: Send ENTER to the menu while aria-activedescendant is the third item - await openMenu(t); + await scrollToAndOpenMenu(t); itemText = await items[2].getText(); await menu.sendKeys(Key.ARROW_DOWN, Key.ARROW_DOWN, Key.ENTER); @@ -334,7 +339,7 @@ ariaTest('"enter" on role="menu"', exampleFile, 'menu-enter', async (t) => { // Select the FOURTH item: Send ENTER to the menu while aria-activedescendant is the fourth item - await openMenu(t); + await scrollToAndOpenMenu(t); itemText = await items[3].getText(); await menu.sendKeys( Key.ARROW_DOWN, @@ -366,7 +371,7 @@ ariaTest('"escape" on role="menu"', exampleFile, 'menu-escape', async (t) => { const menu = await t.context.session.findElement(By.css(ex.menuSelector)); const items = await t.context.queryElements(t, ex.menuitemSelector); for (let item of items) { - await openMenu(t); + await scrollToAndOpenMenu(t); const itemText = await item.getText(); await item.sendKeys(Key.ESCAPE); @@ -432,7 +437,7 @@ ariaTest('"escape" on role="menu"', exampleFile, 'menu-escape', async (t) => { ariaTest('"home" on role="menu"', exampleFile, 'menu-home', async (t) => { const menu = await t.context.session.findElement(By.css(ex.menuSelector)); const items = await t.context.queryElements(t, ex.menuitemSelector); - await openMenu(t); + await scrollToAndOpenMenu(t); // Send HOME to the menu while aria-activedescendant is the first item @@ -459,7 +464,7 @@ ariaTest('"end" on role="menu"', exampleFile, 'menu-end', async (t) => { const menu = await t.context.session.findElement(By.css(ex.menuSelector)); const items = await t.context.queryElements(t, ex.menuitemSelector); const last = ex.numMenuitems - 1; - await openMenu(t); + await scrollToAndOpenMenu(t); // Send END to the menu while aria-activedescendant is the first item diff --git a/test/tests/menu-button_actions.js b/test/tests/menu-button_actions.js index 4be9345e6c..eafbad0732 100644 --- a/test/tests/menu-button_actions.js +++ b/test/tests/menu-button_actions.js @@ -15,6 +15,12 @@ const ex = { lastactionSelector: '#action_output', }; +const getFocusText = function (t) { + return t.context.session.executeScript(function () { + return document.activeElement.textContent.trim(); + }); +}; + const checkFocus = function (t, selector, index) { return t.context.session.executeScript( function () { @@ -28,7 +34,9 @@ const checkFocus = function (t, selector, index) { }; const openMenu = async function (t) { - return t.context.session.findElement(By.css(ex.menubuttonSelector)).click(); + return await t.context.session + .findElement(By.css(ex.menubuttonSelector)) + .sendKeys(Key.ARROW_DOWN); }; // Attributes @@ -304,7 +312,9 @@ ariaTest( await checkFocus(t, ex.menuitemSelector, index + 1), 'down arrow on item "' + itemText + - '" should put focus on the next item.' + ' (' + + index + + ') " should put focus on the next item.' ); } @@ -313,7 +323,9 @@ ariaTest( const itemText = await items[items.length - 1].getText(); t.true( await checkFocus(t, ex.menuitemSelector, 0), - 'down arrow on item "' + itemText + '" should put focus to first item.' + 'down arrow on item "' + + itemText + + ' (0) " should put focus to first item.' ); } ); @@ -332,7 +344,11 @@ ariaTest( const itemText = await items[0].getText(); t.true( await checkFocus(t, ex.menuitemSelector, items.length - 1), - 'up arrow on item "' + itemText + '" should put focus to last item.' + 'up arrow on item "' + + itemText + + ' (0 of ' + + items.length + + ') " should put focus to last item.' ); for (let index = items.length - 1; index > 0; index--) { @@ -341,9 +357,11 @@ ariaTest( const itemText = await items[index].getText(); t.true( await checkFocus(t, ex.menuitemSelector, index - 1), - 'down arrow on item "' + + 'up arrow on item "' + itemText + - '" should put focus on the previous item.' + ' (' + + index + + ') " should put focus on the previous item.' ); } } diff --git a/test/tests/menu-button_links.js b/test/tests/menu-button_links.js index 83453a2f8a..d120fe4fc1 100644 --- a/test/tests/menu-button_links.js +++ b/test/tests/menu-button_links.js @@ -33,7 +33,9 @@ const openMenu = async function (t) { .getAttribute('aria-expanded'); if (expanded !== 'true') { - await t.context.session.findElement(By.css(ex.menubuttonSelector)).click(); + await t.context.session + .findElement(By.css(ex.menubuttonSelector)) + .sendKeys(Key.ENTER); } return t.context.session.wait( @@ -282,16 +284,23 @@ ariaTest( 'menu-escape', async (t) => { const items = await t.context.queryElements(t, ex.menuitemSelector); - for (let index = 0; index < ex.numMenuitems; index++) { + for (let index = 0; index < items.length; index++) { const item = items[index]; await openMenu(t); await item.sendKeys(Key.ESCAPE); await waitForNoAriaExpanded(t); + // fixes for running regression tests on windows + let url = t.context.url; + if (url.indexOf('\\') >= 0) { + url = url.replace(/\\/g, '/'); + url = url.replace('file://C:', 'file:///C:'); + } + t.is( await t.context.session.getCurrentUrl(), - t.context.url, + url, 'Key escape when focus on list item at index ' + index + ' should not activate the link'