├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── README.md ├── assets ├── bootstrap.min.css └── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ └── glyphicons-halflings-regular.woff ├── lib ├── angular-mocks.js ├── angular.js ├── jasmine-matchers.js ├── jquery-1.10.2.js ├── moment.js └── positioning.js ├── package.json └── src ├── 01_widgets ├── demo │ ├── README.md │ ├── index.html │ ├── rating.html │ ├── rating.js │ ├── rating.spec.js │ └── ratingNgModel.js └── exercise │ ├── README.md │ ├── index.html │ ├── pagination.html │ ├── pagination.js │ ├── pagination.spec.js │ └── solution │ ├── index.html │ ├── pagination.html │ ├── pagination.js │ └── pagination.spec.js ├── 02_widgets_with_holes ├── demo │ ├── README.md │ ├── alert.html │ ├── alert.js │ ├── alert.spec.js │ └── index.html └── exercise │ ├── README.md │ ├── collapse.html │ ├── collapse.js │ ├── collapse.spec.js │ ├── index.html │ └── solution │ ├── collapse.html │ ├── collapse.js │ ├── collapse.spec.js │ └── index.html ├── 03_attribute_directives ├── demo │ ├── README.md │ ├── index.html │ ├── tooltip.js │ └── tooltip.spec.js └── exercise │ ├── README.md │ ├── index.html │ ├── popover.js │ ├── popover.spec.js │ └── solution │ ├── index.html │ ├── popover.js │ └── popover.spec.js ├── 04_inter_directive_communication ├── demo │ ├── README.md │ ├── index.html │ ├── tab.html │ ├── tabs.html │ ├── tabs.js │ └── tabs.spec.js └── exercise │ ├── README.md │ ├── accordion.html │ ├── accordion.js │ ├── accordion.spec.js │ ├── collapse.html │ ├── index.html │ └── solution │ ├── accordion.html │ ├── accordion.js │ ├── accordion.spec.js │ ├── collapse.html │ └── index.html ├── 05_ngmodelctrl_parse_format ├── demo │ ├── README.md │ ├── hourfield.spec.js │ ├── index.html │ ├── minutesfield.spec.js │ └── timefields.js └── exercise │ ├── README.md │ ├── datefield.js │ ├── datefield.spec.js │ ├── index.html │ └── solution │ ├── datefield.js │ ├── datefield.spec.js │ └── index.html ├── 06_ngmodelctrl_buttons ├── demo │ ├── README.md │ ├── buttons-checkbox.js │ ├── buttons-checkbox.spec.js │ └── index.html └── exercise │ ├── README.md │ ├── buttons-radio.js │ ├── buttons-radio.spec.js │ ├── index.html │ └── solution │ ├── buttons-radio.js │ ├── buttons-radio.spec.js │ └── index.html └── 07_manual_compilation ├── demo ├── README.md ├── index.html ├── tooltipTpl.js └── tooltipTpl.spec.js └── exercise ├── README.md ├── index.html ├── popoverTpl.js ├── popoverTpl.spec.js └── solution ├── index.html ├── popoverTpl.js └── popoverTpl.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | 17 | tmp 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | 5 | before_script: 6 | - export DISPLAY=:99.0 7 | - sh -e /etc/init.d/xvfb start 8 | - npm install --quiet -g grunt-cli karma 9 | - npm install 10 | 11 | script: grunt -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | var cp = require('child_process'); 2 | 3 | module.exports = function (grunt) { 4 | 5 | grunt.loadNpmTasks('grunt-contrib-connect'); 6 | grunt.loadNpmTasks('grunt-contrib-jshint'); 7 | grunt.loadNpmTasks('grunt-html2js'); 8 | grunt.loadNpmTasks('grunt-karma'); 9 | 10 | grunt.initConfig({ 11 | jshint: { 12 | files: ['Gruntfile.js', 'package.json', 'src/**/*.js'], 13 | options: { 14 | } 15 | }, 16 | html2js: { 17 | options: { 18 | base: '', 19 | module: 'templates', 20 | rename: function (moduleName) { 21 | return '/' + moduleName; 22 | } 23 | }, 24 | main: { 25 | src: ['src/**/*.html', '!src/**/index.html', '!src/**/solution/*.html'], 26 | dest: 'tmp/templates.js' 27 | } 28 | }, 29 | karma: { 30 | options: { 31 | frameworks: ['jasmine'], 32 | files: [ 33 | 'lib/jquery-1.10.2.js', 34 | 'lib/positioning.js', 35 | 'lib/angular.js', 36 | 'lib/angular-mocks.js', 37 | 'lib/jasmine-matchers.js', 38 | 'lib/moment.js', 39 | 'src/**/*.js', 40 | 'src/**/*.html' 41 | ], 42 | exclude: [ 43 | 'src/**/index.html' 44 | ], 45 | browsers: process.env.TRAVIS ? ['Firefox'] : ['Chrome'], 46 | ngHtml2JsPreprocessor: { 47 | prependPrefix: '/', 48 | // setting this option will create only a single module that contains templates 49 | // from all the files, so you can load them all with module('templates') 50 | moduleName: 'templates' 51 | } 52 | }, 53 | tdd: { 54 | autoWatch: true 55 | }, 56 | ci: { 57 | singleRun: true 58 | } 59 | }, 60 | connect: { 61 | server: { 62 | options: { 63 | port: 8000, 64 | base: '.', 65 | keepalive: true, 66 | middleware: function(connect, options){ 67 | return [ 68 | connect.static(options.base), 69 | connect.directory(options.base) 70 | ]; 71 | } 72 | } 73 | } 74 | } 75 | }); 76 | 77 | grunt.registerTask('tdd', ['karma:tdd']); 78 | grunt.registerTask('default', ['jshint', 'html2js', 'karma:ci']); 79 | grunt.registerTask('server', ['connect:server']); 80 | }; 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/pkozlowski-opensource/directives-workshop.png?branch=master)](https://travis-ci.org/pkozlowski-opensource/directives-workshop) 2 | [![devDependency Status](https://david-dm.org/pkozlowski-opensource/directives-workshop.png?branch=master)](https://david-dm.org/pkozlowski-opensource/directives-workshop#info=devDependencies) 3 | 4 | directives-workshop 5 | =================== 6 | 7 | ## About this workshop 8 | 9 | This repository contains demo and exercises for the AngularJS directives workshop. During the workshop participants 10 | are going to build several AngularJS directives, mostly based on [http://getbootstrap.com](Bootstrap's) HTML and CSS. 11 | 12 | The aim here is to go over several kinds of directives and illustrate typical coding and testing patterns. 13 | 14 | ## Installation 15 | 16 | Before proceeding with the instructions below make sure that you've got node.js (version 0.10.x) installed for your 17 | operating system: http://nodejs.org/download/. When you've got node.js and npm (comes with the node.js installation) 18 | set up npm dependencies of this project: 19 | 20 | * `npm install -g grunt-cli` 21 | * `npm install` 22 | * test your setup by running `grunt` 23 | 24 | ## Demo 25 | 26 | As soon as your environment is set up you can see directives demo by: 27 | * starting a build-in web server: `grunt server` 28 | * pointing your favorite browser to [http://127.0.0.1:8000/src/](http://127.0.0.1:8000/src/) 29 | 30 | ## Development workflow 31 | 32 | * `grunt tdd` - for TDD development. This will watch source and test files running all the test on each change. 33 | * `grunt` - default build. This will lint the code and run all the tests. -------------------------------------------------------------------------------- /assets/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMinar/directives-workshop/5f3bfa2c2a2ed80a6519399e63dc5c756772ca09/assets/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /assets/fonts/glyphicons-halflings-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /assets/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMinar/directives-workshop/5f3bfa2c2a2ed80a6519399e63dc5c756772ca09/assets/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /assets/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMinar/directives-workshop/5f3bfa2c2a2ed80a6519399e63dc5c756772ca09/assets/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /lib/jasmine-matchers.js: -------------------------------------------------------------------------------- 1 | // jasmine matcher for expecting an element to have a css class 2 | // https://github.com/angular/angular.js/blob/master/test/helpers/matchers.js 3 | beforeEach(function() { 4 | this.addMatchers({ 5 | toHaveClass: function(clazz) { 6 | this.message = function() { 7 | return "Expected '" + angular.mock.dump(this.actual) + "' to have class '" + clazz + "'."; 8 | }; 9 | return this.actual.hasClass ? 10 | this.actual.hasClass(clazz) : 11 | angular.element(this.actual).hasClass(clazz); 12 | } 13 | }); 14 | }); -------------------------------------------------------------------------------- /lib/positioning.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A utility function that can be used to position one DOM element (elToPosition) in relation 3 | * to another DOM element(hostEl) in such a way that elToPosition appears on left, right, top 4 | * or bottom of the host element. 5 | * @param {Object} hostEl - jQuery wrapper around DOM element to be used as a positioning reference. 6 | * @param {Object} elToPosition - jQuery wrapper around DOM element to be positioned. 7 | * @param {string} placement - One of: 'top', 'bottom', 'left', 'right' 8 | * @returns {Object} - Calculated position (a object with left and top properties). 9 | */ 10 | function calculatePosition(hostEl, elToPosition, placement) { 11 | 12 | var calculatedPosition; 13 | 14 | // Get the position, height and width of both elements 15 | // so we can center tooltips / popovers 16 | var hostPosition = angular.extend({}, hostEl.position(), { 17 | width: hostEl.prop('offsetWidth'), 18 | height: hostEl.prop('offsetHeight') 19 | }); 20 | 21 | var ttWidth = elToPosition.prop('offsetWidth'); 22 | var ttHeight = elToPosition.prop('offsetHeight'); 23 | 24 | // Calculate the tooltip's top and left coordinates to center it 25 | switch (placement) { 26 | case 'right': 27 | calculatedPosition = { 28 | top: hostPosition.top + hostPosition.height / 2 - ttHeight / 2, 29 | left: hostPosition.left + hostPosition.width 30 | }; 31 | break; 32 | case 'bottom': 33 | calculatedPosition = { 34 | top: hostPosition.top + hostPosition.height, 35 | left: hostPosition.left + hostPosition.width / 2 - ttWidth / 2 36 | }; 37 | break; 38 | case 'left': 39 | calculatedPosition = { 40 | top: hostPosition.top + hostPosition.height / 2 - ttHeight / 2, 41 | left: hostPosition.left - ttWidth 42 | }; 43 | break; 44 | default: // top 45 | calculatedPosition = { 46 | top: hostPosition.top - ttHeight, 47 | left: hostPosition.left + hostPosition.width / 2 - ttWidth / 2 48 | }; 49 | break; 50 | } 51 | 52 | return calculatedPosition; 53 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directives-workshop", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "grunt": "~0.4.1", 6 | "grunt-contrib-jshint": "~0.7.0", 7 | "grunt-contrib-connect": "~0.5.0", 8 | "grunt-html2js": "~0.1.8", 9 | "grunt-karma": "~0.6.2", 10 | "karma-ng-html2js-preprocessor": "~0.1.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/01_widgets/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | This demo covers a rating directive as a typical example of a widget that: 4 | * has its own template, 5 | * is driven by model, 6 | * uses an isolated scope. 7 | 8 | ## Covered topics 9 | 10 | * Widgets deserve their own HTML element. 11 | * Declarative UI driven by model applies to widgets as well - we should apply AngularJS-way inside widgets too! 12 | * `template` / `templateUrl` + widget's own model = isolated scope. Variables can clash if we don't isolate scopes. 13 | * Using `=` and `&` in scope definition (2-way data binding vs. reaching out to a parent scope) 14 | * We can offer different APIs for the rating selection - either `$watch` based or callback-based 15 | * Testing: 16 | * testing with externalized templateUrl - feeding the $templateCache 17 | * custom matchers to have compact assertions 18 | 19 | ## Bootstrap CSS 20 | 21 | Bootstrap glyph icons can be used to render stars in the rating directives, ex.: 22 | * filled-in star: `` 23 | * empty star: `` 24 | 25 | Relevant bootstrap [documentation](http://getbootstrap.com/components/#glyphicons). -------------------------------------------------------------------------------- /src/01_widgets/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom widgets 5 | 6 | 7 | 8 | 9 | 10 | 26 | 27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | 43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /src/01_widgets/demo/rating.html: -------------------------------------------------------------------------------- 1 | 8 | {{rating}}/{{maxRating}} 9 | -------------------------------------------------------------------------------- /src/01_widgets/demo/rating.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.rating', []) 2 | .directive('bsRating', function () { 3 | return { 4 | restrict: 'E', // trigger on Element e.g. 5 | templateUrl: '/src/01_widgets/demo/rating.html', // template location 6 | scope: { // isolate scope variable mappings 7 | rating: '=', // two-way data-binding to the expression specified by `rating` attribute 8 | // you could also use '=ngModel' instead to get the component to support validation 9 | maxRating: '=', // two-way data-binding to the expression specified by `max-rating` attribute 10 | rated: '&' // expose function that will evaluate expression specified by `rated` attribute 11 | }, 12 | link: function (scope, iElement, iAttrs) { 13 | 14 | // initialize internal array that we'll be iterating over 15 | var ratingValues = scope.ratingValues = []; 16 | var maxRating; 17 | 18 | 19 | // watch maxRating value changes and reinitialize the internal array 20 | scope.$watch('maxRating', function maxRatingWatchAction(newMaxRating) { 21 | maxRating = parseInt(newMaxRating, 10) || 5; 22 | 23 | ratingValues.length = 0; 24 | for (var i = 1; i <= maxRating; i++) { 25 | ratingValues.push(i); 26 | } 27 | }); 28 | 29 | 30 | // return true if a given position should be rendered as filled 31 | scope.isFilled = function(ratingValue) { 32 | return (scope.highlightedRating || scope.rating) >= ratingValue; 33 | }; 34 | 35 | 36 | // mouseenter event handler 37 | scope.enter = function(ratingValue) { 38 | scope.highlightedRating = ratingValue; 39 | }; 40 | 41 | 42 | // mouseleave event handler 43 | scope.leave = function() { 44 | scope.highlightedRating = undefined; 45 | }; 46 | 47 | 48 | // click event handler 49 | scope.select = function (ratingValue) { 50 | scope.rated({$new: ratingValue, $old: scope.rating}); 51 | scope.rating = ratingValue; 52 | }; 53 | } 54 | }; 55 | }); 56 | -------------------------------------------------------------------------------- /src/01_widgets/demo/rating.spec.js: -------------------------------------------------------------------------------- 1 | describe('rating', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.rating')); 6 | beforeEach(module('templates')); 7 | 8 | beforeEach(inject(function ($rootScope, _$compile_) { 9 | $scope = $rootScope; 10 | $compile = _$compile_; 11 | })); 12 | 13 | beforeEach(function() { 14 | this.addMatchers({ 15 | toEqualRating: function(requiredScores) { 16 | 17 | var scores = []; 18 | var spans = this.actual.find('span'); 19 | for (var i=0; i', $scope); 43 | expect(elm).toEqualRating([1, 1, 1, 0, 0]); 44 | 45 | $scope.$apply(function(){ 46 | $scope.myValue = 5; 47 | }); 48 | expect(elm).toEqualRating([1, 1, 1, 1, 1]); 49 | }); 50 | 51 | 52 | it('should accept maxRating attribute', function () { 53 | $scope.myValue = 3; 54 | var elm = compileElement('', $scope); 55 | 56 | expect(elm).toEqualRating([1, 1, 1]); 57 | }); 58 | }); 59 | 60 | 61 | describe('UI to model', function () { 62 | 63 | it('should update model on click', function () { 64 | $scope.myValue = 3; 65 | var elm = compileElement('', $scope); 66 | 67 | elm.find('span').eq(0).click(); 68 | 69 | expect($scope.myValue).toEqual(1); 70 | expect(elm).toEqualRating([1, 0, 0, 0, 0]); 71 | }); 72 | 73 | 74 | it('should support selection callback', function () { 75 | 76 | $scope.myValue = 3; 77 | 78 | $scope.onRate = function ($new, $old) { 79 | $scope.newRating = $new; 80 | $scope.oldRating = $old; 81 | }; 82 | 83 | var elm = compileElement('', $scope); 84 | 85 | elm.find('span').eq(0).click(); 86 | 87 | expect($scope.newRating).toEqual(1); 88 | expect($scope.oldRating).toEqual(3); 89 | }); 90 | 91 | 92 | it('should highlighted score without rating change on mouse hover', function () { 93 | 94 | $scope.myValue = 3; 95 | var elm = compileElement('', $scope); 96 | 97 | elm.find('span').eq(0).mouseenter(); 98 | 99 | expect($scope.myValue).toEqual(3); 100 | expect(elm).toEqualRating([1, 0, 0, 0, 0]); 101 | 102 | elm.find('span').eq(0).mouseleave(); 103 | 104 | expect($scope.myValue).toEqual(3); 105 | expect(elm).toEqualRating([1, 1, 1, 0, 0]); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/01_widgets/demo/ratingNgModel.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.rating2', []) 2 | .directive('bsRating2', function () { 3 | return { 4 | restrict: 'E', 5 | templateUrl: 'rating.html', 6 | scope: { 7 | maxRating: '=' 8 | }, 9 | require: 'ngModel', 10 | link: function (scope, iElement, iAttrs, ngModelCtrl) { 11 | 12 | var maxRating = scope.maxRating || 5; 13 | 14 | scope.ratings = []; 15 | for (var i = 1; i <= maxRating; i++) { 16 | scope.ratings.push(i); 17 | } 18 | 19 | scope.isFilled = function(ratingValue) { 20 | return (scope.highlightedRating || ngModelCtrl.$viewValue) >= ratingValue; 21 | }; 22 | 23 | scope.enter = function (ratingValue) { 24 | scope.highlightedRating = ratingValue; 25 | }; 26 | 27 | scope.leave = function (ratingValue) { 28 | scope.highlightedRating = undefined; 29 | }; 30 | 31 | scope.select = function (ratingValue) { 32 | ngModelCtrl.$setViewValue(ratingValue); 33 | }; 34 | } 35 | }; 36 | }); 37 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Create a pagination directive as seen on the Bootstrap's [demo page](http://getbootstrap.com/components/#pagination). 4 | This directive should be used as an HTML element and should work with the following attributes: 5 | * `selected-page` - model indicating index (0-based) of an active (selected page) 6 | * `collection-size` - (integer) total number of items in a collection to be iterated over 7 | * `items-per-page` - (integer, optional - defaults to 10) - number of collection items per page. 8 | 9 | Example usage: 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | The directive should watch changes to the `collection-size` and `items-per-page` attributes and update UI 16 | in response to model changes. 17 | 18 | ## Bootstrap CSS 19 | 20 | Bootstrap 3 is using the following HTML structure to render the pagination widget: 21 | 22 | ```html 23 | 32 | ``` 33 | 34 | A pagination is simply an un-ordered list with the `pagination` class. 35 | Each item (`
  • `) inside the pagination widget can be in one of 3 states, marked with a CSS class: 36 | * default (no additional CSS class) 37 | * `active` - indicating that a give item is selected 38 | * `disabled` - indicating that a give item is disabled and can't be selected 39 | 40 | See index.html which is already wired to use the component you are about to write. 41 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom widgets 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 |
    20 | 21 |
    22 | 23 | 24 | 25 | 26 | 27 |
    28 | 29 | 30 |
    31 | 32 | 33 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/pagination.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMinar/directives-workshop/5f3bfa2c2a2ed80a6519399e63dc5c756772ca09/src/01_widgets/exercise/pagination.html -------------------------------------------------------------------------------- /src/01_widgets/exercise/pagination.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.pagination', []) 2 | .directive('bsPagination', function () { 3 | return { 4 | restrict: 'E' 5 | }; 6 | }); 7 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/pagination.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('pagination', function () { 2 | var $scope, $compile; 3 | 4 | beforeEach(module('bs.pagination')); 5 | beforeEach(module('templates')); 6 | 7 | beforeEach(inject(function ($rootScope, _$compile_) { 8 | $scope = $rootScope; 9 | $compile = _$compile_; 10 | })); 11 | 12 | beforeEach(function() { 13 | this.addMatchers({ 14 | toHavePageStates: function(requiredStates) { 15 | 16 | var states = []; 17 | var pages = this.actual.find('li'); 18 | 19 | for (var i=0; i', $scope); 51 | expect(elm).toHavePageStates([0, 0, 1, 0, 0]); 52 | 53 | $scope.$apply(function(){ 54 | $scope.myPage = 0; 55 | }); 56 | expect(elm).toHavePageStates([-1, 1, 0, 0, 0]); 57 | }); 58 | 59 | 60 | it('should re-render pages in response to collection size change', function () { 61 | 62 | $scope.myPage = 5; 63 | $scope.myCollectionLen = 50; 64 | var elm = compileElement('', $scope); 65 | expect(elm).toHavePageStates([0, 0, 0, 0, 0, 1, -1]); 66 | 67 | $scope.$apply(function(){ 68 | $scope.myCollectionLen = 30; 69 | }); 70 | 71 | expect(elm).toHavePageStates([0, 0, 0, 1, -1]); 72 | expect($scope.myPage).toBe(3); 73 | }); 74 | 75 | 76 | it('should re-render pages in response to selected page change', function () { 77 | 78 | $scope.myPage = 5; 79 | $scope.myCollectionLen = 50; 80 | var elm = compileElement('', $scope); 81 | 82 | $scope.$apply(function(){ 83 | $scope.myPage = 4; 84 | }); 85 | expect(elm).toHavePageStates([0, 0, 0, 0, 1, 0, 0]); 86 | }); 87 | 88 | 89 | it('should correct selected page to be within available pages range', function () { 90 | 91 | $scope.myPage = -5; 92 | $scope.myCollectionLen = 50; 93 | var elm = compileElement('', $scope); 94 | 95 | expect(elm).toHavePageStates([-1, 1, 0, 0, 0, 0, 0]); 96 | expect($scope.myPage).toBe(1); 97 | 98 | $scope.$apply(function(){ 99 | $scope.myPage = 10; 100 | }); 101 | expect(elm).toHavePageStates([0, 0, 0, 0, 0, 1, -1]); 102 | expect($scope.myPage).toBe(5); 103 | }); 104 | }); 105 | 106 | 107 | describe('Ui to model', function () { 108 | 109 | it('should update selected page on page no click', function () { 110 | 111 | $scope.myPage = 4; 112 | $scope.myCollectionLen = 50; 113 | var elm = compileElement('', $scope); 114 | 115 | //select 116 | elm.find('li > a').eq(1).click(); 117 | expect($scope.myPage).toBe(1); 118 | }); 119 | 120 | 121 | it('should update selected page on page arrow clicks', function () { 122 | 123 | $scope.myPage = 1; 124 | $scope.myCollectionLen = 20; 125 | var elm = compileElement('', $scope); 126 | 127 | elm.find('li > a').eq(3).click(); 128 | expect(elm).toHavePageStates([0, 0, 1, -1]); 129 | 130 | elm.find('li > a').eq(0).click(); 131 | expect(elm).toHavePageStates([-1, 1, 0, 0]); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom widgets 5 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 |
    20 | 21 |
    22 | 23 | 24 | 25 | 26 | 27 |
    28 | 29 | 30 |
    31 | 32 | 33 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/solution/pagination.html: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/solution/pagination.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.pagination', []) 2 | .directive('bsPagination', function () { 3 | return { 4 | restrict: 'E', 5 | scope: { 6 | selectedPage: '=', 7 | collectionSize: '=', 8 | itemsPerPage: '=' 9 | }, 10 | templateUrl: '/src/01_widgets/exercise/solution/pagination.html', 11 | link: function (scope, iElement, iAttrs) { 12 | 13 | function updatePagesModel() { 14 | 15 | //re-calculate new length of pages 16 | var pageCount = Math.ceil(scope.collectionSize / (scope.itemsPerPage || 10)); 17 | 18 | //fill-in model needed to render pages 19 | scope.pageNumbers.length = 0; 20 | for (var i = 1; i <= pageCount; i++) { 21 | scope.pageNumbers.push(i); 22 | } 23 | 24 | //make sure that the selected page is within available pages range 25 | scope.selectPage(scope.selectedPage); 26 | } 27 | 28 | scope.pageNumbers = []; 29 | 30 | scope.hasPrevious = function () { 31 | return scope.selectedPage > 1; 32 | }; 33 | 34 | scope.hasNext = function () { 35 | return scope.selectedPage < scope.pageNumbers.length; 36 | }; 37 | 38 | scope.selectPage = function (pageNumber) { 39 | scope.selectedPage = Math.max(Math.min(pageNumber, scope.pageNumbers.length), 1); 40 | }; 41 | 42 | //re-render pages on collection / page size changes 43 | scope.$watch('collectionSize', updatePagesModel); 44 | scope.$watch('itemsPerPage', updatePagesModel); 45 | 46 | //make sure that page is within available pages range on model changes 47 | scope.$watch('selectedPage', scope.selectPage); 48 | } 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /src/01_widgets/exercise/solution/pagination.spec.js: -------------------------------------------------------------------------------- 1 | describe('pagination', function () { 2 | var $scope, $compile; 3 | 4 | beforeEach(module('bs.pagination')); 5 | beforeEach(module('templates')); 6 | 7 | beforeEach(inject(function ($rootScope, _$compile_) { 8 | $scope = $rootScope; 9 | $compile = _$compile_; 10 | })); 11 | 12 | beforeEach(function() { 13 | this.addMatchers({ 14 | toHavePageStates: function(requiredStates) { 15 | 16 | var states = []; 17 | var pages = this.actual.find('li'); 18 | 19 | for (var i=0; i', $scope); 51 | expect(elm).toHavePageStates([0, 0, 1, 0, 0]); 52 | 53 | $scope.$apply(function(){ 54 | $scope.myPage = 0; 55 | }); 56 | expect(elm).toHavePageStates([-1, 1, 0, 0, 0]); 57 | }); 58 | 59 | 60 | it('should re-render pages in response to collection size change', function () { 61 | 62 | $scope.myPage = 5; 63 | $scope.myCollectionLen = 50; 64 | var elm = compileElement('', $scope); 65 | expect(elm).toHavePageStates([0, 0, 0, 0, 0, 1, -1]); 66 | 67 | $scope.$apply(function(){ 68 | $scope.myCollectionLen = 30; 69 | }); 70 | 71 | expect(elm).toHavePageStates([0, 0, 0, 1, -1]); 72 | expect($scope.myPage).toBe(3); 73 | }); 74 | 75 | 76 | it('should re-render pages in response to selected page change', function () { 77 | 78 | $scope.myPage = 5; 79 | $scope.myCollectionLen = 50; 80 | var elm = compileElement('', $scope); 81 | 82 | $scope.$apply(function(){ 83 | $scope.myPage = 4; 84 | }); 85 | expect(elm).toHavePageStates([0, 0, 0, 0, 1, 0, 0]); 86 | }); 87 | 88 | 89 | it('should correct selected page to be within available pages range', function () { 90 | 91 | $scope.myPage = -5; 92 | $scope.myCollectionLen = 50; 93 | var elm = compileElement('', $scope); 94 | 95 | expect(elm).toHavePageStates([-1, 1, 0, 0, 0, 0, 0]); 96 | expect($scope.myPage).toBe(1); 97 | 98 | $scope.$apply(function(){ 99 | $scope.myPage = 10; 100 | }); 101 | expect(elm).toHavePageStates([0, 0, 0, 0, 0, 1, -1]); 102 | expect($scope.myPage).toBe(5); 103 | }); 104 | }); 105 | 106 | 107 | describe('Ui to model', function () { 108 | 109 | it('should update selected page on page no click', function () { 110 | 111 | $scope.myPage = 4; 112 | $scope.myCollectionLen = 50; 113 | var elm = compileElement('', $scope); 114 | 115 | //select 116 | elm.find('li > a').eq(1).click(); 117 | expect($scope.myPage).toBe(1); 118 | }); 119 | 120 | 121 | it('should update selected page on page arrow clicks', function () { 122 | 123 | $scope.myPage = 1; 124 | $scope.myCollectionLen = 20; 125 | var elm = compileElement('', $scope); 126 | 127 | elm.find('li > a').eq(3).click(); 128 | expect(elm).toHavePageStates([0, 0, 1, -1]); 129 | 130 | elm.find('li > a').eq(0).click(); 131 | expect(elm).toHavePageStates([-1, 1, 0, 0]); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | This demo covers an alert directive as seen on Bootstrap's [demo page](http://getbootstrap.com/components/#alerts). 4 | It is typical example of a "widget with a hole", that can wrap over other HTML markup with AngularJS directives. 5 | 6 | Example usage: 7 | 8 | ```html 9 | Hey, it worked! 10 | ``` 11 | 12 | This simple directive also demonstrates how AngularJS directives can be used to create your own HTML vocabluary and 13 | remove HTML markup duplication. 14 | 15 | ## Covered topics 16 | 17 | * Understanding transclusion: 18 | * conceptually it takes content from the directive element and puts it in the final template, 19 | * content can contain other AngularJS directives so it is all recursive. 20 | * Indicating where the transcluded content should go: 21 | * we can mark a place where the content should be placed by using the `ngTransclude` directive, 22 | * if a more fine-grained control is needed we could use a transclusion function. 23 | * Transclusion scope and its pitfalls (to be demonstrated with an input) 24 | * Multiple elements asking for transclusion on the same element 25 | * Reminders: 26 | * A widget usually means element-level directive 27 | * We need an isolated scope here since a widgets has its own model 28 | * Don't hesitate to create such simple directives as those remove markup duplication and create your own DSL 29 | 30 | ## Bootstrap CSS 31 | 32 | Bootstrap 3 is using the following HTML structure to render the alert widget: 33 | 34 | ```html 35 |
    36 | 37 |
    Alert's content goes here...
    38 |
    39 | ``` 40 | 41 | where the `[alert type]` class can be one of: `alert-success`, `alert-info`, `alert-warning`, `alert-danger`. -------------------------------------------------------------------------------- /src/02_widgets_with_holes/demo/alert.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 |
    -------------------------------------------------------------------------------- /src/02_widgets_with_holes/demo/alert.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.alert', []) 2 | .directive('bsAlert', function () { 3 | return { 4 | restrict: 'E', // trigger on Element e.g. `` 5 | templateUrl: '/src/02_widgets_with_holes/demo/alert.html', // template location 6 | transclude: true, // enable transclusion of contents of the template element 7 | scope: { // isolate scope variable mappings 8 | type: '@', // one-way data-binding from the current `type` attribute value to scope 9 | close: '&' // expose function that will evaluate expression specified by `close` attribute 10 | }, 11 | link: function (scope, iElement, iAttrs) { 12 | scope.closeable = "close" in iAttrs; 13 | } 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/demo/alert.spec.js: -------------------------------------------------------------------------------- 1 | describe("alert", function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.alert')); 6 | beforeEach(module('templates')); 7 | 8 | beforeEach(inject(function ($rootScope, _$compile_) { 9 | $scope = $rootScope; 10 | $compile = _$compile_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | function findCloseButton(element) { 20 | return element.find('button.close'); 21 | } 22 | 23 | it('should set "warning" CSS class by default', function () { 24 | var element = compileElement('Default', $scope); 25 | expect(element.find('div.alert')).toHaveClass('alert-warning'); 26 | }); 27 | 28 | it('should set appropriate CSS class based on the alert type', function () { 29 | var element = compileElement('Info', $scope); 30 | expect(element.find('div.alert')).toHaveClass('alert-info'); 31 | }); 32 | 33 | it('should not show close buttons if no close callback specified', function () { 34 | var element = compileElement('No close', $scope); 35 | expect(findCloseButton(element).is(':visible')).toBeFalsy(); 36 | }); 37 | 38 | it('should fire callback when closed', function () { 39 | $scope.removeAlert = function() { 40 | $scope.removed = true; 41 | }; 42 | var element = compileElement('Has close', $scope); 43 | 44 | findCloseButton(element).click(); 45 | expect($scope.removed).toBeTruthy(); 46 | }); 47 | 48 | }); -------------------------------------------------------------------------------- /src/02_widgets_with_holes/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 26 | 27 | 28 | 29 |
    30 |
    31 |         Hello, {{model.name}}!
    32 |     
    33 | Hey, this is just a default warning, you can't close me. I can contain markup and even other 34 | directive (although bound to a different scope that you might expect).
    35 | 36 |
    37 |
    38 | 39 | {{alert.msg}} 40 | 41 |
    42 |
    43 | 44 | 45 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Based on the experience gained from analyzing the `bs-alert` directive, create a collapse directive 4 | that could be used to quickly create collapsible sections with a title, similar to the one on the 5 | [Bootstrap 3 demo page](http://getbootstrap.com/javascript/#collapse). 6 | 7 | Example usage: 8 | 9 | ```html 10 | 11 | So I can show and hide this content... 12 | 13 | ``` 14 | 15 | It should be possible to interpolate the `heading` attribute so the following syntax could be used: 16 | `heading="A title: {{title}}"`. 17 | 18 | 19 | ## Bootstrap CSS 20 | 21 | Bootstrap 3 is using the following HTML structure to render the alert widget: 22 | 23 | ```html 24 |
    25 |
    26 |

    27 | Title goes here! 28 |

    29 |
    30 |
    31 |
    Content goes here...
    32 |
    33 |
    34 | ``` 35 | 36 | The `div.panel-collapse` element should get the `in` class when expanded. 37 | The mentioned class should be removed when a panel gets collapsed. 38 | Also the `height` style should be toggled between `auto` (expanded) and `0` (collapsed). 39 | 40 | Collapse toggling should happen by clicking on the `` element of the heading. 41 | 42 | See index.html which is already wired to use the component you are about to write. 43 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/collapse.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMinar/directives-workshop/5f3bfa2c2a2ed80a6519399e63dc5c756772ca09/src/02_widgets_with_holes/exercise/collapse.html -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/collapse.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.collapse', []) 2 | .directive('bsCollapse', function () { 3 | return { 4 | restrict: 'E' 5 | }; 6 | }); 7 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/collapse.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('collapse', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.collapse')); 6 | beforeEach(module('templates')); 7 | 8 | beforeEach(inject(function ($rootScope, _$compile_) { 9 | $scope = $rootScope; 10 | $compile = _$compile_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | function findHeader(element) { 20 | return element.find('a.accordion-toggle'); 21 | } 22 | 23 | it('should render open panel by default', function () { 24 | var element = compileElement('content', $scope); 25 | 26 | expect(findHeader(element).text()).toEqual('title'); 27 | expect(element.find('div.panel-body').text()).toEqual('content'); 28 | expect(element.find('div.panel-collapse')).toHaveClass('in'); 29 | }); 30 | 31 | it('should toggle visibility on heading click', function () { 32 | var element = compileElement('content', $scope); 33 | 34 | findHeader(element).click(); 35 | expect(element.find('div.panel-collapse')).not.toHaveClass('in'); 36 | expect(element.find('div.panel-collapse')).toHaveClass('collapse'); 37 | 38 | findHeader(element).click(); 39 | expect(element.find('div.panel-collapse')).not.toHaveClass('collapse'); 40 | expect(element.find('div.panel-collapse')).toHaveClass('in'); 41 | }); 42 | }); -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 |
    16 | 17 | 18 | Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon 19 | officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf 20 | moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim 21 | keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur 22 | butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably 23 | haven't heard of them accusamus labore sustainable VHS. 24 | 25 |
    26 | 27 | 28 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/solution/collapse.html: -------------------------------------------------------------------------------- 1 |
    2 | 7 |
    10 |
    11 |
    12 |
    13 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/solution/collapse.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.collapse', []) 2 | .directive('bsCollapse', function () { 3 | return { 4 | restrict: 'E', 5 | templateUrl: '/src/02_widgets_with_holes/exercise/solution/collapse.html', 6 | transclude: true, 7 | scope: { 8 | heading: '@' 9 | }, 10 | link: function (scope, iElement, iAttrs) { 11 | scope.isOpen = true; 12 | 13 | scope.toggleCollapse = function() { 14 | scope.isOpen = !scope.isOpen; 15 | }; 16 | } 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/solution/collapse.spec.js: -------------------------------------------------------------------------------- 1 | describe('collapse', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.collapse')); 6 | beforeEach(module('templates')); 7 | beforeEach(inject(function ($rootScope, _$compile_) { 8 | $scope = $rootScope; 9 | $compile = _$compile_; 10 | })); 11 | 12 | function compileElement(elementString, scope) { 13 | var element = $compile(elementString)(scope); 14 | scope.$digest(); 15 | return element; 16 | } 17 | 18 | function findHeader(element) { 19 | return element.find('a.accordion-toggle'); 20 | } 21 | 22 | it('should render open panel by default', function () { 23 | var element = compileElement('content', $scope); 24 | 25 | expect(findHeader(element).text()).toEqual('title'); 26 | expect(element.find('div.panel-body').text()).toEqual('content'); 27 | expect(element.find('div.panel-collapse')).toHaveClass('in'); 28 | }); 29 | 30 | it('should toggle visibility on heading click', function () { 31 | var element = compileElement('content', $scope); 32 | 33 | findHeader(element).click(); 34 | expect(element.find('div.panel-collapse')).not.toHaveClass('in'); 35 | expect(element.find('div.panel-collapse')).toHaveClass('collapse'); 36 | 37 | findHeader(element).click(); 38 | expect(element.find('div.panel-collapse')).not.toHaveClass('collapse'); 39 | expect(element.find('div.panel-collapse')).toHaveClass('in'); 40 | }); 41 | }); -------------------------------------------------------------------------------- /src/02_widgets_with_holes/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 |
    16 | 17 | 18 | Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon 19 | officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf 20 | moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim 21 | keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur 22 | butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably 23 | haven't heard of them accusamus labore sustainable VHS. 24 | 25 |
    26 | 27 | 28 | -------------------------------------------------------------------------------- /src/03_attribute_directives/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | This demo shows how to build a simple tooltip directive, 4 | similar to the one shown on the Bootstrap's [demo page](http://getbootstrap.com/javascript/#tooltips) 5 | 6 | ## Covered topics 7 | 8 | * creating directive basics 9 | * declaring directives - `.directive()` factory method 10 | * factory method vs. compile vs. link 11 | * prefix directives to avoid collisions, ideally with your own project identifier (2-3 letters) 12 | * DOM manipulation goes into directives 13 | * direct DOM manipulation in a directive - jQuery can be useful for low-level DOM routines 14 | * element passed to a directive is already jQuery / jqLite - wrapped 15 | * observing attributes vs. attribute value straight from the DOM 16 | * registering DOM event handlers 17 | * normalization of directive / attribute names 18 | * tests 19 | * introduction to the DOM-based directives testing 20 | * jQuery is useful for: 21 | * matching rendered HTML 22 | * triggering events 23 | 24 | ## Bootstrap CSS 25 | 26 | Bootstrap 3 uses the following markup to create tooltip elements: 27 | 28 | ```html 29 |
    30 |
    I'm tooltip's content
    31 |
    32 |
    33 | ``` 34 | 35 | Tooltips, after being created are inserted after the host element in the DOM tree. 36 | Tooltip's text goes into the `div.tooltip-inner` element. 37 | 38 | There are 2 additional important CSS classes at play as well: 39 | * - one of `top`, `bottom`, `left`, `right` - needs to be added to `div.tooltip` to indicate positioning 40 | * - `in` - to actually show a tooltip 41 | 42 | Tooltip can be seen in action on Bootstrap's [demo page](http://getbootstrap.com/javascript/#tooltips) 43 | -------------------------------------------------------------------------------- /src/03_attribute_directives/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 19 | 20 | 21 |
    22 | 23 |
    24 |
    25 | 26 | 27 | 28 | 29 |
    30 | 31 | 32 | -------------------------------------------------------------------------------- /src/03_attribute_directives/demo/tooltip.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.tooltip', []) 2 | .directive('bsTooltip', function () { 3 | 4 | 5 | // simple tooltip template 6 | var tooltipTpl = 7 | '
    ' + 8 | '
    ' + 9 | '
    ' + 10 | '
    '; 11 | 12 | return { 13 | 14 | compile: function compileFunction(tElement, tAttrs) { 15 | 16 | // prepare directive template! 17 | // this executes only once for each occurrence of `tooltip` *in template* 18 | 19 | var placement = tAttrs.bsTooltipPlacement || 'top'; 20 | var tooltipTplEl = angular.element(tooltipTpl); 21 | tooltipTplEl.addClass(placement); 22 | 23 | return function linkingFunction(scope, iElement, iAttrs) { 24 | 25 | // instantiate directive! 26 | // this executes once for each occurrence of `tooltip` *in the view* 27 | 28 | var tooltipInstanceEl = tooltipTplEl.clone(); 29 | 30 | //observe interpolated attributes and update tooltip's content accordingly 31 | iAttrs.$observe('bsTooltip', function (newContent) { 32 | tooltipInstanceEl.find('div.tooltip-inner').text(newContent); 33 | }); 34 | 35 | iElement.on('mouseenter', function () { 36 | 37 | //attach tooltip to the DOM to get its size (needed to calculate positioning) 38 | iElement.after(tooltipInstanceEl); 39 | 40 | //calculate position 41 | var ttipPosition = calculatePosition(iElement, tooltipInstanceEl, placement); 42 | tooltipInstanceEl.css(ttipPosition); 43 | //finally show the tooltip 44 | tooltipInstanceEl.addClass('in'); 45 | }); 46 | 47 | iElement.on('mouseleave', function () { 48 | tooltipInstanceEl.remove(); 49 | }); 50 | }; 51 | } 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /src/03_attribute_directives/demo/tooltip.spec.js: -------------------------------------------------------------------------------- 1 | describe('tooltip', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.tooltip')); 6 | beforeEach(inject(function ($rootScope, _$compile_) { 7 | $scope = $rootScope; 8 | $compile = _$compile_; 9 | })); 10 | 11 | function compileElement(elementString, scope) { 12 | var element = $compile(elementString)(scope); 13 | scope.$digest(); 14 | return element; 15 | } 16 | 17 | 18 | it('should show and hide tooltip on mouse enter / leave', function () { 19 | var elm = compileElement('
    ', $scope); 20 | 21 | elm.find('button').mouseenter(); 22 | expect(elm.find('.tooltip').length).toEqual(1); 23 | 24 | elm.find('button').mouseleave(); 25 | expect(elm.find('.tooltip').length).toEqual(0); 26 | }); 27 | 28 | 29 | it('should observe interpolated content', function () { 30 | $scope.content = 'foo'; 31 | var elm = compileElement('
    ', $scope); 32 | 33 | elm.find('button').mouseenter(); 34 | expect(elm.find('.tooltip-inner').text()).toEqual('foo'); 35 | 36 | $scope.content = 'bar'; 37 | $scope.$digest(); 38 | expect(elm.find('.tooltip-inner').text()).toEqual('bar'); 39 | }); 40 | 41 | 42 | describe('placement', function () { 43 | 44 | it('should be placed on top by default', function () { 45 | var elm = compileElement('
    ', $scope); 46 | 47 | elm.find('button').mouseenter(); 48 | expect(elm.find('.tooltip')).toHaveClass('top'); 49 | }); 50 | 51 | 52 | it('should accept placement attribute', function () { 53 | var elm = compileElement('
    ', $scope); 54 | 55 | elm.find('button').mouseenter(); 56 | expect(elm.find('.tooltip')).toHaveClass('right'); 57 | expect(elm.find('.tooltip')).not.toHaveClass('top'); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Create a popover directive similar to the one seen on the 4 | Bootstrap's [demo page](http://getbootstrap.com/javascript/#popovers) 5 | 6 | You don't need to spend time on DOM-positioning logic - there is an utility function - 7 | `calculatePosition(hostEl, elToPosition, placement)` - 8 | that can position a DOM element in relation to another one (`/lib/positioning.js`). 9 | Check its jsDoc for more details. 10 | 11 | ## Bootstrap CSS 12 | 13 | Bootstrap 3 uses the following markup to create popover elements: 14 | 15 | ```html 16 |
    17 |
    18 |

    I'm a title!

    19 |
    Content goes here...
    20 |
    21 | ``` 22 | 23 | **Heads up!** Popover elements needs to get `display: block` styling to have their position 24 | calculated correctly and assure proper display. 25 | 26 | Popovers's content goes into the `div.popover-content` element while its title to the `div.popover-title` element. 27 | There is one more, important CSS classes at play here: 28 | one of `top`, `bottom`, `left`, `right` - needs to be added to `div.popover` to indicate positioning. 29 | 30 | 31 | Popovers, after being created are inserted after the host element in the DOM tree. 32 | By default popovers are shown / hidden in response to the DOM click events. 33 | 34 | See index.html which is already wired to use the component you are about to write. 35 | -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 |
    23 |
    24 | 25 |
    26 |
    27 | 28 |
    29 |
    30 |
    31 | 32 | 33 | 34 | 35 |
    36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/popover.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.popover', []) 2 | .directive('bsPopover', function () { 3 | 4 | var popoverTpl = '...'; 5 | 6 | return { 7 | 8 | }; 9 | }); 10 | -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/popover.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('popover', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.popover')); 6 | beforeEach(inject(function ($rootScope, _$compile_) { 7 | $scope = $rootScope; 8 | $compile = _$compile_; 9 | })); 10 | 11 | function compileElement(elementString, scope) { 12 | var element = $compile(elementString)(scope); 13 | scope.$digest(); 14 | return element; 15 | } 16 | 17 | it('should show and hide popover on click', function () { 18 | var elm = compileElement('
    ', $scope); 19 | 20 | elm.find('button').click(); 21 | expect(elm.find('.popover').length).toEqual(1); 22 | 23 | elm.find('button').click(); 24 | expect(elm.find('.popover').length).toEqual(0); 25 | }); 26 | 27 | 28 | it('should observe interpolated content and title', function () { 29 | $scope.title = 't1'; 30 | $scope.content = 'foo'; 31 | var elm = compileElement('
    ', $scope); 32 | 33 | elm.find('button').click(); 34 | expect(elm.find('.popover-title').text()).toEqual('t1'); 35 | expect(elm.find('.popover-content').text()).toEqual('foo'); 36 | 37 | $scope.title = 't2'; 38 | $scope.content = 'bar'; 39 | $scope.$digest(); 40 | expect(elm.find('.popover-title').text()).toEqual('t2'); 41 | expect(elm.find('.popover-content').text()).toEqual('bar'); 42 | }); 43 | 44 | 45 | describe('placement', function () { 46 | 47 | it('should be placed on top by default', function () { 48 | var elm = compileElement('
    ', $scope); 49 | 50 | elm.find('button').click(); 51 | expect(elm.find('.popover')).toHaveClass('top'); 52 | }); 53 | 54 | 55 | it('should accept placement attribute', function () { 56 | var elm = compileElement('
    ', $scope); 57 | 58 | elm.find('button').click(); 59 | expect(elm.find('.popover')).toHaveClass('right'); 60 | expect(elm.find('.popover')).not.toHaveClass('top'); 61 | }); 62 | 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 |
    23 |
    24 | 25 |
    26 |
    27 | 28 |
    29 |
    30 |
    31 | 32 | 33 | 34 | 35 |
    36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/solution/popover.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.popover', []) 2 | .directive('bsPopover', function () { 3 | 4 | var popoverTpl = 5 | '
    ' + 6 | '
    ' + 7 | '

    ' + 8 | '
    ' + 9 | '
    '; 10 | 11 | return { 12 | 13 | compile: function compileFunction(tElement, tAttrs) { 14 | 15 | var placement = tAttrs.bsPopoverPlacement || 'top'; 16 | var popoverTplEl = angular.element(popoverTpl); 17 | popoverTplEl.addClass(placement); 18 | popoverTplEl.css('display', 'block'); 19 | 20 | return function linkingFunction(scope, iElement, iAttrs) { 21 | 22 | var shown = false; 23 | var popoverInstanceEl = popoverTplEl.clone(); 24 | 25 | //observe interpolated attributes and update popover's content accordingly 26 | iAttrs.$observe('bsPopoverTitle', function(newTitle) { 27 | popoverInstanceEl.find('.popover-title').text(newTitle); 28 | }); 29 | iAttrs.$observe('bsPopover', function(newContent) { 30 | popoverInstanceEl.find('.popover-content').text(newContent); 31 | }); 32 | 33 | iElement.on('click', function () { 34 | 35 | if (!shown) { 36 | 37 | //attach popover to the DOM to get its size 38 | iElement.after(popoverInstanceEl); 39 | 40 | //calculate position 41 | var popoverPosition = calculatePosition(iElement, popoverInstanceEl, placement); 42 | popoverInstanceEl.css(popoverPosition); 43 | 44 | } else { 45 | popoverInstanceEl.remove(); 46 | } 47 | 48 | shown = !shown; 49 | }); 50 | }; 51 | } 52 | }; 53 | }); -------------------------------------------------------------------------------- /src/03_attribute_directives/exercise/solution/popover.spec.js: -------------------------------------------------------------------------------- 1 | describe('popover', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.popover')); 6 | beforeEach(inject(function ($rootScope, _$compile_) { 7 | $scope = $rootScope; 8 | $compile = _$compile_; 9 | })); 10 | 11 | function compileElement(elementString, scope) { 12 | var element = $compile(elementString)(scope); 13 | scope.$digest(); 14 | return element; 15 | } 16 | 17 | it('should show and hide popover on click', function () { 18 | var elm = compileElement('
    ', $scope); 19 | 20 | elm.find('button').click(); 21 | expect(elm.find('.popover').length).toEqual(1); 22 | 23 | elm.find('button').click(); 24 | expect(elm.find('.popover').length).toEqual(0); 25 | }); 26 | 27 | 28 | it('should observe interpolated content and title', function () { 29 | $scope.title = 't1'; 30 | $scope.content = 'foo'; 31 | var elm = compileElement('
    ', $scope); 32 | 33 | elm.find('button').click(); 34 | expect(elm.find('.popover-title').text()).toEqual('t1'); 35 | expect(elm.find('.popover-content').text()).toEqual('foo'); 36 | 37 | $scope.title = 't2'; 38 | $scope.content = 'bar'; 39 | $scope.$digest(); 40 | expect(elm.find('.popover-title').text()).toEqual('t2'); 41 | expect(elm.find('.popover-content').text()).toEqual('bar'); 42 | }); 43 | 44 | 45 | describe('placement', function () { 46 | 47 | it('should be placed on top by default', function () { 48 | var elm = compileElement('
    ', $scope); 49 | 50 | elm.find('button').click(); 51 | expect(elm.find('.popover')).toHaveClass('top'); 52 | }); 53 | 54 | 55 | it('should accept placement attribute', function () { 56 | var elm = compileElement('
    ', $scope); 57 | 58 | elm.find('button').click(); 59 | expect(elm.find('.popover')).toHaveClass('right'); 60 | expect(elm.find('.popover')).not.toHaveClass('top'); 61 | }); 62 | 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/04_inter_directive_communication/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | This demo builds a simplified version of a tabset directive as seen on the Bootstrap's 4 | [demo page](http://getbootstrap.com/javascript/#tabs). 5 | 6 | ## Covered topics 7 | 8 | * Directive controllers, they syntax and roles: 9 | * inter-directive communication through controllers 10 | * while templates can be overridden to customize UI, controllers are here to extend behavior 11 | * different ways of requiring a controller (on the same element, ^, ?) 12 | * Tests: 13 | * controllers are good since allow us to test logic on the lowest possible level, without DOM interactions 14 | * using the $controller service to instantiate controllers 15 | * listening to scope's $destroy event to clean up after yourself 16 | 17 | ## Bootstrap CSS 18 | 19 | Bootstrap 3 is using the following HTML structure to render a tabset: 20 | 21 | ```html 22 |
    23 | 24 | 29 | 30 | 31 |
    32 |
    ...
    33 |
    ...
    34 |
    ...
    35 |
    36 |
    37 | ``` 38 | To activate a given tab the `active` class needs to be added to an `
  • ` element representing a heading 39 | as well as to a tab-pane div. -------------------------------------------------------------------------------- /src/04_inter_directive_communication/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom widgets 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth 18 | master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro 19 | keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat 20 | salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui. 21 | 22 | 23 | Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore 24 | velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui 25 | photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo 26 | nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna 27 | velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard 28 | ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui 29 | sapiente accusamus tattooed echo park. 30 | 31 | 32 | Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack 33 | lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore 34 | carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred 35 | pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice 36 | blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable 37 | tofu synth chambray yr. 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/04_inter_directive_communication/demo/tab.html: -------------------------------------------------------------------------------- 1 |
    -------------------------------------------------------------------------------- /src/04_inter_directive_communication/demo/tabs.html: -------------------------------------------------------------------------------- 1 |
    2 | 7 |
    8 |
    -------------------------------------------------------------------------------- /src/04_inter_directive_communication/demo/tabs.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.tabs', []) 2 | 3 | .controller('BsTabsController', function ($scope) { 4 | 5 | // internal array of all registered tabs for this tabset 6 | $scope.tabs = []; 7 | 8 | // event handler for selecting active tab 9 | $scope.selectActiveTab = function (tabIdx) { 10 | if (tabIdx >= 0 && tabIdx < $scope.tabs.length) { 11 | angular.forEach($scope.tabs, function (tab) { 12 | tab.isActive = false; 13 | }); 14 | $scope.tabs[tabIdx].isActive = true; 15 | } 16 | }; 17 | 18 | // api exposed to individual tabs so that they can register themselves with the tabset 19 | this.addTab = function (tabScope) { 20 | $scope.tabs.push(tabScope); 21 | //it is the first tab in the collection or an active tab was added, let's make it active 22 | if ($scope.tabs.length === 1 || tabScope.isActive) { 23 | $scope.selectActiveTab($scope.tabs.length - 1); 24 | } 25 | }; 26 | 27 | // api exposed to individual tabs so that they can deregister themselves with the tabset 28 | this.removeTab = function (tabScope) { 29 | $scope.tabs.splice($scope.tabs.indexOf(tabScope), 1); 30 | }; 31 | }) 32 | 33 | .directive('bsTabs', function () { 34 | return { 35 | restrict: 'EA', // Element or Attribute 36 | scope: {}, // get isolate scope without any mapping 37 | templateUrl: '/src/04_inter_directive_communication/demo/tabs.html', // template location 38 | transclude: true, // turn on content transclusion 39 | replace: true, // replace the current element with the root element of the template 40 | controller: 'BsTabsController' // controller 41 | }; 42 | }) 43 | 44 | .directive('bsTab', function () { 45 | return { 46 | restrict: 'EA', // Element or Attribute 47 | scope: { // isolate scope definition with mapping 48 | heading: '@' // observe the value of `heading` attribute and place it on the isolate scope as `heading` 49 | }, 50 | templateUrl: '/src/04_inter_directive_communication/demo/tab.html', // template location 51 | transclude: true, // turn on content transclusion 52 | replace: true, // replace the current element with the root element of the template 53 | require: '^bsTabs', // require that this directive be placed on a child element of bsTabs directive element 54 | link: function (scope, element, attrs, tabsCtrl) { 55 | 56 | // when the component is instatiated register it with the parent tabs controller 57 | tabsCtrl.addTab(scope); 58 | 59 | // when the context of this element is going away, deregister it from the parent tabs controller 60 | scope.$on('$destroy', function () { 61 | tabsCtrl.removeTab(scope); 62 | }); 63 | } 64 | }; 65 | }); 66 | -------------------------------------------------------------------------------- /src/04_inter_directive_communication/demo/tabs.spec.js: -------------------------------------------------------------------------------- 1 | describe('tabs', function () { 2 | 3 | var $scope; 4 | var $compile; 5 | 6 | beforeEach(module('bs.tabs')); 7 | beforeEach(module('templates')); 8 | beforeEach(inject(function (_$rootScope_, _$compile_) { 9 | $scope = _$rootScope_; 10 | $compile = _$compile_; 11 | })); 12 | 13 | describe('tabs controller', function () { 14 | 15 | var tabsCtrl; 16 | 17 | beforeEach(inject(function ($controller) { 18 | tabsCtrl = $controller('BsTabsController', {'$scope': $scope}); 19 | })); 20 | 21 | it('should allow adding new tabs making the first one active', function () { 22 | var t1 = {}; 23 | var t2 = {isActive: true}; 24 | 25 | tabsCtrl.addTab(t1); 26 | expect(t1.isActive).toBeTruthy(); 27 | 28 | tabsCtrl.addTab(t2); 29 | expect(t2.isActive).toBeTruthy(); 30 | expect(t1.isActive).toBeFalsy(); 31 | }); 32 | 33 | it('should remove existing tab', function () { 34 | var t1 = {}; 35 | 36 | tabsCtrl.addTab(t1); 37 | expect($scope.tabs.length).toEqual(1); 38 | 39 | tabsCtrl.removeTab(t1); 40 | expect($scope.tabs.length).toEqual(0); 41 | }); 42 | 43 | it('should select an active tab based on valid index', function () { 44 | 45 | var t1 = {}; 46 | var t2 = {}; 47 | 48 | tabsCtrl.addTab(t1); 49 | tabsCtrl.addTab(t2); 50 | $scope.selectActiveTab(1); 51 | 52 | expect(t2.isActive).toBeTruthy(); 53 | expect(t1.isActive).toBeFalsy(); 54 | }); 55 | 56 | it('should ignore selections with invalid index', function () { 57 | 58 | var t1 = {}; 59 | var t2 = {}; 60 | 61 | tabsCtrl.addTab(t1); 62 | tabsCtrl.addTab(t2); 63 | 64 | $scope.selectActiveTab(-1); 65 | expect(t1.isActive).toBeTruthy(); 66 | expect(t2.isActive).toBeFalsy(); 67 | 68 | $scope.selectActiveTab(5); 69 | expect(t1.isActive).toBeTruthy(); 70 | expect(t2.isActive).toBeFalsy(); 71 | }); 72 | 73 | }); 74 | 75 | describe('tabs UI', function () { 76 | 77 | function compileElement(elementString, scope) { 78 | var element = $compile(elementString)(scope); 79 | scope.$digest(); 80 | return element; 81 | } 82 | 83 | it('should render tabs marking the first one as active', function () { 84 | 85 | var elm = compileElement( 86 | '' + 87 | 'foo content' + 88 | 'bar content' + 89 | '', $scope); 90 | 91 | var headings = elm.find('ul.nav-tabs > li'); 92 | var body = elm.find('div.tab-content > div.tab-pane'); 93 | 94 | expect(headings.eq(0).find('a').text()).toEqual('foo'); 95 | expect(headings.eq(0)).toHaveClass('active'); 96 | expect(body.eq(0)).toHaveClass('active'); 97 | expect(body.eq(0).text()).toEqual('foo content'); 98 | 99 | expect(headings.eq(1).find('a').text()).toEqual('bar'); 100 | expect(headings.eq(1)).not.toHaveClass('active'); 101 | expect(body.eq(1)).not.toHaveClass('active'); 102 | expect(body.eq(1).text()).toEqual('bar content'); 103 | }); 104 | 105 | it('should switch active tab on heading click', function () { 106 | 107 | var elm = compileElement( 108 | '' + 109 | 'foo content' + 110 | 'bar content' + 111 | '', $scope); 112 | 113 | var headings = elm.find('ul.nav-tabs > li'); 114 | var body = elm.find('div.tab-content > div.tab-pane'); 115 | 116 | headings.eq(1).find('a').click(); 117 | 118 | expect(headings.eq(0)).not.toHaveClass('active'); 119 | expect(body.eq(0)).not.toHaveClass('active'); 120 | 121 | expect(headings.eq(1)).toHaveClass('active'); 122 | expect(body.eq(1)).toHaveClass('active'); 123 | }); 124 | 125 | }); 126 | }); -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Build a simple accordion directive as seen on the Bootstrap's [demo page](http://getbootstrap.com/javascript/#collapse). 4 | Hint: you can adapt the collapse directive 5 | 6 | ## Bootstrap CSS 7 | 8 | Bootstrap 3 accordion's are nothing more than a group of collapse directives 9 | wrapped into a `
    ` with the `panel-group` class. Check the solution for the collapse directive 10 | to see the HTML structure needed for an individual collapsible panel. -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/accordion.html: -------------------------------------------------------------------------------- 1 |
    -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/accordion.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.accordion', []) 2 | 3 | .controller('BsAccordionController', function ($scope) { 4 | 5 | }) 6 | 7 | .directive('bsAccordion', function () { 8 | return { 9 | 10 | }; 11 | }) 12 | 13 | .directive('bsCollapse', function () { 14 | return { 15 | 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/accordion.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('accordion', function () { 2 | 3 | var $scope; 4 | var $compile; 5 | 6 | beforeEach(module('bs.accordion')); 7 | beforeEach(module('templates')); 8 | beforeEach(inject(function (_$rootScope_, _$compile_) { 9 | $scope = _$rootScope_; 10 | $compile = _$compile_; 11 | })); 12 | 13 | describe('accordion controller', function () { 14 | 15 | var accordionCtrl; 16 | 17 | beforeEach(inject(function ($controller) { 18 | accordionCtrl = $controller('BsAccordionController', {'$scope': $scope}); 19 | })); 20 | 21 | it('should allow adding and removing panels', function () { 22 | 23 | var p1 = {}; 24 | 25 | accordionCtrl.addPanel(p1); 26 | accordionCtrl.addPanel({}); 27 | expect($scope.panels.length).toEqual(2); 28 | 29 | accordionCtrl.removePanel(p1); 30 | expect($scope.panels.length).toEqual(1); 31 | }); 32 | 33 | it('should correctly toggle open panels', function () { 34 | 35 | var p1 = {}; 36 | var p2 = {}; 37 | 38 | accordionCtrl.addPanel(p1); 39 | accordionCtrl.addPanel(p2); 40 | 41 | expect(p1.isOpen).toBeFalsy(); 42 | expect(p2.isOpen).toBeFalsy(); 43 | 44 | accordionCtrl.toggleCollapse(p1); 45 | expect(p1.isOpen).toBeTruthy(); 46 | expect(p2.isOpen).toBeFalsy(); 47 | 48 | accordionCtrl.toggleCollapse(p2); 49 | expect(p1.isOpen).toBeFalsy(); 50 | expect(p2.isOpen).toBeTruthy(); 51 | }); 52 | }); 53 | 54 | describe('accordion UI', function () { 55 | 56 | function compileElement(elementString, scope) { 57 | var element = $compile(elementString)(scope); 58 | scope.$digest(); 59 | return element; 60 | } 61 | 62 | it('should render accordion', function () { 63 | 64 | var elm = compileElement( 65 | '' + 66 | 'foo content' + 67 | 'bar content' + 68 | '', $scope); 69 | 70 | var panels = elm.find('div.panel'); 71 | 72 | expect(panels.eq(0).find('a').text()).toEqual('foo'); 73 | expect(panels.eq(0).find('div.panel-body').text()).toEqual('foo content'); 74 | 75 | expect(panels.eq(1).find('a').text()).toEqual('bar'); 76 | expect(panels.eq(1).find('div.panel-body').text()).toEqual('bar content'); 77 | }); 78 | 79 | it('should open and collapse individual panels on click', function () { 80 | 81 | var elm = compileElement( 82 | '' + 83 | 'foo content' + 84 | 'bar content' + 85 | '', $scope); 86 | 87 | var panels = elm.find('div.panel'); 88 | 89 | expect(panels.eq(0).find('div.panel-collapse')).toHaveClass('collapse'); 90 | expect(panels.eq(1).find('div.panel-collapse')).toHaveClass('collapse'); 91 | 92 | panels.eq(0).find('a').click(); 93 | expect(panels.eq(0).find('div.panel-collapse')).not.toHaveClass('collapse'); 94 | expect(panels.eq(1).find('div.panel-collapse')).toHaveClass('collapse'); 95 | 96 | panels.eq(1).find('a').click(); 97 | expect(panels.eq(0).find('div.panel-collapse')).toHaveClass('collapse'); 98 | expect(panels.eq(1).find('div.panel-collapse')).not.toHaveClass('collapse'); 99 | }); 100 | 101 | }); 102 | }); -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/collapse.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | {{heading}} 5 |

    6 |
    7 |
    8 |
    9 |
    10 |
    -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom widgets 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth 18 | master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro 19 | keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat 20 | salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui. 21 | 22 | 23 | Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore 24 | velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui 25 | photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo 26 | nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna 27 | velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard 28 | ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui 29 | sapiente accusamus tattooed echo park. 30 | 31 | 32 | Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack 33 | lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore 34 | carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred 35 | pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice 36 | blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable 37 | tofu synth chambray yr. 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/solution/accordion.html: -------------------------------------------------------------------------------- 1 |
    -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/solution/accordion.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.accordion', []) 2 | 3 | .controller('BsAccordionController', function ($scope) { 4 | 5 | $scope.panels = []; 6 | 7 | this.toggleCollapse = function (panelScope) { 8 | panelScope.isOpen = !panelScope.isOpen; 9 | //close other panels if needed 10 | if (panelScope.isOpen) { 11 | angular.forEach($scope.panels, function (otherPanelScope) { 12 | if (otherPanelScope != panelScope) { 13 | otherPanelScope.isOpen = false; 14 | } 15 | }); 16 | } 17 | }; 18 | 19 | this.addPanel = function (panelScope) { 20 | $scope.panels.push(panelScope); 21 | }; 22 | 23 | this.removePanel = function (panelScope) { 24 | $scope.panels.splice($scope.panels.indexOf(panelScope), 1); 25 | }; 26 | }) 27 | 28 | .directive('bsAccordion', function () { 29 | return { 30 | restrict: 'EA', 31 | scope: {}, 32 | templateUrl: '/src/04_inter_directive_communication/exercise/solution/accordion.html', 33 | transclude: true, 34 | replace: true, 35 | controller: 'BsAccordionController' 36 | }; 37 | }) 38 | 39 | .directive('bsCollapse', function () { 40 | return { 41 | restrict: 'E', 42 | templateUrl: '/src/04_inter_directive_communication/exercise/solution/collapse.html', 43 | transclude: true, 44 | replace: true, 45 | scope: { 46 | heading: '@' 47 | }, 48 | require: '^bsAccordion', 49 | link: function (scope, iElement, iAttrs, accordionCtrl) { 50 | 51 | scope.isOpen = false; 52 | 53 | scope.toggleCollapse = function () { 54 | accordionCtrl.toggleCollapse(scope); 55 | }; 56 | 57 | accordionCtrl.addPanel(scope); 58 | 59 | //remove this panel from accordion when panel's element gets destroyed 60 | scope.$on('$destroy', function () { 61 | accordionCtrl.removePanel(scope); 62 | }); 63 | } 64 | }; 65 | }); -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/solution/accordion.spec.js: -------------------------------------------------------------------------------- 1 | describe('accordion', function () { 2 | 3 | var $scope; 4 | var $compile; 5 | 6 | beforeEach(module('bs.accordion')); 7 | beforeEach(module('templates')); 8 | beforeEach(inject(function (_$rootScope_, _$compile_) { 9 | $scope = _$rootScope_; 10 | $compile = _$compile_; 11 | })); 12 | 13 | describe('accordion controller', function () { 14 | 15 | var accordionCtrl; 16 | 17 | beforeEach(inject(function ($controller) { 18 | accordionCtrl = $controller('BsAccordionController', {'$scope': $scope}); 19 | })); 20 | 21 | it('should allow adding and removing panels', function () { 22 | 23 | var p1 = {}; 24 | 25 | accordionCtrl.addPanel(p1); 26 | accordionCtrl.addPanel({}); 27 | expect($scope.panels.length).toEqual(2); 28 | 29 | accordionCtrl.removePanel(p1); 30 | expect($scope.panels.length).toEqual(1); 31 | }); 32 | 33 | it('should correctly toggle open panels', function () { 34 | 35 | var p1 = {}; 36 | var p2 = {}; 37 | 38 | accordionCtrl.addPanel(p1); 39 | accordionCtrl.addPanel(p2); 40 | 41 | expect(p1.isOpen).toBeFalsy(); 42 | expect(p2.isOpen).toBeFalsy(); 43 | 44 | accordionCtrl.toggleCollapse(p1); 45 | expect(p1.isOpen).toBeTruthy(); 46 | expect(p2.isOpen).toBeFalsy(); 47 | 48 | accordionCtrl.toggleCollapse(p2); 49 | expect(p1.isOpen).toBeFalsy(); 50 | expect(p2.isOpen).toBeTruthy(); 51 | }); 52 | }); 53 | 54 | describe('accordion UI', function () { 55 | 56 | function compileElement(elementString, scope) { 57 | var element = $compile(elementString)(scope); 58 | scope.$digest(); 59 | return element; 60 | } 61 | 62 | it('should render accordion', function () { 63 | 64 | var elm = compileElement( 65 | '' + 66 | 'foo content' + 67 | 'bar content' + 68 | '', $scope); 69 | 70 | var panels = elm.find('div.panel'); 71 | 72 | expect(panels.eq(0).find('a').text()).toEqual('foo'); 73 | expect(panels.eq(0).find('div.panel-body').text()).toEqual('foo content'); 74 | 75 | expect(panels.eq(1).find('a').text()).toEqual('bar'); 76 | expect(panels.eq(1).find('div.panel-body').text()).toEqual('bar content'); 77 | }); 78 | 79 | it('should open and collapse individual panels on click', function () { 80 | 81 | var elm = compileElement( 82 | '' + 83 | 'foo content' + 84 | 'bar content' + 85 | '', $scope); 86 | 87 | var panels = elm.find('div.panel'); 88 | 89 | expect(panels.eq(0).find('div.panel-collapse')).toHaveClass('collapse'); 90 | expect(panels.eq(1).find('div.panel-collapse')).toHaveClass('collapse'); 91 | 92 | panels.eq(0).find('a').click(); 93 | expect(panels.eq(0).find('div.panel-collapse')).not.toHaveClass('collapse'); 94 | expect(panels.eq(1).find('div.panel-collapse')).toHaveClass('collapse'); 95 | 96 | panels.eq(1).find('a').click(); 97 | expect(panels.eq(0).find('div.panel-collapse')).toHaveClass('collapse'); 98 | expect(panels.eq(1).find('div.panel-collapse')).not.toHaveClass('collapse'); 99 | }); 100 | 101 | }); 102 | }); -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/solution/collapse.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | {{heading}} 5 |

    6 |
    7 |
    8 |
    9 |
    10 |
    -------------------------------------------------------------------------------- /src/04_inter_directive_communication/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom widgets 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth 18 | master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro 19 | keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat 20 | salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui. 21 | 22 | 23 | Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore 24 | velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui 25 | photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo 26 | nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna 27 | velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard 28 | ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui 29 | sapiente accusamus tattooed echo park. 30 | 31 | 32 | Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack 33 | lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore 34 | carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred 35 | pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice 36 | blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable 37 | tofu synth chambray yr. 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | A demo consist of 2 time fields (hours and minutes) that can be used to build 4 | time-picker widgets. 5 | 6 | ## Covered topics 7 | 8 | * Role of parsing and formatting pipelines: 9 | * parse: ui->model 10 | * format: model->ui 11 | * validation 12 | * Patterns: 13 | * failed parsing binds `undefined` to the model 14 | * failed formatting should result in `undefined` being returned 15 | * Testing: 16 | * DOM-based testing requires a bit of gymnastic to trigger input changes (different on different browsers) 17 | * it is good to test parsing / formatting functions in isolation (see minutesfield.spec.js) 18 | 19 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/demo/hourfield.spec.js: -------------------------------------------------------------------------------- 1 | describe('hour field', function () { 2 | 3 | var $scope, $compile; 4 | var $sniffer; 5 | 6 | beforeEach(module('bs.timefields')); 7 | beforeEach(inject(function (_$rootScope_, _$compile_, _$sniffer_) { 8 | $scope = _$rootScope_; 9 | $compile = _$compile_; 10 | $sniffer = _$sniffer_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | function changeInputValueTo(element, value) { 20 | var inputEl = element.find('input'); 21 | inputEl.val(value); 22 | inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); 23 | $scope.$digest(); 24 | } 25 | 26 | describe('format', function () { 27 | 28 | it('should format a valid hour', function () { 29 | ($scope.model = new Date()).setHours(15); 30 | var elm = compileElement('
    ', $scope); 31 | 32 | expect(elm.find('input').val()).toEqual('15'); 33 | expect($scope.f.i.$valid).toBeTruthy(); 34 | }); 35 | 36 | it('should leave an input field blank and mark a field as invalid for invalid hour', function () { 37 | var elm = compileElement('
    ', $scope); 38 | 39 | expect(elm.find('input').val()).toEqual(''); 40 | expect($scope.f.i.$invalid).toBeTruthy(); 41 | }); 42 | 43 | }); 44 | 45 | describe('parsing', function () { 46 | 47 | it('should correctly parse hour in the 24 hour format', function () { 48 | var elm = compileElement('
    ', $scope); 49 | changeInputValueTo(elm, '20'); 50 | 51 | expect($scope.model.getHours()).toEqual(20); 52 | expect($scope.f.i.$valid).toBeTruthy(); 53 | }); 54 | 55 | it('should not change hour value and mark a field as invalid if parsing fails', function () { 56 | 57 | $scope.model = new Date(); 58 | var elm = compileElement('
    ', $scope); 59 | changeInputValueTo(elm, '40'); 60 | 61 | expect($scope.model.getHours()).toEqual($scope.model.getHours()); 62 | expect($scope.f.i.$invalid).toBeTruthy(); 63 | }); 64 | 65 | }); 66 | }); -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - parsing and formatting 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 |
    20 |   {{ myDate | date:'medium'}}
    21 | 
    22 |
    23 |
    24 |
    25 | Hour: 26 |
    27 |
    28 | Minute: 29 |
    30 |
    31 |
    32 | 33 | 34 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/demo/minutesfield.spec.js: -------------------------------------------------------------------------------- 1 | describe('minutes field', function () { 2 | 3 | var $scope, ngModelCtrl; 4 | 5 | beforeEach(module('bs.timefields')); 6 | beforeEach(inject(function (_$rootScope_) { 7 | $scope = _$rootScope_; 8 | ngModelCtrl = { 9 | $setValidity: function(key, isValid) { 10 | ngModelCtrl[key] = isValid; 11 | } 12 | }; 13 | })); 14 | 15 | describe('parsing', function () { 16 | 17 | var parser; 18 | 19 | beforeEach(inject(function (minutesParserFactory) { 20 | parser = minutesParserFactory(ngModelCtrl, 'key'); 21 | })); 22 | 23 | it('should parse valid minutes and set validation key accordingly', function () { 24 | expect(parser('5').getMinutes()).toEqual(5); 25 | expect(ngModelCtrl.key).toBeTruthy(); 26 | }); 27 | 28 | it('invalid minutes should not change date values and set validation key accordingly', function () { 29 | ngModelCtrl.$modelValue = new Date(2*60*1000); 30 | expect(parser('foo').getMinutes()).toEqual(2); 31 | expect(ngModelCtrl.key).toBeFalsy(); 32 | }); 33 | }); 34 | 35 | describe('formatting', function () { 36 | 37 | var formatter; 38 | 39 | beforeEach(inject(function (minutesFormatterFactory) { 40 | formatter = minutesFormatterFactory(ngModelCtrl, 'key'); 41 | })); 42 | 43 | it('should format minutes and set validity of a valid date', function () { 44 | expect(formatter(new Date(2*60*1000))).toEqual(2); 45 | expect(ngModelCtrl.key).toBeTruthy(); 46 | }); 47 | 48 | it('should return undefined for non-model dates and mark field as invalid', function () { 49 | expect(formatter('not a date')).toBeUndefined(); 50 | expect(ngModelCtrl.key).toBeFalsy(); 51 | }); 52 | }); 53 | 54 | }); -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/demo/timefields.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.timefields', []) 2 | 3 | .directive('bsHourfield', function () { 4 | return { 5 | require: 'ngModel', 6 | link: function (scope, element, attrs, ngModelCtrl) { 7 | 8 | ngModelCtrl.$parsers.push(function (viewValue) { 9 | 10 | var newDate = ngModelCtrl.$modelValue ? new Date(ngModelCtrl.$modelValue) : new Date(); 11 | 12 | //convert string input into hours 13 | var hours = parseInt(viewValue); 14 | var isValidHour = hours >= 0 && hours < 24; 15 | 16 | //toggle validity 17 | ngModelCtrl.$setValidity('hourfield', isValidHour); 18 | 19 | //return model value 20 | if (isValidHour) { 21 | newDate.setHours(hours); 22 | } 23 | 24 | return newDate; 25 | }); 26 | 27 | ngModelCtrl.$formatters.push(function (modelValue) { 28 | 29 | var isModelADate = angular.isDate(modelValue); 30 | ngModelCtrl.$setValidity('hourfield', isModelADate); 31 | 32 | return isModelADate ? modelValue.getHours() : undefined; 33 | }); 34 | } 35 | }; 36 | }) 37 | 38 | 39 | /** 40 | * Let's have a look at an alternative way of defining parsers / formatters. What we are trying 41 | * to do here is to decouple parsing / formatting functions from any DOM manipulation 42 | * in order to ease unit-testing. 43 | */ 44 | .directive('bsMinutefield', function (minutesParserFactory, minutesFormatterFactory) { 45 | return { 46 | require: 'ngModel', 47 | link: function (scope, element, attrs, ngModelCtrl) { 48 | ngModelCtrl.$parsers.push(minutesParserFactory(ngModelCtrl, 'minutefield')); 49 | ngModelCtrl.$formatters.push(minutesFormatterFactory(ngModelCtrl, 'minutefield')); 50 | } 51 | }; 52 | }) 53 | 54 | .factory('minutesFormatterFactory', function () { 55 | return function(ngModelCtrl, validationKey) { 56 | return function minutesFormatter(modelValue) { 57 | var isModelADate = angular.isDate(modelValue); 58 | ngModelCtrl.$setValidity(validationKey, isModelADate); 59 | 60 | return isModelADate ? modelValue.getMinutes() : undefined; 61 | }; 62 | }; 63 | }) 64 | 65 | .factory('minutesParserFactory', function () { 66 | return function(ngModelCtrl, validationKey) { 67 | return function minutesParser(viewValue) { 68 | var newDate = ngModelCtrl.$modelValue ? new Date(ngModelCtrl.$modelValue) : new Date(); 69 | 70 | //convert string input into minutes 71 | var minutes = parseInt(viewValue); 72 | var isValidMinute = minutes >= 0 && minutes < 60; 73 | 74 | //toggle validity 75 | ngModelCtrl.$setValidity(validationKey, isValidMinute); 76 | 77 | //return model value 78 | if (isValidMinute) { 79 | newDate.setMinutes(minutes); 80 | } 81 | 82 | return newDate; 83 | }; 84 | }; 85 | }); 86 | 87 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Create a date field that parses and formats a date according to a specified format. 4 | A date field should work with model of type `Date` and a desired format should be specified as `String`. 5 | Example usage: 6 | 7 | ```html 8 | 9 | ``` 10 | 11 | ### Use moment.js library for data parsing and formatting 12 | 13 | While doing the exercise you can use the [moment.js](http://momentjs.com/) library. Some hints: 14 | * parsing date with moment.js: 15 | * parse string to a moment: `var parsedMoment = moment(viewValue, dateFormat);` 16 | * check parsing status: `parsedMoment.isValid()` 17 | * get date object from a moment: `parsedMoment.toDate()` 18 | * formatting: 19 | * `moment(modelValue).format(dateFormat)` 20 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/datefield.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.datefield', []) 2 | .directive('bsDatefield', function () { 3 | return { 4 | require: 'ngModel', 5 | link: function (scope, element, attrs, ngModelCtrl) { 6 | 7 | 8 | } 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/datefield.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('datefield', function () { 2 | 3 | var $scope, $compile; 4 | var $sniffer; 5 | 6 | beforeEach(module('bs.datefield')); 7 | beforeEach(inject(function (_$rootScope_, _$compile_, _$sniffer_) { 8 | $scope = _$rootScope_; 9 | $compile = _$compile_; 10 | $sniffer = _$sniffer_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | function changeInputValueTo(element, value) { 20 | var inputEl = element.find('input'); 21 | inputEl.val(value); 22 | inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); 23 | $scope.$digest(); 24 | } 25 | 26 | describe('format', function () { 27 | 28 | it('should format a valid date with a default format (YYYY/MM/DD)', function () { 29 | $scope.model = new Date(0); 30 | var elm = compileElement('
    ', $scope); 31 | 32 | expect(elm.find('input').val()).toEqual('1970/01/01'); 33 | expect($scope.f.i.$valid).toBeTruthy(); 34 | }); 35 | 36 | it('should format a valid date with a specified format (YYYY-MM-DD)', function () { 37 | $scope.model = new Date(0); 38 | var elm = compileElement('
    ', $scope); 39 | 40 | expect(elm.find('input').val()).toEqual('1970-01-01'); 41 | expect($scope.f.i.$valid).toBeTruthy(); 42 | }); 43 | 44 | it('should leave an input field blank and mark a field as invalid for invalid date', function () { 45 | $scope.model = 'invalid'; 46 | var elm = compileElement('
    ', $scope); 47 | 48 | expect(elm.find('input').val()).toEqual(''); 49 | expect($scope.f.i.$invalid).toBeTruthy(); 50 | }); 51 | 52 | }); 53 | 54 | describe('parse', function () { 55 | 56 | it('should correctly parse date in the default format', function () { 57 | var elm = compileElement('
    ', $scope); 58 | changeInputValueTo(elm, '2013/11/02'); 59 | 60 | expect($scope.model.getFullYear()).toEqual(2013); 61 | expect($scope.model.getMonth()).toEqual(10); 62 | expect($scope.model.getDate()).toEqual(2); 63 | expect($scope.f.i.$valid).toBeTruthy(); 64 | }); 65 | 66 | 67 | it('should correctly parse date in the specified format', function () { 68 | var elm = compileElement('
    ', $scope); 69 | changeInputValueTo(elm, '2013-11-02'); 70 | 71 | expect($scope.model.getFullYear()).toEqual(2013); 72 | expect($scope.model.getMonth()).toEqual(10); 73 | expect($scope.model.getDate()).toEqual(2); 74 | expect($scope.f.i.$valid).toBeTruthy(); 75 | }); 76 | 77 | it('should bind undefined to the model and mark a field as invalid if parsing fails', function () { 78 | 79 | var elm = compileElement('
    ', $scope); 80 | changeInputValueTo(elm, 'gibberish'); 81 | 82 | expect($scope.model).toBeUndefined(); 83 | expect($scope.f.i.$invalid).toBeTruthy(); 84 | }); 85 | 86 | }); 87 | 88 | }); -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - parsing and formatting 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 |
    21 |   {{ myDate | date:'medium'}}
    22 | 
    23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | 31 | 32 | -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/solution/datefield.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.datefield', []) 2 | .directive('bsDatefield', function () { 3 | return { 4 | require: 'ngModel', 5 | link: function (scope, element, attrs, ngModelCtrl) { 6 | 7 | var dateFormat = attrs.bsDatefield || 'YYYY/MM/DD'; 8 | 9 | ngModelCtrl.$parsers.push(function (viewValue) { 10 | 11 | //convert string input into moment data model 12 | var parsedMoment = moment(viewValue, dateFormat); 13 | 14 | //toggle validity 15 | ngModelCtrl.$setValidity('datefield', parsedMoment.isValid()); 16 | 17 | //return model value 18 | return parsedMoment.isValid() ? parsedMoment.toDate() : undefined; 19 | }); 20 | 21 | ngModelCtrl.$formatters.push(function (modelValue) { 22 | 23 | var isModelADate = angular.isDate(modelValue); 24 | ngModelCtrl.$setValidity('datefield', isModelADate); 25 | 26 | return isModelADate ? moment(modelValue).format(dateFormat) : undefined; 27 | }); 28 | } 29 | }; 30 | }); -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/solution/datefield.spec.js: -------------------------------------------------------------------------------- 1 | describe('datefield', function () { 2 | 3 | var $scope, $compile; 4 | var $sniffer; 5 | 6 | beforeEach(module('bs.datefield')); 7 | beforeEach(inject(function (_$rootScope_, _$compile_, _$sniffer_) { 8 | $scope = _$rootScope_; 9 | $compile = _$compile_; 10 | $sniffer = _$sniffer_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | function changeInputValueTo(element, value) { 20 | var inputEl = element.find('input'); 21 | inputEl.val(value); 22 | inputEl.trigger($sniffer.hasEvent('input') ? 'input' : 'change'); 23 | $scope.$digest(); 24 | } 25 | 26 | describe('format', function () { 27 | 28 | it('should format a valid date with a default format (YYYY/MM/DD)', function () { 29 | $scope.model = new Date(0); 30 | var elm = compileElement('
    ', $scope); 31 | 32 | expect(elm.find('input').val()).toEqual('1970/01/01'); 33 | expect($scope.f.i.$valid).toBeTruthy(); 34 | }); 35 | 36 | it('should format a valid date with a specified format (YYYY-MM-DD)', function () { 37 | $scope.model = new Date(0); 38 | var elm = compileElement('
    ', $scope); 39 | 40 | expect(elm.find('input').val()).toEqual('1970-01-01'); 41 | expect($scope.f.i.$valid).toBeTruthy(); 42 | }); 43 | 44 | it('should leave an input field blank and mark a field as invalid for invalid date', function () { 45 | $scope.model = 'invalid'; 46 | var elm = compileElement('
    ', $scope); 47 | 48 | expect(elm.find('input').val()).toEqual(''); 49 | expect($scope.f.i.$invalid).toBeTruthy(); 50 | }); 51 | 52 | }); 53 | 54 | describe('parse', function () { 55 | 56 | it('should correctly parse date in the default format', function () { 57 | var elm = compileElement('
    ', $scope); 58 | changeInputValueTo(elm, '2013/11/02'); 59 | 60 | expect($scope.model.getFullYear()).toEqual(2013); 61 | expect($scope.model.getMonth()).toEqual(10); 62 | expect($scope.model.getDate()).toEqual(2); 63 | expect($scope.f.i.$valid).toBeTruthy(); 64 | }); 65 | 66 | 67 | it('should correctly parse date in the specified format', function () { 68 | var elm = compileElement('
    ', $scope); 69 | changeInputValueTo(elm, '2013-11-02'); 70 | 71 | expect($scope.model.getFullYear()).toEqual(2013); 72 | expect($scope.model.getMonth()).toEqual(10); 73 | expect($scope.model.getDate()).toEqual(2); 74 | expect($scope.f.i.$valid).toBeTruthy(); 75 | }); 76 | 77 | it('should bind undefined to the model and mark a field as invalid if parsing fails', function () { 78 | 79 | var elm = compileElement('
    ', $scope); 80 | changeInputValueTo(elm, 'gibberish'); 81 | 82 | expect($scope.model).toBeUndefined(); 83 | expect($scope.f.i.$invalid).toBeTruthy(); 84 | }); 85 | 86 | }); 87 | 88 | }); -------------------------------------------------------------------------------- /src/05_ngmodelctrl_parse_format/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - parsing and formatting 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 |
    21 |   {{ myDate | date:'medium'}}
    22 | 
    23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | 31 | 32 | -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | Create checkbox buttons where each button in a group can be clicked to toggle model values. 4 | Multiple buttons can be checked in a given group. 5 | 6 | ## Covered topics 7 | 8 | * Requiring other directive's mandatory controller (on the same element) 9 | * Understanding and plugging into the `NgModelController.$render()` infrastructure: 10 | * `NgModelController.$render()` is invoked every time model changes lead to `NgModelController.$viewValue` changes 11 | * default implementation of the `$render` method is empty, custom implementation should update DOM 12 | * no need to observe model, it is already observed in `NgModelController` 13 | * Understanding and using `NgModelController.$setViewValue()` to propagate control state from the DOM to the model 14 | * Using `Scope.$eval()` to get just-in-time value of an attributes' expression (no need to use `Scope.$watch`) 15 | * No need to create an isolated scope as there is no model that is internal to this directive 16 | 17 | ## Bootstrap CSS 18 | 19 | Bootstrap uses the following HTML structure to render a group of buttons: 20 | 21 | ```html 22 |
    23 | 24 | 25 | 26 |
    27 | ``` 28 | 29 | Notable CSS classes: 30 | * `btn` - default styling of Bootstrap buttons 31 | * `active` - added to a button element to mark it as "checked" 32 | -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/demo/buttons-checkbox.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.buttons-checkbox', []) 2 | 3 | .directive('bsBtnCheckbox', function () { 4 | 5 | return { 6 | require: 'ngModel', 7 | link: function (scope, element, attrs, ngModelCtrl) { 8 | 9 | function getTrueValue() { 10 | var trueValue = scope.$eval(attrs.bsBtnCheckboxTrue); 11 | return angular.isDefined(trueValue) ? trueValue : true; 12 | } 13 | 14 | function getFalseValue() { 15 | var falseValue = scope.$eval(attrs.bsBtnCheckboxFalse); 16 | return angular.isDefined(falseValue) ? falseValue : false; 17 | } 18 | 19 | //model -> UI 20 | ngModelCtrl.$render = function () { 21 | element.toggleClass('active', angular.equals(ngModelCtrl.$modelValue, getTrueValue())); 22 | }; 23 | 24 | //ui->model 25 | element.on('click', function () { 26 | scope.$apply(function () { 27 | ngModelCtrl.$setViewValue(element.hasClass('active') ? getFalseValue() : getTrueValue()); 28 | ngModelCtrl.$render(); 29 | }); 30 | }); 31 | } 32 | }; 33 | }); -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/demo/buttons-checkbox.spec.js: -------------------------------------------------------------------------------- 1 | describe('buttons - checkbox', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.buttons-checkbox')); 6 | beforeEach(inject(function (_$rootScope_, _$compile_) { 7 | $scope = _$rootScope_; 8 | $compile = _$compile_; 9 | })); 10 | 11 | var compileButton = function (markup, scope) { 12 | var el = $compile(markup)(scope); 13 | scope.$digest(); 14 | return el; 15 | }; 16 | 17 | //model -> UI 18 | it('should work correctly with default model values', function () { 19 | $scope.model = false; 20 | var btn = compileButton('', $scope); 21 | expect(btn).not.toHaveClass('active'); 22 | 23 | $scope.model = true; 24 | $scope.$digest(); 25 | expect(btn).toHaveClass('active'); 26 | }); 27 | 28 | it('should bind custom model values', function () { 29 | $scope.model = 1; 30 | var btn = compileButton('', $scope); 31 | expect(btn).toHaveClass('active'); 32 | 33 | $scope.model = 0; 34 | $scope.$digest(); 35 | expect(btn).not.toHaveClass('active'); 36 | }); 37 | 38 | //UI-> model 39 | it('should toggle default model values on click', function () { 40 | $scope.model = false; 41 | var btn = compileButton('', $scope); 42 | 43 | btn.click(); 44 | expect($scope.model).toEqual(true); 45 | expect(btn).toHaveClass('active'); 46 | 47 | btn.click(); 48 | expect($scope.model).toEqual(false); 49 | expect(btn).not.toHaveClass('active'); 50 | }); 51 | 52 | it('should toggle custom model values on click', function () { 53 | $scope.model = 0; 54 | var btn = compileButton('', $scope); 55 | 56 | btn.click(); 57 | expect($scope.model).toEqual(1); 58 | expect(btn).toHaveClass('active'); 59 | 60 | btn.click(); 61 | expect($scope.model).toEqual(0); 62 | expect(btn).not.toHaveClass('active'); 63 | }); 64 | 65 | it('should monitor true / false value changes', function () { 66 | 67 | $scope.model = 1; 68 | $scope.trueVal = 1; 69 | var btn = compileButton('', $scope); 70 | 71 | expect(btn).toHaveClass('active'); 72 | expect($scope.model).toEqual(1); 73 | 74 | $scope.model = 2; 75 | $scope.trueVal = 2; 76 | $scope.$digest(); 77 | 78 | expect(btn).toHaveClass('active'); 79 | expect($scope.model).toEqual(2); 80 | }); 81 | }); -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | 22 | 23 |
    {{model | json}}
    24 | 25 |
    26 | 27 | 28 | 29 |
    30 | 31 | 32 | -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Based on the buttons-checkbox demo create a similar directive for radio buttons. Such buttons should 4 | work in a way that only one button in a given group (bound to the same model variable) is checked at 5 | any given time. The example usage should look like: 6 | 7 | ```html 8 |
    9 | 10 | 11 | 12 |
    13 | ``` 14 | 15 | ## Bootstrap CSS 16 | 17 | Bootstrap uses the following HTML structure to render a group of buttons: 18 | 19 | ```html 20 |
    21 | 22 | 23 | 24 |
    25 | ``` 26 | 27 | Notable CSS classes: 28 | * `btn` - default styling of Bootstrap buttons 29 | * `active` - added to a button element to mark it as "checked" -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/buttons-radio.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.buttons-radio', []) 2 | 3 | .directive('bsBtnRadio', function () { 4 | 5 | return { 6 | require: 'ngModel', 7 | link: function (scope, element, attrs, ngModelCtrl) { 8 | 9 | 10 | } 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/buttons-radio.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('buttons', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.buttons-radio')); 6 | beforeEach(inject(function (_$rootScope_, _$compile_) { 7 | $scope = _$rootScope_; 8 | $compile = _$compile_; 9 | })); 10 | 11 | var compileButtons = function (markup, scope) { 12 | var el = $compile('
    ' + markup + '
    ')(scope); 13 | scope.$digest(); 14 | return el.find('button'); 15 | }; 16 | 17 | //model -> UI 18 | it('should work correctly set active class based on model', function () { 19 | var btns = compileButtons( 20 | '' + 21 | '', $scope); 22 | expect(btns.eq(0)).not.toHaveClass('active'); 23 | expect(btns.eq(1)).not.toHaveClass('active'); 24 | 25 | $scope.model = 2; 26 | $scope.$digest(); 27 | expect(btns.eq(0)).not.toHaveClass('active'); 28 | expect(btns.eq(1)).toHaveClass('active'); 29 | }); 30 | 31 | //UI->model 32 | it('should work correctly set active class based on model', function () { 33 | var btns = compileButtons( 34 | '' + 35 | '', $scope); 36 | expect($scope.model).toBeUndefined(); 37 | 38 | btns.eq(0).click(); 39 | expect($scope.model).toEqual(1); 40 | expect(btns.eq(0)).toHaveClass('active'); 41 | expect(btns.eq(1)).not.toHaveClass('active'); 42 | 43 | btns.eq(1).click(); 44 | expect($scope.model).toEqual(2); 45 | expect(btns.eq(1)).toHaveClass('active'); 46 | expect(btns.eq(0)).not.toHaveClass('active'); 47 | }); 48 | 49 | it('should watch bs-btn-radio values and update state accordingly', function () { 50 | $scope.values = ["value1", "value2"]; 51 | 52 | var btns = compileButtons( 53 | '' + 54 | '', $scope); 55 | expect(btns.eq(0)).not.toHaveClass('active'); 56 | expect(btns.eq(1)).not.toHaveClass('active'); 57 | 58 | $scope.model = "value2"; 59 | $scope.$digest(); 60 | expect(btns.eq(0)).not.toHaveClass('active'); 61 | expect(btns.eq(1)).toHaveClass('active'); 62 | 63 | $scope.values[1] = "value3"; 64 | $scope.model = "value3"; 65 | $scope.$digest(); 66 | expect(btns.eq(0)).not.toHaveClass('active'); 67 | expect(btns.eq(1)).toHaveClass('active'); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 |
    {{model | json}}
    20 | 21 |
    22 | 23 | 24 | 25 |
    26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/solution/buttons-radio.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.buttons-radio', []) 2 | 3 | .directive('bsBtnRadio', function () { 4 | 5 | return { 6 | require: 'ngModel', 7 | link: function (scope, element, attrs, ngModelCtrl) { 8 | 9 | //model -> UI 10 | ngModelCtrl.$render = function () { 11 | element.toggleClass('active', angular.equals(ngModelCtrl.$modelValue, scope.$eval(attrs.bsBtnRadio))); 12 | }; 13 | 14 | //ui->model 15 | element.bind('click', function () { 16 | if (!element.hasClass('active')) { 17 | scope.$apply(function () { 18 | ngModelCtrl.$setViewValue(scope.$eval(attrs.bsBtnRadio)); 19 | ngModelCtrl.$render(); 20 | }); 21 | } 22 | }); 23 | } 24 | }; 25 | }); 26 | -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/solution/buttons-radio.spec.js: -------------------------------------------------------------------------------- 1 | describe('buttons', function () { 2 | 3 | var $scope, $compile; 4 | 5 | beforeEach(module('bs.buttons-radio')); 6 | beforeEach(inject(function (_$rootScope_, _$compile_) { 7 | $scope = _$rootScope_; 8 | $compile = _$compile_; 9 | })); 10 | 11 | var compileButtons = function (markup, scope) { 12 | var el = $compile('
    ' + markup + '
    ')(scope); 13 | scope.$digest(); 14 | return el.find('button'); 15 | }; 16 | 17 | //model -> UI 18 | it('should work correctly set active class based on model', function () { 19 | var btns = compileButtons( 20 | '' + 21 | '', $scope); 22 | expect(btns.eq(0)).not.toHaveClass('active'); 23 | expect(btns.eq(1)).not.toHaveClass('active'); 24 | 25 | $scope.model = 2; 26 | $scope.$digest(); 27 | expect(btns.eq(0)).not.toHaveClass('active'); 28 | expect(btns.eq(1)).toHaveClass('active'); 29 | }); 30 | 31 | //UI->model 32 | it('should work correctly set active class based on model', function () { 33 | var btns = compileButtons( 34 | '' + 35 | '', $scope); 36 | expect($scope.model).toBeUndefined(); 37 | 38 | btns.eq(0).click(); 39 | expect($scope.model).toEqual(1); 40 | expect(btns.eq(0)).toHaveClass('active'); 41 | expect(btns.eq(1)).not.toHaveClass('active'); 42 | 43 | btns.eq(1).click(); 44 | expect($scope.model).toEqual(2); 45 | expect(btns.eq(1)).toHaveClass('active'); 46 | expect(btns.eq(0)).not.toHaveClass('active'); 47 | }); 48 | 49 | it('should watch bs-btn-radio values and update state accordingly', function () { 50 | $scope.values = ["value1", "value2"]; 51 | 52 | var btns = compileButtons( 53 | '' + 54 | '', $scope); 55 | expect(btns.eq(0)).not.toHaveClass('active'); 56 | expect(btns.eq(1)).not.toHaveClass('active'); 57 | 58 | $scope.model = "value2"; 59 | $scope.$digest(); 60 | expect(btns.eq(0)).not.toHaveClass('active'); 61 | expect(btns.eq(1)).toHaveClass('active'); 62 | 63 | $scope.values[1] = "value3"; 64 | $scope.model = "value3"; 65 | $scope.$digest(); 66 | expect(btns.eq(0)).not.toHaveClass('active'); 67 | expect(btns.eq(1)).toHaveClass('active'); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/06_ngmodelctrl_buttons/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 |
    {{model | json}}
    20 | 21 |
    22 | 23 | 24 | 25 |
    26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/07_manual_compilation/demo/README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | 3 | This demo builds on top of the previously seen tooltip directive. This time the 4 | tooltip directive gets extended in the way that its content can contain HTML 5 | markup as well as other AngularJS directives! A template for the content is 6 | fetched using XHR request. 7 | 8 | ## Covered topics 9 | 10 | * fetching templates over $http (using $templateCache) 11 | * manual compilation with $compile 12 | * tests: filling in $templateCache in tests to avoid mocking $http 13 | 14 | ## Bootstrap CSS 15 | 16 | Bootstrap 3 uses the following markup to create tooltip elements: 17 | 18 | ```html 19 |
    20 |
    I'm tooltip's content
    21 |
    22 |
    23 | ``` 24 | 25 | Tooltips, after being created are inserted after the host element in the DOM tree. 26 | Tooltip's text goes into the `div.tooltip-inner` element. 27 | 28 | There are 2 additional important CSS classes at play as well: 29 | * - one of `top`, `bottom`, `left`, `right` - needs to be added to `div.tooltip` to indicate positioning 30 | * - `in` - to actually show a tooltip 31 | 32 | Tooltip can be seen in action on Bootstrap's [demo page](http://getbootstrap.com/javascript/#tooltips) -------------------------------------------------------------------------------- /src/07_manual_compilation/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 24 | 25 |
    26 | 27 |
    28 |
    29 | 30 | 31 | 32 | 33 |
    34 | 35 | 36 | -------------------------------------------------------------------------------- /src/07_manual_compilation/demo/tooltipTpl.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.tooltipTpl', []) 2 | .directive('bsTooltipTpl', function ($http, $templateCache, $compile) { 3 | 4 | var tooltipTpl = 5 | '
    ' + 6 | '
    ' + 7 | '
    ' + 8 | '
    '; 9 | 10 | return { 11 | 12 | compile: function compileFunction(tElement, tAttrs) { 13 | 14 | var placement = tAttrs.bsTooltipPlacement || 'top'; 15 | var tooltipTplEl = angular.element(tooltipTpl); 16 | tooltipTplEl.addClass(placement); 17 | 18 | return function linkingFunction(scope, iElement, iAttrs) { 19 | 20 | //fetch a template with content over $http, making sure that it is 21 | //retrieved only once (note usage of $templateCache) 22 | $http.get(iAttrs.bsTooltipTpl, { 23 | cache: $templateCache 24 | }).then(function (response) { 25 | 26 | var tooltipTemplateElement = tooltipTplEl.clone(); 27 | tooltipTemplateElement.find('div.tooltip-inner').html(response.data.trim()); 28 | 29 | var tooltipLinker = $compile(tooltipTemplateElement); 30 | var tooltipScope; 31 | var tooltipInstanceEl; 32 | 33 | //register DOM handlers only when a template is fetched and ready to be used 34 | iElement.on('mouseenter', function () { 35 | 36 | tooltipScope = scope.$new(); 37 | scope.$apply(function(){ 38 | tooltipInstanceEl = tooltipLinker(tooltipScope); 39 | }); 40 | 41 | //attach tooltip to the DOM to get its size (needed to calculate positioning) 42 | iElement.after(tooltipInstanceEl); 43 | 44 | //calculate position 45 | var ttipPosition = calculatePosition(iElement, tooltipInstanceEl, placement); 46 | tooltipInstanceEl.css(ttipPosition); 47 | //finally show the tooltip 48 | tooltipInstanceEl.addClass('in'); 49 | }); 50 | 51 | iElement.on('mouseleave', function () { 52 | tooltipScope.$destroy(); 53 | tooltipInstanceEl.remove(); 54 | }); 55 | }); 56 | }; 57 | } 58 | }; 59 | }); -------------------------------------------------------------------------------- /src/07_manual_compilation/demo/tooltipTpl.spec.js: -------------------------------------------------------------------------------- 1 | describe('tooltipTpl', function () { 2 | 3 | var $scope, $compile; 4 | var $templateCache; 5 | 6 | beforeEach(module('bs.tooltipTpl')); 7 | beforeEach(inject(function ($rootScope, _$compile_, _$templateCache_) { 8 | $scope = $rootScope; 9 | $compile = _$compile_; 10 | $templateCache = _$templateCache_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | it('should show and hide tooltip on mouse enter / leave', function () { 20 | $templateCache.put('content.html', 'some content'); 21 | var elm = compileElement('
    ', $scope); 22 | 23 | elm.find('button').mouseenter(); 24 | expect(elm.find('.tooltip').length).toEqual(1); 25 | 26 | elm.find('button').mouseleave(); 27 | expect(elm.find('.tooltip').length).toEqual(0); 28 | }); 29 | 30 | it('should allow HTML and directives in content templates', function () { 31 | $scope.content = 'foo'; 32 | $templateCache.put('content.html', ''); 33 | 34 | var elm = compileElement('
    ', $scope); 35 | 36 | elm.find('button').mouseenter(); 37 | var contentEl = elm.find('div.tooltip-inner>span>i'); 38 | 39 | expect(contentEl.text()).toEqual('foo'); 40 | 41 | $scope.$apply(function(){ 42 | $scope.content = 'bar'; 43 | }); 44 | expect(contentEl.text()).toEqual('bar'); 45 | }); 46 | }); -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/README.md: -------------------------------------------------------------------------------- 1 | ## Exercise 2 | 3 | Build on top of the previously seen popover directive and extended in the way 4 | that its content can contain HTML markup as well as other AngularJS directives. 5 | A template for the content is to be fetched using XHR request. 6 | 7 | ## Bootstrap CSS 8 | 9 | Bootstrap 3 uses the following markup to create popover elements: 10 | 11 | ```html 12 |
    13 |
    14 |

    I'm a title!

    15 |
    Content goes here...
    16 |
    17 | ``` 18 | 19 | Popovers, after being created are inserted after the host element in the DOM tree. 20 | 21 | Popovers's content goes into the `div.popover-content` element while its title to the `div.popover-title` element. 22 | There is one more, important CSS classes at play here: 23 | one of `top`, `bottom`, `left`, `right` - needs to be added to `div.popover` to indicate positioning. 24 | Additionally the popover elements needs to get `display: block` styling to have its position 25 | calculated and be displayed properly. 26 | 27 | By default popovers are shown / hidden in response to the DOM click events. 28 | 29 | Popover can be seen in action on Bootstrap's [demo page](http://getbootstrap.com/javascript/#popovers) -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 | 25 | 26 |
    27 |
    28 | 29 |
    30 |
    31 | 32 |
    33 |
    34 |
    35 | 36 | 37 | 38 | 39 |
    40 | 41 | 42 | -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/popoverTpl.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.popoverTpl', []) 2 | 3 | .directive('bsPopoverTpl', function ($http, $templateCache, $compile, $interpolate) { 4 | 5 | var popoverTpl = 6 | '
    ' + 7 | '
    ' + 8 | '

    {{title}}

    ' + 9 | '
    ' + 10 | '
    '; 11 | 12 | return { 13 | 14 | compile: function compileFunction(tElement, tAttrs) { 15 | 16 | return function linkingFunction(scope, iElement, iAttrs) { 17 | 18 | }; 19 | } 20 | }; 21 | }); -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/popoverTpl.spec.js: -------------------------------------------------------------------------------- 1 | xdescribe('popover', function () { 2 | 3 | var $scope, $compile; 4 | var $templateCache; 5 | 6 | beforeEach(module('bs.popoverTpl')); 7 | beforeEach(inject(function ($rootScope, _$compile_, _$templateCache_) { 8 | $scope = $rootScope; 9 | $compile = _$compile_; 10 | $templateCache = _$templateCache_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | it('should show and hide popover on click', function () { 20 | $templateCache.put('content.html', 'some content'); 21 | var elm = compileElement('
    ', $scope); 22 | 23 | elm.find('button').click(); 24 | expect(elm.find('.popover').length).toEqual(1); 25 | 26 | elm.find('button').click(); 27 | expect(elm.find('.popover').length).toEqual(0); 28 | }); 29 | 30 | it('should observe interpolated content', function () { 31 | $templateCache.put('content.html', ''); 32 | $scope.title = 't1'; 33 | $scope.content = 'foo'; 34 | var elm = compileElement('
    ', $scope); 35 | 36 | elm.find('button').click(); 37 | expect(elm.find('.popover-title').text()).toEqual('t1'); 38 | expect(elm.find('.popover-content').text()).toEqual('foo'); 39 | 40 | $scope.$apply(function(){ 41 | $scope.content = 'bar'; 42 | }); 43 | 44 | expect(elm.find('.popover-content').text()).toEqual('bar'); 45 | }); 46 | }); -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/solution/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Integration with NgModelController - using $render() 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 19 | 20 | 21 | 22 | 25 | 26 |
    27 |
    28 | 29 |
    30 |
    31 | 32 |
    33 |
    34 |
    35 | 36 | 37 | 38 | 39 |
    40 | 41 | 42 | -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/solution/popoverTpl.js: -------------------------------------------------------------------------------- 1 | angular.module('bs.popoverTpl', []) 2 | 3 | .directive('bsPopoverTpl', function ($http, $templateCache, $compile, $interpolate) { 4 | 5 | var popoverTpl = 6 | '
    ' + 7 | '
    ' + 8 | '

    {{title}}

    ' + 9 | '
    ' + 10 | '
    '; 11 | 12 | return { 13 | 14 | compile: function compileFunction(tElement, tAttrs) { 15 | 16 | var placement = tAttrs.bsPopoverPlacement || 'top'; 17 | var popoverTemplateElement = angular.element(popoverTpl); 18 | popoverTemplateElement.addClass(placement); 19 | popoverTemplateElement.css('display', 'block'); 20 | 21 | return function linkingFunction(scope, iElement, iAttrs) { 22 | 23 | //fetch a template with content over $http, making sure that it is 24 | //retrieved only once (note usage of $templateCache) 25 | $http.get(iAttrs.bsPopoverTpl, { 26 | cache: $templateCache 27 | }).then(function (response) { 28 | 29 | var shown = false; 30 | var popoverInstanceTemplateElement = popoverTemplateElement.clone(); 31 | popoverInstanceTemplateElement 32 | .find('div.popover-content') 33 | .html(response.data.trim()); 34 | 35 | //prepare a linking function for the whole element, including content retrieved via $http 36 | var popoverLinker = $compile(popoverInstanceTemplateElement); 37 | 38 | var popoverInstanceEl; 39 | var popoverScope; 40 | 41 | iElement.on('click', function () { 42 | 43 | if (!shown) { 44 | 45 | //create a child scope for popovers so directives present in popover's content 46 | //have their own namespace and don't clash with other model variables 47 | popoverScope = scope.$new(); 48 | 49 | //get the current value of a title attribute 50 | popoverScope.title = $interpolate(iAttrs.bsPopoverTitle || '')(scope); 51 | scope.$apply(function(){ 52 | popoverInstanceEl = popoverLinker(popoverScope); 53 | }); 54 | 55 | //attach popover to the DOM to gets its size 56 | iElement.after(popoverInstanceEl); 57 | 58 | //calculate position 59 | var popoverPosition = calculatePosition(iElement, popoverInstanceEl, placement); 60 | popoverInstanceEl.css(popoverPosition); 61 | 62 | } else { 63 | popoverScope.$destroy(); 64 | popoverInstanceEl.remove(); 65 | } 66 | 67 | shown = !shown; 68 | }); 69 | }); 70 | }; 71 | } 72 | }; 73 | }); -------------------------------------------------------------------------------- /src/07_manual_compilation/exercise/solution/popoverTpl.spec.js: -------------------------------------------------------------------------------- 1 | describe('popover', function () { 2 | 3 | var $scope, $compile; 4 | var $templateCache; 5 | 6 | beforeEach(module('bs.popoverTpl')); 7 | beforeEach(inject(function ($rootScope, _$compile_, _$templateCache_) { 8 | $scope = $rootScope; 9 | $compile = _$compile_; 10 | $templateCache = _$templateCache_; 11 | })); 12 | 13 | function compileElement(elementString, scope) { 14 | var element = $compile(elementString)(scope); 15 | scope.$digest(); 16 | return element; 17 | } 18 | 19 | it('should show and hide popover on click', function () { 20 | $templateCache.put('content.html', 'some content'); 21 | var elm = compileElement('
    ', $scope); 22 | 23 | elm.find('button').click(); 24 | expect(elm.find('.popover').length).toEqual(1); 25 | 26 | elm.find('button').click(); 27 | expect(elm.find('.popover').length).toEqual(0); 28 | }); 29 | 30 | it('should observe interpolated content', function () { 31 | $templateCache.put('content.html', ''); 32 | $scope.title = 't1'; 33 | $scope.content = 'foo'; 34 | var elm = compileElement('
    ', $scope); 35 | 36 | elm.find('button').click(); 37 | expect(elm.find('.popover-title').text()).toEqual('t1'); 38 | expect(elm.find('.popover-content').text()).toEqual('foo'); 39 | 40 | $scope.$apply(function(){ 41 | $scope.content = 'bar'; 42 | }); 43 | 44 | expect(elm.find('.popover-content').text()).toEqual('bar'); 45 | }); 46 | }); --------------------------------------------------------------------------------