Repository: Heydon/inclusive-menu-button Branch: master Commit: 218114116595 Files: 10 Total size: 21.2 KB Directory structure: gitextract_vdrgq3rv/ ├── .gitignore ├── LICENSE ├── README.md ├── examples/ │ ├── basic.html │ ├── disabled-items.html │ ├── menuitemcheckbox.html │ └── menuitemradio.html ├── inclusive-menu-button.css ├── inclusive-menu-button.js └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store *.log node_modules ================================================ FILE: LICENSE ================================================ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: README.md ================================================ # Inclusive Menu Button A **menu button** module that implements the correct ARIA semantics and keyboard behavior. ## Installation ``` npm i inclusive-menu-button --save ``` ## Expected markup In the following example, three menu items are provided. ```html
``` * The parent element must take `data-inclusive-menu`. * `data-inclusive-menu-opens` takes a value that must match the menu element's `id`. In this case, it is `difficulty`. * `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. * The menu items must be sibling buttons. The script adds the `menuitem` role (as well as the `menu` role to the parent menu element). ### After initialization Once you've initialized the menu button, this will be the resulting markup, including all of the necessary ARIA attribution: ```html
``` ## CSS 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. ```css [data-inclusive-menu] { position: relative; display: inline-block; } [data-inclusive-menu-opens], [data-inclusive-menu] [role^="menuitem"] { text-align: left; border: 0; } [data-inclusive-menu] [role="menu"] { position: absolute; left: 0; } [data-inclusive-menu] [data-inclusive-menu-from="right"] { left: auto; right: 0; } [data-inclusive-menu] [role^="menuitem"] { display: block; min-width: 100%; white-space: nowrap; } [data-inclusive-menu] [role^="menuitem"][aria-checked="true"]::before { content: '\2713\0020'; } ``` ## Initialization Initialize the menu button / menu like so: ```js // get a menu button const exampleButton = document.querySelector('[data-inclusive-menu-opens]') // Make it a menu button const exampleMenuButton = new MenuButton(exampleButton) ``` ### Checked items 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"`. 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. ```js // Make it a menu button with menuitemradio buttons const exampleMenuButton = new MenuButton(exampleButton, { checkable: 'one' }) ``` If you want to set default checked items, just do that in the HTML: ```html
``` 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. ### API methods You can open and close the menu programmatically. ```js // Open exampleMenuBtn.open() // Close exampleMenuBtn.close() // Toggle exampleMenuBtn.toggle() ``` ### Event subscription You can subscribe to emitted `open`, `close`, and `choose` events. #### `open` and `close` examples ```js exampleMenuButton.on('open', function () { // Do something when the menu gets open }) exampleMenuButton.on('close', function () { // Do something when the menu gets closed }) ``` #### `choose` example The `choose` event is passed the chosen item’s DOM node. ```js exampleMenuButton.on('choose', function (choice) {  // Do something with `choice` DOM node }) ``` ### Unsubscribing There is an `off` method included for terminating event listeners. ```js exampleMenuButton.off('choose', exampleHandler) ``` ================================================ FILE: examples/basic.html ================================================ Inclusive Menu Button | Basic Example
================================================ FILE: examples/disabled-items.html ================================================ Inclusive Menu Button | Disable Items Example
================================================ FILE: examples/menuitemcheckbox.html ================================================ Inclusive Menu Button | menuitemcheckbox example
================================================ FILE: examples/menuitemradio.html ================================================ Inclusive Menu Button | menuitemradio example
================================================ FILE: inclusive-menu-button.css ================================================ [data-inclusive-menu] { position: relative; display: inline-block; } [data-inclusive-menu-opens], [data-inclusive-menu] [role^="menuitem"] { text-align: left; border: 0; } [data-inclusive-menu] [role="menu"] { position: absolute; left: 0; } [data-inclusive-menu] [data-inclusive-menu-from="right"] { left: auto; right: 0; } [data-inclusive-menu] [role^="menuitem"] { display: block; min-width: 100%; white-space: nowrap; } [data-inclusive-menu] [role^="menuitem"][aria-checked="true"]::before { content: '\2713\0020'; } ================================================ FILE: inclusive-menu-button.js ================================================ /* global define */ (function (global) { 'use strict' // Constructor function MenuButton(button, options) { options = options || {} // The default settings this.settings = { checkable: 'none' } // Overwrite defaults where they are provided in options for (var setting in options) { if (options.hasOwnProperty(setting)) { this.settings[setting] = options[setting] } } // Save a reference to the element this.button = button // Add (initial) button semantics this.button.setAttribute('aria-haspopup', true) this.button.setAttribute('aria-expanded', false) // Get the menu this.menuId = this.button.getAttribute('data-inclusive-menu-opens') this.menu = document.getElementById(this.menuId) // If the menu doesn't exist // exit with an error referencing the missing // menu's id if (!this.menu) { throw new Error('Element `#' + this.menuId + '` not found.') } // Add menu semantics this.menu.setAttribute('role', 'menu') // Hide menu initially this.menu.hidden = true // Get the menu item buttons 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) { // Determine which item is the startItem (first or last) var goingDown = startItem === this.firstItem // helper function for getting next legitimate element function move(elem) { return (goingDown ? elem.nextElementSibling : elem.previousElementSibling) || startItem } // make first move var nextItem = move(currentItem) // if the menuitem is disabled move on while (nextItem.disabled) { nextItem = move(nextItem) } // focus the first one that's not disabled nextItem.focus() }.bind(this) Array.prototype.forEach.call(this.menuItems, function (menuItem) { // Disable menu button if all menu items are disabled var active = Array.prototype.filter.call(this.menuItems, function (item) { return !item.disabled }) if (active.length < 1) { this.button.disabled = true return } // Add menu item semantics if (this.settings.checkable === 'one') { menuItem.setAttribute('role', 'menuitemradio') } else if (this.settings.checkable === 'many') { menuItem.setAttribute('role', 'menuitemcheckbox') } else { menuItem.setAttribute('role', 'menuitem') } // Prevent tab focus on menu items menuItem.setAttribute('tabindex', '-1') // Handle key presses for menuItem menuItem.addEventListener('keydown', function (e) { // Go to next/previous item if it exists // or loop around if (e.keyCode === 40) { e.preventDefault() focusNext(menuItem, this.firstItem) } if (e.keyCode === 38) { e.preventDefault() focusNext(menuItem, this.lastItem) } // Close on escape or tab if (e.keyCode === 27 || e.keyCode === 9) { this.toggle() } // If escape, refocus menu button if (e.keyCode === 27) { e.preventDefault() this.button.focus() } }.bind(this)) menuItem.addEventListener('click', function (e) { // pass menu item node to select method this.choose(menuItem) // close menu and focus menu button this.close() this.button.focus() }.bind(this)) }.bind(this)) // Handle button click this.button.addEventListener('click', this.toggle.bind(this)) // Also toggle on down arrow this.button.addEventListener('keydown', function (e) { if (e.keyCode === 40) { if (this.menu.hidden) { this.open() } else { this.menu.querySelector(':not([disabled])').focus() } } // close menu on up arrow if (e.keyCode === 38) { this.close() } }.bind(this)) // initiate listeners object for public events this._listeners = {} } // Open method 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"]') } // Check the checked item if using menuitemradio 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)) // fire open event this._fire('open') return this } // Close method MenuButton.prototype.close = function () { this.button.setAttribute('aria-expanded', false) this.menu.hidden = true // fire open event this._fire('close') return this } // Toggle method 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') { // Remove aria-checked from whichever item it's on Array.prototype.forEach.call(this.menuItems, function (menuItem) { menuItem.removeAttribute('aria-checked'); }) // Set aria-checked="true" on the chosen item choice.setAttribute('aria-checked', 'true') } if (this.settings.checkable === 'many') { // check or uncheck item var checked = choice.getAttribute('aria-checked') === 'true' || false choice.setAttribute('aria-checked', !checked) } // fire open event 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 } // Export MenuButton 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') { // attach to window global.MenuButton = MenuButton } }(this)) ================================================ FILE: package.json ================================================ { "name": "inclusive-menu-button", "version": "0.1.4", "description": "A menu button module that implements the correct ARIA semantics and keyboard behavior.", "main": "inclusive-menu-button.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "lint": "standard ./inclusive-menu-button.js", "uglify": "uglifyjs inclusive-menu-button.js -o inclusive-menu-button.min.js", "extract-version": "cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]'", "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", "build": "npm run uglify && npm run add-version", "precommit": "npm run build" }, "repository": { "type": "git", "url": "git+https://github.com/Heydon/inclusive-menu-button.git" }, "keywords": [ "menu", "button", "ARIA", "accessibility", "dropdown" ], "author": "Heydon Pickering", "license": "MIT", "bugs": { "url": "https://github.com/Heydon/inclusive-menu-button/issues" }, "homepage": "https://github.com/Heydon/inclusive-menu-button#readme", "devDependencies": { "husky": "^0.13.3", "standard": "^10.0.2", "uglify-js": "^2.8.22" } }