├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── client.js ├── index.js ├── package.json ├── test.js └── test ├── basic.expect.html ├── basic.html ├── before.expect.html └── before.html /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = tab 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | 13 | [*.{json,yml}] 14 | indent_size = 2 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-shadow-restricted-names": [2], 4 | "computed-property-spacing": [2], 5 | "no-empty-character-class": [2], 6 | "no-irregular-whitespace": [2], 7 | "no-unexpected-multiline": [2], 8 | "no-multiple-empty-lines": [2], 9 | "space-return-throw-case": [2], 10 | "no-constant-condition": [2], 11 | "no-extra-boolean-cast": [2], 12 | "no-inner-declarations": [2], 13 | "no-this-before-super": [2], 14 | "no-array-constructor": [2], 15 | "object-curly-spacing": [2, "always"], 16 | "no-floating-decimal": [2], 17 | "no-warning-comments": [2], 18 | "handle-callback-err": [2], 19 | "no-unneeded-ternary": [2], 20 | "operator-assignment": [2], 21 | "space-before-blocks": [2], 22 | "no-native-reassign": [2], 23 | "no-trailing-spaces": [2], 24 | "operator-linebreak": [2, "after"], 25 | "consistent-return": [2], 26 | "no-duplicate-case": [2], 27 | "no-invalid-regexp": [2], 28 | "no-negated-in-lhs": [2], 29 | "constructor-super": [2], 30 | "no-nested-ternary": [0], 31 | "no-extend-native": [2], 32 | "block-scoped-var": [2], 33 | "no-control-regex": [2], 34 | "no-sparse-arrays": [2], 35 | "no-throw-literal": [2], 36 | "no-return-assign": [2], 37 | "no-const-assign": [2], 38 | "no-class-assign": [2], 39 | "no-extra-parens": [2], 40 | "no-regex-spaces": [2], 41 | "no-implied-eval": [2], 42 | "no-useless-call": [2], 43 | "no-self-compare": [2], 44 | "no-octal-escape": [2], 45 | "no-new-wrappers": [2], 46 | "no-process-exit": [2], 47 | "no-catch-shadow": [2], 48 | "linebreak-style": [2], 49 | "space-infix-ops": [2], 50 | "space-unary-ops": [2], 51 | "no-func-assign": [2], 52 | "no-unreachable": [2], 53 | "accessor-pairs": [2], 54 | "no-empty-label": [2], 55 | "no-fallthrough": [2], 56 | "no-path-concat": [2], 57 | "no-new-require": [2], 58 | "no-spaced-func": [2], 59 | "no-unused-vars": [2], 60 | "spaced-comment": [2], 61 | "no-delete-var": [2], 62 | "comma-spacing": [2], 63 | "no-extra-semi": [2], 64 | "no-extra-bind": [2], 65 | "arrow-spacing": [2], 66 | "prefer-spread": [2], 67 | "no-new-object": [2], 68 | "no-multi-str": [2], 69 | "semi-spacing": [2], 70 | "no-lonely-if": [2], 71 | "dot-notation": [2], 72 | "dot-location": [2, "property"], 73 | "comma-dangle": [2, "never"], 74 | "no-dupe-args": [2], 75 | "no-dupe-keys": [2], 76 | "no-ex-assign": [2], 77 | "no-obj-calls": [2], 78 | "valid-typeof": [2], 79 | "default-case": [2], 80 | "no-redeclare": [2], 81 | "no-div-regex": [2], 82 | "no-sequences": [2], 83 | "no-label-var": [2], 84 | "comma-style": [2], 85 | "brace-style": [2], 86 | "no-debugger": [2], 87 | "quote-props": [0], 88 | "no-iterator": [2], 89 | "no-new-func": [2], 90 | "key-spacing": [2, { "align": "value" }], 91 | "complexity": [2], 92 | "new-parens": [2], 93 | "no-eq-null": [2], 94 | "no-bitwise": [0], 95 | "wrap-iife": [2], 96 | "no-caller": [2], 97 | "use-isnan": [2], 98 | "no-labels": [2], 99 | "no-shadow": [2], 100 | "camelcase": [2], 101 | "eol-last": [2], 102 | "no-octal": [2], 103 | "no-empty": [2], 104 | "no-alert": [2], 105 | "no-proto": [2], 106 | "no-undef": [2], 107 | "no-eval": [2], 108 | "no-with": [2], 109 | "no-void": [2], 110 | "new-cap": [2], 111 | "eqeqeq": [2], 112 | "no-new": [2], 113 | "quotes": [2, "single"], 114 | "indent": [2, "tab"], 115 | "semi": [2, "always"], 116 | "yoda": [2, "never"] 117 | }, 118 | "env": { 119 | "mocha": true, 120 | "node": true 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | test/*.actual.html 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - stable 5 | - "0.12" 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 (2016-01-04) 2 | 3 | - Added: Initial version 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | You want to help? You rock! Now, take a moment to be sure your contributions make sense to everyone else. 2 | 3 | ## Reporting Issues 4 | 5 | Found a problem? Want a new feature? 6 | 7 | - See if your issue or idea has [already been reported]. 8 | - Provide a [reduced test case] or a [live example]. 9 | 10 | Remember, a bug is a _demonstrable problem_ caused by _our_ code. 11 | 12 | ## Submitting Pull Requests 13 | 14 | Pull requests are the greatest contributions, so be sure they are focused in scope, and do avoid unrelated commits. 15 | 16 | 1. To begin, [fork this project], clone your fork, and add our upstream. 17 | ```bash 18 | # Clone your fork of the repo into the current directory 19 | git clone https://github.com//posthtml-aria-tabs 20 | # Navigate to the newly cloned directory 21 | cd posthtml-aria-tabs 22 | # Assign the original repo to a remote called "upstream" 23 | git remote add upstream https://github.com/jonathantneal/posthtml-aria-tabs 24 | # Install the tools necessary for development 25 | npm install 26 | ``` 27 | 28 | 2. Create a branch for your feature or fix: 29 | ```bash 30 | # Move into a new branch for a feature 31 | git checkout -b feature/thing 32 | ``` 33 | ```bash 34 | # Move into a new branch for a fix 35 | git checkout -b fix/something 36 | ``` 37 | 38 | 3. Be sure your code follows our practices. 39 | ```bash 40 | # Test current code 41 | npm run test 42 | ``` 43 | 44 | 4. Push your branch up to your fork: 45 | ```bash 46 | # Push a feature branch 47 | git push origin feature/thing 48 | ``` 49 | ```bash 50 | # Push a fix branch 51 | git push origin fix/something 52 | ``` 53 | 54 | 5. Now [open a pull request] with a clear title and description. 55 | 56 | [already been reported]: issues 57 | [fork this project]: fork 58 | [live example]: http://codepen.io/pen 59 | [open a pull request]: https://help.github.com/articles/using-pull-requests/ 60 | [reduced test case]: https://css-tricks.com/reduced-test-cases/ 61 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # CC0 1.0 Universal License 2 | 3 | Public Domain Dedication 4 | 5 | The person(s) who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law. 6 | 7 | You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission. 8 | 9 | In no way are the patent or trademark rights of any person affected by CC0, nor are the rights that other persons may have in the work or in how the work is used, such as publicity or privacy rights. 10 | 11 | Unless expressly stated otherwise, the person(s) who associated a work with this deed makes no warranties about the work, and disclaims liability for all uses of the work, to the fullest extent permitted by applicable law. 12 | 13 | When using or citing the work, you should not imply endorsement by the author or the affirmer. 14 | 15 | This is a [human-readable summary of the Legal Code](https://creativecommons.org/publicdomain/zero/1.0/) ([read the full text](https://creativecommons.org/publicdomain/zero/1.0/legalcode)). 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ARIA Tabs 2 | 3 | PostHTML Logo 4 | 5 | [![NPM Version][npm-img]][npm] [![Build Status][ci-img]][ci] 6 | 7 | [ARIA Tabs] lets you write accessible tabs with minimal markup. It intelligently appends ARIA roles and attributes to your tabs and panels, where implied or duplicated data would have reduced readability. 8 | 9 | ```html 10 | 11 | 24 | 25 |
26 | This is the foo tab. 27 |
28 | 29 |
30 | This is the bar tab. 31 |
32 | 33 |
34 | This is the qux tab. 35 |
36 | 37 | 38 | 51 | 52 |
53 | This is the foo tab. 54 |
55 | 56 | 59 | 60 | 63 | ``` 64 | 65 | For a [fully accessible implementation], [client.js](client.js) should be included somewhere in the front-end. 66 | 67 | ## Usage 68 | 69 | Add [ARIA Tabs] to your build tool: 70 | 71 | ```bash 72 | npm install posthtml-aria-tabs --save-dev 73 | ``` 74 | 75 | #### Node 76 | 77 | ```js 78 | require('posthtml-aria-tabs').process(YOUR_HTML); 79 | ``` 80 | 81 | #### PostHTML 82 | 83 | Add [PostHTML] to your build tool: 84 | 85 | ```bash 86 | npm install posthtml --save-dev 87 | ``` 88 | 89 | Load [ARIA Tabs] as a PostHTML plugin: 90 | 91 | ```js 92 | posthtml([ 93 | require('posthtml-aria-tabs')() 94 | ]).process(YOUR_HTML); 95 | ``` 96 | 97 | #### Gulp 98 | 99 | Add [Gulp PostHTML] to your build tool: 100 | 101 | ```bash 102 | npm install gulp-posthtml --save-dev 103 | ``` 104 | 105 | Enable [ARIA Tabs] within your Gulpfile: 106 | 107 | ```js 108 | var posthtml = require('gulp-posthtml'); 109 | 110 | gulp.task('html', function () { 111 | return gulp.src('./src/*.html').pipe( 112 | posthtml([ 113 | require('posthtml-aria-tabs')() 114 | ]) 115 | ).pipe( 116 | gulp.dest('.') 117 | ); 118 | }); 119 | ``` 120 | 121 | #### Grunt 122 | 123 | Add [Grunt PostHTML] to your build tool: 124 | 125 | ```bash 126 | npm install grunt-posthtml --save-dev 127 | ``` 128 | 129 | Enable [ARIA Tabs] within your Gruntfile: 130 | 131 | ```js 132 | grunt.loadNpmTasks('grunt-posthtml'); 133 | 134 | grunt.initConfig({ 135 | posthtml: { 136 | options: { 137 | use: [ 138 | require('posthtml-aria-tabs')() 139 | ] 140 | }, 141 | dist: { 142 | src: '*.html' 143 | } 144 | } 145 | }); 146 | ``` 147 | 148 | [ci]: https://travis-ci.org/jonathantneal/posthtml-aria-tabs 149 | [ci-img]: https://img.shields.io/travis/jonathantneal/posthtml-aria-tabs.svg 150 | [npm]: https://www.npmjs.com/package/posthtml-aria-tabs 151 | [npm-img]: https://img.shields.io/npm/v/posthtml-aria-tabs.svg 152 | 153 | [Gulp PostHTML]: https://github.com/posthtml/gulp-posthtml 154 | [Grunt PostHTML]: https://github.com/TCotton/grunt-posthtml 155 | [PostHTML]: https://github.com/posthtml/posthtml 156 | 157 | [fully accessible implementation]: http://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel 158 | 159 | [ARIA Tabs]: https://github.com/jonathantneal/posthtml-aria-tabs 160 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | /* global window, location, document */ 2 | 3 | document.addEventListener('DOMContentLoaded', function () { 4 | var cache = {}, last; 5 | 6 | Array.prototype.forEach.call(document.querySelectorAll('[role="tablist"]'), function (tablist) { 7 | Array.prototype.forEach.call(tablist.querySelectorAll('[href^="#"][role="tab"]'), function (tab, index, tabs) { 8 | cache[tab.hash] = [tab, document.getElementById(tab.getAttribute('aria-controls'))]; 9 | 10 | if (tab.getAttribute('aria-selected') === 'true') { 11 | last = cache[''] = cache[tab.hash]; 12 | } else { 13 | tab.setAttribute('tabindex', -1); 14 | } 15 | 16 | tab.addEventListener('keydown', function (event) { 17 | var next = event.keyCode === 37 ? tabs[index - 1] : event.keyCode === 39 ? tabs[index + 1] : null; 18 | 19 | if (next) { 20 | location.hash = next.hash; 21 | 22 | next.focus(); 23 | } 24 | }); 25 | }); 26 | }); 27 | 28 | window.addEventListener('hashchange', onhashchange); 29 | 30 | onhashchange(); 31 | 32 | function onhashchange() { 33 | var tab = cache[location.hash]; 34 | 35 | if (tab) { 36 | if (last) { 37 | last[0].removeAttribute('aria-selected'); 38 | last[0].setAttribute('tabindex', -1); 39 | last[1].setAttribute('hidden', ''); 40 | } 41 | 42 | tab[0].setAttribute('aria-selected', 'true'); 43 | tab[0].removeAttribute('tabindex'); 44 | tab[1].removeAttribute('hidden', ''); 45 | 46 | last = tab; 47 | } 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var posthtml = require('posthtml'); 2 | 3 | module.exports = function (opts) { 4 | var suffix = opts && opts.idSuffix || 'tab'; 5 | 6 | return function AriaTabs(tree) { 7 | var tabs = {}; 8 | 9 | walkTabLists(tree); 10 | 11 | walkTabPanels(tree); 12 | 13 | function walkTabLists(parentNode, isTablist) { 14 | parentNode.forEach(function (node) { 15 | // if already in a tab list 16 | if (isTablist) { 17 | // conditionally transform list items 18 | if (node.tag === 'li') { 19 | node.attrs = node.attrs || {}; 20 | 21 | node.attrs.role = 'presentation'; 22 | } 23 | 24 | // conditionally transform tabs 25 | if (node.attrs && /^#/.test(node.attrs.href)) { 26 | var id = node.attrs.href.slice(1); 27 | var isActive = node.attrs['aria-selected']; 28 | 29 | node.attrs.id = id + '-' + suffix; 30 | node.attrs.role = 'tab'; 31 | node.attrs['aria-controls'] = id; 32 | 33 | if (isActive) { 34 | node.attrs['aria-selected'] = 'true'; 35 | } 36 | 37 | // save tab reference 38 | tabs[id] = isActive; 39 | } 40 | } 41 | 42 | // detect tab lists 43 | if (node.attrs && node.attrs.role === 'tablist') { 44 | isTablist = true; 45 | } 46 | 47 | // conditionally walk children 48 | if (node.content) { 49 | walkTabLists(node.content, isTablist); 50 | } 51 | }); 52 | } 53 | 54 | function walkTabPanels(parentNode, isTablist) { 55 | parentNode.forEach(function (node) { 56 | // conditionally define tab panels 57 | if (node.attrs && node.attrs.id in tabs) { 58 | var id = node.attrs.id; 59 | 60 | node.attrs.role = 'tabpanel'; 61 | 62 | node.attrs['aria-labelledby'] = id + '-' + suffix; 63 | 64 | if (!tabs[id]) { 65 | node.attrs.hidden = true; 66 | } 67 | } 68 | 69 | // conditionally walk children 70 | if (node.content) { 71 | walkTabPanels(node.content, isTablist); 72 | } 73 | }); 74 | } 75 | }; 76 | }; 77 | 78 | module.exports.process = function (contents, options) { 79 | return posthtml().use(module.exports(options)).process(contents); 80 | }; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "posthtml-aria-tabs", 3 | "version": "1.0.0", 4 | "description": "Write accessible tabs with minimal markup", 5 | "keywords": [ 6 | "posthtml", 7 | "html", 8 | "posthtml-plugin", 9 | "arias", 10 | "tabs", 11 | "lists", 12 | "panels", 13 | "automatic", 14 | "markup", 15 | "roles" 16 | ], 17 | "author": "Jonathan Neal ", 18 | "license": "CC0-1.0", 19 | "repository": "jonathantneal/posthtml-aria-tabs", 20 | "homepage": "https://github.com/jonathantneal/posthtml-aria-tabs#readme", 21 | "bugs": "https://github.com/jonathantneal/posthtml-aria-tabs/issues", 22 | "dependencies": { 23 | "posthtml": "^0.8.0" 24 | }, 25 | "devDependencies": { 26 | "eslint": "^1.10.3", 27 | "tap-spec": "^4.1.1", 28 | "tape": "^4.2.2" 29 | }, 30 | "scripts": { 31 | "lint": "eslint . --ignore-path .gitignore", 32 | "tape": "tape test.js | tap-spec", 33 | "test": "npm run lint && npm run tape" 34 | }, 35 | "engines": { 36 | "iojs": ">=2.0.0", 37 | "node": ">=0.12.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var tests = { 2 | 'posthtml-aria-tabs': { 3 | 'basic': { 4 | message: 'supports basic usage', 5 | options: { 6 | from: './test/index.html' 7 | } 8 | }, 9 | 'before': { 10 | message: 'supports basic usage with panels coming before tabs', 11 | options: { 12 | from: './test/index.html' 13 | } 14 | } 15 | } 16 | }; 17 | 18 | var debug = true; 19 | var dir = './test/'; 20 | 21 | var fs = require('fs'); 22 | var path = require('path'); 23 | var plugin = require('./'); 24 | var test = require('tape'); 25 | 26 | Object.keys(tests).forEach(function (name) { 27 | var parts = tests[name]; 28 | 29 | test(name, function (t) { 30 | var fixtures = Object.keys(parts); 31 | 32 | t.plan(fixtures.length); 33 | 34 | fixtures.forEach(function (fixture) { 35 | var message = parts[fixture].message; 36 | var options = parts[fixture].options; 37 | 38 | var baseName = fixture.split(':')[0]; 39 | var testName = fixture.split(':').join('.'); 40 | 41 | var inputPath = path.resolve(dir + baseName + '.html'); 42 | var expectPath = path.resolve(dir + testName + '.expect.html'); 43 | var actualPath = path.resolve(dir + testName + '.actual.html'); 44 | 45 | var inputHTML = ''; 46 | var expectHTML = ''; 47 | 48 | try { 49 | inputHTML = fs.readFileSync(inputPath, 'utf8'); 50 | } catch (error) { 51 | fs.writeFileSync(inputPath, inputHTML); 52 | } 53 | 54 | try { 55 | expectHTML = fs.readFileSync(expectPath, 'utf8'); 56 | } catch (error) { 57 | fs.writeFileSync(expectPath, expectHTML); 58 | } 59 | 60 | plugin.process(inputHTML, options).then(function (result) { 61 | var actualHTML = result.html; 62 | 63 | if (debug) { 64 | fs.writeFileSync(actualPath, actualHTML); 65 | } 66 | 67 | t.equal(actualHTML, expectHTML, message); 68 | }); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/basic.expect.html: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | This is the foo tab. 17 |
18 | 19 | 22 | 23 | 26 | -------------------------------------------------------------------------------- /test/basic.html: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 | This is the foo tab. 17 |
18 | 19 |
20 | This is the bar tab. 21 |
22 | 23 |
24 | This is the qux tab. 25 |
26 | -------------------------------------------------------------------------------- /test/before.expect.html: -------------------------------------------------------------------------------- 1 |
2 | This is the foo tab. 3 |
4 | 5 | 8 | 9 | 12 | 13 | 26 | -------------------------------------------------------------------------------- /test/before.html: -------------------------------------------------------------------------------- 1 |
2 | This is the foo tab. 3 |
4 | 5 |
6 | This is the bar tab. 7 |
8 | 9 |
10 | This is the qux tab. 11 |
12 | 13 | 26 | --------------------------------------------------------------------------------