diff --git a/.travis.yml b/.travis.yml index 6e5919d..a575c51 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ language: node_js node_js: - "0.10" +before_script: + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4518a5b..2667006 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,6 @@ always be in sync. ### Development - `npm test` will fire up a karma test runner and watch for changes -- `npm run examples` fires up a webpack dev server that will watch - for changes and build the examples ### Build diff --git a/README.md b/README.md index 98774db..a58a21e 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,6 @@ use the `includeSrcNode` option. a11y(React, { throw: true, includeSrcNode: true }); ``` -All failures are also accessible via the `getFailures()` method. - -``` -a11y.getFailures(); -``` - Some test are only relevant for certain device types. For example, if you are building a mobile web app, you can filter out desktop-specific rules by specifying a specific device type: diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..bb1f22e --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,43 @@ +var webpack = require('webpack'); + +module.exports = function (config) { + config.set({ + + browserNoActivityTimeout: 30000, + + browsers: [ process.env.CONTINUOUS_INTEGRATION ? 'Firefox' : 'Chrome' ], + + singleRun: process.env.CONTINUOUS_INTEGRATION === 'true', + + frameworks: [ 'mocha' ], + + files: [ + 'tests.webpack.js' + ], + + preprocessors: { + 'tests.webpack.js': [ 'webpack', 'sourcemap' ] + }, + + reporters: [ 'dots' ], + + webpack: { + devtool: 'inline-source-map', + module: { + loaders: [ + { test: /\.js$/, loader: 'babel-loader' } + ] + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify('test') + }) + ] + }, + + webpackServer: { + noInfo: true + } + + }); +}; diff --git a/lib/__tests__/index-test.js b/lib/__tests__/index-test.js index 579155d..3780a1e 100644 --- a/lib/__tests__/index-test.js +++ b/lib/__tests__/index-test.js @@ -36,76 +36,6 @@ describe('props', () => { }); describe('onClick', () => { - - describe('labels', () => { - it('warns if there is no label of any sort', () => { - expectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
; - }); - }); - - it('does not warn if onClick is null', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
; - }); - }); - - it('does not warn if onClick is undefined', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
; - }); - }); - - it('does not warn if there is an aria-label', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
; - }); - }); - - it('does not warn if there is an aria-labelled-by', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
; - }); - }); - - it('does not warn if there are text node children', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
foo
; - }); - }); - - it('does not warn if there are deeply nested text node children', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
foo
; - }); - }); - - it('does not error if there are undefined children', () => { - var undefChild; - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
{ undefChild } bar
; - }); - }); - - it('does not error if there are null children', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
bar { null }
; - }); - }); - - it('does not warn if there is an image with an alt attribute', () => { - doNotExpectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
Foo
; - }); - }); - - it('warns if there is an image with an empty alt attribute', () => { - expectWarning(assertions.props.onClick.NO_LABEL.msg, () => { -
; - }); - }); - }); - describe('when role="button"', () => { it('requires onKeyDown', () => { expectWarning(assertions.props.onClick.BUTTON_ROLE_SPACE.msg, () => { @@ -210,6 +140,14 @@ describe('tags', () => { }); }); + describe('with [tabIndex="0"] and no href', () => { + it('warns', () => { + expectWarning(assertions.tags.a.TABINDEX_NEEDS_BUTTON.msg, () => { + ; + }); + }); + }); + describe('with a real href', () => { it('does not warn', () => { doNotExpectWarning(assertions.tags.a.HASH_HREF_NEEDS_BUTTON.msg, () => { @@ -220,6 +158,279 @@ describe('tags', () => { }); }); +describe('labels', () => { + var createElement = React.createElement; + var fixture; + + before(() => { + a11y(React); + }); + + after(() => { + React.createElement = createElement; + }); + + beforeEach(() => { + fixture = document.createElement('div'); + fixture.id = 'fixture-1'; + document.body.appendChild(fixture); + }); + + afterEach(() => { + fixture = document.getElementById('fixture-1'); + if (fixture) + document.body.removeChild(fixture); + }); + + it('warns if there is no label on an interactive element', () => { + expectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not warn if there are deeply nested text node children', () => { + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not error if there are undefined children', () => { + var undefChild; + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not error if there are null children', () => { + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not warn if there is an image with an alt attribute', () => { + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('warns if an image without alt is the only content', () => { + expectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not warn if an image without alt is accompanied by text', () => { + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not warn if a hidden input', () => { + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('warns if a visible input', () => { + expectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not warn if an anchor has no href', () => { + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('warns if an anchor has a tabIndex but no href', () => { + expectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('warns if an anchor has an href', () => { + expectWarning(assertions.render.NO_LABEL.msg, () => { + ; + }); + }); + + it('does not warn when the label text is inside a child component', (done) => { + var Foo = React.createClass({ + render: function() { + return ( +
+ foo +
+ ); + } + }); + + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture, done); + }); + }); + + it('does not warn when the label is an image with alt text inside a child component', (done) => { + var Foo = React.createClass({ + render: function() { + return ( +
+ foo +
+ ); + } + }); + + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture, done); + }); + }); + + it('warns when a child is a component with image content without alt', (done) => { + var Foo = React.createClass({ + render: function() { + return ( +
+ +
+ ); + } + }); + + expectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture, done); + }); + }); + + it('does not warn when a child is a component with text and and an image without alt', (done) => { + var Foo = React.createClass({ + render: function() { + return ( +
+ Foo +
+ ); + } + }); + + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture, done); + }); + }); + + it('warns when a child is a component without text content', () => { + var Bar = React.createClass({ + render: () => { + return ( +
+ ); + } + }); + + expectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture); + }); + }); + + it('does not warn as long as one child component has label text', (done) => { + var Bar = React.createClass({ + render: () => { + return ( +
+ ); + } + }); + + var Foo = React.createClass({ + render: function() { + return ( +
+ foo +
+ ); + } + }); + + doNotExpectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture, done); + }); + }); + + it('warns if no child components have label text', () => { + var Bar = React.createClass({ + render: () => { + return ( +
+ ); + } + }); + + var Foo = React.createClass({ + render: function() { + return ( +
+ ); + } + }); + + expectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture); + }); + }); + + + it('does not error when the component has a componentDidMount callback', () => { + var Bar = React.createClass({ + _privateProp: 'bar', + + componentDidMount: function() { + return this._privateProp; + }, + render: () => { + return ( +
+ ); + } + }); + + expectWarning(assertions.render.NO_LABEL.msg, () => { + React.render(
, fixture); + }); + }); + +}); + describe('filterFn', () => { var createElement = React.createElement; @@ -255,29 +466,6 @@ describe('filterFn', () => { }); }); -describe('getFailures()', () => { - var createElement = React.createElement; - - before(() => { - a11y(React); - }); - - after(() => { - React.createElement = createElement; - }); - - describe('when there are failures', () => { - it('returns the failures', () => { -
- - -
; - - assert(a11y.getFailures().length == 2); - }); - }); -}); - describe('device is set to mobile', () => { var createElement = React.createElement; diff --git a/lib/after.js b/lib/after.js new file mode 100644 index 0000000..9685847 --- /dev/null +++ b/lib/after.js @@ -0,0 +1,14 @@ +var after = (host, name, cb) => { + var originalFn = host[name]; + + if (originalFn) { + host[name] = function(...args) { + originalFn.apply(this, args); + cb.apply(this, args); + }; + } else { + host[name] = cb; + } +}; + +module.exports = after; diff --git a/lib/assertions.js b/lib/assertions.js index dce87ae..b670572 100644 --- a/lib/assertions.js +++ b/lib/assertions.js @@ -1,3 +1,5 @@ +var after = require('./after'); + var React; exports.setReact = function(R) { @@ -6,10 +8,16 @@ exports.setReact = function(R) { var INTERACTIVE = { 'button': true, - 'input': true, + 'input' (props) { + return props.type != 'hidden'; + }, 'textarea': true, + 'select': true, + 'option': true, 'a' (props) { - return typeof props.href === 'string'; + var hasHref = typeof props.href === 'string'; + var hasTabIndex = props.tabIndex != null; + return (hasHref || !hasHref && hasTabIndex); } }; @@ -21,11 +29,49 @@ var hasAlt = (props) => { var isInteractive = (tagName, props) => { var tag = INTERACTIVE[tagName]; - return (typeof tagName === 'function') ? tag(props) : tag; + return (typeof tag === 'function') ? tag(props) : tag; +}; + +var getComponents = (children) => { + var childComponents = []; + React.Children.forEach(children, function(child) { + if (child && typeof child.type === 'function') + childComponents.push(child); + }); + return childComponents; +}; + +var hasLabel = (node) => { + var hasTextContent = node.textContent.trim().length > 0; + var images = node.querySelectorAll('img[alt]'); + images = Array.prototype.slice.call(images); + + var hasAltText = (images.filter((image) => { + return image.alt.length > 0; + }).length) > 0; + + return hasTextContent || hasAltText; +}; + +var assertLabel = function(node, context, failureCB) { + if (context.passed) + return; + + context.passed = hasLabel(node); + + if (!context.passed && context.totalChildren == (++context.childrenTested)) + failureCB(); }; -var hasChildTextNode = (props, children) => { +var hasChildTextNode = (props, children, failureCB) => { var hasText = false; + var childComponents = getComponents(children); + var hasChildComponents = childComponents.length > 0; + var context; + + if (hasChildComponents) + context = { totalChildren: childComponents.length, childrenTested: 0 }; + React.Children.forEach(children, (child) => { if (hasText) return; @@ -38,7 +84,16 @@ var hasChildTextNode = (props, children) => { else if (child.type === 'img' && child.props.alt) hasText = true; else if (child.props.children) - hasText = hasChildTextNode(child.props, child.props.children); + hasText = hasChildTextNode(child.props, child.props.children, failureCB); + else if (typeof child.type === 'function') { + // There can be false negatives if one of the children is a Component, + // as Components' children are inaccessible until it's is rendered. + // To account for this, check each Component's HTML after it's + // been mounted. + after(child.type.prototype, 'componentDidMount', function() { + assertLabel(React.findDOMNode(this), context, failureCB); + }); + } }); return hasText; }; @@ -63,18 +118,6 @@ exports.props = { } }, - NO_LABEL: { - msg: 'You have a click handler on an element with no screen-readable text. Add `aria-label` or `aria-labelled-by` attribute, or put some text in the element.', - test (tagName, props, children) { - return ( - props['aria-label'] || - props['aria-labelled-by'] || - (tagName === 'img' && props.alt) || - hasChildTextNode(props, children) - ); - } - }, - BUTTON_ROLE_SPACE: { device: DEVICE.DESKTOP, msg: 'You have `role="button"` but did not define an `onKeyDown` handler. Add it, and have the "Space" key do the same thing as an `onClick` handler.', @@ -101,6 +144,12 @@ exports.tags = { test (tagName, props, children) { return !(!props.role && props.href === '#'); } + }, + TABINDEX_NEEDS_BUTTON: { + msg: 'You have an anchor with a tabIndex, no `href` and no `role` DOM property. Add `role="button"` or better yet, use a `