├── .gitignore ├── inclusive-menu-button.css ├── LICENSE ├── package.json ├── examples ├── basic.html ├── menuitemcheckbox.html ├── menuitemradio.html └── disabled-items.html ├── inclusive-menu-button.min.js ├── README.md └── inclusive-menu-button.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | -------------------------------------------------------------------------------- /inclusive-menu-button.css: -------------------------------------------------------------------------------- 1 | [data-inclusive-menu] { 2 | position: relative; 3 | display: inline-block; 4 | } 5 | 6 | [data-inclusive-menu-opens], 7 | [data-inclusive-menu] [role^="menuitem"] { 8 | text-align: left; 9 | border: 0; 10 | } 11 | 12 | [data-inclusive-menu] [role="menu"] { 13 | position: absolute; 14 | left: 0; 15 | } 16 | 17 | [data-inclusive-menu] [data-inclusive-menu-from="right"] { 18 | left: auto; 19 | right: 0; 20 | } 21 | 22 | [data-inclusive-menu] [role^="menuitem"] { 23 | display: block; 24 | min-width: 100%; 25 | white-space: nowrap; 26 | } 27 | 28 | [data-inclusive-menu] [role^="menuitem"][aria-checked="true"]::before { 29 | content: '\2713\0020'; 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inclusive-menu-button", 3 | "version": "0.1.4", 4 | "description": "A menu button module that implements the correct ARIA semantics and keyboard behavior.", 5 | "main": "inclusive-menu-button.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint": "standard ./inclusive-menu-button.js", 9 | "uglify": "uglifyjs inclusive-menu-button.js -o inclusive-menu-button.min.js", 10 | "extract-version": "cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]'", 11 | "add-version": "echo \"/*! inclusive-menu-button $(npm run extract-version --silent) — © Heydon Pickering */\n$(cat inclusive-menu-button.min.js)\" > inclusive-menu-button.min.js", 12 | "build": "npm run uglify && npm run add-version", 13 | "precommit": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/Heydon/inclusive-menu-button.git" 18 | }, 19 | "keywords": [ 20 | "menu", 21 | "button", 22 | "ARIA", 23 | "accessibility", 24 | "dropdown" 25 | ], 26 | "author": "Heydon Pickering", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/Heydon/inclusive-menu-button/issues" 30 | }, 31 | "homepage": "https://github.com/Heydon/inclusive-menu-button#readme", 32 | "devDependencies": { 33 | "husky": "^0.13.3", 34 | "standard": "^10.0.2", 35 | "uglify-js": "^2.8.22" 36 | } 37 | } -------------------------------------------------------------------------------- /examples/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 37 | 38 | Inclusive Menu Button | Basic Example 39 | 40 | 41 | 42 |
43 | 47 |
48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 | 56 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/menuitemcheckbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 37 | 38 | Inclusive Menu Button | menuitemcheckbox example 39 | 40 | 41 | 42 |
43 | 47 |
48 | 49 | 50 | 51 |
52 |
53 | 54 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /examples/menuitemradio.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 37 | 38 | Inclusive Menu Button | menuitemradio example 39 | 40 | 41 | 42 |
43 | 47 |
48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 | 56 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /examples/disabled-items.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 42 | 43 | Inclusive Menu Button | Disable Items Example 44 | 45 | 46 | 47 |
48 | 52 |
53 | 54 | 55 | 56 | 57 | 58 |
59 |
60 | 61 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /inclusive-menu-button.min.js: -------------------------------------------------------------------------------- 1 | /*! inclusive-menu-button 0.1.3 — © Heydon Pickering */ 2 | (function(global){"use strict";function MenuButton(button,options){options=options||{};this.settings={checkable:"none"};for(var setting in options){if(options.hasOwnProperty(setting)){this.settings[setting]=options[setting]}}this.button=button;this.button.setAttribute("aria-haspopup",true);this.button.setAttribute("aria-expanded",false);this.menuId=this.button.getAttribute("data-inclusive-menu-opens");this.menu=document.getElementById(this.menuId);if(!this.menu){throw new Error("Element `#"+this.menuId+"` not found.")}this.menu.setAttribute("role","menu");this.menu.hidden=true;this.menuItems=this.menu.querySelectorAll("button");if(this.menuItems.length<1){throw new Error("The #"+this.menuId+" menu has no menu items")}this.firstItem=this.menuItems[0];this.lastItem=this.menuItems[this.menuItems.length-1];var focusNext=function(currentItem,startItem){var goingDown=startItem===this.firstItem;function move(elem){return(goingDown?elem.nextElementSibling:elem.previousElementSibling)||startItem}var nextItem=move(currentItem);while(nextItem.disabled){nextItem=move(nextItem)}nextItem.focus()}.bind(this);Array.prototype.forEach.call(this.menuItems,function(menuItem){var active=Array.prototype.filter.call(this.menuItems,function(item){return!item.disabled});if(active.length<1){this.button.disabled=true;return}if(this.settings.checkable==="one"){menuItem.setAttribute("role","menuitemradio")}else if(this.settings.checkable==="many"){menuItem.setAttribute("role","menuitemcheckbox")}else{menuItem.setAttribute("role","menuitem")}menuItem.setAttribute("tabindex","-1");menuItem.addEventListener("keydown",function(e){if(e.keyCode===40){e.preventDefault();focusNext(menuItem,this.firstItem)}if(e.keyCode===38){e.preventDefault();focusNext(menuItem,this.lastItem)}if(e.keyCode===27||e.keyCode===9){this.toggle()}if(e.keyCode===27){e.preventDefault();this.button.focus()}}.bind(this));menuItem.addEventListener("click",function(e){this.choose(menuItem);this.close();this.button.focus()}.bind(this))}.bind(this));this.button.addEventListener("click",this.toggle.bind(this));this.button.addEventListener("keydown",function(e){if(e.keyCode===40){if(this.menu.hidden){this.open()}else{this.menu.querySelector(":not([disabled])").focus()}}if(e.keyCode===38){this.close()}}.bind(this));this._listeners={}}MenuButton.prototype.open=function(){this.button.setAttribute("aria-expanded",true);this.menu.hidden=false;if(this.settings.checkable==="one"){var checked=this.menu.querySelector('[aria-checked="true"]')}if(checked){checked.focus()}else{this.menu.querySelector('[role^="menuitem"]:not([disabled])').focus()}this.outsideClick=function(e){if(!this.menu.contains(e.target)&&!this.button.contains(e.target)){this.close();document.removeEventListener("click",this.outsideClick.bind(this))}}.bind(this);document.addEventListener("click",this.outsideClick.bind(this));this._fire("open");return this};MenuButton.prototype.close=function(){this.button.setAttribute("aria-expanded",false);this.menu.hidden=true;this._fire("close");return this};MenuButton.prototype.toggle=function(){var expanded=this.button.getAttribute("aria-expanded")==="true";return expanded?this.close():this.open()};MenuButton.prototype.choose=function(choice){if(this.settings.checkable==="one"){Array.prototype.forEach.call(this.menuItems,function(menuItem){menuItem.removeAttribute("aria-checked")});choice.setAttribute("aria-checked","true")}if(this.settings.checkable==="many"){var checked=choice.getAttribute("aria-checked")==="true"||false;choice.setAttribute("aria-checked",!checked)}this._fire("choose",choice);return this};MenuButton.prototype._fire=function(type,data){var listeners=this._listeners[type]||[];listeners.forEach(function(listener){listener(data)})};MenuButton.prototype.on=function(type,handler){if(typeof this._listeners[type]==="undefined"){this._listeners[type]=[]}this._listeners[type].push(handler);return this};MenuButton.prototype.off=function(type,handler){var index=this._listeners[type].indexOf(handler);if(index>-1){this._listeners[type].splice(index,1)}return this};if(typeof module!=="undefined"&&typeof module.exports!=="undefined"){module.exports=MenuButton}else if(typeof define==="function"&&define.amd){define("MenuButton",[],function(){return MenuButton})}else if(typeof global==="object"){global.MenuButton=MenuButton}})(this); 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inclusive Menu Button 2 | 3 | A **menu button** module that implements the correct ARIA semantics and keyboard behavior. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm i inclusive-menu-button --save 9 | ``` 10 | 11 | ## Expected markup 12 | 13 | In the following example, three menu items are provided. 14 | 15 | ```html 16 |
17 | 21 |
22 | 23 | 24 | 25 |
26 |
27 | ``` 28 | 29 | * The parent element must take `data-inclusive-menu`. 30 | * `data-inclusive-menu-opens` takes a value that must match the menu element's `id`. In this case, it is `difficulty`. 31 | * `data-inclusive-menu-from` defines from which side of the button the menu will grow. Any value but "right" will mean it grows from the left. 32 | * The menu items must be sibling buttons. The script adds the `menuitem` role (as well as the `menu` role to the parent menu element). 33 | 34 | ### After initialization 35 | 36 | Once you've initialized the menu button, this will be the resulting markup, including all of the necessary ARIA attribution: 37 | 38 | ```html 39 |
40 | 44 | 49 |
50 | ``` 51 | 52 | ## CSS 53 | 54 | The following functional styling is provided for the basic layout of an archetypal "dropdown" menu appearance. You can either override and add to these styles in the cascade or remove them altogether and start from scratch. 55 | 56 | ```css 57 | [data-inclusive-menu] { 58 | position: relative; 59 | display: inline-block; 60 | } 61 | 62 | [data-inclusive-menu-opens], 63 | [data-inclusive-menu] [role^="menuitem"] { 64 | text-align: left; 65 | border: 0; 66 | } 67 | 68 | [data-inclusive-menu] [role="menu"] { 69 | position: absolute; 70 | left: 0; 71 | } 72 | 73 | [data-inclusive-menu] [data-inclusive-menu-from="right"] { 74 | left: auto; 75 | right: 0; 76 | } 77 | 78 | [data-inclusive-menu] [role^="menuitem"] { 79 | display: block; 80 | min-width: 100%; 81 | white-space: nowrap; 82 | } 83 | 84 | [data-inclusive-menu] [role^="menuitem"][aria-checked="true"]::before { 85 | content: '\2713\0020'; 86 | } 87 | ``` 88 | 89 | ## Initialization 90 | 91 | Initialize the menu button / menu like so: 92 | 93 | ```js 94 | // get a menu button 95 | const exampleButton = document.querySelector('[data-inclusive-menu-opens]') 96 | 97 | // Make it a menu button 98 | const exampleMenuButton = new MenuButton(exampleButton) 99 | ``` 100 | 101 | ### Checked items 102 | 103 | Sometimes you'd like to persist the selected menu item, using a checked state. WAI-ARIA provides `menuitemradio` (allowing the checking of just one item) and `menuitemcheckbox` (allowing the checking of multiple items). Checked items are marked with `aria-checked="true"`. 104 | 105 | You can supply the constructor with a `checkable` value of 'none' (default), 'one', or 'many'. In the following example, 'one' is chosen, implementing `menuitemradio`. See the examples folder for working demonstrations. 106 | 107 | ```js 108 | // Make it a menu button with menuitemradio buttons 109 | const exampleMenuButton = new MenuButton(exampleButton, { checkable: 'one' }) 110 | ``` 111 | 112 | If you want to set default checked items, just do that in the HTML: 113 | 114 | ```html 115 |
116 | 117 | 118 | 119 |
120 | ``` 121 | 122 | The basic CSS (see above) prefixes the checked item with a check mark. This declaration can be removed safely and replaced with a different form of indication. 123 | 124 | ### API methods 125 | 126 | You can open and close the menu programmatically. 127 | 128 | ```js 129 | // Open 130 | exampleMenuBtn.open() 131 | 132 | // Close 133 | exampleMenuBtn.close() 134 | 135 | // Toggle 136 | exampleMenuBtn.toggle() 137 | ``` 138 | 139 | ### Event subscription 140 | 141 | You can subscribe to emitted `open`, `close`, and `choose` events. 142 | 143 | #### `open` and `close` examples 144 | 145 | ```js 146 | exampleMenuButton.on('open', function () { 147 | // Do something when the menu gets open 148 | }) 149 | 150 | exampleMenuButton.on('close', function () { 151 | // Do something when the menu gets closed 152 | }) 153 | ``` 154 | 155 | #### `choose` example 156 | 157 | The `choose` event is passed the chosen item’s DOM node. 158 | 159 | ```js 160 | exampleMenuButton.on('choose', function (choice) { 161 |  // Do something with `choice` DOM node 162 | }) 163 | ``` 164 | 165 | ### Unsubscribing 166 | 167 | There is an `off` method included for terminating event listeners. 168 | 169 | ```js 170 | exampleMenuButton.off('choose', exampleHandler) 171 | ``` 172 | -------------------------------------------------------------------------------- /inclusive-menu-button.js: -------------------------------------------------------------------------------- 1 | /* global define */ 2 | 3 | (function (global) { 4 | 'use strict' 5 | 6 | // Constructor 7 | function MenuButton(button, options) { 8 | options = options || {} 9 | 10 | // The default settings 11 | this.settings = { 12 | checkable: 'none' 13 | } 14 | 15 | // Overwrite defaults where they are provided in options 16 | for (var setting in options) { 17 | if (options.hasOwnProperty(setting)) { 18 | this.settings[setting] = options[setting] 19 | } 20 | } 21 | 22 | // Save a reference to the element 23 | this.button = button 24 | 25 | // Add (initial) button semantics 26 | this.button.setAttribute('aria-haspopup', true) 27 | this.button.setAttribute('aria-expanded', false) 28 | 29 | // Get the menu 30 | this.menuId = this.button.getAttribute('data-inclusive-menu-opens') 31 | this.menu = document.getElementById(this.menuId) 32 | 33 | // If the menu doesn't exist 34 | // exit with an error referencing the missing 35 | // menu's id 36 | if (!this.menu) { 37 | throw new Error('Element `#' + this.menuId + '` not found.') 38 | } 39 | 40 | // Add menu semantics 41 | this.menu.setAttribute('role', 'menu') 42 | 43 | // Hide menu initially 44 | this.menu.hidden = true 45 | 46 | // Get the menu item buttons 47 | this.menuItems = this.menu.querySelectorAll('button') 48 | 49 | if (this.menuItems.length < 1) { 50 | throw new Error('The #' + this.menuId + ' menu has no menu items') 51 | } 52 | 53 | this.firstItem = this.menuItems[0] 54 | this.lastItem = this.menuItems[this.menuItems.length - 1] 55 | 56 | var focusNext = function (currentItem, startItem) { 57 | // Determine which item is the startItem (first or last) 58 | var goingDown = startItem === this.firstItem 59 | 60 | // helper function for getting next legitimate element 61 | function move(elem) { 62 | return (goingDown ? elem.nextElementSibling : elem.previousElementSibling) || startItem 63 | } 64 | 65 | // make first move 66 | var nextItem = move(currentItem) 67 | 68 | // if the menuitem is disabled move on 69 | while (nextItem.disabled) { 70 | nextItem = move(nextItem) 71 | } 72 | 73 | // focus the first one that's not disabled 74 | nextItem.focus() 75 | }.bind(this) 76 | 77 | Array.prototype.forEach.call(this.menuItems, function (menuItem) { 78 | // Disable menu button if all menu items are disabled 79 | var active = Array.prototype.filter.call(this.menuItems, function (item) { 80 | return !item.disabled 81 | }) 82 | if (active.length < 1) { 83 | this.button.disabled = true 84 | return 85 | } 86 | 87 | // Add menu item semantics 88 | if (this.settings.checkable === 'one') { 89 | menuItem.setAttribute('role', 'menuitemradio') 90 | } else if (this.settings.checkable === 'many') { 91 | menuItem.setAttribute('role', 'menuitemcheckbox') 92 | } else { 93 | menuItem.setAttribute('role', 'menuitem') 94 | } 95 | 96 | // Prevent tab focus on menu items 97 | menuItem.setAttribute('tabindex', '-1') 98 | 99 | // Handle key presses for menuItem 100 | menuItem.addEventListener('keydown', function (e) { 101 | // Go to next/previous item if it exists 102 | // or loop around 103 | 104 | if (e.keyCode === 40) { 105 | e.preventDefault() 106 | focusNext(menuItem, this.firstItem) 107 | } 108 | 109 | if (e.keyCode === 38) { 110 | e.preventDefault() 111 | focusNext(menuItem, this.lastItem) 112 | } 113 | 114 | // Close on escape or tab 115 | if (e.keyCode === 27 || e.keyCode === 9) { 116 | this.toggle() 117 | } 118 | 119 | // If escape, refocus menu button 120 | if (e.keyCode === 27) { 121 | e.preventDefault() 122 | this.button.focus() 123 | } 124 | }.bind(this)) 125 | 126 | menuItem.addEventListener('click', function (e) { 127 | // pass menu item node to select method 128 | this.choose(menuItem) 129 | 130 | // close menu and focus menu button 131 | this.close() 132 | this.button.focus() 133 | }.bind(this)) 134 | }.bind(this)) 135 | 136 | // Handle button click 137 | this.button.addEventListener('click', this.toggle.bind(this)) 138 | 139 | // Also toggle on down arrow 140 | this.button.addEventListener('keydown', function (e) { 141 | if (e.keyCode === 40) { 142 | if (this.menu.hidden) { 143 | this.open() 144 | } else { 145 | this.menu.querySelector(':not([disabled])').focus() 146 | } 147 | } 148 | 149 | // close menu on up arrow 150 | if (e.keyCode === 38) { 151 | this.close() 152 | } 153 | }.bind(this)) 154 | 155 | // initiate listeners object for public events 156 | this._listeners = {} 157 | } 158 | 159 | // Open method 160 | MenuButton.prototype.open = function () { 161 | this.button.setAttribute('aria-expanded', true) 162 | this.menu.hidden = false 163 | 164 | if (this.settings.checkable === 'one') { 165 | var checked = this.menu.querySelector('[aria-checked="true"]') 166 | } 167 | // Check the checked item if using menuitemradio 168 | if (checked) { 169 | checked.focus() 170 | } else { 171 | this.menu.querySelector('[role^="menuitem"]:not([disabled])').focus() 172 | } 173 | 174 | this.outsideClick = function (e) { 175 | if (!this.menu.contains(e.target) && !this.button.contains(e.target)) { 176 | this.close() 177 | document.removeEventListener('click', this.outsideClick.bind(this)) 178 | } 179 | }.bind(this) 180 | 181 | document.addEventListener('click', this.outsideClick.bind(this)) 182 | 183 | // fire open event 184 | this._fire('open') 185 | 186 | return this 187 | } 188 | 189 | // Close method 190 | MenuButton.prototype.close = function () { 191 | this.button.setAttribute('aria-expanded', false) 192 | this.menu.hidden = true 193 | 194 | // fire open event 195 | this._fire('close') 196 | 197 | return this 198 | } 199 | 200 | // Toggle method 201 | MenuButton.prototype.toggle = function () { 202 | var expanded = this.button.getAttribute('aria-expanded') === 'true' 203 | return expanded ? this.close() : this.open() 204 | } 205 | 206 | MenuButton.prototype.choose = function (choice) { 207 | if (this.settings.checkable === 'one') { 208 | // Remove aria-checked from whichever item it's on 209 | Array.prototype.forEach.call(this.menuItems, function (menuItem) { 210 | menuItem.removeAttribute('aria-checked'); 211 | }) 212 | 213 | // Set aria-checked="true" on the chosen item 214 | choice.setAttribute('aria-checked', 'true') 215 | } 216 | 217 | if (this.settings.checkable === 'many') { 218 | // check or uncheck item 219 | var checked = choice.getAttribute('aria-checked') === 'true' || false 220 | choice.setAttribute('aria-checked', !checked) 221 | } 222 | 223 | // fire open event 224 | this._fire('choose', choice) 225 | 226 | return this 227 | } 228 | 229 | MenuButton.prototype._fire = function (type, data) { 230 | var listeners = this._listeners[type] || [] 231 | 232 | listeners.forEach(function (listener) { 233 | listener(data) 234 | }) 235 | } 236 | 237 | MenuButton.prototype.on = function (type, handler) { 238 | if (typeof this._listeners[type] === 'undefined') { 239 | this._listeners[type] = [] 240 | } 241 | 242 | this._listeners[type].push(handler) 243 | 244 | return this 245 | } 246 | 247 | MenuButton.prototype.off = function (type, handler) { 248 | var index = this._listeners[type].indexOf(handler) 249 | 250 | if (index > -1) { 251 | this._listeners[type].splice(index, 1) 252 | } 253 | 254 | return this 255 | } 256 | 257 | // Export MenuButton 258 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 259 | module.exports = MenuButton 260 | } else if (typeof define === 'function' && define.amd) { 261 | define('MenuButton', [], function () { 262 | return MenuButton 263 | }) 264 | } else if (typeof global === 'object') { 265 | // attach to window 266 | global.MenuButton = MenuButton 267 | } 268 | }(this)) 269 | --------------------------------------------------------------------------------