diff --git a/README.md b/README.md index 36fbee57b..e0ba9dd95 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,18 @@ git diff step-?..step-? - CSS transition animations. - CSS keyframe animations. - JavaScript-based animations. + +### step-15 _Accessibility (a11y)_ + +- Add labels to the search and order fields. +- Add accessibility plugin for Protractor. + - Add missing alt attributes in the phone detail. +- Add aria live regions to inform the user about results after searching and filtering elements. +- Improve access via keyboard: + - Navigate between the images in a phone detail. + - Add headings elements. +- Add information about checkmarks in the phone detail specs. + ## Development with `angular-phonecat` @@ -276,4 +288,4 @@ For more information on AngularJS, please check out https://angularjs.org/. [karma]: https://karma-runner.github.io/ [node]: https://nodejs.org/ [protractor]: http://www.protractortest.org/ -[selenium]: http://docs.seleniumhq.org/ +[protractor-accessibility-plugin]: https://github.com/angular/protractor-accessibility-plugin diff --git a/app/app.animations.css b/app/app.animations.css new file mode 100644 index 000000000..175320b50 --- /dev/null +++ b/app/app.animations.css @@ -0,0 +1,67 @@ +/* Animate `ngRepeat` in `phoneList` component */ +.phone-list-item.ng-enter, +.phone-list-item.ng-leave, +.phone-list-item.ng-move { + overflow: hidden; + transition: 0.5s linear all; +} + +.phone-list-item.ng-enter, +.phone-list-item.ng-leave.ng-leave-active, +.phone-list-item.ng-move { + height: 0; + margin-bottom: 0; + opacity: 0; + padding-bottom: 0; + padding-top: 0; +} + +.phone-list-item.ng-enter.ng-enter-active, +.phone-list-item.ng-leave, +.phone-list-item.ng-move.ng-move-active { + height: 120px; + margin-bottom: 20px; + opacity: 1; + padding-bottom: 4px; + padding-top: 15px; +} + +/* Animate view transitions with `ngView` */ +.view-container { + position: relative; +} + +.view-frame { + margin-top: 20px; +} + +.view-frame.ng-enter, +.view-frame.ng-leave { + background: white; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.view-frame.ng-enter { + animation: 1s fade-in; + z-index: 100; +} + +.view-frame.ng-leave { + animation: 1s fade-out; + z-index: 99; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fade-out { + from { opacity: 1; } + to { opacity: 0; } +} + +/* Older browsers might need vendor-prefixes for keyframes and animation! */ diff --git a/app/app.animations.js b/app/app.animations.js new file mode 100644 index 000000000..394fcc944 --- /dev/null +++ b/app/app.animations.js @@ -0,0 +1,43 @@ +'use strict'; + +angular. + module('phonecatApp'). + animation('.phone', function phoneAnimationFactory() { + return { + addClass: animateIn, + removeClass: animateOut + }; + + function animateIn(element, className, done) { + if (className !== 'selected') return; + + element.css({ + display: 'block', + position: 'absolute', + top: 500, + left: 0 + }).animate({ + top: 0 + }, done); + + return function animateInEnd(wasCanceled) { + if (wasCanceled) element.stop(); + }; + } + + function animateOut(element, className, done) { + if (className !== 'selected') return; + + element.css({ + position: 'absolute', + top: 0, + left: 0 + }).animate({ + top: -500 + }, done); + + return function animateOutEnd(wasCanceled) { + if (wasCanceled) element.stop(); + }; + } + }); diff --git a/app/app.config.js b/app/app.config.js new file mode 100644 index 000000000..a060f5906 --- /dev/null +++ b/app/app.config.js @@ -0,0 +1,18 @@ +'use strict'; + +angular. + module('phonecatApp'). + config(['$locationProvider' ,'$routeProvider', + function config($locationProvider, $routeProvider) { + $locationProvider.hashPrefix('!'); + + $routeProvider. + when('/phones', { + template: '' + }). + when('/phones/:phoneId', { + template: '' + }). + otherwise('/phones'); + } + ]); diff --git a/app/app.css b/app/app.css new file mode 100644 index 000000000..3854d3bf9 --- /dev/null +++ b/app/app.css @@ -0,0 +1,122 @@ +body { + padding: 20px; +} + +h1 { + border-bottom: 1px solid gray; + margin-top: 0; +} + +.aria-status { margin-bottom: 10px; } +.aria-status-order{ margin-bottom: 5px; } + +/* View: Phone list */ +.phones { + list-style: none; +} + +.phones li { + clear: both; + height: 115px; + padding-top: 15px; +} + +.thumb { + float: left; + height: 100px; + margin: -0.5em 1em 1.5em 0; + padding-bottom: 1em; + width: 100px; +} + +/* View: Phone detail */ +.phone { + background-color: white; + display: none; + float: left; + height: 400px; + margin-bottom: 2em; + margin-right: 3em; + padding: 2em; + width: 400px; +} + +.phone:first-child { + display: block; +} + +.phone-images { + background-color: white; + float: left; + height: 450px; + overflow: hidden; + position: relative; + width: 450px; +} + +.phone-thumbs { + list-style: none; + margin: 0; +} + +.phone-thumbs img { + height: 100px; + padding: 1em; + width: 100px; +} + +.phone-thumbs li { + background-color: white; + border: 1px solid black; + cursor: pointer; + display: inline-block; + margin: 1em; +} + +.phone-thumbs img:focus { + border:2px solid black; +} + +.phone-thumbs img:hover { + cursor: pointer; +} + +.phone-thumbs button { + background: none; + border: 0; + } + +.specs { + clear: both; + list-style: none; + margin: 0; + padding: 0; +} + +.specs dt { + font-weight: bold; +} + +.specs > li { + display: inline-block; + vertical-align: top; + width: 200px; +} + +.specs > li > span { + font-size: 1.2em; + font-weight: bold; +} + +ul.specs h2 { font-size:1.5em } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} \ No newline at end of file diff --git a/app/app.module.js b/app/app.module.js new file mode 100644 index 000000000..8c392c4d6 --- /dev/null +++ b/app/app.module.js @@ -0,0 +1,10 @@ +'use strict'; + +// Define the `phonecatApp` module +angular.module('phonecatApp', [ + 'ngAnimate', + 'ngRoute', + 'core', + 'phoneDetail', + 'phoneList' +]); diff --git a/app/core/checkmark/checkmark.filter.js b/app/core/checkmark/checkmark.filter.js new file mode 100644 index 000000000..0132dfc02 --- /dev/null +++ b/app/core/checkmark/checkmark.filter.js @@ -0,0 +1,9 @@ +'use strict'; + +angular. + module('core'). + filter('checkmark', function() { + return function(input) { + return input ? '\u2713' : '\u2718'; + }; + }); diff --git a/app/core/checkmark/checkmark.filter.spec.js b/app/core/checkmark/checkmark.filter.spec.js new file mode 100644 index 000000000..4d53baab9 --- /dev/null +++ b/app/core/checkmark/checkmark.filter.spec.js @@ -0,0 +1,14 @@ +'use strict'; + +describe('checkmark', function() { + + beforeEach(module('core')); + + it('should convert boolean values to unicode checkmark or cross', + inject(function(checkmarkFilter) { + expect(checkmarkFilter(true)).toBe('\u2713'); + expect(checkmarkFilter(false)).toBe('\u2718'); + }) + ); + +}); diff --git a/app/core/core.module.js b/app/core/core.module.js new file mode 100644 index 000000000..84a91dc7a --- /dev/null +++ b/app/core/core.module.js @@ -0,0 +1,4 @@ +'use strict'; + +// Define the `core` module +angular.module('core', ['core.phone']); diff --git a/app/core/phone/phone.module.js b/app/core/phone/phone.module.js new file mode 100644 index 000000000..0b6b34889 --- /dev/null +++ b/app/core/phone/phone.module.js @@ -0,0 +1,4 @@ +'use strict'; + +// Define the `core.phone` module +angular.module('core.phone', ['ngResource']); diff --git a/app/core/phone/phone.service.js b/app/core/phone/phone.service.js new file mode 100644 index 000000000..048e66ae8 --- /dev/null +++ b/app/core/phone/phone.service.js @@ -0,0 +1,15 @@ +'use strict'; + +angular. + module('core.phone'). + factory('Phone', ['$resource', + function($resource) { + return $resource('phones/:phoneId.json', {}, { + query: { + method: 'GET', + params: {phoneId: 'phones'}, + isArray: true + } + }); + } + ]); diff --git a/app/core/phone/phone.service.spec.js b/app/core/phone/phone.service.spec.js new file mode 100644 index 000000000..f045c561c --- /dev/null +++ b/app/core/phone/phone.service.spec.js @@ -0,0 +1,43 @@ +'use strict'; + +describe('Phone', function() { + var $httpBackend; + var Phone; + var phonesData = [ + {name: 'Phone X'}, + {name: 'Phone Y'}, + {name: 'Phone Z'} + ]; + + // Add a custom equality tester before each test + beforeEach(function() { + jasmine.addCustomEqualityTester(angular.equals); + }); + + // Load the module that contains the `Phone` service before each test + beforeEach(module('core.phone')); + + // Instantiate the service and "train" `$httpBackend` before each test + beforeEach(inject(function(_$httpBackend_, _Phone_) { + $httpBackend = _$httpBackend_; + $httpBackend.expectGET('phones/phones.json').respond(phonesData); + + Phone = _Phone_; + })); + + // Verify that there are no outstanding expectations or requests after each test + afterEach(function () { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should fetch the phones data from `/phones/phones.json`', function() { + var phones = Phone.query(); + + expect(phones).toEqual([]); + + $httpBackend.flush(); + expect(phones).toEqual(phonesData); + }); + +}); diff --git a/app/index.html b/app/index.html index 748eef01f..b10570885 100644 --- a/app/index.html +++ b/app/index.html @@ -1,11 +1,35 @@ - + - My HTML File + Google Phone Gallery + + + + + + + + + + + + + + + + + + + + - + +
+
+
+ diff --git a/app/phone-detail/phone-detail.component.js b/app/phone-detail/phone-detail.component.js new file mode 100644 index 000000000..3b38bf3af --- /dev/null +++ b/app/phone-detail/phone-detail.component.js @@ -0,0 +1,20 @@ +'use strict'; + +// Register `phoneDetail` component, along with its associated controller and template +angular. + module('phoneDetail'). + component('phoneDetail', { + templateUrl: 'phone-detail/phone-detail.template.html', + controller: ['$routeParams', 'Phone', + function PhoneDetailController($routeParams, Phone) { + var self = this; + self.phone = Phone.get({phoneId: $routeParams.phoneId}, function(phone) { + self.setImage(phone.images[0]); + }); + + self.setImage = function setImage(imageUrl) { + self.mainImageUrl = imageUrl; + }; + } + ] + }); diff --git a/app/phone-detail/phone-detail.component.spec.js b/app/phone-detail/phone-detail.component.spec.js new file mode 100644 index 000000000..8f4982682 --- /dev/null +++ b/app/phone-detail/phone-detail.component.spec.js @@ -0,0 +1,36 @@ +'use strict'; + +describe('phoneDetail', function() { + + // Load the module that contains the `phoneDetail` component before each test + beforeEach(module('phoneDetail')); + + // Test the controller + describe('PhoneDetailController', function() { + var $httpBackend, ctrl; + var xyzPhoneData = { + name: 'phone xyz', + images: ['image/url1.png', 'image/url2.png'] + }; + + beforeEach(inject(function($componentController, _$httpBackend_, $routeParams) { + $httpBackend = _$httpBackend_; + $httpBackend.expectGET('phones/xyz.json').respond(xyzPhoneData); + + $routeParams.phoneId = 'xyz'; + + ctrl = $componentController('phoneDetail'); + })); + + it('should fetch the phone details', function() { + jasmine.addCustomEqualityTester(angular.equals); + + expect(ctrl.phone).toEqual({}); + + $httpBackend.flush(); + expect(ctrl.phone).toEqual(xyzPhoneData); + }); + + }); + +}); diff --git a/app/phone-detail/phone-detail.module.js b/app/phone-detail/phone-detail.module.js new file mode 100644 index 000000000..fd7cb3b92 --- /dev/null +++ b/app/phone-detail/phone-detail.module.js @@ -0,0 +1,7 @@ +'use strict'; + +// Define the `phoneDetail` module +angular.module('phoneDetail', [ + 'ngRoute', + 'core.phone' +]); diff --git a/app/phone-detail/phone-detail.template.html b/app/phone-detail/phone-detail.template.html new file mode 100644 index 000000000..03d92fb08 --- /dev/null +++ b/app/phone-detail/phone-detail.template.html @@ -0,0 +1,120 @@ +
+ +
+ +

{{$ctrl.phone.name}}

+ +

{{$ctrl.phone.description}}

+ +

Click on each image to see the enlarge version.

+ + + + diff --git a/app/phone-list/phone-list.component.js b/app/phone-list/phone-list.component.js new file mode 100644 index 000000000..484be4f88 --- /dev/null +++ b/app/phone-list/phone-list.component.js @@ -0,0 +1,14 @@ +'use strict'; + +// Register `phoneList` component, along with its associated controller and template +angular. + module('phoneList'). + component('phoneList', { + templateUrl: 'phone-list/phone-list.template.html', + controller: ['Phone', + function PhoneListController(Phone) { + this.phones = Phone.query(); + this.orderProp = 'age'; + } + ] + }); diff --git a/app/phone-list/phone-list.component.spec.js b/app/phone-list/phone-list.component.spec.js new file mode 100644 index 000000000..572260e49 --- /dev/null +++ b/app/phone-list/phone-list.component.spec.js @@ -0,0 +1,35 @@ +'use strict'; + +describe('phoneList', function() { + + // Load the module that contains the `phoneList` component before each test + beforeEach(module('phoneList')); + + // Test the controller + describe('PhoneListController', function() { + var $httpBackend, ctrl; + + beforeEach(inject(function($componentController, _$httpBackend_) { + $httpBackend = _$httpBackend_; + $httpBackend.expectGET('phones/phones.json') + .respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]); + + ctrl = $componentController('phoneList'); + })); + + it('should create a `phones` property with 2 phones fetched with `$http`', function() { + jasmine.addCustomEqualityTester(angular.equals); + + expect(ctrl.phones).toEqual([]); + + $httpBackend.flush(); + expect(ctrl.phones).toEqual([{name: 'Nexus S'}, {name: 'Motorola DROID'}]); + }); + + it('should set a default value for the `orderProp` property', function() { + expect(ctrl.orderProp).toBe('age'); + }); + + }); + +}); diff --git a/app/phone-list/phone-list.directive.js b/app/phone-list/phone-list.directive.js new file mode 100644 index 000000000..2ed354493 --- /dev/null +++ b/app/phone-list/phone-list.directive.js @@ -0,0 +1,42 @@ + +'use strict'; + +/* Directives */ + +angular. + module('phoneList'). + directive('resultList', [function () { + var ariaStatus = document.querySelector('.aria-status'); + return { + restrict: 'A', + link: function ($scope) { + $scope.$watch('filtered.length', function (length) { + if(length === 1) { + ariaStatus.innerHTML = length + ' item found'; + } else if(length > 1) { + ariaStatus.innerHTML = length + ' items found'; + } else { + ariaStatus.innerHTML = 'No items found'; + } + }); + } + } +} +]). + directive('informOrder', [function () { + var ariaStatusSort = document.querySelector('.aria-status-sort'); + return { + restrict: 'A', + link: function ($scope) { + $scope.$watch('$ctrl.orderProp', function (order) { + if(order === 'age') { + ariaStatusSort.innerHTML = 'Items filter by newest' + } else { + ariaStatusSort.innerHTML = 'Items filter by alphabetical order' + } + + }); + } + } + } +]); \ No newline at end of file diff --git a/app/phone-list/phone-list.module.js b/app/phone-list/phone-list.module.js new file mode 100644 index 000000000..8ade7c5b8 --- /dev/null +++ b/app/phone-list/phone-list.module.js @@ -0,0 +1,4 @@ +'use strict'; + +// Define the `phoneList` module +angular.module('phoneList', ['core.phone']); diff --git a/app/phone-list/phone-list.template.html b/app/phone-list/phone-list.template.html new file mode 100644 index 000000000..7016920a4 --- /dev/null +++ b/app/phone-list/phone-list.template.html @@ -0,0 +1,40 @@ +
+
+
+ + +
+ + +
+ +
+ +
+ + +
+ +
+ +
+
+ + + + +
+
+
diff --git a/bower.json b/bower.json index a7a00a589..08280c3de 100644 --- a/bower.json +++ b/bower.json @@ -7,7 +7,11 @@ "private": true, "dependencies": { "angular": "1.5.x", + "angular-animate": "1.5.x", "angular-mocks": "1.5.x", - "bootstrap": "3.3.x" + "angular-resource": "1.5.x", + "angular-route": "1.5.x", + "bootstrap": "3.3.x", + "jquery": "2.2.x" } } diff --git a/e2e-tests/protractor.conf.js b/e2e-tests/protractor.conf.js index 13c5cb626..b9a5a0e33 100644 --- a/e2e-tests/protractor.conf.js +++ b/e2e-tests/protractor.conf.js @@ -17,6 +17,16 @@ exports.config = { jasmineNodeOpts: { defaultTimeoutInterval: 30000 - } + }, + + chromeOnly: true, + directConnect: true, + + plugins: [{ + chromeA11YDevTools: { + treatWarningsAsFailures: true + }, + package: 'protractor-accessibility-plugin' + }] }; diff --git a/e2e-tests/scenarios.js b/e2e-tests/scenarios.js index 8ecf38b99..ee914bd17 100644 --- a/e2e-tests/scenarios.js +++ b/e2e-tests/scenarios.js @@ -3,10 +3,111 @@ // Angular E2E Testing Guide: // https://docs.angularjs.org/guide/e2e-testing -describe('My app', function() { +describe('PhoneCat Application', function() { - beforeEach(function() { + it('should redirect `index.html` to `index.html#!/phones', function() { browser.get('index.html'); + expect(browser.getLocationAbsUrl()).toBe('/phones'); + }); + + describe('View: Phone list', function() { + + beforeEach(function() { + browser.get('index.html#!/phones'); + }); + + it('should filter the phone list as a user types into the search box', function() { + var phoneList = element.all(by.repeater('phone in filtered')); + var query = element(by.model('$ctrl.query')); + + expect(phoneList.count()).toBe(20); + + query.sendKeys('nexus'); + + browser.sleep(1000).then(function(){ + expect(phoneList.count()).toBe(1); + }); + + query.clear(); + query.sendKeys('motorola'); + + browser.sleep(1000).then(function(){ + expect(phoneList.count()).toBe(8); + }); + + }); + + it('should be possible to control phone order via the drop-down menu', function() { + var queryField = element(by.model('$ctrl.query')); + var orderSelect = element(by.model('$ctrl.orderProp')); + var nameOption = orderSelect.element(by.css('option[value="name"]')); + var phoneNameColumn = element.all(by.repeater('phone in filtered').column('phone.name')); + + function getNames() { + return phoneNameColumn.map(function(elem) { + return elem.getText(); + }); + } + + queryField.sendKeys('tablet'); // Let's narrow the dataset to make the assertions shorter + + browser.sleep(1000).then(function(){ + + expect(getNames()).toEqual([ + 'Motorola XOOM\u2122 with Wi-Fi', + 'MOTOROLA XOOM\u2122' + ]); + + nameOption.click(); + + expect(getNames()).toEqual([ + 'MOTOROLA XOOM\u2122', + 'Motorola XOOM\u2122 with Wi-Fi' + ]); + + }); + }); + + it('should render phone specific links', function() { + var query = element(by.model('$ctrl.query')); + query.sendKeys('nexus'); + + browser.sleep(1000).then(function(){ + element.all(by.css('.phones li a')).first().click(); + expect(browser.getLocationAbsUrl()).toBe('/phones/nexus-s'); + }); + + }); + + }); + + describe('View: Phone detail', function() { + + beforeEach(function() { + browser.get('index.html#!/phones/nexus-s'); + }); + + it('should display the `nexus-s` page', function() { + expect(element(by.binding('$ctrl.phone.name')).getText()).toBe('Nexus S'); + }); + + it('should display the first phone image as the main phone image', function() { + var mainImage = element(by.css('img.phone.selected')); + + expect(mainImage.getAttribute('src')).toMatch(/img\/phones\/nexus-s.0.jpg/); + }); + + it('should swap the main image when clicking on a thumbnail image', function() { + var mainImage = element(by.css('img.phone.selected')); + var thumbnails = element.all(by.css('.phone-thumbs img')); + + thumbnails.get(2).click(); + expect(mainImage.getAttribute('src')).toMatch(/img\/phones\/nexus-s.2.jpg/); + + thumbnails.get(0).click(); + expect(mainImage.getAttribute('src')).toMatch(/img\/phones\/nexus-s.0.jpg/); + }); + }); }); diff --git a/karma.conf.js b/karma.conf.js index 704f45ed4..b365c8ead 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -6,6 +6,9 @@ module.exports = function(config) { files: [ 'bower_components/angular/angular.js', + 'bower_components/angular-animate/angular-animate.js', + 'bower_components/angular-resource/angular-resource.js', + 'bower_components/angular-route/angular-route.js', 'bower_components/angular-mocks/angular-mocks.js', '**/*.module.js', '*!(.module|.spec).js', diff --git a/package.json b/package.json index 358938fde..54c7a668f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "karma-chrome-launcher": "^0.2.3", "karma-firefox-launcher": "^0.1.7", "karma-jasmine": "^0.3.8", - "protractor": "^4.0.9" + "protractor": "^3.2.2", + "protractor-accessibility-plugin": "^0.1.1", + "shelljs": "^0.6.0" }, "scripts": { "postinstall": "bower install",