diff --git a/README.md b/README.md
index fc2b0472..e5199a78 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,18 @@ time it is rendered. This ensures that the component is still accessible even
after state changes, and since the checks are scoped to a component's element,
it means that any state change propagated downwards is also caught.
+#### Inspecting Violations
+When a violation is detected for a component's element, the element will have the `.axe-violation` class added to it. Visually, this will produce a striping pattern over the element (which will disappear on hover) designed to make it easily distinguishable from its expected appearance, even for users with low vision.
+
+Take this text input without a label, for example:
+
+![](docs/assets/violation-styling.png)
+
+At the same time, a violation error message will be logged to the console with even more detailed information as to what went wrong. The following message corresponds to the same text input element above:
+
+![](docs/assets/violation-console-output.png)
+
+
#### Component Hooks
Since development is not a uniform experience, Ember A11y Testing provides
diff --git a/app/instance-initializers/axe-component.js b/app/instance-initializers/axe-component.js
index 4d3dcbea..2e4d702e 100644
--- a/app/instance-initializers/axe-component.js
+++ b/app/instance-initializers/axe-component.js
@@ -39,6 +39,17 @@ export function initialize(application) {
*/
turnAuditOff: false,
+ /**
+ * An array of classNames to add to the component when a violation occurs.
+ * If unspecified, the `axe-violation` class is used to apply our default
+ * styling
+ *
+ * @public
+ * @type {Array}
+ */
+ axeViolationClassNames: ['axe-violation'],
+
+
/**
* Runs an accessibility audit on any render of the component.
* @private
@@ -59,7 +70,8 @@ export function initialize(application) {
audit() {
if (this.get('tagName') !== '') {
axe.a11yCheck(this.$(), this.axeOptions, (results) => {
- let violations = results.violations;
+ const violations = results.violations;
+ const violationClassNames = this.get('axeViolationClassNames');
for (let i = 0, l = violations.length; i < l; i++) {
let violation = violations[i];
@@ -72,7 +84,7 @@ export function initialize(application) {
let node = nodes[j];
if (node) {
- Ember.$(node.target.join(','))[0].classList.add('axe-violation');
+ Ember.$(node.target.join(','))[0].classList.add(...violationClassNames);
}
}
}
diff --git a/content-for/head-footer.html b/content-for/head-footer.html
index 8ef69e1e..80cfb853 100644
--- a/content-for/head-footer.html
+++ b/content-for/head-footer.html
@@ -1,5 +1,19 @@
diff --git a/docs/assets/violation-console-output.png b/docs/assets/violation-console-output.png
new file mode 100644
index 00000000..07b1837c
Binary files /dev/null and b/docs/assets/violation-console-output.png differ
diff --git a/docs/assets/violation-styling.png b/docs/assets/violation-styling.png
new file mode 100644
index 00000000..c67174f0
Binary files /dev/null and b/docs/assets/violation-styling.png differ
diff --git a/tests/.jshintrc b/tests/.jshintrc
index 6ec0b7c1..ad23df82 100644
--- a/tests/.jshintrc
+++ b/tests/.jshintrc
@@ -19,6 +19,7 @@
"wait",
"DS",
"andThen",
+ "axe",
"currentURL",
"currentPath",
"currentRouteName"
diff --git a/tests/acceptance/auto-run-test.js b/tests/acceptance/auto-run-test.js
index 42db68cb..b10f22fe 100644
--- a/tests/acceptance/auto-run-test.js
+++ b/tests/acceptance/auto-run-test.js
@@ -6,6 +6,10 @@ import sinon from 'sinon';
let application;
let sandbox;
+const SELECTORS = {
+ passingInput: '[data-test-selector="passing-input"]'
+};
+
module('Acceptance | auto-run', {
beforeEach: function() {
application = startApp();
@@ -39,7 +43,7 @@ test('should run the function whenever a render occurs', function(assert) {
assert.equal(currentPath(), 'index');
});
- click('label');
+ click(`${SELECTORS.passingInput} label`);
andThen(() => {
assert.ok(callbackStub.calledTwice);
diff --git a/tests/acceptance/violations-test.js b/tests/acceptance/violations-test.js
new file mode 100644
index 00000000..40770814
--- /dev/null
+++ b/tests/acceptance/violations-test.js
@@ -0,0 +1,43 @@
+import Ember from 'ember';
+import { test } from 'qunit';
+import moduleForAcceptance from '../../tests/helpers/module-for-acceptance';
+import sinon from 'sinon';
+
+const { A } = Ember;
+
+const IDs = {
+ emptyButton: '#empty-button',
+ sloppyInput: '#sloppy-input'
+};
+
+
+let actual, expected, sandbox;
+
+moduleForAcceptance('Acceptance | violations', {
+ beforeEach() {
+ sandbox = sinon.sandbox.create();
+ },
+
+ afterEach() {
+ sandbox.restore();
+ }
+});
+
+test('marking DOM nodes with violations', function(assert) {
+
+ sandbox.stub(axe.ember, 'a11yCheckCallback', function (results) {
+ actual = results.violations.length;
+ expected = 2;
+
+ assert.equal(actual, expected);
+
+ const buttonNameViolation = A(results.violations).findBy('id', 'button-name');
+ actual = buttonNameViolation.nodes[0].target[0];
+ expected = IDs.emptyButton;
+
+ assert.equal(actual, expected);
+ });
+
+ visit('/violations');
+
+});
diff --git a/tests/dummy/app/components/_base-demo-component.js b/tests/dummy/app/components/_base-demo-component.js
new file mode 100644
index 00000000..72b0d076
--- /dev/null
+++ b/tests/dummy/app/components/_base-demo-component.js
@@ -0,0 +1,7 @@
+import Ember from 'ember';
+
+const { Component } = Ember;
+
+export default Component.extend({
+ attributeBindings: ['data-test-selector']
+});
diff --git a/tests/dummy/app/components/empty-button.js b/tests/dummy/app/components/empty-button.js
new file mode 100644
index 00000000..d2070d07
--- /dev/null
+++ b/tests/dummy/app/components/empty-button.js
@@ -0,0 +1,6 @@
+import BaseDemoComponent from './_base-demo-component';
+
+export default BaseDemoComponent.extend({
+ classNames: ['button'],
+ tagName: 'button'
+});
diff --git a/tests/dummy/app/components/passing-component.js b/tests/dummy/app/components/passing-component.js
index 10c85285..b6ae22a3 100644
--- a/tests/dummy/app/components/passing-component.js
+++ b/tests/dummy/app/components/passing-component.js
@@ -1,6 +1,6 @@
-import Ember from 'ember';
+import BaseDemoComponent from './_base-demo-component';
-export default Ember.Component.extend({
+export default BaseDemoComponent.extend({
actions: {
toggle() {
this.set('isFailing', true);
diff --git a/tests/dummy/app/components/sloppy-input.js b/tests/dummy/app/components/sloppy-input.js
new file mode 100644
index 00000000..eb9a3cb4
--- /dev/null
+++ b/tests/dummy/app/components/sloppy-input.js
@@ -0,0 +1,5 @@
+import BaseDemoComponent from './_base-demo-component';
+
+export default BaseDemoComponent.extend({
+ tagName: 'input',
+});
diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js
index 3bba78eb..9f99b137 100644
--- a/tests/dummy/app/router.js
+++ b/tests/dummy/app/router.js
@@ -6,6 +6,7 @@ const Router = Ember.Router.extend({
});
Router.map(function() {
+ this.route('violations');
});
export default Router;
diff --git a/tests/dummy/app/routes/index.js b/tests/dummy/app/routes/index.js
new file mode 100644
index 00000000..26d9f312
--- /dev/null
+++ b/tests/dummy/app/routes/index.js
@@ -0,0 +1,4 @@
+import Ember from 'ember';
+
+export default Ember.Route.extend({
+});
diff --git a/tests/dummy/app/routes/violations.js b/tests/dummy/app/routes/violations.js
new file mode 100644
index 00000000..26d9f312
--- /dev/null
+++ b/tests/dummy/app/routes/violations.js
@@ -0,0 +1,4 @@
+import Ember from 'ember';
+
+export default Ember.Route.extend({
+});
diff --git a/tests/dummy/app/styles/app.css b/tests/dummy/app/styles/app.css
index e69de29b..77189728 100644
--- a/tests/dummy/app/styles/app.css
+++ b/tests/dummy/app/styles/app.css
@@ -0,0 +1,10 @@
+.button {
+ background-color: aqua;
+ border-radius: 0.25em;
+ border: none;
+ min-height: 1.5rem;
+ height: 1.5rem;
+
+ width: 2.2rem;
+ min-width: 2.2rem;
+}
diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs
index d6a7f4ef..f8bc38e7 100644
--- a/tests/dummy/app/templates/application.hbs
+++ b/tests/dummy/app/templates/application.hbs
@@ -1,3 +1,3 @@
-
Welcome to Ember.js
+Welcome to Ember
-{{passing-component}}
+{{outlet}}
diff --git a/tests/dummy/app/templates/components/empty-button.hbs b/tests/dummy/app/templates/components/empty-button.hbs
new file mode 100644
index 00000000..889d9eea
--- /dev/null
+++ b/tests/dummy/app/templates/components/empty-button.hbs
@@ -0,0 +1 @@
+{{yield}}
diff --git a/tests/dummy/app/templates/components/sloppy-input.hbs b/tests/dummy/app/templates/components/sloppy-input.hbs
new file mode 100644
index 00000000..889d9eea
--- /dev/null
+++ b/tests/dummy/app/templates/components/sloppy-input.hbs
@@ -0,0 +1 @@
+{{yield}}
diff --git a/tests/dummy/app/templates/index.hbs b/tests/dummy/app/templates/index.hbs
new file mode 100644
index 00000000..f7f52238
--- /dev/null
+++ b/tests/dummy/app/templates/index.hbs
@@ -0,0 +1,2 @@
+{{passing-component data-test-selector="passing-input"}}
+{{outlet}}
diff --git a/tests/dummy/app/templates/violations.hbs b/tests/dummy/app/templates/violations.hbs
new file mode 100644
index 00000000..a46a7a12
--- /dev/null
+++ b/tests/dummy/app/templates/violations.hbs
@@ -0,0 +1,3 @@
+{{empty-button id="empty-button" data-test-selector="empty-button"}}
+
+{{sloppy-input id="sloppy-input" data-test-selector="sloppy-input"}}
diff --git a/tests/unit/instance-initializers/axe-component-test.js b/tests/unit/instance-initializers/axe-component-test.js
index bf163eef..67988584 100644
--- a/tests/unit/instance-initializers/axe-component-test.js
+++ b/tests/unit/instance-initializers/axe-component-test.js
@@ -1,15 +1,17 @@
/* global sinon, axe */
import Ember from 'ember';
import { initialize } from 'dummy/instance-initializers/axe-component';
-import { module, test, skip } from 'qunit';
+import { module, test } from 'qunit';
+
+const { Application, Component, Logger, run } = Ember;
let application;
let sandbox;
module('Unit | Instance Initializer | axe-component', {
beforeEach() {
- Ember.run(() => {
- application = Ember.Application.create({
+ run(() => {
+ application = Application.create({
rootElement: '#ember-testing'
});
application.deferReadiness();
@@ -25,11 +27,11 @@ module('Unit | Instance Initializer | axe-component', {
/* Basic Behavior */
-test('initializer should not re-open Ember.Component more than once', function(assert) {
+test('initializer should not re-open Component more than once', function(assert) {
// Depending on if the initializer has already ran, we will either expect the
// reopen method to be called once or not at all.
- let assertMethod = Ember.Component.prototype.audit ? 'notCalled' : 'calledOnce';
- let reopenSpy = sandbox.spy(Ember.Component, 'reopen');
+ let assertMethod = Component.prototype.audit ? 'notCalled' : 'calledOnce';
+ let reopenSpy = sandbox.spy(Component, 'reopen');
initialize(application);
initialize(application);
@@ -40,19 +42,19 @@ test('initializer should not re-open Ember.Component more than once', function(a
test('audit is run on didRender when not in testing mode', function(assert) {
initialize(application);
- let component = Ember.Component.create({});
+ let component = Component.create({});
let auditSpy = sandbox.spy(component, 'audit');
// In order for the audit to run, we have to act like we're not in testing
Ember.testing = false;
- Ember.run(() => component.appendTo('#ember-testing'));
+ run(() => component.appendTo('#ember-testing'));
assert.ok(auditSpy.calledOnce);
- Ember.run(() => component.trigger('didRender'));
+ run(() => component.trigger('didRender'));
assert.ok(auditSpy.calledTwice);
- Ember.run(() => component.destroy());
+ run(() => component.destroy());
// Turn testing mode back on to ensure validity of other tests
Ember.testing = true;
@@ -61,36 +63,36 @@ test('audit is run on didRender when not in testing mode', function(assert) {
test('audit is not run on didRender when in testing mode', function(assert) {
initialize(application);
- let component = Ember.Component.create({});
+ let component = Component.create({});
let auditSpy = sandbox.spy(component, 'audit');
- Ember.run(() => component.appendTo('#ember-testing'));
+ run(() => component.appendTo('#ember-testing'));
assert.ok(auditSpy.notCalled);
- Ember.run(() => component.destroy());
+ run(() => component.destroy());
});
-/* Ember.Component.turnAuditOff */
+/* Component.turnAuditOff */
test('turnAuditOff prevents audit from running on didRender', function(assert) {
initialize(application);
- let component = Ember.Component.create({ turnAuditOff: true });
+ let component = Component.create({ turnAuditOff: true });
let auditSpy = sandbox.spy(component, 'audit');
// In order for the audit to run, we have to act like we're not in testing
Ember.testing = false;
- Ember.run(() => component.appendTo('#ember-testing'));
+ run(() => component.appendTo('#ember-testing'));
assert.ok(auditSpy.notCalled);
- Ember.run(() => component.destroy());
+ run(() => component.destroy());
// Turn testing mode back on to ensure validity of other tests
Ember.testing = true;
});
-/* Ember.Component.audit */
+/* Component.audit */
test('audit should log any violations found', function(assert) {
sandbox.stub(axe, 'a11yCheck', function(el, options, callback) {
@@ -102,17 +104,14 @@ test('audit should log any violations found', function(assert) {
});
});
- let logSpy = sandbox.spy(Ember.Logger, 'error');
+ let logSpy = sandbox.spy(Logger, 'error');
- let component = Ember.Component.create({});
+ let component = Component.create({});
component.audit();
assert.ok(logSpy.calledOnce);
});
-skip('audit should mark the DOM nodes of any violations', function(/* assert */) {
-
-});
test('audit should do nothing if no violations found', function(assert) {
sandbox.stub(axe, 'a11yCheck', function(el, options, callback) {
@@ -121,15 +120,15 @@ test('audit should do nothing if no violations found', function(assert) {
});
});
- let logSpy = sandbox.spy(Ember.Logger, 'error');
+ let logSpy = sandbox.spy(Logger, 'error');
- let component = Ember.Component.create({});
+ let component = Component.create({});
component.audit();
assert.ok(logSpy.notCalled);
});
-/* Ember.Component.axeCallback */
+/* Component.axeCallback */
test('axeCallback receives the results of the audit', function(assert) {
let results = { violations: [] };
@@ -139,7 +138,7 @@ test('axeCallback receives the results of the audit', function(assert) {
});
let axeCallbackSpy = sandbox.spy();
- let component = Ember.Component.create({
+ let component = Component.create({
axeCallback: axeCallbackSpy
});
@@ -156,22 +155,83 @@ test('axeCallback throws an error if it is not a function', function(assert) {
callback(results);
});
- let component = Ember.Component.create({
+ let component = Component.create({
axeCallback: 'axeCallbackSpy'
});
assert.throws(() => component.audit(), 'axeCallback should be a function.');
});
-/* Ember.Component.axeOptions */
+/* Component.axeOptions */
test('axeOptions are passed in as the second param to a11yCheck', function(assert) {
let a11yCheckStub = sandbox.stub(axe, 'a11yCheck');
let axeOptions = { test: 'test' };
- let component = Ember.Component.create({ axeOptions });
+ let component = Component.create({ axeOptions });
component.audit();
assert.ok(a11yCheckStub.calledOnce);
assert.ok(a11yCheckStub.calledWith(component.$(), axeOptions));
});
+
+test('custom classNames are set on the violating elmenet if they are defined', function (assert) {
+ const dummyDOMNodeID = 'sign-up-button';
+ const dummyDOMNodeClass = 'icon-left-shark';
+ const dummyDOMNode = document.createElement('div');
+
+ const component = Component.create({
+ axeViolationClassNames: [dummyDOMNodeClass]
+ });
+
+ dummyDOMNode.setAttribute('id', dummyDOMNodeID);
+ document.body.appendChild(dummyDOMNode);
+
+ // run(() => component.appendTo('#ember-testing'));
+ sandbox.stub(axe, 'a11yCheck', function(el, options, callback) {
+ callback({
+ violations: [{
+ name: 'test',
+ nodes: [
+ { target: [`#${dummyDOMNodeID}`] }
+ ]
+ }]
+ });
+ });
+
+ component.audit();
+
+ assert.ok(dummyDOMNode.classList.contains(dummyDOMNodeClass));
+ assert.notOk(dummyDOMNode.classList.contains('axe-violation'));
+
+ run(() => dummyDOMNode.remove());
+});
+
+test(`the component defaults to setting the \`axe-violation\` class on
+ the element if no custom class names are set`, function (assert) {
+
+ const dummyDOMNodeID = 'sign-up-button';
+ const dummyDOMNode = document.createElement('div');
+ const component = Component.create();
+
+ dummyDOMNode.setAttribute('id', dummyDOMNodeID);
+ document.body.appendChild(dummyDOMNode);
+
+ // run(() => component.appendTo('#ember-testing'));
+ sandbox.stub(axe, 'a11yCheck', function(el, options, callback) {
+ callback({
+ violations: [{
+ name: 'test',
+ nodes: [
+ { target: [`#${dummyDOMNodeID}`] }
+ ]
+ }]
+ });
+ });
+
+ component.audit();
+
+ assert.ok(dummyDOMNode.classList.contains('axe-violation'));
+
+ run(() => dummyDOMNode.remove());
+});