;
- });
- });
-
- 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, () => {
-
;
- });
- });
-
- 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('warns if there is no label on an element with an ARIA role', () => {
+ expectWarning(assertions.render.NO_LABEL.msg, () => {
+
;
+ });
+ });
+
+ it('does not warn if the element is not interactive', () => {
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
;
+ });
+ });
+
+ it('does not warn if there is an aria-label', () => {
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
;
+ });
+ });
+
+ it('does not warn if there is an aria-labelled-by', () => {
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
;
+ });
+ });
+
+ it('does not warn if there are text node children', () => {
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
foo ;
+ });
+ });
+
+ it('does not warn if there are deeply nested text node children', () => {
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
foo ;
+ });
+ });
+
+ it('does not error if there are undefined children', () => {
+ var undefChild;
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
{ undefChild } bar ;
+ });
+ });
+
+ it('does not error if there are null children', () => {
+ doNotExpectWarning(assertions.render.NO_LABEL.msg, () => {
+
bar { null } ;
+ });
+ });
+
+ 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, () => {
+
foo ;
+ });
+ });
+
+ 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 (
+
+
+
+ );
+ }
+ });
+
+ 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 `
`.',
+ test (tagName, props, children) {
+ return !(!props.role && props.tabIndex !== null && !props.href);
+ }
}
},
@@ -121,3 +170,25 @@ exports.tags = {
}
}
};
+
+exports.render = {
+ NO_LABEL: {
+ msg: 'You have an unlabled element or control. Add `aria-label` or `aria-labelled-by` attribute, or put some text in the element.',
+ test (tagName, props, children, failureCB) {
+ if (!(isInteractive(tagName, props) || props.role))
+ return;
+
+ var failed = !(
+ (isInteractive(tagName, props) || props.role) &&
+ (props['aria-label'] ||
+ props['aria-labelled-by'] ||
+ (tagName === 'img' && props.alt) ||
+ hasChildTextNode(props, children, failureCB))
+ );
+
+ if (failed)
+ failureCB();
+ }
+ }
+
+};
diff --git a/lib/index.js b/lib/index.js
index 534ed42..afbd19e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,69 +1,88 @@
var assertions = require('./assertions');
+var after = require('./after');
var deviceMatches = (test, deviceFilter) => {
- if (!test.device) {
+ if (!test.device)
return true;
- }
return (deviceFilter.indexOf(test.device) != -1);
};
-var assertAccessibility = (tagName, props, children, deviceFilter) => {
+var runTagTests = (tagName, props, children, deviceFilter, onFailure) => {
var key;
- var failures = [];
var tagTests = assertions.tags[tagName] || [];
- for (key in tagTests)
- if (tagTests[key] && deviceMatches(tagTests[key], deviceFilter) && !tagTests[key].test(tagName, props, children))
- failures.push(tagTests[key].msg);
+ for (key in tagTests) {
+ let shouldRunTest = deviceMatches(tagTests[key], deviceFilter);
+ let testFailed = shouldRunTest &&
+ !tagTests[key].test(tagName, props, children);
+
+ if (tagTests[key] && testFailed)
+ onFailure(tagName, props, children, tagTests[key].msg);
+ }
+};
+
+var runPropTests = (tagName, props, children, deviceFilter, onFailure) => {
+ var key;
var propTests;
+
for (var propName in props) {
if (props[propName] === null || props[propName] === undefined) continue;
+
propTests = assertions.props[propName] || [];
- for (key in propTests)
- if (propTests[key] && deviceMatches(propTests[key], deviceFilter) && !propTests[key].test(tagName, props, children))
- failures.push(propTests[key].msg);
+
+ for (key in propTests) {
+ let shouldRunTest = deviceMatches(propTests[key], deviceFilter);
+ let testTailed = shouldRunTest &&
+ !propTests[key].test(tagName, props, children);
+
+ if (propTests[key] && testTailed)
+ onFailure(tagName, props, children, propTests[key].msg);
+ }
}
- return failures;
};
-var filterFailures = (failureInfo, options) => {
- var failures = failureInfo.failures;
- var filterFn = options.filterFn &&
- options.filterFn.bind(undefined, failureInfo.name, failureInfo.id);
+var runLabelTests = (tagName, props, children, deviceFilter, onFailure) => {
+ var key;
+ var renderTests = assertions.render;
+
+ for (key in renderTests) {
+ if (renderTests[key]) {
+ let failureCB = onFailure.bind(
+ undefined, tagName, props, children, renderTests[key].msg);
- if (filterFn) {
- failures = failures.filter(filterFn);
+ renderTests[key].test(tagName, props, children, failureCB);
+ }
}
+};
+
+var runTests = (tagName, props, children, deviceFilter, onFailure) => {
+ var tests = [runTagTests, runPropTests, runLabelTests];
+ tests.map((test) => {
+ test(tagName, props, children, deviceFilter, onFailure);
+ });
+};
+
+var shouldShowError = (failureInfo, options) => {
+ var filterFn = options.filterFn;
+ if (filterFn)
+ return filterFn(failureInfo.name, failureInfo.id);
- return failures;
+ return true;
};
var throwError = (failureInfo, options) => {
- var failures = filterFailures(failureInfo, options);
- var msg = failures.pop();
- var error = [failureInfo.name, msg];
+ if (!shouldShowError(failureInfo, options))
+ return;
+
+ var error = [failureInfo.name, failureInfo.failure];
- if (options.includeSrcNode) {
+ if (options.includeSrcNode)
error.push(failureInfo.id);
- }
throw new Error(error.join(' '));
};
-var after = (host, name, cb) => {
- var originalFn = host[name];
-
- if (originalFn) {
- host[name] = () => {
- originalFn.call(host);
- cb.call(host);
- };
- } else {
- host[name] = cb;
- }
-};
-
var logAfterRender = (component, log) => {
after(component, 'componentDidMount', log);
after(component, 'componentDidUpdate', log);
@@ -73,20 +92,15 @@ var logWarning = (component, failureInfo, options) => {
var includeSrcNode = options.includeSrcNode;
var warn = () => {
- var failures = filterFailures(failureInfo, options);
-
- failures.forEach((failure) => {
- var msg = failure;
- var warning = [failureInfo.name, msg];
+ if (!shouldShowError(failureInfo, options))
+ return;
- if (includeSrcNode) {
- warning.push(document.getElementById(failureInfo.id));
- }
+ var warning = [failureInfo.name, failureInfo.failure];
- console.warn.apply(console, warning);
- });
+ if (includeSrcNode)
+ warning.push(document.getElementById(failureInfo.id));
- totalFailures.push(failureInfo);
+ console.warn.apply(console, warning);
};
if (component && includeSrcNode) {
@@ -99,69 +113,64 @@ var logWarning = (component, failureInfo, options) => {
}
};
-var nextId = 0;
-var totalFailures;
+var handleFailure = (options, reactEl, type, props, children, failure) => {
+ var includeSrcNode = options && !!options.includeSrcNode;
+ var reactComponent = reactEl._owner;
+
+ // If a Component instance, use the component's name,
+ // if a ReactElement instance, use the tag name + id (e.g. div#foo)
+ var name = reactComponent && reactComponent.getName() ||
+ type + '#' + props.id;
+
+ var failureInfo = {
+ 'name': name ,
+ 'id': props.id,
+ 'failure': failure
+ };
+
+ var notifyOpts = {
+ 'includeSrcNode': includeSrcNode,
+ 'filterFn': options && options.filterFn
+ };
+
+ if (options && options.throw)
+ throwError(failureInfo, notifyOpts);
+ else
+ logWarning(reactComponent, failureInfo, notifyOpts);
+};
+
+
+var _createElement;
+
+var createId = function() {
+ var nextId = 0;
+ return (props) => {
+ return (props.id || 'a11y-' + nextId++);
+ };
+}();
var reactA11y = (React, options) => {
if (!React && !React.createElement) {
throw new Error('Missing parameter: React');
}
+
assertions.setReact(React);
- totalFailures = [];
- var _createElement = React.createElement;
- var includeSrcNode = options && !!options.includeSrcNode;
+ _createElement = React.createElement;
var deviceFilter = options && options.device || ['desktop'];
React.createElement = (type, _props, ...children) => {
var props = _props || {};
- var reactEl;
-
- if (typeof type === 'string') {
- let failures = assertAccessibility(type, props, children, deviceFilter);
- if (failures.length) {
- // Generate an id if one doesn't exist
- props.id = (props.id || 'a11y-' + nextId++);
- reactEl = _createElement.apply(this, [type, props].concat(children));
-
- let reactComponent = reactEl._owner;
-
- // If a Component instance, use the component's name,
- // if a ReactElement instance, use the node DOM + id (e.g. div#foo)
- let name = reactComponent && reactComponent.getName() ||
- reactEl.type + '#' + props.id;
-
- let failureInfo = {
- 'name': name ,
- 'id': props.id,
- 'failures': failures
- };
-
- let notifyOpts = {
- 'includeSrcNode': includeSrcNode,
- 'filterFn': options && options.filterFn
- };
-
- if (options && options.throw) {
- throwError(failureInfo, notifyOpts);
- } else {
- logWarning(reactComponent, failureInfo, notifyOpts);
- }
-
- } else {
- reactEl = _createElement.apply(this, [type, props].concat(children));
- }
- } else {
- reactEl = _createElement.apply(this, [type, props].concat(children));
- }
- return reactEl;
- };
+ props.id = createId(props);
+ var reactEl = _createElement.apply(this, [type, props].concat(children));
+ var failureCB = handleFailure.bind(undefined, options, reactEl);
- reactA11y.getFailures = () => {
- return totalFailures;
- };
+ if (typeof type === 'string')
+ runTests(type, props, children, deviceFilter, failureCB);
+ return reactEl;
+ };
};
module.exports = reactA11y;
diff --git a/package.json b/package.json
index 8ff93f2..f40bb2d 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"homepage": "https://github.com/rackt/react-a11y/blob/latest/README.md",
"bugs": "https://github.com/rackt/react-a11y/issues",
"scripts": {
- "test": "jsxhint . && mocha --compilers js:babel/register lib/__tests__",
+ "test": "jsxhint . && karma start --single-run",
"watch-tests": "npm test -- --watch",
"prepublish": "babel -d dist lib",
"release": "release"
@@ -23,10 +23,21 @@
"license": "MIT",
"devDependencies": {
"babel": "^5.2.17",
+ "babel-core": "^5.2.17",
+ "babel-loader": "^5.0.0",
+ "jsx-loader": "^0.12.2",
"jsxhint": "^0.8.1",
+ "karma": "^0.12.28",
+ "karma-chrome-launcher": "^0.1.7",
+ "karma-cli": "0.0.4",
+ "karma-firefox-launcher": "^0.1.3",
+ "karma-mocha": "^0.1.10",
+ "karma-sourcemap-loader": "^0.3.2",
+ "karma-webpack": "^1.3.1",
"mocha": "^2.0.1",
- "react": "0.13.x",
- "rf-release": "0.4.0"
+ "react": "^0.12 || ^0.13",
+ "rf-release": "0.4.0",
+ "webpack": "^1.4.13"
},
"tags": [
"accessibility",
@@ -38,4 +49,4 @@
"react",
"a11y"
]
-}
\ No newline at end of file
+}
diff --git a/tests.webpack.js b/tests.webpack.js
new file mode 100644
index 0000000..019924e
--- /dev/null
+++ b/tests.webpack.js
@@ -0,0 +1,2 @@
+var context = require.context('./lib', true, /-test\.js$/);
+context.keys().forEach(context);
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..74a5e0a
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,42 @@
+var webpack = require('webpack');
+
+var plugins = [
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production')
+ })
+];
+
+if (process.env.COMPRESS) {
+ plugins.push(
+ new webpack.optimize.UglifyJsPlugin({
+ compressor: {
+ warnings: false
+ }
+ })
+ );
+}
+
+module.exports = {
+
+ output: {
+ library: 'Rackt.A11y',
+ libraryTarget: 'var'
+ },
+
+ externals: process.env.NODE_DIST ? {} : {
+ react: 'React'
+ },
+
+ node: {
+ buffer: false
+ },
+
+ plugins: plugins,
+
+ module: {
+ loaders: [
+ { test: /\.js$/, loader: 'jsx-loader?harmony' }
+ ]
+ }
+
+};