From 4ca5aae3195bddc028fb0d563747935cd831fe51 Mon Sep 17 00:00:00 2001 From: Todd Kloots Date: Wed, 20 May 2015 11:25:59 -0700 Subject: [PATCH] Add a means of filtering failures on a per-component basis (fixes #34). Provide a means of logging DOM element references (fixes #35). --- README.md | 29 +++++++- lib/__tests__/index-test.js | 80 +++++++++++++++++++++- lib/index.js | 128 ++++++++++++++++++++++++++++++++---- 3 files changed, 223 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ea47f07..fadb0d2 100644 --- a/README.md +++ b/README.md @@ -34,5 +34,32 @@ yet, alias the module to nothing with webpack in production. If you want it to throw errors instead of just warnings: ``` -a11y(React, {throw: true}); +a11y(React, { throw: true }); +``` + +You can filter failures by passing a function to the `filterFn` option. The +filter function will receive three arguments: the name of the Component +instance or ReactElement, the id of the element, and the failure message. +Note: If a ReactElement, the name will be the node type followed by the id +(e.g. div#foo). + +``` +var commentListFailures = (name, id, msg) => { + return name === "CommentList"; +}; + +a11y(React, { filterFn: commentListFailures }); +``` + +If you want to log DOM element references for easy lookups in the DOM inspector, +use the `includeSrcNode` option. + +``` +a11y(React, { throw: true, includeSrcNode: true }); +``` + +All failures are also accessible via the `getFailures()` method. + +``` +a11y.getFailures(); ``` diff --git a/lib/__tests__/index-test.js b/lib/__tests__/index-test.js index 49880f3..0d82aeb 100644 --- a/lib/__tests__/index-test.js +++ b/lib/__tests__/index-test.js @@ -1,6 +1,6 @@ var React = require('react'); var assert = require('assert'); -require('../index')(React); +var a11y = require('../index'); var assertions = require('../assertions'); var k = () => {}; @@ -25,6 +25,15 @@ var doNotExpectWarning = (notExpected, fn) => { }; describe('props', () => { + var createElement = React.createElement; + + before(() => { + a11y(React); + }); + + after(() => { + React.createElement = createElement; + }); describe('onClick', () => { @@ -162,6 +171,16 @@ describe('props', () => { }); describe('tags', () => { + var createElement = React.createElement; + + before(() => { + a11y(React); + }); + + after(() => { + React.createElement = createElement; + }); + describe('img', () => { it('requires alt attributes', () => { expectWarning(assertions.tags.img.MISSING_ALT.msg, () => { @@ -200,3 +219,62 @@ describe('tags', () => { }); }); }); + +describe('filterFn', () => { + var createElement = React.createElement; + + before(() => { + var barOnly = (name, id, msg) => { + return id === "bar"; + }; + + a11y(React, { filterFn: barOnly }); + }); + + after(() => { + React.createElement = createElement; + }); + + describe('when the source element has been filtered out', () => { + it('does not warn', () => { + doNotExpectWarning(assertions.tags.img.MISSING_ALT.msg, () => { + ; + }); + }); + }); + + describe('when there are filtered results', () => { + it('warns', () => { + expectWarning(assertions.tags.img.MISSING_ALT.msg, () => { +
+ + +
; + }); + }); + }); +}); + +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); + }); + }); + +}); \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index 37b0898..ea69f3a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -20,36 +20,140 @@ var assertAccessibility = (tagName, props, children) => { return failures; }; -var error = (id, msg) => { - throw new Error('#' + id + ": " + msg); +var filterFailures = (failureInfo, options) => { + var failures = failureInfo.failures; + var filterFn = options.filterFn && + options.filterFn.bind(undefined, failureInfo.name, failureInfo.id); + + if (filterFn) { + failures = failures.filter(filterFn); + } + + return failures; }; -var warn = (id, msg) => { - console.warn('#' + id, msg); +var throwError = (failureInfo, options) => { + var failures = filterFailures(failureInfo, options); + var msg = failures.pop(); + var error = [failureInfo.name, msg]; + + 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); +}; + +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 (includeSrcNode) { + warning.push(document.getElementById(failureInfo.id)); + } + + console.warn.apply(console, warning); + }); + + totalFailures.push(failureInfo); + }; + + if (component && includeSrcNode) { + // Cannot log a node reference until the component is in the DOM, + // so defer the document.getElementById call until componentDidMount + // or componentDidUpdate. + logAfterRender(component._instance, warn); + } else { + warn(); + } }; var nextId = 0; -module.exports = (React, options) => { +var totalFailures; + +var reactA11y = (React, options) => { if (!React && !React.createElement) { throw new Error('Missing parameter: React'); } assertions.setReact(React); + totalFailures = []; var _createElement = React.createElement; - var log = options && options.throw ? error : warn; - React.createElement = function (type, _props, ...children) { + var includeSrcNode = options && !!options.includeSrcNode; + + React.createElement = (type, _props, ...children) => { var props = _props || {}; + var reactEl; + if (typeof type === 'string') { - var failures = assertAccessibility(type, props, children); + let failures = assertAccessibility(type, props, children); 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; - for (var i = 0; i < failures.length; i++) - log(props.id, failures[i]); + // 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)); } - // make sure props with the id is passed down, even if no props were passed in. - return _createElement.apply(this, [type, props].concat(children)); + + return reactEl; }; + + reactA11y.getFailures = () => { + return totalFailures; + }; + }; + +module.exports = reactA11y;