├── .bowerrc ├── .gitattributes ├── .gitignore ├── Gruntfile.js ├── ISSUE_TEMPLATE.md ├── README.md ├── bower.json ├── karma.conf.js ├── license.txt ├── package.json ├── src ├── angularUtils.js ├── directives │ ├── disqus │ │ ├── README.md │ │ ├── dirDisqus.js │ │ └── dirDisqus.spec.js │ ├── pagination │ │ ├── README.md │ │ ├── dirPagination.js │ │ ├── dirPagination.spec.js │ │ ├── dirPagination.tpl.html │ │ └── testTemplate.tpl.html │ ├── tagbox │ │ ├── README.md │ │ ├── dirTagbox.js │ │ └── dirTagbox.spec.js │ ├── terminalType │ │ ├── README.md │ │ ├── dirTerminalType.css │ │ ├── dirTerminalType.js │ │ └── dirTerminalType.spec.js │ └── uiBreadcrumbs │ │ ├── README.md │ │ ├── uiBreadcrumbs.js │ │ ├── uiBreadcrumbs.spec.js │ │ └── uiBreadcrumbs.tpl.html ├── filters │ ├── ordinalDate │ │ ├── README.md │ │ ├── ordinalDate.js │ │ └── ordinalDate.spec.js │ └── startsWith │ │ ├── README.md │ │ ├── startsWith.js │ │ └── startsWith.spec.js └── services │ └── noise │ ├── README.md │ ├── noise.js │ └── noise.spec.js └── vendor ├── angular └── angular.js └── jquery └── jquery-2.1.0.min.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "vendor" 3 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | 131 | # NuGet Packages Directory 132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 133 | #packages/ 134 | 135 | # Windows Azure Build Output 136 | csx 137 | *.build.csdef 138 | 139 | # Windows Store app package directory 140 | AppPackages/ 141 | 142 | # Others 143 | sql/ 144 | *.Cache 145 | ClientBin/ 146 | [Ss]tyle[Cc]op.* 147 | ~$* 148 | *~ 149 | *.dbmdl 150 | *.[Pp]ublish.xml 151 | *.pfx 152 | *.publishsettings 153 | 154 | # RIA/Silverlight projects 155 | Generated_Code/ 156 | 157 | # Backup & report files from converting an old project file to a newer 158 | # Visual Studio version. Backup files are not needed, because we have git ;-) 159 | _UpgradeReport_Files/ 160 | Backup*/ 161 | UpgradeLog*.XML 162 | UpgradeLog*.htm 163 | 164 | # SQL Server files 165 | App_Data/*.mdf 166 | App_Data/*.ldf 167 | 168 | ############# 169 | ## Windows detritus 170 | ############# 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac crap 183 | .DS_Store 184 | 185 | 186 | ############# 187 | ## Python 188 | ############# 189 | 190 | *.py[co] 191 | 192 | # Packages 193 | *.egg 194 | *.egg-info 195 | dist/ 196 | build/ 197 | eggs/ 198 | parts/ 199 | var/ 200 | sdist/ 201 | develop-eggs/ 202 | .installed.cfg 203 | 204 | # Installer logs 205 | pip-log.txt 206 | 207 | # Unit test / coverage reports 208 | .coverage 209 | .tox 210 | 211 | #Translations 212 | *.mo 213 | 214 | #Mr Developer 215 | .mr.developer.cfg 216 | 217 | # custom 218 | .idea/ 219 | node_modules/ 220 | vendor/ 221 | 222 | 223 | ############# 224 | ## Sublime Text 225 | ############# 226 | 227 | *.sublime-project 228 | *.sublime-workspace 229 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | grunt.initConfig({ 4 | pkg: grunt.file.readJSON('package.json'), 5 | 6 | jshint: { 7 | src: [ 8 | 'src/**/*.js' 9 | ], 10 | options: { 11 | curly: true, 12 | immed: true, 13 | newcap: true, 14 | noarg: true, 15 | sub: true, 16 | boss: true, 17 | eqnull: true 18 | } 19 | }, 20 | 21 | karma: { 22 | unit: { 23 | options: { 24 | files: [ 25 | 'vendor/jquery/jquery-2.1.0.min.js', // jQuery is included for the purposes of easier DOM selection when testing directives. 26 | 'vendor/angular/angular.js', 27 | 'vendor/angular-mocks/angular-mocks.js', 28 | 'vendor/angular-ui-router/release/angular-ui-router.js', 29 | 'tmp/templates.js', 30 | 'src/angularUtils.js', 31 | 'src/filters/**/*.js', 32 | 'src/filters/**/*.spec.js', 33 | 'src/directives/**/*.js', 34 | 'src/directives/**/*.spec.js', 35 | 'src/services/**/*.js', 36 | 'src/services/**/*.spec.js' 37 | ], 38 | frameworks: [ 'jasmine' ], 39 | plugins: [ 'karma-jasmine', 'karma-firefox-launcher', 'karma-chrome-launcher', 'karma-phantomjs-launcher' ] 40 | 41 | }, 42 | singleRun: true, 43 | port: 9877, 44 | browsers: [ 45 | 'PhantomJS' 46 | ] 47 | } 48 | }, 49 | 50 | watch: { 51 | jssrc: { 52 | files: [ 53 | 'src/**/*.js' 54 | ], 55 | tasks: [ 'default' ] 56 | } 57 | }, 58 | 59 | html2js: { 60 | options: { 61 | // custom options, see below 62 | }, 63 | main: { 64 | src: ['src/**/*.tpl.html'], 65 | dest: 'tmp/templates.js' 66 | } 67 | }, 68 | 69 | copy: { 70 | // used to copy certain of the utils to external repo directories 71 | dirPagination: { 72 | expand: true, 73 | flatten: true, 74 | src: [ 75 | 'src/directives/pagination/dirPagination.js', 76 | 'src/directives/pagination/dirPagination.tpl.html'], 77 | dest: '../angularUtils-dist/angularUtils-pagination/' 78 | 79 | }, 80 | uiBreadcrumbs: { 81 | expand: true, 82 | flatten: true, 83 | src: [ 84 | 'src/directives/uiBreadcrumbs/uiBreadcrumbs.js', 85 | 'src/directives/uiBreadcrumbs/uiBreadcrumbs.tpl.html'], 86 | dest: '../angularUtils-dist/angularUtils-uiBreadcrumbs/' 87 | 88 | }, 89 | dirDisqus: { 90 | expand: true, 91 | flatten: true, 92 | src: ['src/directives/disqus/dirDisqus.js'], 93 | dest: '../angularUtils-dist/angularUtils-disqus/' 94 | } 95 | } 96 | }); 97 | 98 | grunt.loadNpmTasks('grunt-karma'); 99 | grunt.loadNpmTasks('grunt-contrib-jshint'); 100 | grunt.loadNpmTasks('grunt-contrib-watch'); 101 | grunt.loadNpmTasks('grunt-html2js'); 102 | grunt.loadNpmTasks('grunt-contrib-copy'); 103 | 104 | grunt.registerTask('default', ['jshint', 'html2js', 'karma']); 105 | grunt.registerTask('publish', ['copy']); 106 | 107 | }; -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Hi, thanks for contributing! 2 | 3 | This project is maintained in my spare time, so in order to help me address your issue as quickly as 4 | possible, please provide as much of the following information as you can: 5 | 6 | - Title: Please indicate the module in question, e.g. "dirPagination: X is broken when Y" 7 | - If reporting on dirPagination, please include the version you are using (can be found in the package.json / bower.json file) 8 | - If you are able to reproduce your issue on Plunker, this will vastly increase the chances of a rapid response from me. 9 | 10 | -- Michael 11 | 12 | ======= 13 | (Delete the above. Fill in the rest as applicable) 14 | 15 | **Description of issue**: 16 | 17 | **Steps to reproduce**: 18 | 19 | **Expected result**: 20 | 21 | **Actual result**: 22 | 23 | **Demo**: (for dirPagination, fork and modify this Plunk: http://plnkr.co/edit/b37IdFFJUokaeSummETX?p=preview) 24 | 25 | Any relevant code: 26 | ``` 27 | 28 | ``` 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular Utilities 2 | 3 | ### No longer maintained 4 | (20/04/2017) - I am no longer actively maintaining this project. I no longer use AngularJS in my own projects and do not have the time to dedicate to maintiaining this project as well as my other active open source projects. Thank you for your understanding. 5 | 6 | --- 7 | 8 | I am working on a large-scale AngularJS-based project and I'll be extracting 9 | any useful re-usable components that I make and putting them here. 10 | 11 | I'm following the convention of the [Ng-Boilerplate] (https://github.com/ngbp/ngbp) project of bundling 12 | all relevant code together, so that you should be able to simply copy the 13 | directory of the component you want to use, and it will include unit tests 14 | and any other dependencies. 15 | 16 | This code is made available under the MIT license, so feel free to use any of these is your projects. If you find any of them useful I'd be happy to get feedback! 17 | 18 | ## Index of Utilities 19 | 20 | ### Filters 21 | 22 | 23 | - [**ordinalDate**](https://github.com/michaelbromley/angularUtils/tree/master/src/filters/ordinalDate) : Works like the built-in date filter, but will add the English ordinal suffix to the day. 24 | - [**startsWith**](https://github.com/michaelbromley/angularUtils/tree/master/src/filters/startsWith) : Filter for strings which *start with* the search string. 25 | 26 | ### Directives 27 | 28 | - [**Disqus**](https://github.com/michaelbromley/angularUtils/tree/master/src/directives/disqus) : Embed a Disqus comments widget in your app 29 | - [**tagbox**](https://github.com/michaelbromley/angularUtils/tree/master/src/directives/tagbox) : A Twitter-style tag suggestion and auto-complete directive that can be added to any text input or textarea. 30 | - [**uiBreadcrumbs**](https://github.com/michaelbromley/angularUtils/tree/master/src/directives/uiBreadcrumbs) : Auto-generated breadcrumbs for angular-ui-router using nested views. 31 | - [**pagination**](https://github.com/michaelbromley/angularUtils/tree/master/src/directives/pagination) : Magical automatic pagination for anything. 32 | - [**terminalType**](https://github.com/michaelbromley/angularUtils/tree/master/src/directives/terminalType) : Terminal-like typing effect for DOM nodes containing text. 33 | 34 | ### Services 35 | 36 | - [**noise**](https://github.com/michaelbromley/angularUtils/tree/master/src/services/noise) : A simple 1D interpolated noise generator. 37 | 38 | 39 | ## License 40 | 41 | MIT 42 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angularUtils", 3 | "version": "0.0.0", 4 | "homepage": "https://github.com/michaelbromley/angularUtils", 5 | "authors": [ 6 | "Michael Bromley " 7 | ], 8 | "description": "A collection of AngularJS utilities", 9 | "main": "src/angularUtils.js", 10 | "license": "MIT", 11 | "private": true, 12 | "ignore": [ 13 | "**/.*", 14 | "node_modules", 15 | "bower_components", 16 | "test", 17 | "tests" 18 | ], 19 | "dependencies": { 20 | "angular-ui-router": "~0.2.13", 21 | "angular": "~1.4.0" 22 | }, 23 | "devDependencies": { 24 | "angular-mocks": "~1.4.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Karma config file for the internal PHPStorm Karma runner. 3 | */ 4 | 5 | module.exports = function(config) { 6 | config.set({ 7 | 8 | // base path, that will be used to resolve files and exclude 9 | basePath: '', 10 | 11 | 12 | // frameworks to use 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'vendor/jquery/jquery-2.1.0.min.js', // jQuery is included for the purposes of easier DOM selection when testing directives. 19 | 'vendor/angular/angular.js', 20 | 'vendor/angular-ui-router/release/angular-ui-router.js', 21 | 'vendor/angular-mocks/angular-mocks.js', 22 | 'tmp/templates.js', 23 | 'src/angularUtils.js', 24 | 'src/filters/**/*.js', 25 | 'src/filters/**/*.spec.js', 26 | 'src/directives/**/*.js', 27 | 'src/directives/**/*.spec.js', 28 | 'src/services/**/*.js', 29 | 'src/services/**/*.spec.js' 30 | ], 31 | 32 | 33 | // list of files to exclude 34 | exclude: [ 35 | ], 36 | 37 | 38 | // test results reporter to use 39 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' 40 | reporters: ['progress'], 41 | 42 | 43 | // web server port 44 | port: 9876, 45 | 46 | 47 | // enable / disable colors in the output (reporters and logs) 48 | colors: true, 49 | 50 | 51 | // level of logging 52 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 53 | logLevel: config.LOG_INFO, 54 | 55 | 56 | // enable / disable watching file and executing tests whenever any file changes 57 | autoWatch: false, 58 | 59 | 60 | // Start these browsers, currently available: 61 | // - Chrome 62 | // - ChromeCanary 63 | // - Firefox 64 | // - Opera (has to be installed with `npm install karma-opera-launcher`) 65 | // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) 66 | // - PhantomJS 67 | // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) 68 | browsers: ['Chrome'], 69 | 70 | 71 | // If browser does not capture in given timeout [ms], kill it 72 | captureTimeout: 60000, 73 | 74 | 75 | // Continuous Integration mode 76 | // if true, it capture browsers, run tests and exit 77 | singleRun: false 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014 Michael Bromley 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angularUtils", 3 | "version": "0.0.1", 4 | "description": "A collection of re-usable AngularJS components", 5 | "main": "index.js", 6 | "author": "Michael Bromley", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "karma": "~0.12.0", 10 | "bower": "^1.3.1", 11 | "grunt": "~0.4.2", 12 | "grunt-contrib-copy": "^0.5.0", 13 | "grunt-contrib-jshint": "~0.8.0", 14 | "grunt-contrib-watch": "^0.6.1", 15 | "grunt-html2js": "^0.2.4", 16 | "grunt-karma": "~0.9.0", 17 | "karma-chrome-launcher": "^0.1.5", 18 | "karma-firefox-launcher": "^0.1.3", 19 | "karma-jasmine": "^0.2.2", 20 | "karma-phantomjs-launcher": "^0.1.4", 21 | "requirejs": "~2.1.10" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/angularUtils.js: -------------------------------------------------------------------------------- 1 | angular.module( 'angularUtils', []); -------------------------------------------------------------------------------- /src/directives/disqus/README.md: -------------------------------------------------------------------------------- 1 | # Disqus Directive 2 | 3 | ### No longer maintained 4 | (20/04/2017) - I am no longer actively maintaining this project. I no longer use AngularJS in my own projects and do not have the time to dedicate to maintiaining this project as well as my other active open source projects. Thank you for your understanding. 5 | 6 | --- 7 | 8 | 9 | A directive to embed a Disqus comments widget on your AngularJS page. 10 | 11 | ## Prerequisites 12 | 13 | This directive will only work if your Angular app is configured in one of the following ways: 14 | 15 | - HTML5 mode. This is done by setting `$locationProvider.html5Mode(true)` in your module's config block. Disqus is able to correctly distinguish between separate pages when in html5 mode, since each page has a fully unique URL. 16 | - Using hashbang (`#!`) URLs. This is done by setting `$locationProvider.hashPrefix('!')` in your module's config block. This is required if you aren't using html5 mode as above, due to a limitation imposed by Disqus. See http://help.disqus.com/customer/portal/articles/472107-using-disqus-on-ajax-sites 17 | 18 | Setting up as above will ensure that Disqus is able to correctly distinguish between separate pages, without showing the same comments on every single page of your app. 19 | By default, Angular does not use html5 mode, and also has no hashPrefix set, so you'll have to do one of the above set-up actions in order to use this directive. As far as I know, there is no way to get it to work with 20 | the default hash-only (no `!`) urls that Angular uses. 21 | 22 | ## Installation 23 | 24 | 1. Download the file `dirDisqus.js` or: 25 | * via Bower: `bower install angular-utils-disqus` 26 | * via npm: `npm install angular-utils-disqus` 27 | 2. Include the JavaScript file in your index.html page. 28 | 2. Add a reference to the module `angularUtils.directives.dirDisqus` to your app. 29 | 30 | ## Usage 31 | 32 | First, put the directive code in your app, wherever you store your directives. 33 | 34 | Wherever you want the Disqus comments to appear, add the following to your template: 35 | 36 | ``` 37 | 38 | ``` 39 | 40 | And in your controller: 41 | 42 | ``` 43 | $scope.disqusConfig = { 44 | disqus_shortname: 'Your disqus shortname', 45 | disqus_identifier: 'Comments identifier', 46 | disqus_url: 'Comments url' 47 | }; 48 | ``` 49 | 50 | The attributes given above are all required. The inclusion of the identifier and URL ensure that identifier conflicts will not occur. See http://help.disqus.com/customer/portal/articles/662547-why-are-the-same-comments-showing-up-on-multiple-pages- 51 | 52 | If the identifier and URL and not included as attributes, the directive will not appear. 53 | 54 | ## Full API 55 | 56 | You can optionally specify the other configuration variables by including the as attributes 57 | on the directive's element tag. For more information on the available config vars, see the 58 | [Disqus docs](http://help.disqus.com/customer/portal/articles/472098-javascript-configuration-variables). 59 | 60 | ``` 61 | $scope.disqusConfig = { 62 | disqus_shortname: 'Your disqus shortname', 63 | disqus_identifier: 'Comments identifier', 64 | disqus_url: 'Comments url', 65 | disqus_title: 'Comments title', 66 | disqus_category_id: 'Comments category id }}', 67 | disqus_disable_mobile: 'false', 68 | disqus_config_language: 'Comments language', 69 | disqus_remote_auth_s3: 'remote_auth_s3', 70 | disqus_api_key: 'public_api_key', 71 | disqus_on_ready: ready() 72 | }; 73 | ``` 74 | 75 | If using the `disqus-config-language` setting, please see [this Disqus article on multi-lingual websites](https://help.disqus.com/customer/portal/articles/466249-multi-lingual-websites) 76 | for which languages are supported. 77 | 78 | ## `disqus-remote-auth-s3 and disqus-api-key` attributes for SSO 79 | If using the `disqus-remote-auth-s3 and disqus-api-key` setting, please see [Integrating Single Sign-On](https://help.disqus.com/customer/portal/articles/236206#sso-script) 80 | to know how to generate a remote_auth_s3 and public_api_key. 81 | 82 | Note: Single Sign-on (SSO) allows users to sign into a site and be able to use Disqus Comments without having to re-authenticate Disqus. SSO will create a site-specific user profile on Disqus, in a way that will prevent conflict with existing Disqus users. 83 | 84 | ## `disqus-on-ready` attribute 85 | 86 | If Disqus is rendered, `disqus-on-ready` function will be called. Callback is registered to disqus by similar technique as explained in [this post](https://help.disqus.com/customer/portal/articles/466258-capturing-disqus-commenting-activity-via-callbacks). 87 | -------------------------------------------------------------------------------- /src/directives/disqus/dirDisqus.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A directive to embed a Disqus comments widget on your AngularJS page. 3 | * 4 | * Created by Michael on 22/01/14. 5 | * Modified by Serkan "coni2k" Holat on 24/02/16. 6 | * Copyright Michael Bromley 2014 7 | * Available under the MIT license. 8 | */ 9 | 10 | (function () { 11 | 12 | /** 13 | * Config 14 | */ 15 | var moduleName = 'angularUtils.directives.dirDisqus'; 16 | 17 | /** 18 | * Module 19 | */ 20 | var module; 21 | try { 22 | module = angular.module(moduleName); 23 | } catch (err) { 24 | // named module does not exist, so create one 25 | module = angular.module(moduleName, []); 26 | } 27 | 28 | module.directive('dirDisqus', ['$window', function ($window) { 29 | return { 30 | restrict: 'E', 31 | scope: { 32 | config: '=' 33 | }, 34 | template: '
', 35 | link: function (scope) { 36 | 37 | scope.$watch('config', configChanged, true); 38 | 39 | function configChanged() { 40 | 41 | // Ensure that the disqus_identifier and disqus_url are both set, otherwise we will run in to identifier conflicts when using URLs with "#" in them 42 | // see http://help.disqus.com/customer/portal/articles/662547-why-are-the-same-comments-showing-up-on-multiple-pages- 43 | if (!scope.config.disqus_shortname || 44 | !scope.config.disqus_identifier || 45 | !scope.config.disqus_url) { 46 | return; 47 | } 48 | 49 | $window.disqus_shortname = scope.config.disqus_shortname; 50 | $window.disqus_identifier = scope.config.disqus_identifier; 51 | $window.disqus_url = scope.config.disqus_url; 52 | $window.disqus_title = scope.config.disqus_title; 53 | $window.disqus_category_id = scope.config.disqus_category_id; 54 | $window.disqus_disable_mobile = scope.config.disqus_disable_mobile; 55 | $window.disqus_config = function () { 56 | this.language = scope.config.disqus_config_language; 57 | this.page.remote_auth_s3 = scope.config.disqus_remote_auth_s3; 58 | this.page.api_key = scope.config.disqus_api_key; 59 | if (scope.config.disqus_on_ready) { 60 | this.callbacks.onReady = [function () { 61 | scope.config.disqus_on_ready(); 62 | }]; 63 | } 64 | }; 65 | 66 | // Get the remote Disqus script and insert it into the DOM, but only if it not already loaded (as that will cause warnings) 67 | if (!$window.DISQUS) { 68 | var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true; 69 | dsq.src = '//' + scope.config.disqus_shortname + '.disqus.com/embed.js'; 70 | (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq); 71 | } else { 72 | $window.DISQUS.reset({ 73 | reload: true, 74 | config: function () { 75 | this.page.identifier = scope.config.disqus_identifier; 76 | this.page.url = scope.config.disqus_url; 77 | this.page.title = scope.config.disqus_title; 78 | this.language = scope.config.disqus_config_language; 79 | this.page.remote_auth_s3 = scope.config.disqus_remote_auth_s3; 80 | this.page.api_key = scope.config.disqus_api_key; 81 | } 82 | }); 83 | } 84 | } 85 | } 86 | }; 87 | }]); 88 | })(); 89 | -------------------------------------------------------------------------------- /src/directives/disqus/dirDisqus.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For some reason, when these tests are run along with all the others in this project, I get a "script error". Running 3 | * them on their own using `ddescribe` works okay. Therefore this test is ignored in general unless specifically testing 4 | * this directive, in which case change `xdescribe` to `ddescribe`. 5 | */ 6 | xdescribe('dirDisqus directive', function() { 7 | var scope, 8 | elem, 9 | compiled, 10 | html; 11 | 12 | beforeEach(module('angularUtils.directives.dirDisqus')); 13 | beforeEach(function (){ 14 | //set our view html. 15 | html = '' + 24 | ''; 25 | 26 | inject(function($compile, $rootScope) { 27 | //create a scope and populate it 28 | scope = $rootScope.$new(); 29 | scope.post = { 30 | ID: 123, 31 | title: 'test title', 32 | link: 'http://www.test.com', 33 | catId: 999, 34 | lang: 'en' 35 | }; 36 | scope.loaded = false; 37 | scope.readyCalled = false; 38 | scope.ready = function() { 39 | scope.readyCalled = true; 40 | }; 41 | 42 | //get the jqLite or jQuery element 43 | elem = angular.element(html); 44 | 45 | //compile the element into a function to process the view. 46 | compiled = $compile(elem); 47 | 48 | //run the compiled view. 49 | var element = compiled(scope); 50 | 51 | var div = document.createElement("div"); 52 | div.innerHTML = element.html(); 53 | 54 | // Just add disqus to document - it is needed to work embed.js properly 55 | document.getElementsByTagName('body')[0].appendChild(div); 56 | }); 57 | }); 58 | 59 | it('should not do anything when ready to bind is false', function() { 60 | //call digest on the scope! 61 | scope.$digest(); 62 | 63 | expect(elem.find("#disqus_thread")).toBeTruthy(); 64 | expect($("script[src='//shortname.disqus.com/embed.js']").length).toEqual(0); 65 | expect(window.disqus_shortname).toBeFalsy(); 66 | expect(window.disqus_identifier).toBeFalsy(); 67 | expect(window.disqus_title).toBeFalsy(); 68 | expect(window.disqus_url).toBeFalsy(); 69 | expect(window.disqus_category_id).toBeFalsy(); 70 | expect(window.disqus_disable_mobile).toBeFalsy(); 71 | expect(scope.readyCalled).toBeFalsy(); 72 | expect(window.language).toBeFalsy(); 73 | }); 74 | 75 | it('should activate when ready to bind is true', function() { 76 | scope.loaded = true; 77 | scope.$digest(); 78 | expect($("script[src='//shortname.disqus.com/embed.js']").length).toEqual(1); 79 | expect(window.disqus_shortname).toEqual('shortname'); 80 | expect(window.disqus_identifier).toEqual('123'); 81 | expect(window.disqus_title).toEqual('test title'); 82 | expect(window.disqus_url).toEqual('http://www.test.com'); 83 | expect(window.disqus_category_id).toEqual('999'); 84 | expect(window.disqus_disable_mobile).toEqual('false'); 85 | 86 | window.page = {}; 87 | window.callbacks = {}; 88 | window.disqus_config(); 89 | 90 | expect(window.language).toEqual('en'); 91 | expect(window.callbacks.onReady).toBeDefined(); 92 | expect(window.callbacks.onReady.length).toEqual(1); 93 | window.callbacks.onReady[0](); 94 | expect(scope.readyCalled).toBeTruthy(); 95 | 96 | }); 97 | }); -------------------------------------------------------------------------------- /src/directives/pagination/README.md: -------------------------------------------------------------------------------- 1 | # Pagination Directive 2 | 3 | ### No longer maintained 4 | (20/04/2017) - I am no longer actively maintaining this project. I no longer use AngularJS in my own projects and do not have the time to dedicate to maintiaining this project as well as my other active open source projects. Thank you for your understanding. 5 | 6 | --- 7 | 8 | ## Another one? 9 | 10 | Yes, there are quite a few pagination solutions for Angular out there already, but what I wanted to do was make 11 | something that would be truly plug-n-play - no need to do any set-up or logic in your controller. Just add 12 | an attribute, drop in your navigation wherever you like, and boom - instant, full-featured pagination. 13 | 14 | (**Looking for the Angular2 version? [Right here!](https://github.com/michaelbromley/ng2-pagination)**) 15 | 16 | ## Demo 17 | 18 | [Here is a working demo on Plunker](http://plnkr.co/edit/Wtkv71LIqUR4OhzhgpqL?p=preview) which demonstrates some cool features such as live-binding the "itemsPerPage" and 19 | filtering of the collection. 20 | 21 | # Table of Contents 22 | 23 | - [Basic Example](#basic-example) 24 | - [Installation](#installation) 25 | - [Usage](#usage) 26 | - [Specifying The Template](#customising--specifying-the-template) 27 | - [Directives API](#directives-api) 28 | - [dir-paginate](#dir-paginate) 29 | - [dir-pagination-controls](#dir-pagination-controls) 30 | - [Writing A Custom Pagination-Controls Template](#writing-a-custom-pagination-controls-template) 31 | - [Special Repeat Start and End Points](#special-repeat-start-and-end-points) 32 | - [Multiple Pagination Instances on One Page](#multiple-pagination-instances-on-one-page) 33 | - [Multiple Instances With ngRepeat](#multiple-instances-with-ngrepeat) 34 | - [Demo](#demo-1) 35 | - [Working With Asynchronous Data](#working-with-asynchronous-data) 36 | - [Example Asynchronous Setup](#example-asynchronous-setup) 37 | - [Styling](#styling) 38 | - [FAQ](#frequently-asked-questions) 39 | - [Contribution](#contribution) 40 | - [Changelog](#changelog) 41 | - [Credits](#credits) 42 | 43 | ## Basic Example 44 | 45 | Let's say you have a collection of items on your controller's `$scope`. Often you want to display them with 46 | the `ng-repeat` directive and then paginate the results if there are too many to fit on one page. This is what this 47 | module will enable: 48 | 49 | ```HTML 50 | 53 | 54 | // then somewhere else on the page .... 55 | 56 | 57 | ``` 58 | 59 | ...and that's literally it. 60 | 61 | ## Installation 62 | 63 | You can install with Bower: 64 | 65 | `bower install angular-utils-pagination` 66 | 67 | or npm: 68 | 69 | `npm install angular-utils-pagination` 70 | 71 | Alternatively just download the files `dirPagination.js` and `dirPagination.tpl.html`. Using Bower or npm has the advantage of making version management easier. 72 | 73 | ## Usage 74 | 75 | First you need to include the module in your project: 76 | 77 | ```JavaScript 78 | // in your app 79 | angular.module('myApp', ['angularUtils.directives.dirPagination']); 80 | ``` 81 | 82 | Then create the paginated content: 83 | ```HTML 84 | 89 | ... 90 | 91 | ``` 92 | And finally include the pagination itself. 93 | 94 | 95 | ```HTML 96 | ... 97 | 105 | 106 | ``` 107 | 108 | ### Customising & Specifying The Template 109 | 110 | By default, the pagination controls will use a built-in template which uses the exact same markup as is found in the 111 | dirPagination.tpl.html file (which conforms to Bootstrap's pagination markup). Therefore, it is not necessary to specify a template. 112 | 113 | However, you may not want to use the default embedded template - for example if you use a another CSS framework that 114 | expects pagination lists to have a particular structure different from the default. 115 | 116 | If you plan to use a custom template, take a look at the default as demonstrated in dirPagination.tpl.html to get 117 | an idea of how it interacts with the directive. 118 | 119 | There are three ways to specify the template of the pagination controls directive: 120 | 121 | **1. Use the `paginationTemplateProvider` in your app's config block to set a global templateUrl for your app:** 122 | 123 | ```JavaScript 124 | myApp.config(function(paginationTemplateProvider) { 125 | paginationTemplateProvider.setPath('path/to/dirPagination.tpl.html'); 126 | }); 127 | ``` 128 | 129 | **2. Use the `paginationTemplateProvider` in your app's config block to set a global template string for your app:** 130 | 131 | ```JavaScript 132 | myApp.config(function(paginationTemplateProvider) { 133 | paginationTemplateProvider.setString(''); 134 | 135 | // or with e.g. Webpack you might do 136 | paginationTemplateProvider.setString(require('/path/to/customPagination.tpl.html')); 137 | }); 138 | ``` 139 | 140 | **3. Use the `template-url` attribute on each pagination controls directive:** 141 | 142 | ```HTML 143 | 144 | ``` 145 | 146 | #### Template Priority 147 | 148 | If you use more than one method for specifying the template, the actual template to use will be decided based on 149 | the following order of precedence (highest priority first): 150 | 151 | 1. `paginationTemplate.getString()` 152 | 2. `template-url` 153 | 3. `paginationTemplate.getPath()` 154 | 4. (default built-in template) 155 | 156 | ## Directives API 157 | 158 | The following attributes form the API for the pagination and pagination-controls directives. Optional attributes are marked as such, 159 | otherwise they are required. 160 | 161 | ### `dir-paginate` 162 | 163 | * **`expression`** Under the hood, this directive delegates to the `ng-repeat` directive, so the syntax for the 164 | expression is exactly as you would expect. [See the ng-repeat docs for the full rundown](https://docs.angularjs.org/api/ng/directive/ngRepeat). 165 | This means that you can also use any kind of filters you like, etc. 166 | 167 | * **`itemsPerPage`** The `expression` **must** include this filter. It is required by the pagination logic. The syntax 168 | is the same as any filter: `itemsPerPage: 10`, or you can also bind it to a property of the $scope: `itemsPerPage: pageSize`. **Note:** This filter should come *after* any other filters in order to work as expected. A safe rule is to always put it at the end of the expression. 169 | The optional third argument `paginationId` is used when you need more than one independent pagination instance on one page. See the section below 170 | on setting up multiple instances. 171 | 172 | * **`current-page`** (optional) Specify a property on your controller's $scope that will be bound to the current 173 | page of the pagination. If this is not specified, the directive will automatically create a property named `__default__currentPage` and use 174 | that instead. 175 | 176 | * **`pagination-id`** (optional) Used to group together the dir-paginate directive with a corresponding dir-pagination-controls when you need more than 177 | one pagination instance per page. See the section below on setting up multiple instances. 178 | 179 | * **`total-items`** When working with asynchronous data (i.e. data that is paginated on the server and sent one page at a time to the client), you would be sent 180 | only one page of results and then some meta-data containing the total number of results. In this case, the pagination directive would think that your one page 181 | of result was the full dataset, and therefore no pagination is needed. To prevent the default behaviour, you need to specify the `total-items` attribute, which 182 | will then be used to calculate the pagination. For more information see the section on working with asynchronous data below. 183 | 184 | ### `dir-pagination-controls` 185 | 186 | * **`max-size`** (optional, default = 9) Specify a maximum number of pagination links to display. The default is 9, and 187 | the minimum is 5 (setting a lower value than 5 will not have an effect). 188 | 189 | * **`direction-links`** (optional, default = true) Specify whether to display the "forwards" & "backwards" arrows in the 190 | pagination. 191 | 192 | * **`boundary-links`** (optional, default = false) Specify whether to display the "start" & "end" arrows in the 193 | pagination. 194 | 195 | * **`on-page-change`** (optional, default = null) Specify a callback method to run each time one of the pagination links is clicked. The method will be passed the 196 | optional arguments `newPageNumber` and `oldPageNumber`, which are integers equal to the page number that has just been navigated to, and the one just left, respectively. **Note** you must use that exact argument name in your view, 197 | i.e. ``, and the method you specify must be defined on your controller $scope. 198 | 199 | * **`pagination-id`** (optional) Used to group together the dir-pagination-controls with a corresponding dir-paginate when you need more than 200 | one pagination instance per page. See the section below on setting up multiple instances. 201 | 202 | * **`template-url`** (optional, default = `directives/pagination/dirPagination.tpl.html`) Specifies the template to use. 203 | 204 | * **`auto-hide`** (optional, default = true) Specify whether the dir-pagination-controls should be hidden when there's not enough elements to paginate over. 205 | 206 | Note: you cannot use the `dir-pagination-controls` directive without `dir-paginate`. Attempting to do so will result in an 207 | exception. 208 | 209 | ## Writing A Custom Pagination-Controls Template 210 | 211 | The default template ([dirPagination.tpl.html](dirPagination.tpl.html)) is based on the [Bootstrap pagination markup](http://getbootstrap.com/components/#pagination). If you wish to modify the template or write your own, 212 | there are a few useful values exposed by the directive which you can use: 213 | 214 | - `pages` The array of page numbers, typically used in an `ng-repeat` to generate the individual page links. 215 | - `{{ pagination.current }}` The current page. 216 | - `{{ pagination.last }}` The number of the last page in the collection. 217 | - `{{ range.lower }}` The ordinal number of the first item on the current page. E.g. assuming 10 items per page, when on page 2 this will equal 11. 218 | - `{{ range.upper }}` The ordinal number of the last item on the current page. E.g. assuming 10 items per page, when on page 2 this will equal 20. 219 | - `{{ range.total }}` The total number of items in the collection. 220 | 221 | The three `range` values can be used to generate a label like *"Displaying 16-20 of 53 items"*. 222 | 223 | Here is an example of a custom template which uses the range values along with "previous" and "next" arrow links, but no page links: 224 | 225 | ```HTML 226 |
Displaying {{ range.lower }} - {{ range.upper }} of {{ range.total }}
227 | 228 | 229 | 230 | ``` 231 | 232 | To use a custom template in your app, see the section on [specifying the template](#specifying-the-template). 233 | 234 | ## Special Repeat Start and End Points 235 | 236 | As with the [ngRepeat directive](https://docs.angularjs.org/api/ng/directive/ngRepeat#special-repeat-start-and-end-points), you can use the `-start` and `-end` suffix on the `dir-paginate` directive to 237 | repeat a series of elements instead of just one parent element: 238 | 239 | ```HTML 240 |
241 | Header {{ item }} 242 |
243 |
244 | Body {{ item }} 245 |
246 |
247 | Footer {{ item }} 248 |
249 | ``` 250 | 251 | ## Multiple Pagination Instances on One Page 252 | 253 | Multiple instances of the directives may be included on a single page by specifying a `pagination-id`. This property **must** be specified in **2** places 254 | for this to work: 255 | 256 | 1. Specify the `pagination-id` attribute on the `dir-paginate` directive. 257 | 3. Specify the `pagination-id` attribute on the `dir-paginations-controls` directive. 258 | 259 | **Note:** Prior to version 0.5.0, there was an additional requirement to add the ID as a second parameter of the `itemsPerPage` filter. This is now no longer required, as the 260 | directive will add this parameter automatically. Old code that *does* explicitly declare the ID in the filter will still work. 261 | 262 | An example of two independent paginations on one page would look like this: 263 | 264 | ```HTML 265 | 266 | 269 | 270 | 271 | 272 | 273 | 276 | 277 | 278 | ``` 279 | 280 | The pagination-ids above are set to "cust" in the first instance and "branch" in the second. The pagination-ids can be any [valid JavaScript identifier](https://mathiasbynens.be/notes/javascript-identifiers) (i.e. no hyphens, cannot begin with a number etc. [further discussion here](http://www.michaelbromley.co.uk/blog/410/a-note-on-angular-expressions-and-javascript-identifiers)), 281 | the important thing is to make sure the exact same id is used on both the pagination and the controls directives. If the 2 ids don't match, you should see a helpful 282 | exception in the console. 283 | 284 | ### Multiple Instances With ngRepeat 285 | 286 | You can use the pagination-id feature to dynamically create pagination instances, for example inside an `ng-repeat` block. Here is a bare-bones example to 287 | demonstrate how that would work: 288 | 289 | ```JavaScript 290 | // in the controller 291 | $scope.lists = [ 292 | { 293 | id: 'list1', 294 | collection: [1, 2, 3, 4, 5] 295 | }, 296 | { 297 | id: 'list2', 298 | collection: ['a', 'b', 'c', 'd', 'e'] 299 | }]; 300 | ``` 301 | 302 | ```HTML 303 | 304 |
305 |
    306 |
  • ID: {{ list.id }}, item: {{ item }}
  • 307 |
308 | 309 |
310 | ``` 311 | 312 | ### Demo 313 | 314 | Here is a working demo featuring two instances on one page: [http://plnkr.co/edit/xmjmIId0c9Glh5QH97xz?p=preview](http://plnkr.co/edit/xmjmIId0c9Glh5QH97xz?p=preview) 315 | 316 | 317 | ## Working With Asynchronous Data 318 | 319 | The arrangement described above works well for smaller collections, but once your data set reaches a certain size, you may not want to have to get the entire collection 320 | from the server just to view a few pages. The solution to this is to paginate on the server-side, whereby the server will only send one page of data at a time. In this case, 321 | the directive would see the small (one page) data set and think that was all, resulting in no pagination at all (assuming you set the `itemsPerPage` filter to match the number 322 | of items returned per server-side page). 323 | 324 | The solution is to use the `total-items` attribute on the `dir-paginate` directive. Commonly, in such a server-side paging scenario, the server would also return some meta-data 325 | along with the result set, specifying the total number of results. A typical example would look like this: 326 | 327 | ```JavaScript 328 | { 329 | Count: 1400, 330 | Items: [ 331 | { // item 1... }, 332 | { // item 2... }, 333 | { // item 3... }, 334 | ... 335 | { // item 25... } 336 | ] 337 | } 338 | ``` 339 | 340 | In this case, the server is returning the first *page* - 25 results - of a set that totals 1400 results. Therefore we must tell the directive that, even though it can only see 341 | 25 items in the collection right now, there are actually a total of 1400 items, so it should therefore make 1400/25 = **56** pagination links. 342 | 343 | Of course, once the user clicks on page 2, you will need to make a new request to the server to fetch the results for page 2. This will have to be implemented by you in your 344 | controller, but you will want to either 1) use the `on-page-change` callback to trigger the request or 2) set up a $watch on the property you specified in the `current-page` 345 | attribute. Either of those methods will allow you to call a function whenever the page changes, which will fetch the new page of results. The second method has the 346 | potential advantage of being triggered whenever the current-page changes, rather than only when the pagination links are clicked. 347 | 348 | ### Example Asynchronous Setup 349 | 350 | ```JavaScript 351 | .controller('UsersController', function($scope, $http) { 352 | $scope.users = []; 353 | $scope.totalUsers = 0; 354 | $scope.usersPerPage = 25; // this should match however many results your API puts on one page 355 | getResultsPage(1); 356 | 357 | $scope.pagination = { 358 | current: 1 359 | }; 360 | 361 | $scope.pageChanged = function(newPage) { 362 | getResultsPage(newPage); 363 | }; 364 | 365 | function getResultsPage(pageNumber) { 366 | // this is just an example, in reality this stuff should be in a service 367 | $http.get('path/to/api/users?page=' + pageNumber) 368 | .then(function(result) { 369 | $scope.users = result.data.Items; 370 | $scope.totalUsers = result.data.Count 371 | }); 372 | } 373 | }) 374 | ``` 375 | 376 | ```HTML 377 |
378 | 379 | 380 | 381 | 382 | 383 |
{{ user.name }}{{ user.email }}
384 | 385 | 386 |
387 | ```` 388 | 389 | ## Styling 390 | 391 | I've based the pagination navigation on the Bootstrap 3 component, so if you use Bootstrap in your project, 392 | you'll get some nice styling for free. If you don't use Bootstrap, it's simple to style the links with css. 393 | 394 | ## Frequently Asked Questions 395 | 396 | ### Why does my sort / filter only affect the current page? 397 | This is a common problem and is usually due to the `itemsPerPage` filter not being at the end of the expression. For example, consider the following: 398 | 399 | ```HTML 400 |
  • ...
  • 401 | ``` 402 | 403 | In this case, the collection is first truncated to 10 items by the `itemsPerPage` filter, and then *those 10 items only* are filtered. The solution is to ensure the `itemsPerPage` filter comes after any sorting / filtering: 404 | 405 | ```HTML 406 |
  • ...
  • 407 | ``` 408 | 409 | ### What is the `paginationService` and why is it not documented? 410 | 411 | The [`paginationService`](https://github.com/michaelbromley/angularUtils/blob/6055d260be44c0ba221a8c9bea015ac97e836a10/src/directives/pagination/dirPagination.js#L466-L521) is used internally to facilitate communication between the instances of the `dir-pagination` and `dir-pagination-controls` directives. Due to the way Angular's dependency injection system works, the service will be exposed in your app, meaning you can inject it directly into your controllers etc. 412 | 413 | However, since the `paginationService` is intended as an internal service, I cannot make any guarantees about the API, so it is dangerous to rely on using it directly in your code. Therefore I am not documenting it currently, so as not to encourage its general use. If you have a case that you feel can only be solved by direct use of this API, please open an issue and we can discuss it. 414 | 415 | ## Contribution 416 | 417 | Pull requests are welcome. If you are adding a new feature or fixing an as-yet-untested use case, please consider 418 | writing unit tests to cover your change(s). All unit tests are contained in the `dirPagination.spec.js` file, and 419 | Karma is set up if you run `grunt watch` as you make changes. 420 | 421 | At a minimum, make sure that all the tests still pass. Thanks! 422 | 423 | ## Changelog 424 | 425 | Please see the [releases page of the package repo](https://github.com/michaelbromley/angularUtils-pagination/releases) for details 426 | of each released version. 427 | 428 | ## Credits 429 | 430 | I did quite a bit of research before I figured I needed to make my own directive, and I picked up a lot of good ideas 431 | from various sources: 432 | 433 | * Daniel Tabuenca: https://groups.google.com/d/msg/angular/an9QpzqIYiM/r8v-3W1X5vcJ. This is where I learned how to 434 | delegate to the ng-repeat directive from within my own, and I used some of the code he gives here. 435 | 436 | * AngularUI Bootstrap: https://github.com/angular-ui/bootstrap. I used a few ideas, and a couple of attribute names, 437 | from their pagination directive. 438 | 439 | * StackOverflow: http://stackoverflow.com/questions/10816073/how-to-do-paging-in-angularjs. Picked up a lot of ideas 440 | from the various contributors to this thread. 441 | 442 | * Massive credit is due to all the [contributors](https://github.com/michaelbromley/angularUtils/graphs/contributors) to this project - they have brought improvements that I would not have the time or insight to figure out myself. 443 | 444 | ## License 445 | 446 | MIT 447 | -------------------------------------------------------------------------------- /src/directives/pagination/dirPagination.js: -------------------------------------------------------------------------------- 1 | /** 2 | * dirPagination - AngularJS module for paginating (almost) anything. 3 | * 4 | * 5 | * Credits 6 | * ======= 7 | * 8 | * Daniel Tabuenca: https://groups.google.com/d/msg/angular/an9QpzqIYiM/r8v-3W1X5vcJ 9 | * for the idea on how to dynamically invoke the ng-repeat directive. 10 | * 11 | * I borrowed a couple of lines and a few attribute names from the AngularUI Bootstrap project: 12 | * https://github.com/angular-ui/bootstrap/blob/master/src/pagination/pagination.js 13 | * 14 | * Copyright 2014 Michael Bromley 15 | */ 16 | 17 | (function() { 18 | 19 | /** 20 | * Config 21 | */ 22 | var moduleName = 'angularUtils.directives.dirPagination'; 23 | var DEFAULT_ID = '__default'; 24 | 25 | /** 26 | * Module 27 | */ 28 | angular.module(moduleName, []) 29 | .directive('dirPaginate', ['$compile', '$parse', 'paginationService', dirPaginateDirective]) 30 | .directive('dirPaginateNoCompile', noCompileDirective) 31 | .directive('dirPaginationControls', ['paginationService', 'paginationTemplate', dirPaginationControlsDirective]) 32 | .filter('itemsPerPage', ['paginationService', itemsPerPageFilter]) 33 | .service('paginationService', paginationService) 34 | .provider('paginationTemplate', paginationTemplateProvider) 35 | .run(['$templateCache',dirPaginationControlsTemplateInstaller]); 36 | 37 | function dirPaginateDirective($compile, $parse, paginationService) { 38 | 39 | return { 40 | terminal: true, 41 | multiElement: true, 42 | priority: 100, 43 | compile: dirPaginationCompileFn 44 | }; 45 | 46 | function dirPaginationCompileFn(tElement, tAttrs){ 47 | 48 | var expression = tAttrs.dirPaginate; 49 | // regex taken directly from https://github.com/angular/angular.js/blob/v1.4.x/src/ng/directive/ngRepeat.js#L339 50 | var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); 51 | 52 | var filterPattern = /\|\s*itemsPerPage\s*:\s*(.*\(\s*\w*\)|([^\)]*?(?=\s+as\s+))|[^\)]*)/; 53 | if (match[2].match(filterPattern) === null) { 54 | throw 'pagination directive: the \'itemsPerPage\' filter must be set.'; 55 | } 56 | var itemsPerPageFilterRemoved = match[2].replace(filterPattern, ''); 57 | var collectionGetter = $parse(itemsPerPageFilterRemoved); 58 | 59 | addNoCompileAttributes(tElement); 60 | 61 | // If any value is specified for paginationId, we register the un-evaluated expression at this stage for the benefit of any 62 | // dir-pagination-controls directives that may be looking for this ID. 63 | var rawId = tAttrs.paginationId || DEFAULT_ID; 64 | paginationService.registerInstance(rawId); 65 | 66 | return function dirPaginationLinkFn(scope, element, attrs){ 67 | 68 | // Now that we have access to the `scope` we can interpolate any expression given in the paginationId attribute and 69 | // potentially register a new ID if it evaluates to a different value than the rawId. 70 | var paginationId = $parse(attrs.paginationId)(scope) || attrs.paginationId || DEFAULT_ID; 71 | 72 | // (TODO: this seems sound, but I'm reverting as many bug reports followed it's introduction in 0.11.0. 73 | // Needs more investigation.) 74 | // In case rawId != paginationId we deregister using rawId for the sake of general cleanliness 75 | // before registering using paginationId 76 | // paginationService.deregisterInstance(rawId); 77 | paginationService.registerInstance(paginationId); 78 | 79 | var repeatExpression = getRepeatExpression(expression, paginationId); 80 | addNgRepeatToElement(element, attrs, repeatExpression); 81 | 82 | removeTemporaryAttributes(element); 83 | var compiled = $compile(element); 84 | 85 | var currentPageGetter = makeCurrentPageGetterFn(scope, attrs, paginationId); 86 | paginationService.setCurrentPageParser(paginationId, currentPageGetter, scope); 87 | 88 | if (typeof attrs.totalItems !== 'undefined') { 89 | paginationService.setAsyncModeTrue(paginationId); 90 | scope.$watch(function() { 91 | return $parse(attrs.totalItems)(scope); 92 | }, function (result) { 93 | if (0 <= result) { 94 | paginationService.setCollectionLength(paginationId, result); 95 | } 96 | }); 97 | } else { 98 | paginationService.setAsyncModeFalse(paginationId); 99 | scope.$watchCollection(function() { 100 | return collectionGetter(scope); 101 | }, function(collection) { 102 | if (collection) { 103 | var collectionLength = (collection instanceof Array) ? collection.length : Object.keys(collection).length; 104 | paginationService.setCollectionLength(paginationId, collectionLength); 105 | } 106 | }); 107 | } 108 | 109 | // Delegate to the link function returned by the new compilation of the ng-repeat 110 | compiled(scope); 111 | 112 | // (TODO: Reverting this due to many bug reports in v 0.11.0. Needs investigation as the 113 | // principle is sound) 114 | // When the scope is destroyed, we make sure to remove the reference to it in paginationService 115 | // so that it can be properly garbage collected 116 | // scope.$on('$destroy', function destroyDirPagination() { 117 | // paginationService.deregisterInstance(paginationId); 118 | // }); 119 | }; 120 | } 121 | 122 | /** 123 | * If a pagination id has been specified, we need to check that it is present as the second argument passed to 124 | * the itemsPerPage filter. If it is not there, we add it and return the modified expression. 125 | * 126 | * @param expression 127 | * @param paginationId 128 | * @returns {*} 129 | */ 130 | function getRepeatExpression(expression, paginationId) { 131 | var repeatExpression, 132 | idDefinedInFilter = !!expression.match(/(\|\s*itemsPerPage\s*:[^|]*:[^|]*)/); 133 | 134 | if (paginationId !== DEFAULT_ID && !idDefinedInFilter) { 135 | repeatExpression = expression.replace(/(\|\s*itemsPerPage\s*:\s*[^|\s]*)/, "$1 : '" + paginationId + "'"); 136 | } else { 137 | repeatExpression = expression; 138 | } 139 | 140 | return repeatExpression; 141 | } 142 | 143 | /** 144 | * Adds the ng-repeat directive to the element. In the case of multi-element (-start, -end) it adds the 145 | * appropriate multi-element ng-repeat to the first and last element in the range. 146 | * @param element 147 | * @param attrs 148 | * @param repeatExpression 149 | */ 150 | function addNgRepeatToElement(element, attrs, repeatExpression) { 151 | if (element[0].hasAttribute('dir-paginate-start') || element[0].hasAttribute('data-dir-paginate-start')) { 152 | // using multiElement mode (dir-paginate-start, dir-paginate-end) 153 | attrs.$set('ngRepeatStart', repeatExpression); 154 | element.eq(element.length - 1).attr('ng-repeat-end', true); 155 | } else { 156 | attrs.$set('ngRepeat', repeatExpression); 157 | } 158 | } 159 | 160 | /** 161 | * Adds the dir-paginate-no-compile directive to each element in the tElement range. 162 | * @param tElement 163 | */ 164 | function addNoCompileAttributes(tElement) { 165 | angular.forEach(tElement, function(el) { 166 | if (el.nodeType === 1) { 167 | angular.element(el).attr('dir-paginate-no-compile', true); 168 | } 169 | }); 170 | } 171 | 172 | /** 173 | * Removes the variations on dir-paginate (data-, -start, -end) and the dir-paginate-no-compile directives. 174 | * @param element 175 | */ 176 | function removeTemporaryAttributes(element) { 177 | angular.forEach(element, function(el) { 178 | if (el.nodeType === 1) { 179 | angular.element(el).removeAttr('dir-paginate-no-compile'); 180 | } 181 | }); 182 | element.eq(0).removeAttr('dir-paginate-start').removeAttr('dir-paginate').removeAttr('data-dir-paginate-start').removeAttr('data-dir-paginate'); 183 | element.eq(element.length - 1).removeAttr('dir-paginate-end').removeAttr('data-dir-paginate-end'); 184 | } 185 | 186 | /** 187 | * Creates a getter function for the current-page attribute, using the expression provided or a default value if 188 | * no current-page expression was specified. 189 | * 190 | * @param scope 191 | * @param attrs 192 | * @param paginationId 193 | * @returns {*} 194 | */ 195 | function makeCurrentPageGetterFn(scope, attrs, paginationId) { 196 | var currentPageGetter; 197 | if (attrs.currentPage) { 198 | currentPageGetter = $parse(attrs.currentPage); 199 | } else { 200 | // If the current-page attribute was not set, we'll make our own. 201 | // Replace any non-alphanumeric characters which might confuse 202 | // the $parse service and give unexpected results. 203 | // See https://github.com/michaelbromley/angularUtils/issues/233 204 | // Adding the '_' as a prefix resolves an issue where paginationId might be have a digit as its first char 205 | // See https://github.com/michaelbromley/angularUtils/issues/400 206 | var defaultCurrentPage = '_' + (paginationId + '__currentPage').replace(/\W/g, '_'); 207 | scope[defaultCurrentPage] = 1; 208 | currentPageGetter = $parse(defaultCurrentPage); 209 | } 210 | return currentPageGetter; 211 | } 212 | } 213 | 214 | /** 215 | * This is a helper directive that allows correct compilation when in multi-element mode (ie dir-paginate-start, dir-paginate-end). 216 | * It is dynamically added to all elements in the dir-paginate compile function, and it prevents further compilation of 217 | * any inner directives. It is then removed in the link function, and all inner directives are then manually compiled. 218 | */ 219 | function noCompileDirective() { 220 | return { 221 | priority: 5000, 222 | terminal: true 223 | }; 224 | } 225 | 226 | function dirPaginationControlsTemplateInstaller($templateCache) { 227 | $templateCache.put('angularUtils.directives.dirPagination.template', ''); 228 | } 229 | 230 | function dirPaginationControlsDirective(paginationService, paginationTemplate) { 231 | 232 | var numberRegex = /^\d+$/; 233 | 234 | var DDO = { 235 | restrict: 'AE', 236 | scope: { 237 | maxSize: '=?', 238 | onPageChange: '&?', 239 | paginationId: '=?', 240 | autoHide: '=?' 241 | }, 242 | link: dirPaginationControlsLinkFn 243 | }; 244 | 245 | // We need to check the paginationTemplate service to see whether a template path or 246 | // string has been specified, and add the `template` or `templateUrl` property to 247 | // the DDO as appropriate. The order of priority to decide which template to use is 248 | // (highest priority first): 249 | // 1. paginationTemplate.getString() 250 | // 2. attrs.templateUrl 251 | // 3. paginationTemplate.getPath() 252 | var templateString = paginationTemplate.getString(); 253 | if (templateString !== undefined) { 254 | DDO.template = templateString; 255 | } else { 256 | DDO.templateUrl = function(elem, attrs) { 257 | return attrs.templateUrl || paginationTemplate.getPath(); 258 | }; 259 | } 260 | return DDO; 261 | 262 | function dirPaginationControlsLinkFn(scope, element, attrs) { 263 | 264 | // rawId is the un-interpolated value of the pagination-id attribute. This is only important when the corresponding dir-paginate directive has 265 | // not yet been linked (e.g. if it is inside an ng-if block), and in that case it prevents this controls directive from assuming that there is 266 | // no corresponding dir-paginate directive and wrongly throwing an exception. 267 | var rawId = attrs.paginationId || DEFAULT_ID; 268 | var paginationId = scope.paginationId || attrs.paginationId || DEFAULT_ID; 269 | 270 | if (!paginationService.isRegistered(paginationId) && !paginationService.isRegistered(rawId)) { 271 | var idMessage = (paginationId !== DEFAULT_ID) ? ' (id: ' + paginationId + ') ' : ' '; 272 | if (window.console) { 273 | console.warn('Pagination directive: the pagination controls' + idMessage + 'cannot be used without the corresponding pagination directive, which was not found at link time.'); 274 | } 275 | } 276 | 277 | if (!scope.maxSize) { scope.maxSize = 9; } 278 | scope.autoHide = scope.autoHide === undefined ? true : scope.autoHide; 279 | scope.directionLinks = angular.isDefined(attrs.directionLinks) ? scope.$parent.$eval(attrs.directionLinks) : true; 280 | scope.boundaryLinks = angular.isDefined(attrs.boundaryLinks) ? scope.$parent.$eval(attrs.boundaryLinks) : false; 281 | 282 | var paginationRange = Math.max(scope.maxSize, 5); 283 | scope.pages = []; 284 | scope.pagination = { 285 | last: 1, 286 | current: 1 287 | }; 288 | scope.range = { 289 | lower: 1, 290 | upper: 1, 291 | total: 1 292 | }; 293 | 294 | scope.$watch('maxSize', function(val) { 295 | if (val) { 296 | paginationRange = Math.max(scope.maxSize, 5); 297 | generatePagination(); 298 | } 299 | }); 300 | 301 | scope.$watch(function() { 302 | if (paginationService.isRegistered(paginationId)) { 303 | return (paginationService.getCollectionLength(paginationId) + 1) * paginationService.getItemsPerPage(paginationId); 304 | } 305 | }, function(length) { 306 | if (0 < length) { 307 | generatePagination(); 308 | } 309 | }); 310 | 311 | scope.$watch(function() { 312 | if (paginationService.isRegistered(paginationId)) { 313 | return (paginationService.getItemsPerPage(paginationId)); 314 | } 315 | }, function(current, previous) { 316 | if (current != previous && typeof previous !== 'undefined') { 317 | goToPage(scope.pagination.current); 318 | } 319 | }); 320 | 321 | scope.$watch(function() { 322 | if (paginationService.isRegistered(paginationId)) { 323 | return paginationService.getCurrentPage(paginationId); 324 | } 325 | }, function(currentPage, previousPage) { 326 | if (currentPage != previousPage) { 327 | goToPage(currentPage); 328 | } 329 | }); 330 | 331 | scope.setCurrent = function(num) { 332 | if (paginationService.isRegistered(paginationId) && isValidPageNumber(num)) { 333 | num = parseInt(num, 10); 334 | paginationService.setCurrentPage(paginationId, num); 335 | } 336 | }; 337 | 338 | /** 339 | * Custom "track by" function which allows for duplicate "..." entries on long lists, 340 | * yet fixes the problem of wrongly-highlighted links which happens when using 341 | * "track by $index" - see https://github.com/michaelbromley/angularUtils/issues/153 342 | * @param id 343 | * @param index 344 | * @returns {string} 345 | */ 346 | scope.tracker = function(id, index) { 347 | return id + '_' + index; 348 | }; 349 | 350 | function goToPage(num) { 351 | if (paginationService.isRegistered(paginationId) && isValidPageNumber(num)) { 352 | var oldPageNumber = scope.pagination.current; 353 | 354 | scope.pages = generatePagesArray(num, paginationService.getCollectionLength(paginationId), paginationService.getItemsPerPage(paginationId), paginationRange); 355 | scope.pagination.current = num; 356 | updateRangeValues(); 357 | 358 | // if a callback has been set, then call it with the page number as the first argument 359 | // and the previous page number as a second argument 360 | if (scope.onPageChange) { 361 | scope.onPageChange({ 362 | newPageNumber : num, 363 | oldPageNumber : oldPageNumber 364 | }); 365 | } 366 | } 367 | } 368 | 369 | function generatePagination() { 370 | if (paginationService.isRegistered(paginationId)) { 371 | var page = parseInt(paginationService.getCurrentPage(paginationId)) || 1; 372 | scope.pages = generatePagesArray(page, paginationService.getCollectionLength(paginationId), paginationService.getItemsPerPage(paginationId), paginationRange); 373 | scope.pagination.current = page; 374 | scope.pagination.last = scope.pages[scope.pages.length - 1]; 375 | if (scope.pagination.last < scope.pagination.current) { 376 | scope.setCurrent(scope.pagination.last); 377 | } else { 378 | updateRangeValues(); 379 | } 380 | } 381 | } 382 | 383 | /** 384 | * This function updates the values (lower, upper, total) of the `scope.range` object, which can be used in the pagination 385 | * template to display the current page range, e.g. "showing 21 - 40 of 144 results"; 386 | */ 387 | function updateRangeValues() { 388 | if (paginationService.isRegistered(paginationId)) { 389 | var currentPage = paginationService.getCurrentPage(paginationId), 390 | itemsPerPage = paginationService.getItemsPerPage(paginationId), 391 | totalItems = paginationService.getCollectionLength(paginationId); 392 | 393 | scope.range.lower = (currentPage - 1) * itemsPerPage + 1; 394 | scope.range.upper = Math.min(currentPage * itemsPerPage, totalItems); 395 | scope.range.total = totalItems; 396 | } 397 | } 398 | function isValidPageNumber(num) { 399 | return (numberRegex.test(num) && (0 < num && num <= scope.pagination.last)); 400 | } 401 | } 402 | 403 | /** 404 | * Generate an array of page numbers (or the '...' string) which is used in an ng-repeat to generate the 405 | * links used in pagination 406 | * 407 | * @param currentPage 408 | * @param rowsPerPage 409 | * @param paginationRange 410 | * @param collectionLength 411 | * @returns {Array} 412 | */ 413 | function generatePagesArray(currentPage, collectionLength, rowsPerPage, paginationRange) { 414 | var pages = []; 415 | var totalPages = Math.ceil(collectionLength / rowsPerPage); 416 | var halfWay = Math.ceil(paginationRange / 2); 417 | var position; 418 | 419 | if (currentPage <= halfWay) { 420 | position = 'start'; 421 | } else if (totalPages - halfWay < currentPage) { 422 | position = 'end'; 423 | } else { 424 | position = 'middle'; 425 | } 426 | 427 | var ellipsesNeeded = paginationRange < totalPages; 428 | var i = 1; 429 | while (i <= totalPages && i <= paginationRange) { 430 | var pageNumber = calculatePageNumber(i, currentPage, paginationRange, totalPages); 431 | 432 | var openingEllipsesNeeded = (i === 2 && (position === 'middle' || position === 'end')); 433 | var closingEllipsesNeeded = (i === paginationRange - 1 && (position === 'middle' || position === 'start')); 434 | if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) { 435 | pages.push('...'); 436 | } else { 437 | pages.push(pageNumber); 438 | } 439 | i ++; 440 | } 441 | return pages; 442 | } 443 | 444 | /** 445 | * Given the position in the sequence of pagination links [i], figure out what page number corresponds to that position. 446 | * 447 | * @param i 448 | * @param currentPage 449 | * @param paginationRange 450 | * @param totalPages 451 | * @returns {*} 452 | */ 453 | function calculatePageNumber(i, currentPage, paginationRange, totalPages) { 454 | var halfWay = Math.ceil(paginationRange/2); 455 | if (i === paginationRange) { 456 | return totalPages; 457 | } else if (i === 1) { 458 | return i; 459 | } else if (paginationRange < totalPages) { 460 | if (totalPages - halfWay < currentPage) { 461 | return totalPages - paginationRange + i; 462 | } else if (halfWay < currentPage) { 463 | return currentPage - halfWay + i; 464 | } else { 465 | return i; 466 | } 467 | } else { 468 | return i; 469 | } 470 | } 471 | } 472 | 473 | /** 474 | * This filter slices the collection into pages based on the current page number and number of items per page. 475 | * @param paginationService 476 | * @returns {Function} 477 | */ 478 | function itemsPerPageFilter(paginationService) { 479 | 480 | return function(collection, itemsPerPage, paginationId) { 481 | if (typeof (paginationId) === 'undefined') { 482 | paginationId = DEFAULT_ID; 483 | } 484 | if (!paginationService.isRegistered(paginationId)) { 485 | throw 'pagination directive: the itemsPerPage id argument (id: ' + paginationId + ') does not match a registered pagination-id.'; 486 | } 487 | var end; 488 | var start; 489 | if (angular.isObject(collection)) { 490 | itemsPerPage = parseInt(itemsPerPage) || 9999999999; 491 | if (paginationService.isAsyncMode(paginationId)) { 492 | start = 0; 493 | } else { 494 | start = (paginationService.getCurrentPage(paginationId) - 1) * itemsPerPage; 495 | } 496 | end = start + itemsPerPage; 497 | paginationService.setItemsPerPage(paginationId, itemsPerPage); 498 | 499 | if (collection instanceof Array) { 500 | // the array just needs to be sliced 501 | return collection.slice(start, end); 502 | } else { 503 | // in the case of an object, we need to get an array of keys, slice that, then map back to 504 | // the original object. 505 | var slicedObject = {}; 506 | angular.forEach(keys(collection).slice(start, end), function(key) { 507 | slicedObject[key] = collection[key]; 508 | }); 509 | return slicedObject; 510 | } 511 | } else { 512 | return collection; 513 | } 514 | }; 515 | } 516 | 517 | /** 518 | * Shim for the Object.keys() method which does not exist in IE < 9 519 | * @param obj 520 | * @returns {Array} 521 | */ 522 | function keys(obj) { 523 | if (!Object.keys) { 524 | var objKeys = []; 525 | for (var i in obj) { 526 | if (obj.hasOwnProperty(i)) { 527 | objKeys.push(i); 528 | } 529 | } 530 | return objKeys; 531 | } else { 532 | return Object.keys(obj); 533 | } 534 | } 535 | 536 | /** 537 | * This service allows the various parts of the module to communicate and stay in sync. 538 | */ 539 | function paginationService() { 540 | 541 | var instances = {}; 542 | var lastRegisteredInstance; 543 | 544 | this.registerInstance = function(instanceId) { 545 | if (typeof instances[instanceId] === 'undefined') { 546 | instances[instanceId] = { 547 | asyncMode: false 548 | }; 549 | lastRegisteredInstance = instanceId; 550 | } 551 | }; 552 | 553 | this.deregisterInstance = function(instanceId) { 554 | delete instances[instanceId]; 555 | }; 556 | 557 | this.isRegistered = function(instanceId) { 558 | return (typeof instances[instanceId] !== 'undefined'); 559 | }; 560 | 561 | this.getLastInstanceId = function() { 562 | return lastRegisteredInstance; 563 | }; 564 | 565 | this.setCurrentPageParser = function(instanceId, val, scope) { 566 | instances[instanceId].currentPageParser = val; 567 | instances[instanceId].context = scope; 568 | }; 569 | this.setCurrentPage = function(instanceId, val) { 570 | instances[instanceId].currentPageParser.assign(instances[instanceId].context, val); 571 | }; 572 | this.getCurrentPage = function(instanceId) { 573 | var parser = instances[instanceId].currentPageParser; 574 | return parser ? parser(instances[instanceId].context) : 1; 575 | }; 576 | 577 | this.setItemsPerPage = function(instanceId, val) { 578 | instances[instanceId].itemsPerPage = val; 579 | }; 580 | this.getItemsPerPage = function(instanceId) { 581 | return instances[instanceId].itemsPerPage; 582 | }; 583 | 584 | this.setCollectionLength = function(instanceId, val) { 585 | instances[instanceId].collectionLength = val; 586 | }; 587 | this.getCollectionLength = function(instanceId) { 588 | return instances[instanceId].collectionLength; 589 | }; 590 | 591 | this.setAsyncModeTrue = function(instanceId) { 592 | instances[instanceId].asyncMode = true; 593 | }; 594 | 595 | this.setAsyncModeFalse = function(instanceId) { 596 | instances[instanceId].asyncMode = false; 597 | }; 598 | 599 | this.isAsyncMode = function(instanceId) { 600 | return instances[instanceId].asyncMode; 601 | }; 602 | } 603 | 604 | /** 605 | * This provider allows global configuration of the template path used by the dir-pagination-controls directive. 606 | */ 607 | function paginationTemplateProvider() { 608 | 609 | var templatePath = 'angularUtils.directives.dirPagination.template'; 610 | var templateString; 611 | 612 | /** 613 | * Set a templateUrl to be used by all instances of 614 | * @param {String} path 615 | */ 616 | this.setPath = function(path) { 617 | templatePath = path; 618 | }; 619 | 620 | /** 621 | * Set a string of HTML to be used as a template by all instances 622 | * of . If both a path *and* a string have been set, 623 | * the string takes precedence. 624 | * @param {String} str 625 | */ 626 | this.setString = function(str) { 627 | templateString = str; 628 | }; 629 | 630 | this.$get = function() { 631 | return { 632 | getPath: function() { 633 | return templatePath; 634 | }, 635 | getString: function() { 636 | return templateString; 637 | } 638 | }; 639 | }; 640 | } 641 | })(); 642 | -------------------------------------------------------------------------------- /src/directives/pagination/dirPagination.tpl.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/directives/pagination/testTemplate.tpl.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |
    {{ pagination.current }}
    4 |
    {{ pagination.last }}
    5 |
    {{ range.lower }}
    6 |
    {{ range.upper }}
    7 |
    {{ range.total }}
    8 |
    9 | -------------------------------------------------------------------------------- /src/directives/tagbox/README.md: -------------------------------------------------------------------------------- 1 | # Tagbox Directive 2 | 3 | This directive adds Twitter-like suggestion and autocompletion of tags in an `` or `'); 25 | document.body.appendChild(textarea[0]); 26 | _$compile_(textarea)(scope); 27 | scope.$apply(); 28 | 29 | suggestions = textarea.next(); 30 | textarea[0].focus(); 31 | })); 32 | 33 | it('should add the suggestions div after the textarea', function() { 34 | expect(suggestions.hasClass('suggestions-container')).toBe(true); 35 | }); 36 | 37 | it('suggestions should start off hidden', function() { 38 | expect(suggestions.hasClass('ng-hide')).toBe(true); 39 | }); 40 | 41 | it('should show the suggestions when typing a hashtag that matches', function() { 42 | textarea.val('#c'); 43 | textarea[0].selectionStart = 2; 44 | 45 | textarea.triggerHandler('keyup'); 46 | scope.$apply(); 47 | expect(suggestions.hasClass('ng-hide')).toBe(false); 48 | }); 49 | 50 | it('should display the correct suggestions', function() { 51 | textarea.val('#c'); 52 | textarea[0].selectionStart = 2; 53 | 54 | textarea.triggerHandler('keyup'); 55 | scope.$apply(); 56 | expect(suggestions.children()[0].innerHTML).toBe('#cake'); 57 | expect(suggestions.children()[1].innerHTML).toBe('#cup'); 58 | }); 59 | 60 | it('should work when hashtag is in the middle of other text', function() { 61 | textarea.val('I want to eat some #ham and pickle'); 62 | textarea[0].selectionStart = 23; // the end of the word "#ham" 63 | 64 | textarea.triggerHandler('keyup'); 65 | scope.$apply(); 66 | expect(suggestions.children()[0].innerHTML).toBe('#hammer'); 67 | }); 68 | 69 | it('should be case-insensitive', function() { 70 | textarea.val('#C'); 71 | textarea[0].selectionStart = 2; 72 | 73 | textarea.triggerHandler('keyup'); 74 | scope.$apply(); 75 | expect(suggestions.children()[0].innerHTML).toBe('#cake'); 76 | expect(suggestions.children()[1].innerHTML).toBe('#cup'); 77 | }); 78 | 79 | describe('specifying the tagToken', function() { 80 | var input; 81 | var suggestions; 82 | beforeEach(inject(function(_$compile_) { 83 | input = angular.element(''); 84 | _$compile_(input)(scope); 85 | scope.$apply(); 86 | 87 | suggestions = input.next(); 88 | document.body.appendChild(input[0]); 89 | })); 90 | 91 | it('should show the correct suggestions with a custom tagToken', function() { 92 | input.val('@c'); 93 | input[0].selectionStart = 2; 94 | 95 | input.triggerHandler('keyup'); 96 | scope.$apply(); 97 | expect(suggestions.children()[0].innerHTML).toBe('@cake'); 98 | expect(suggestions.children()[1].innerHTML).toBe('@cup'); 99 | }); 100 | 101 | }); 102 | 103 | describe('specifying no tagToken', function() { 104 | var input; 105 | var suggestions; 106 | beforeEach(inject(function(_$compile_) { 107 | input = angular.element(''); 108 | _$compile_(input)(scope); 109 | scope.$apply(); 110 | 111 | suggestions = input.next(); 112 | document.body.appendChild(input[0]); 113 | })); 114 | 115 | it('should show the correct suggestions an empty tagToken', function() { 116 | input.val('c'); 117 | input[0].selectionStart = 1; 118 | 119 | input.triggerHandler('keyup'); 120 | scope.$apply(); 121 | expect(suggestions.children()[0].innerHTML).toBe('cake'); 122 | expect(suggestions.children()[1].innerHTML).toBe('cup'); 123 | }); 124 | 125 | }); 126 | 127 | xdescribe('keyboard events', function() { 128 | 129 | /** 130 | * Unfortunately unable to properly unit test keyboard events at the moment. 131 | * See http://stackoverflow.com/questions/22574431/testing-keydown-events-in-jasmine-with-specific-keycode 132 | */ 133 | var suggestedTag1; 134 | var suggestedTag2; 135 | 136 | beforeEach(function() { 137 | textarea.val('#c'); 138 | textarea[0].selectionStart = 2; // the end of the word "#ham" 139 | 140 | textarea.triggerHandler('keyup'); 141 | scope.$apply(); 142 | 143 | suggestedTag1 = angular.element(suggestions.children()[0]); 144 | suggestedTag2 = angular.element(suggestions.children()[1]); 145 | }); 146 | 147 | it('should select suggestion on pressing down arrow', function() { 148 | // var event = new Event('keydown'); 149 | //var event = new CustomEvent('keydown', { 'keyCode': 40 }); 150 | //event.keyCode = 40; 151 | //var event = keyDownEvent(40); 152 | var event = document.createEvent("Events"); 153 | event.initEvent("keydown", true, true); 154 | event.keyCode = 40; 155 | // var event =__triggerKeyboardEvent(40); 156 | textarea.triggerHandler("keydown", [40, 41]); 157 | scope.$apply(); 158 | expect(suggestedTag1.hasClass('selected')).toBe('true'); 159 | }); 160 | 161 | }); 162 | }); -------------------------------------------------------------------------------- /src/directives/terminalType/README.md: -------------------------------------------------------------------------------- 1 | # Terminal Type Directive 2 | 3 | This is a directive that creates an effect akin to text being typed into a computer terminal. 4 | 5 | It probably has pretty limited applications in the real world, but I spent a bit of time working on 6 | the code for a re-design of my personal site, so I thought it would be worth releasing for others to use. 7 | 8 | ## Demo 9 | 10 | http://plnkr.co/edit/Mct3QE?p=preview 11 | 12 | ## Installation 13 | 14 | 1. Download the [dirTerminalType.js file](https://raw.githubusercontent.com/michaelbromley/angularUtils/master/src/directives/terminalType/dirTerminalType.js) 15 | and include it in your AngularJS project. 16 | 17 | 2. Include the module `angularUtils.directives.dirTerminalType` as a dependency for your app. 18 | 19 | 3. Include the contents of [dirTerminalType.css](https://raw.githubusercontent.com/michaelbromley/angularUtils/master/src/directives/terminalType/dirTerminalType.css) in your project, either by just copying the contents 20 | into an existing style sheet (since it is so small), or including the file as an external link. 21 | 22 | ## Usage 23 | 24 | The directive can be added as an attribute to any existing element: 25 | 26 | ```HTML 27 | 28 | 34 | ... 35 | 36 | ``` 37 | 38 | * **`duration`** Optionally specify the length of time in milliseconds it takes to type out the contents of this element. 39 | Default is 1000. 40 | 41 | * **`start-typing`** Optionally specify an expression which, when it evaluates to `true`, will trigger the typing effect. 42 | Until it evaluates to `true`, the typing will not start. Default behaviour when this attribute is not present 43 | it to start typing immediately. 44 | 45 | * **`force-caret`** If you are using the `start-typing` attribute, by default the element's caret will not be displayed until 46 | typing commences. Using `force-caret` forces the caret to be added as soon as the page loads, and the caret will just sit there 47 | blinking until `start-typing` becomes true. 48 | 49 | * **`on-completion`** Optionally provide an expression or method to evaluate once the text has finished being typed. Useful for 50 | chaining elements together by setting a boolean value in the `on-completion` of the first element, which is then used in the 51 | `start-typing` attribute on the second. 52 | 53 | * **`remove-caret`** Optionally specify the delay in milliseconds after which the caret will be removed (counting from once the typing has 54 | completed). Defaults to 1000. 55 | -------------------------------------------------------------------------------- /src/directives/terminalType/dirTerminalType.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This styling is for the flashing caret. 3 | */ 4 | 5 | .dirTerminalType-caret { 6 | display: inline-block; 7 | width: 3px; 8 | margin-bottom: -2px; 9 | margin-left: 3px; 10 | margin-right: -4px; 11 | height: 16px; 12 | -webkit-animation: dirTerminalType-blink 0.8s infinite; 13 | -moz-animation: dirTerminalType-blink 0.8s infinite; 14 | -o-animation: dirTerminalType-blink 0.8s infinite; 15 | animation: dirTerminalType-blink 0.8s infinite; 16 | } 17 | @-webkit-keyframes dirTerminalType-blink { 18 | 0% { 19 | opacity: 0; 20 | } 21 | 49% { 22 | opacity: 0; 23 | } 24 | 50% { 25 | opacity: 1; 26 | } 27 | 100% { 28 | opacity: 1; 29 | } 30 | } 31 | @-moz-keyframes dirTerminalType-blink { 32 | 0% { 33 | opacity: 0; 34 | } 35 | 49% { 36 | opacity: 0; 37 | } 38 | 50% { 39 | opacity: 1; 40 | } 41 | 100% { 42 | opacity: 1; 43 | } 44 | } 45 | @-o-keyframes dirTerminalType-blink { 46 | 0% { 47 | opacity: 0; 48 | } 49 | 49% { 50 | opacity: 0; 51 | } 52 | 50% { 53 | opacity: 1; 54 | } 55 | 100% { 56 | opacity: 1; 57 | } 58 | } 59 | @keyframes dirTerminalType-blink { 60 | 0% { 61 | opacity: 0; 62 | } 63 | 49% { 64 | opacity: 0; 65 | } 66 | 50% { 67 | opacity: 1; 68 | } 69 | 100% { 70 | opacity: 1; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/directives/terminalType/dirTerminalType.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A directive for AngularJS that makes an effect akin to text being typed on a computer terminal. 3 | * 4 | * Copyright 2014 Michael Bromley 5 | */ 6 | (function() { 7 | 8 | /** 9 | * Config 10 | */ 11 | var moduleName = 'angularUtils.directives.dirTerminalType'; 12 | 13 | /** 14 | * Module 15 | */ 16 | var module; 17 | try { 18 | module = angular.module(moduleName); 19 | } catch(err) { 20 | // named module does not exist, so create one 21 | module = angular.module(moduleName, []); 22 | } 23 | 24 | module.directive('dirTerminalType', ['$window', '$document', '$timeout', '$interpolate', '$parse', function ($window, $document, $timeout, $interpolate, $parse) { 25 | 26 | /** 27 | * requestAnimationFrame polyfill from http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/ 28 | */ 29 | (function() { 30 | var lastTime = 0; 31 | var vendors = ['webkit', 'moz']; 32 | for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 33 | window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame']; 34 | window.cancelAnimationFrame = 35 | window[vendors[x]+'CancelAnimationFrame'] || window[vendors[x]+'CancelRequestAnimationFrame']; 36 | } 37 | 38 | if (!window.requestAnimationFrame) { 39 | window.requestAnimationFrame = function(callback, element) { 40 | var currTime = new Date().getTime(); 41 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 42 | var id = window.setTimeout(function() { callback(currTime + timeToCall); }, 43 | timeToCall); 44 | lastTime = currTime + timeToCall; 45 | return id; 46 | }; 47 | } 48 | 49 | if (!window.cancelAnimationFrame) { 50 | window.cancelAnimationFrame = function(id) { 51 | clearTimeout(id); 52 | }; 53 | } 54 | }()); 55 | 56 | /** 57 | * Recursively traverse the node tree and set the nodeValue of any text nodes to '', whilst 58 | * storing the original value in the newly-created field _originalNodeValue for later use. 59 | * 60 | * @param node 61 | * @param totalChars 62 | * @returns {number} 63 | */ 64 | function clearTextAndStoreValues(node, totalChars, originalNodeValues) { 65 | var i; 66 | totalChars = totalChars || 0; 67 | 68 | if (node.nodeValue !== null) { 69 | var nodeValue = node.nodeValue.replace(/\s+/g, ' '); 70 | originalNodeValues.values.push(nodeValue); 71 | node.nodeValue = ''; 72 | totalChars += nodeValue.length; 73 | } 74 | 75 | for (i = 0; i < node.childNodes.length; i++) { 76 | totalChars = clearTextAndStoreValues(node.childNodes[i], totalChars, originalNodeValues); 77 | } 78 | 79 | return totalChars; 80 | } 81 | 82 | /** 83 | * Update the nodeValues of any text nodes within element, filling in the corresponding 84 | * amount of characters commensurate with the current progress. 85 | * 86 | * @param element 87 | * @param currentIteration 88 | * @param totalIterations 89 | * @param totalChars 90 | * @returns {boolean} 91 | */ 92 | function type(element, currentIteration, totalIterations, totalChars, originalNodeValues) { 93 | var currentChar = Math.ceil(currentIteration / totalIterations * totalChars); 94 | 95 | var charsTyped = typeUpToCurrentChar(element, currentChar, 0, originalNodeValues, true); 96 | 97 | var done = totalChars <= charsTyped; 98 | return done; 99 | } 100 | 101 | /** 102 | * Recursive function that traverses a node tree and updates the nodeValue of each 103 | * text node until the total number of characters "typed" is equal to the value 104 | * of currentChar. 105 | * 106 | * @param node 107 | * @param currentChar 108 | * @param charsTyped 109 | * @returns {*} 110 | */ 111 | function typeUpToCurrentChar(node, currentChar, charsTyped, originalNodeValues, resetCounter) { 112 | 113 | if (resetCounter) { 114 | originalNodeValues.counter = 0; 115 | } 116 | 117 | if (node.nodeValue !== null) { 118 | var originalValue = originalNodeValues.values[originalNodeValues.counter]; 119 | if (currentChar - charsTyped < originalValue.length) { 120 | var charsToType = currentChar - charsTyped; 121 | node.nodeValue = originalValue.substring(0, charsToType); 122 | charsTyped += charsToType; 123 | } else { 124 | node.nodeValue = originalValue; 125 | charsTyped += originalValue.length; 126 | } 127 | 128 | originalNodeValues.counter ++; 129 | } 130 | 131 | for (var i = 0; i < node.childNodes.length; i++) { 132 | if (charsTyped < currentChar) { 133 | charsTyped = typeUpToCurrentChar(node.childNodes[i], currentChar, charsTyped, originalNodeValues); 134 | } else { 135 | break; 136 | } 137 | } 138 | 139 | return charsTyped; 140 | } 141 | 142 | /** 143 | * Add the caret to the end of the element, and style it to fit the text. 144 | * First line checks if a caret already exists, in which case do nothing. 145 | * 146 | * @param element 147 | */ 148 | function addCaret(element) { 149 | var elementAlreadyHasCaret = element[0].querySelector('.dirTerminalType-caret') !== null; 150 | 151 | if (!elementAlreadyHasCaret) { 152 | var height = parseInt($window.getComputedStyle(element[0])['font-size']); 153 | height -= 2; // make it a bit smaller to prevent it interfering with document flow. 154 | var backgroundColor = $window.getComputedStyle(element[0])['color']; 155 | var width = Math.ceil(height * 0.05); 156 | var marginBottom = Math.ceil(height * -0.1); 157 | var caret = $document[0].createElement('span'); 158 | caret.classList.add('dirTerminalType-caret'); 159 | caret.style.height = height + 'px'; 160 | caret.style.width = width + 'px'; 161 | caret.style.backgroundColor = backgroundColor; 162 | caret.style.marginBottom = marginBottom + 'px'; 163 | element.append(caret); 164 | } 165 | } 166 | 167 | function removeCaret(element) { 168 | var caret = element[0].querySelector('.dirTerminalType-caret'); 169 | angular.element(caret).remove(); 170 | } 171 | 172 | /** 173 | * If any of the text nodes contain interpolation expressions {{ like.this }}, we need to 174 | * interpolate them to get the actual value to be displayed. This will change the 175 | * totalChars count so that must also be updated. 176 | * 177 | * @param node 178 | * @param scope 179 | * @param totalChars 180 | */ 181 | function interpolateText(node, scope, totalChars, originalNodeValues, resetCounter) { 182 | var i, 183 | currentNodeContent, 184 | currentLength, 185 | interpolatedContent, 186 | interpolatedLength, 187 | lengthDelta; 188 | 189 | if (resetCounter) { 190 | originalNodeValues.counter = 0; 191 | } 192 | 193 | if (node.nodeValue !== null) { 194 | currentNodeContent = originalNodeValues.values[originalNodeValues.counter]; 195 | currentLength = currentNodeContent.length; 196 | interpolatedContent = $interpolate(currentNodeContent)(scope); 197 | interpolatedLength = interpolatedContent.length; 198 | 199 | lengthDelta = interpolatedLength - currentLength; 200 | totalChars += lengthDelta; 201 | originalNodeValues.values[originalNodeValues.counter] = interpolatedContent; 202 | 203 | originalNodeValues.counter ++; 204 | } 205 | 206 | for (i = 0; i < node.childNodes.length; i++) { 207 | totalChars = interpolateText(node.childNodes[i], scope, totalChars, originalNodeValues); 208 | } 209 | 210 | return totalChars; 211 | } 212 | 213 | return { 214 | restrict: 'AE', 215 | link: function(scope, element, attrs) { 216 | 217 | /** 218 | * These two variables are used to store the original text values of any text nodes in the element. The original approach involved 219 | * simply appending a new property onto the DOM node itself, but this proved to be a bad idea since it breaks in IE. This new approach 220 | * is a little more complex since we now have to track the index of each text node in the originalNodeValues array. 221 | * @type {Array} 222 | */ 223 | var originalNodeValues = { 224 | values: [], 225 | counter: 0 226 | }; 227 | 228 | var totalChars = clearTextAndStoreValues(element[0], 0, originalNodeValues); 229 | 230 | var start, elapsed; 231 | var duration = attrs.duration || 1000; 232 | var removeCaretAfter = attrs.removeCaret || 1000; 233 | var onCompletion = $parse(attrs.onCompletion) || null; 234 | var forceCaret = typeof attrs.forceCaret !== 'undefined' ? true : false; 235 | 236 | if (typeof attrs.startTyping !=='undefined') { 237 | if (forceCaret) { 238 | addCaret(element); 239 | } 240 | scope.$watch(function() { 241 | return scope.$eval(attrs.startTyping); 242 | }, function(val) { 243 | if (val) { 244 | startTyping(); 245 | } 246 | }); 247 | } else { 248 | startTyping(); 249 | } 250 | 251 | function startTyping() { 252 | addCaret(element); 253 | totalChars = interpolateText(element[0], scope, totalChars, originalNodeValues, true); 254 | window.requestAnimationFrame(tick); 255 | } 256 | 257 | /** 258 | * This is the animation function that gets looped in a requestAnimationFrame call. 259 | * @param timestamp 260 | */ 261 | function tick(timestamp) { 262 | var currentIteration, totalIterations, done; 263 | 264 | if (typeof start === 'undefined') { 265 | start = timestamp; 266 | } 267 | elapsed = timestamp - start; 268 | 269 | totalIterations = Math.round(duration / 1000 * 60); 270 | currentIteration = Math.round(elapsed / 1000 * 60); 271 | done = type(element[0], currentIteration, totalIterations, totalChars, originalNodeValues); 272 | 273 | if (elapsed < duration && !done) { 274 | window.requestAnimationFrame(tick); 275 | } else { 276 | $timeout(function() { 277 | removeCaret(element); 278 | }, removeCaretAfter); 279 | 280 | start = undefined; // reset 281 | 282 | // if a callback was defined by the on-completion attribute, invoke it now 283 | if (onCompletion !== null) { 284 | onCompletion(scope); 285 | } 286 | } 287 | } 288 | } 289 | }; 290 | }]); 291 | 292 | })(); 293 | -------------------------------------------------------------------------------- /src/directives/terminalType/dirTerminalType.spec.js: -------------------------------------------------------------------------------- 1 | 2 | xdescribe('dirTerminalType directive', function() { 3 | 4 | var $compile; 5 | var $scope; 6 | var $timeout; 7 | var containingElement; 8 | 9 | beforeEach(module('angularUtils.directives.dirTerminalType')); 10 | 11 | beforeEach(inject(function($rootScope, _$compile_, _$timeout_) { 12 | $compile = _$compile_; 13 | $timeout = _$timeout_; 14 | $scope = $rootScope.$new(); 15 | containingElement = angular.element('
    '); 16 | })); 17 | 18 | function compile(text, duration, removeCaret, startTyping) { 19 | var html; 20 | 21 | duration = duration ? 'duration="' + duration + '"' : ''; 22 | removeCaret = removeCaret ? 'remove-caret="' + removeCaret + '"' : ''; 23 | startTyping = startTyping ? 'start-typing="' + startTyping + '"' : ''; 24 | 25 | html = '

    ' + text + '

    '; 26 | containingElement.append($compile(html)($scope)); 27 | $scope.$apply(); 28 | } 29 | 30 | it('should initially remove the contents', function() { 31 | var text = 'Hello, this is some text!'; 32 | compile(text, 200); 33 | 34 | expect(containingElement.text()).toEqual(''); 35 | }); 36 | 37 | it('should type complete text of simple element', function(done) { 38 | var text = 'Hello, this is some text!'; 39 | compile(text, 200); 40 | 41 | setTimeout(function() { 42 | expect(containingElement.html()).toContain(text); 43 | done(); 44 | }, 250); 45 | }); 46 | 47 | it('should type half the text at halfway through duration', function(done) { 48 | var text = 'Hello, this is some text!'; 49 | compile(text, 200); 50 | 51 | setTimeout(function() { 52 | expect(containingElement.html()).toContain(text.substring(0, Math.floor(text.length / 2) - 2)); 53 | expect(containingElement.html()).not.toContain(text); 54 | done(); 55 | }, 100); 56 | }); 57 | 58 | it('should add a caret to the element', function(done) { 59 | var text = 'Hello, this is some text!'; 60 | compile(text); 61 | 62 | setTimeout(function() { 63 | expect(containingElement.find('.caret').length).toEqual(1); 64 | done(); 65 | }, 50); 66 | }); 67 | 68 | it('should hide the caret until typing begins by default if typing has not started', function() { 69 | compile('hello', 100, 100, 'false'); 70 | 71 | expect(containingElement.find('.caret').length).toEqual(0); 72 | }); 73 | 74 | it('should show the caret when force-caret is used, even if typing has not started', function() { 75 | var html = '

    message: {{ myText }}

    '; 76 | containingElement.append($compile(html)($scope)); 77 | $scope.$apply(); 78 | 79 | expect(containingElement.find('.caret').length).toEqual(1); 80 | }); 81 | 82 | it('should remove the caret after the specified time', function(done) { 83 | var text = 'Hello, this is some text!'; 84 | compile(text, 100, 100); 85 | 86 | setTimeout(function() { 87 | $timeout.flush(); 88 | expect(containingElement.find('.caret').length).toEqual(0); 89 | done(); 90 | }, 200); 91 | }); 92 | 93 | it('should handle child elements with text content', function(done) { 94 | var text = 'No! I don\'t want to visit google.com'; 95 | compile(text, 200); 96 | 97 | setTimeout(function() { 98 | expect(containingElement.html()).toContain(text); 99 | done(); 100 | }, 250); 101 | }); 102 | 103 | it('should interpolate simple text content', function(done) { 104 | $scope.myValue = "This is an interpolated string!"; 105 | compile('{{ myValue }}', 200); 106 | 107 | setTimeout(function() { 108 | expect(containingElement.html()).toContain($scope.myValue); 109 | done(); 110 | }, 250); 111 | }); 112 | 113 | it('should interpolate complex nested content', function(done) { 114 | $scope.rap = { 115 | location: 'West Philadelphia', 116 | action1: 'born', 117 | action2: 'raised' 118 | }; 119 | 120 | compile('In {{ rap.location }}
    • {{ rap.action1 }}
    • and {{ rap.action2 }}
    ', 200); 121 | 122 | setTimeout(function() { 123 | expect(containingElement.text()).toContain('In West Philadelphia born and raised'); 124 | done(); 125 | }, 250); 126 | }); 127 | 128 | it('should not start typing if the start-typing attribute is set and evals to false', function(done) { 129 | compile('hello', 100, 100, 'false'); 130 | 131 | setTimeout(function() { 132 | expect(containingElement.text()).toEqual(''); 133 | done(); 134 | }, 250); 135 | }); 136 | 137 | it('should start typing when the start-typing attribute is set and evals to true', function(done) { 138 | compile('hello', 100, 100, 'myVal'); 139 | $scope.$apply(function() { 140 | $scope.myVal = true; 141 | }); 142 | 143 | setTimeout(function() { 144 | expect(containingElement.text()).toEqual('hello'); 145 | done(); 146 | }, 250); 147 | }); 148 | 149 | it('should work when the element starts off hidden', function(done) { 150 | var html = '

    message: {{ myText }}

    '; 151 | containingElement.append($compile(html)($scope)); 152 | $scope.$apply(); 153 | 154 | expect(containingElement.text()).toEqual(''); 155 | 156 | $scope.$apply(function() { 157 | $scope.myText = 'hello'; 158 | }); 159 | 160 | expect(containingElement.text()).toEqual(''); 161 | 162 | setTimeout(function() { 163 | expect(containingElement.text()).toEqual('message: hello'); 164 | done(); 165 | }, 250); 166 | }); 167 | 168 | it('should fire a callback if specified by on-completion', function(done) { 169 | $scope.myMethod = function() {}; 170 | 171 | var html = '

    message: {{ myText }}

    '; 172 | containingElement.append($compile(html)($scope)); 173 | $scope.$apply(); 174 | 175 | spyOn($scope, 'myMethod'); 176 | 177 | setTimeout(function() { 178 | expect($scope.myMethod).toHaveBeenCalled(); 179 | done(); 180 | }, 250); 181 | }); 182 | 183 | it('should execute an expression if specified by on-completion', function(done) { 184 | var html = '

    message: {{ myText }}

    '; 185 | containingElement.append($compile(html)($scope)); 186 | $scope.$apply(); 187 | 188 | setTimeout(function() { 189 | expect($scope.myVal).toEqual('foo'); 190 | done(); 191 | }, 250); 192 | }); 193 | 194 | it('should work with multiple elements at the same time', function(done) { 195 | compile('will', 100); 196 | compile('smith', 100); 197 | 198 | setTimeout(function() { 199 | expect(containingElement.children().eq(0).text()).toEqual('will'); 200 | expect(containingElement.children().eq(1).text()).toEqual('smith'); 201 | done(); 202 | }, 250); 203 | }); 204 | }); -------------------------------------------------------------------------------- /src/directives/uiBreadcrumbs/README.md: -------------------------------------------------------------------------------- 1 | # uiBreadcrumbs Directive 2 | 3 | This is a directive that auto-generates breadcrumbs based on angular-ui-router routes. 4 | 5 | **No longer maintained** (July 2016) I am no longer using this in any of my projects. As such, I have no time to maintain it. Pull requests for fixes and features are welcome. If someone would like to maintain a fork, let me know and I will link to it here. 6 | 7 | ## Demo 8 | 9 | You can see a working demo demonstrating most of the features here: [http://plnkr.co/edit/bBgdxgB91Z6323HLWCzF?p=preview](http://plnkr.co/edit/bBgdxgB91Z6323HLWCzF?p=preview) 10 | 11 | ## Requirements 12 | 13 | This directive is designed to work with [angular-ui-router](https://github.com/angular-ui/ui-router), and will not work with the default Angular router. 14 | 15 | The design of the directive also relies on the use of nested states in order to auto-generate the breadcrumbs hierarchy. See more on nested states here: 16 | [https://github.com/angular-ui/ui-router/wiki/Nested-States-%26-Nested-Views#inherited-custom-data](https://github.com/angular-ui/ui-router/wiki/Nested-States-%26-Nested-Views#inherited-custom-data) 17 | Note that the use of nested states does not imply nested *views*. Often, in a usual breadcrumbs use case, you won't want to have to nest a new view each time you go down the breadcrumb trail. To avoid using 18 | nested views, you should use a named view and refer to it when configuring your states. See the [demo](http://plnkr.co/edit/bBgdxgB91Z6323HLWCzF?p=preview) and the example below for an idea of how this would work. 19 | 20 | ## Installation 21 | 22 | ### 1. Download 23 | You can install with Bower or npm: 24 | 25 | `bower install angular-utils-ui-breadcrumbs` 26 | `npm install angular-utils-ui-breadcrumbs` 27 | 28 | Alternatively just download the files `uiBreadcrumbs.js` and `uiBreadcrumbs.tpl.html`. Using bower has the advantage of making version management easier. 29 | 30 | ### 2. Include in your app 31 | 32 | Make sure the file `uiBreadcrumbs.js` is being loaded in your app, and that the template file is available somewhere (see the next section for how to configure the path to the template). 33 | 34 | Declare the dependency in your Angular module: 35 | 36 | ```JavaScript 37 | angular.module('myApp', ['angularUtils.directives.uiBreadcrumbs']); 38 | ``` 39 | 40 | ## Usage 41 | 42 | Assuming you already have your app configured to use ui-router, you then need to put this directive somewhere and use it like so: 43 | 44 | ```HTML 45 | 48 | 49 | ``` 50 | 51 | * **`displayname-property`** (required) This attribute must point to some property on your state config object that contains the string you wish to display as the 52 | route's breadcrumb. If none is specified, or if the specified property is not found, the directive will default to displaying the route's name. 53 | * **`template-url`** (optional) Use this attribute to specify the URL of the `uiBreadcrumbs.tpl.html` file. Alternatively this may be configured in the JavaScript file 54 | itself, in which case this attribute would not be needed. 55 | * **`abstract-proxy-property`** (optional) Used when working with abstract states. See the section on working with abstract states below for a full explanation. 56 | 57 | ## Example setup 58 | 59 | He is an example that illustrates the main features of the directive: 60 | 61 | ```JavaScript 62 | angular.module('yourModule').config(function($stateProvider) { 63 | $stateProvider 64 | .state('home', { 65 | url: '/', 66 | views: { 67 | 'content@': { 68 | templateUrl: ... 69 | } 70 | }, 71 | data: { 72 | displayName: 'Home' 73 | } 74 | }) 75 | .state('home.usersList', { 76 | url: 'users/', 77 | views: { 78 | 'content@': { 79 | templateUrl: ... 80 | } 81 | }, 82 | data: { 83 | displayName: 'Users' 84 | } 85 | }) 86 | .state('home.userList.detail', { 87 | url: ':id', 88 | views: { 89 | 'content@': { 90 | templateUrl: ... 91 | } 92 | }, 93 | data: { 94 | displayName: '{{ user.firstName }} {{ user.lastName | uppercase }}' 95 | } 96 | resolve: { 97 | user : function($stateParams, userService) { 98 | return userService.getUser($stateParams.id); 99 | } 100 | } 101 | }) 102 | .state('home.userList.detail.image', { 103 | views: { 104 | 'content@': { 105 | templateUrl: ... 106 | } 107 | }, 108 | data: { 109 | displayName: false 110 | } 111 | }) 112 | ``` 113 | 114 | ```html 115 | // in the app template somewhere 116 | 117 |
    118 | ``` 119 | 120 | The first two states are straightforward. The property specified in the `displayname-property` attribute can be seen 121 | to exist in the config object, the value of which is a string with the name we want to display in the breadcrumb. 122 | 123 | The third state illustrates how we can use a resolved value as our breadcrumb display name. This is done by using the 124 | regular Angular interpolation syntax `{{ value }}`. In this case, `user` corresponds to the `resolve` function that is using our 125 | imaginary `userService` to asynchronously grab an object containing the details of the current user. You can also see that, just like in any Angular interpolation 126 | string, you can reference properties of objects, use filters and so on. 127 | 128 | The fourth state illustrates that if we don't want a state to show up in the breadcrumbs, we should set the 129 | display name to `false`. 130 | 131 | ## Working With Abstract States 132 | 133 | AngularUI Router provides the option of setting up [abstract states](https://github.com/angular-ui/ui-router/wiki/Nested-States-%26-Nested-Views#abstract-states), which 134 | by definition cannot be transitioned to. Therefore, we cannot show them in the breadcrumbs, as clicking on an abstract state would cause the router to try 135 | to transition to that state, which results in an exception. 136 | 137 | Therefore, the default behaviour is to ignore abstract states and skip that level of the breadcrumb hierarchy. 138 | 139 | However, in many cases this behaviour would not be desirable. Consider the following setup (taken from the ui-router docs): 140 | 141 | ```JavaScript 142 | $stateProvider 143 | .state('contacts', { 144 | abstract: true, 145 | url: '/contacts', 146 | 147 | // Note: abstract still needs a ui-view for its children to populate. 148 | // You can simply add it inline here. 149 | template: '' 150 | }) 151 | .state('contacts.list', { 152 | // url will become '/contacts/list' 153 | url: '/list' 154 | //...more 155 | }) 156 | .state('contacts.detail', { 157 | // url will become '/contacts/detail' 158 | url: '/detail', 159 | //...more 160 | }) 161 | ``` 162 | 163 | In this case, if we were in the `contacts.detail` state, the breadcrumbs would only display that state and ignore the parent state as it 164 | is abstract. What we really want to do is substitute the `contacts.list` state for the parent state. This is because, logically, the 165 | list of contacts is one level up from the detail page, even though they are strictly at the same level in the $state definition. 166 | 167 | In order to achieve this substitution, we can use the `abstract-proxy-property` attribute on our directive. This tells the directive 168 | to look for the specified property on the state config object, where it should find the name of the state to use instead of the abstract state. 169 | 170 | To implement this, we would modify the above example to look like this: 171 | 172 | ```JavaScript 173 | $stateProvider 174 | .state('contacts', { 175 | abstract: true, 176 | url: '/contacts', 177 | template: '' 178 | data: { 179 | breadcrumbProxy: 'contacts.list' 180 | } 181 | }) 182 | .state('contacts.list', { 183 | url: '/list' 184 | //...more 185 | }) 186 | .state('contacts.detail', { 187 | url: '/detail', 188 | //...more 189 | }) 190 | ``` 191 | 192 | The directive element would then look like this: 193 | 194 | ```HTML 195 | 196 | ``` 197 | 198 | Now, when we are in the `contacts.detail` state, the breadcrumbs will show the `contacts.list` as the immediate parent, 199 | rather than the abstract `contacts` state. 200 | 201 | ## Styling 202 | The template structure is based on the [Bootstrap 3 breadcrumbs component](http://getbootstrap.com/components/#breadcrumbs), so it 203 | includes an `active` class to signify the current (left-most) item in the breadcrumbs list. You can, of course, modify the template as needed 204 | or simply define your own CSS to fit it with your app's style. 205 | 206 | ## Note on the `data` object 207 | A potential "gotcha" is the fact that the `data` object gets inherited by child states ([see docs here](https://github.com/angular-ui/ui-router/wiki/Nested-States-%26-Nested-Views#inherited-custom-data)). 208 | If you want to avoid this behaviour, don't use the `data` object to define your breadcrumb's `displayname-property`. Use another property on the `.state()` config object instead. 209 | 210 | ## Credits 211 | I used some ideas and approaches from the following sources: 212 | 213 | - [http://stackoverflow.com/a/22263990/772859](http://stackoverflow.com/a/22263990/772859) 214 | - [http://milestone.topics.it/2014/03/angularjs-ui-router-and-breadcrumbs.html](http://milestone.topics.it/2014/03/angularjs-ui-router-and-breadcrumbs.html) 215 | -------------------------------------------------------------------------------- /src/directives/uiBreadcrumbs/uiBreadcrumbs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * uiBreadcrumbs automatic breadcrumbs directive for AngularJS & Angular ui-router. 3 | * 4 | * https://github.com/michaelbromley/angularUtils/tree/master/src/directives/uiBreadcrumbs 5 | * 6 | * Copyright 2014 Michael Bromley 7 | */ 8 | 9 | 10 | (function() { 11 | 12 | /** 13 | * Config 14 | */ 15 | var moduleName = 'angularUtils.directives.uiBreadcrumbs'; 16 | var templateUrl = 'directives/uiBreadcrumbs/uiBreadcrumbs.tpl.html'; 17 | 18 | /** 19 | * Module 20 | */ 21 | var module; 22 | try { 23 | module = angular.module(moduleName); 24 | } catch(err) { 25 | // named module does not exist, so create one 26 | module = angular.module(moduleName, ['ui.router']); 27 | } 28 | 29 | module.directive('uiBreadcrumbs', ['$interpolate', '$state', function($interpolate, $state) { 30 | return { 31 | restrict: 'E', 32 | templateUrl: function(elem, attrs) { 33 | return attrs.templateUrl || templateUrl; 34 | }, 35 | scope: { 36 | displaynameProperty: '@', 37 | abstractProxyProperty: '@?' 38 | }, 39 | link: function(scope) { 40 | scope.breadcrumbs = []; 41 | if ($state.$current.name !== '') { 42 | updateBreadcrumbsArray(); 43 | } 44 | scope.$on('$stateChangeSuccess', function() { 45 | updateBreadcrumbsArray(); 46 | }); 47 | 48 | /** 49 | * Start with the current state and traverse up the path to build the 50 | * array of breadcrumbs that can be used in an ng-repeat in the template. 51 | */ 52 | function updateBreadcrumbsArray() { 53 | var workingState; 54 | var displayName; 55 | var breadcrumbs = []; 56 | var currentState = $state.$current; 57 | 58 | while(currentState && currentState.name !== '') { 59 | workingState = getWorkingState(currentState); 60 | if (workingState) { 61 | displayName = getDisplayName(workingState); 62 | 63 | if (displayName !== false && !stateAlreadyInBreadcrumbs(workingState, breadcrumbs)) { 64 | breadcrumbs.push({ 65 | displayName: displayName, 66 | route: workingState.name 67 | }); 68 | } 69 | } 70 | currentState = currentState.parent; 71 | } 72 | breadcrumbs.reverse(); 73 | scope.breadcrumbs = breadcrumbs; 74 | } 75 | 76 | /** 77 | * Get the state to put in the breadcrumbs array, taking into account that if the current state is abstract, 78 | * we need to either substitute it with the state named in the `scope.abstractProxyProperty` property, or 79 | * set it to `false` which means this breadcrumb level will be skipped entirely. 80 | * @param currentState 81 | * @returns {*} 82 | */ 83 | function getWorkingState(currentState) { 84 | var proxyStateName; 85 | var workingState = currentState; 86 | if (currentState.abstract === true) { 87 | if (typeof scope.abstractProxyProperty !== 'undefined') { 88 | proxyStateName = getObjectValue(scope.abstractProxyProperty, currentState); 89 | if (proxyStateName) { 90 | workingState = angular.copy($state.get(proxyStateName)); 91 | if (workingState) { 92 | workingState.locals = currentState.locals; 93 | } 94 | } else { 95 | workingState = false; 96 | } 97 | } else { 98 | workingState = false; 99 | } 100 | } 101 | return workingState; 102 | } 103 | 104 | /** 105 | * Resolve the displayName of the specified state. Take the property specified by the `displayname-property` 106 | * attribute and look up the corresponding property on the state's config object. The specified string can be interpolated against any resolved 107 | * properties on the state config object, by using the usual {{ }} syntax. 108 | * @param currentState 109 | * @returns {*} 110 | */ 111 | function getDisplayName(currentState) { 112 | var interpolationContext; 113 | var propertyReference; 114 | var displayName; 115 | 116 | if (!scope.displaynameProperty) { 117 | // if the displayname-property attribute was not specified, default to the state's name 118 | return currentState.name; 119 | } 120 | propertyReference = getObjectValue(scope.displaynameProperty, currentState); 121 | 122 | if (propertyReference === false) { 123 | return false; 124 | } else if (typeof propertyReference === 'undefined') { 125 | return currentState.name; 126 | } else { 127 | // use the $interpolate service to handle any bindings in the propertyReference string. 128 | interpolationContext = (typeof currentState.locals !== 'undefined') ? currentState.locals.globals : currentState; 129 | displayName = $interpolate(propertyReference)(interpolationContext); 130 | return displayName; 131 | } 132 | } 133 | 134 | /** 135 | * Given a string of the type 'object.property.property', traverse the given context (eg the current $state object) and return the 136 | * value found at that path. 137 | * 138 | * @param objectPath 139 | * @param context 140 | * @returns {*} 141 | */ 142 | function getObjectValue(objectPath, context) { 143 | var i; 144 | var propertyArray = objectPath.split('.'); 145 | var propertyReference = context; 146 | 147 | for (i = 0; i < propertyArray.length; i ++) { 148 | if (angular.isDefined(propertyReference[propertyArray[i]])) { 149 | propertyReference = propertyReference[propertyArray[i]]; 150 | } else { 151 | // if the specified property was not found, default to the state's name 152 | return undefined; 153 | } 154 | } 155 | return propertyReference; 156 | } 157 | 158 | /** 159 | * Check whether the current `state` has already appeared in the current breadcrumbs array. This check is necessary 160 | * when using abstract states that might specify a proxy that is already there in the breadcrumbs. 161 | * @param state 162 | * @param breadcrumbs 163 | * @returns {boolean} 164 | */ 165 | function stateAlreadyInBreadcrumbs(state, breadcrumbs) { 166 | var i; 167 | var alreadyUsed = false; 168 | for(i = 0; i < breadcrumbs.length; i++) { 169 | if (breadcrumbs[i].route === state.name) { 170 | alreadyUsed = true; 171 | } 172 | } 173 | return alreadyUsed; 174 | } 175 | } 176 | }; 177 | }]); 178 | })(); 179 | -------------------------------------------------------------------------------- /src/directives/uiBreadcrumbs/uiBreadcrumbs.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Michael on 02/04/14. 3 | */ 4 | 5 | describe('uiBreadcrumbs directive', function() { 6 | 7 | var $compile; 8 | var $scope; 9 | var element; 10 | var $state; 11 | 12 | beforeEach(module('angularUtils.directives.uiBreadcrumbs')); 13 | beforeEach(module('templates-main')); 14 | 15 | beforeEach(function() { 16 | var mockModule = angular.module('mockModule', []) 17 | .config(function($stateProvider) { 18 | $stateProvider 19 | .state('root', { 20 | url: '/', 21 | data: { 22 | displayName: 'Home' 23 | }, 24 | custom: { 25 | alternateDisplayName: 'Other Home' 26 | } 27 | }) 28 | .state('root.section', { 29 | url: 'section/', 30 | data: { 31 | displayName: 'Section 1' 32 | } 33 | }) 34 | .state('root.section.subsection', { 35 | url: 'subsection/', 36 | data: { 37 | displayName: 'Subsection' 38 | } 39 | }) 40 | .state('root.section.subsection.nobreadcrumb', { 41 | url: 'subsection/bit', 42 | data: { 43 | displayName: false 44 | } 45 | }) 46 | .state('async1', { 47 | url: 'aync1/', 48 | data: { 49 | displayName: 'Async Route 1' 50 | }, 51 | resolve:{ 52 | simpleProperty: function() { 53 | return 'hello'; 54 | } 55 | } 56 | }) 57 | .state('async2', { 58 | url: 'async2/', 59 | data: { 60 | displayName: '{{ resolvedName | uppercase }}' 61 | }, 62 | resolve:{ 63 | resolvedName: function(){ 64 | return "A Good Route"; 65 | } 66 | } 67 | }) 68 | .state( 'root.abstract', { 69 | abstract: true, 70 | url: 'abstract/', 71 | data: { 72 | breadcrumbProxy: 'abstract.child' 73 | } 74 | }) 75 | .state( 'root.abstract.child', { 76 | url: 'list/', 77 | data: { 78 | displayName: 'Concrete' 79 | } 80 | }) 81 | .state( 'root.things', { 82 | abstract: true, 83 | url: 'things/', 84 | data: { 85 | breadcrumbProxy: 'root.things.list' 86 | } 87 | }) 88 | .state( 'root.things.list', { 89 | url: 'all/', 90 | data: { 91 | displayName: 'Things' 92 | } 93 | }) 94 | .state( 'root.things.detail', { 95 | url: 'detail/', 96 | data: { 97 | displayName: 'A Thing' 98 | } 99 | }) 100 | .state( 'root.project', { 101 | abstract: true, 102 | url: 'abstract2/', 103 | data: { 104 | breadcrumbProxy: 'root.project.dashboard' 105 | }, 106 | resolve: { 107 | resolvedName: function(){ 108 | return "Project"; 109 | } 110 | } 111 | }) 112 | .state( 'root.project.dashboard', { 113 | url: 'dashboard/', 114 | data: { 115 | displayName: '{{ resolvedName }} Dashboard' 116 | } 117 | }) 118 | .state( 'root.project.tasks', { 119 | url: 'list/', 120 | data: { 121 | displayName: '{{ resolvedName }} Tasks' 122 | } 123 | }); 124 | }); 125 | module('mockModule'); 126 | // Kickstart the injectors previously registered with calls to angular.mock.module 127 | inject(function () {}); 128 | }); 129 | 130 | beforeEach(inject(function($rootScope, _$compile_, _$state_) { 131 | $compile = _$compile_; 132 | $scope = $rootScope.$new(); 133 | $state = _$state_; 134 | 135 | element = $compile('')($scope); 136 | $scope.$apply(); 137 | 138 | $state.go('root.section.subsection'); 139 | $scope.$apply(); 140 | })); 141 | 142 | it('should display the breadcrumbs
      element', function() { 143 | expect(element.children()[0].tagName).toBe('OL'); 144 | }); 145 | 146 | it('should display the correct number of list items', function() { 147 | expect(element[0].querySelectorAll('li').length).toBe(3); 148 | }); 149 | 150 | it('should display the displayName if it exists', function() { 151 | expect(element[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 152 | expect(element[0].querySelectorAll('li')[1].innerHTML).toContain('Section 1'); 153 | expect(element[0].querySelectorAll('li')[2].innerHTML).toContain('Subsection'); 154 | }); 155 | 156 | it('should insert the correct route in a ui-sref attribute', function() { 157 | expect(element[0].querySelectorAll('li')[1].innerHTML).toContain('ui-sref="root.section"'); 158 | }); 159 | 160 | it('should apply the "active" class to the current state breadcrumb', function() { 161 | expect(angular.element(element[0].querySelectorAll('li')[2]).hasClass("active")).toBe(true); 162 | }); 163 | 164 | it('should not add a link to current state breadcrumb', function() { 165 | var activeLi = angular.element(element[0].querySelectorAll('li')[2]); 166 | expect(activeLi.html()).not.toContain('href'); 167 | }); 168 | 169 | it('should update on route change', function() { 170 | $state.go('root'); 171 | $scope.$apply(); 172 | expect(element[0].querySelectorAll('li').length).toBe(1); 173 | }); 174 | 175 | it('should not make a breadcrumb if displayName is set to false', function() { 176 | $state.go('root.section.subsection.nobreadcrumb'); 177 | $scope.$apply(); 178 | 179 | expect(element[0].querySelectorAll('li').length).toBe(3); 180 | expect(angular.element(element[0].querySelectorAll('li')[2]).hasClass("active")).toBe(true); 181 | }); 182 | 183 | it('should show correct displayName for alternative directive attribute', function() { 184 | var element2 = $compile('')($scope); 185 | $scope.$apply(); 186 | 187 | $state.go('root'); 188 | $scope.$apply(); 189 | 190 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('Other Home'); 191 | }); 192 | 193 | it('should work when the route has a async resolve defined on it', function() { 194 | var element2 = $compile('')($scope); 195 | $scope.$apply(); 196 | 197 | $state.go('async1'); 198 | $scope.$apply(); 199 | 200 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('Async Route 1'); 201 | }); 202 | 203 | it('should interpolate the string against the resolved properties on the config object', function() { 204 | var element2 = $compile('')($scope); 205 | $scope.$apply(); 206 | 207 | $state.go('async2'); 208 | $scope.$apply(); 209 | 210 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('A GOOD ROUTE'); 211 | }); 212 | 213 | it('should not display an abstract state in the breadcrumbs', function() { 214 | $state.go('root.abstract.child'); 215 | $scope.$apply(); 216 | 217 | expect(element[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 218 | expect(element[0].querySelectorAll('li')[1].innerHTML).toContain('Concrete'); 219 | expect(element[0].querySelectorAll('li')[2]).not.toBeDefined(); 220 | expect(element[0].querySelectorAll('li').length).toBe(2); 221 | }); 222 | 223 | it('should not display an abstract state in the breadcrumbs even if proxy attribute is set', function() { 224 | var element2 = $compile('')($scope); 225 | $state.go('root.abstract.child'); 226 | $scope.$apply(); 227 | 228 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 229 | expect(element2[0].querySelectorAll('li')[1].innerHTML).toContain('Concrete'); 230 | expect(element2[0].querySelectorAll('li')[2]).not.toBeDefined(); 231 | expect(element2[0].querySelectorAll('li').length).toBe(2); 232 | }); 233 | 234 | it('should not display an abstract state if no proxy has been set', function() { 235 | $state.go('root.things.detail'); 236 | $scope.$apply(); 237 | 238 | expect(element[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 239 | expect(element[0].querySelectorAll('li')[1].innerHTML).toContain('A Thing'); 240 | }); 241 | 242 | it('should substitute an abstract state with a proxy if one has been set', function() { 243 | var element2 = $compile('')($scope); 244 | $state.go('root.things.detail'); 245 | $scope.$apply(); 246 | 247 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 248 | expect(element2[0].querySelectorAll('li')[1].innerHTML).toContain('Things'); 249 | expect(element2[0].querySelectorAll('li')[2].innerHTML).toContain('A Thing'); 250 | }); 251 | 252 | it('should not display the abstract proxy if it has already appeared in the breadcrumbs', function() { 253 | var element2 = $compile('')($scope); 254 | $state.go('root.things.list'); 255 | $scope.$apply(); 256 | 257 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 258 | expect(element2[0].querySelectorAll('li')[1].innerHTML).toContain('Things'); 259 | expect(element2[0].querySelectorAll('li')[2]).not.toBeDefined(); 260 | expect(element2[0].querySelectorAll('li').length).toBe(2); 261 | }); 262 | 263 | it('should use resolved variables for abstract state proxy', function() { 264 | var element2 = $compile('')($scope); 265 | $state.go('root.project.tasks'); 266 | $scope.$apply(); 267 | 268 | expect(element2[0].querySelectorAll('li')[0].innerHTML).toContain('Home'); 269 | expect(element2[0].querySelectorAll('li')[1].innerHTML).toContain('Project Dashboard'); 270 | expect(element2[0].querySelectorAll('li')[2].innerHTML).toContain('Project Tasks'); 271 | expect(element2[0].querySelectorAll('li')[3]).not.toBeDefined(); 272 | expect(element2[0].querySelectorAll('li').length).toBe(3); 273 | }); 274 | 275 | }); 276 | -------------------------------------------------------------------------------- /src/directives/uiBreadcrumbs/uiBreadcrumbs.tpl.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/filters/ordinalDate/README.md: -------------------------------------------------------------------------------- 1 | # Ordinal Date Filter 2 | 3 | This is a wrapper filter around the built-in [Angular date filter](http://docs.angularjs.org/api/ng.filter:date), 4 | which adds the facility to display the English ordinal date suffix to the 'day' part of the format string. 5 | 6 | ## Example 7 | 8 | Given the timestamp 1384474920000 (15th November 2013, 00:22:00), and the date format string 'd MMMM yyyy', 9 | the regular Angular date filter will return '15 November 2013'. This filter will return '15th November 2013'. Simple. 10 | 11 | ## Usage 12 | 13 | Include the filter definition somewhere in your Angular app and then use it like any filter: 14 | 15 | {{ 1384474920000 | ordinalDate }} 16 | 17 | -------------------------------------------------------------------------------- /src/filters/ordinalDate/ordinalDate.js: -------------------------------------------------------------------------------- 1 | angular.module( 'angularUtils.filters.ordinalDate', [] ) 2 | 3 | .filter('ordinalDate', ['$filter', function($filter) { 4 | 5 | var getOrdinalSuffix = function(number) { 6 | var suffixes = ["'th'", "'st'", "'nd'", "'rd'"]; 7 | var relevantDigits = (number < 30) ? number % 20 : number % 30; 8 | return "d" + ((relevantDigits <= 3) ? suffixes[relevantDigits] : suffixes[0]); 9 | }; 10 | 11 | return function(timestamp, format) { 12 | var regex = /d+((?!\w*(?=')))|d$/g; 13 | var date = new Date(timestamp); 14 | var dayOfMonth = date.getDate(); 15 | var suffix = getOrdinalSuffix(dayOfMonth); 16 | 17 | format = format.replace(regex, function (match) { 18 | return match === "d" ? suffix : match; 19 | }); 20 | return $filter('date')(date, format); 21 | }; 22 | }]); -------------------------------------------------------------------------------- /src/filters/ordinalDate/ordinalDate.spec.js: -------------------------------------------------------------------------------- 1 | describe('ordinalDate', function() { 2 | var ordinalDateFilter; 3 | var timestamp1 = 1384474920000; // 15th November 2013, 00:22:00 4 | 5 | beforeEach(module('angularUtils.filters.ordinalDate')); 6 | beforeEach (function(){ 7 | inject(function ($injector) { 8 | ordinalDateFilter = $injector.get('ordinalDateFilter'); 9 | }); 10 | }); 11 | 12 | it('should add an ordinal suffix to days of month', function() { 13 | expect(ordinalDateFilter(timestamp1, 'd')).toEqual('15th'); 14 | expect(ordinalDateFilter(timestamp1, "EEEE 'the' d of MMMM")).toEqual('Friday the 15th of November'); 15 | expect(ordinalDateFilter(timestamp1, "d d")).toEqual('15th 15th'); 16 | }); 17 | it('should add the correct suffix for all possible variations', function() { 18 | expect(ordinalDateFilter(1383265320000, 'd')).toEqual('1st'); 19 | expect(ordinalDateFilter(1383351720000, 'd')).toEqual('2nd'); 20 | expect(ordinalDateFilter(1383438120000, 'd')).toEqual('3rd'); 21 | expect(ordinalDateFilter(1383524520000, 'd')).toEqual('4th'); 22 | expect(ordinalDateFilter(1384042920000, 'd')).toEqual('10th'); 23 | expect(ordinalDateFilter(1384129320000, 'd')).toEqual('11th'); 24 | expect(ordinalDateFilter(1384215720000, 'd')).toEqual('12th'); 25 | expect(ordinalDateFilter(1384302120000, 'd')).toEqual('13th'); 26 | expect(ordinalDateFilter(1384906920000, 'd')).toEqual('20th'); 27 | expect(ordinalDateFilter(1384993320000, 'd')).toEqual('21st'); 28 | expect(ordinalDateFilter(1385079720000, 'd')).toEqual('22nd'); 29 | expect(ordinalDateFilter(1385079720000, 'd')).toEqual('22nd'); 30 | expect(ordinalDateFilter(1385166120000, 'd')).toEqual('23rd'); 31 | expect(ordinalDateFilter(1385252520000, 'd')).toEqual('24th'); 32 | expect(ordinalDateFilter(1385770920000, 'd')).toEqual('30th'); 33 | expect(ordinalDateFilter(1383178920000, 'd')).toEqual('31st'); 34 | 35 | }); 36 | it('should leave the \'dd\' format day alone', function() { 37 | expect(ordinalDateFilter(timestamp1, "dd/MM/yyyy")).toEqual('15/11/2013'); 38 | expect(ordinalDateFilter(timestamp1, "yyyy-MM-dd")).toEqual('2013-11-15'); 39 | expect(ordinalDateFilter(timestamp1, "'the' d 'day' of MMMM")).toEqual('the 15th day of November'); 40 | }); 41 | }); -------------------------------------------------------------------------------- /src/filters/startsWith/README.md: -------------------------------------------------------------------------------- 1 | # startsWith Filter 2 | 3 | Dead simple - whereas the standard AngularJS `filter` filter will return a match if the search string appears anywhere within the test string, this filter only returns a match if the test string *starts with* the search string. 4 | 5 | For example: 6 | 7 | ```JavaScript 8 | // in your controller 9 | $scope.things = ['cathode', 'house', 'chomp']; 10 | $scope.search = "ho"; 11 | ``` 12 | ```html 13 | // in your template 14 |
    1. {{thing}}
    2. // cathode, house, chomp 15 | 16 |
    3. {{thing}}
    4. // house 17 | ``` -------------------------------------------------------------------------------- /src/filters/startsWith/startsWith.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Michael on 27/03/14. 3 | */ 4 | angular.module('angularUtils.filters.startsWith', []) 5 | 6 | .filter('startsWith', function() { 7 | return function(array, search) { 8 | var matches = []; 9 | for(var i = 0; i < array.length; i++) { 10 | if (array[i].indexOf(search) === 0 && 11 | search.length < array[i].length) { 12 | matches.push(array[i]); 13 | } 14 | } 15 | return matches; 16 | }; 17 | }); -------------------------------------------------------------------------------- /src/filters/startsWith/startsWith.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Michael on 27/03/14. 3 | */ 4 | describe('startsWith filter', function() { 5 | var startsWithFilter; 6 | var testArray; 7 | 8 | beforeEach(module('angularUtils.filters.startsWith')); 9 | beforeEach(inject(function(_$filter_) { 10 | testArray = [ 11 | 'cake', 12 | 'hammer', 13 | 'cup', 14 | 'earth', 15 | 'apple', 16 | 'tap' 17 | ]; 18 | startsWithFilter = _$filter_('startsWith'); 19 | })); 20 | 21 | it('should should return just the items starting with the search string', function() { 22 | expect(startsWithFilter(testArray, 'c')).toEqual(['cake', 'cup']); 23 | }); 24 | 25 | it('should not return items if the search appears mid way through the string', function() { 26 | expect(startsWithFilter(testArray, 'a')).toEqual(['apple']); 27 | }); 28 | 29 | }); -------------------------------------------------------------------------------- /src/services/noise/README.md: -------------------------------------------------------------------------------- 1 | # 1D Noise Generator 2 | 3 | This is a simple implementation of a 1-dimensional interpolated noise generator. I adapted the example in [this excellent article](http://www.scratchapixel.com/lessons/3d-advanced-lessons/noise-part-1/creating-a-simple-1d-noise/) 4 | (which is well worth studying for a easy-to-follow explanation of the concept from basic principles). 5 | 6 | Noise functions are very useful when modelling unpredictability in nature - undulating terrain, movement of objects and (in more complex implementations) natural textures. A 1D noise function, such as this one, takes a numeric input and returns a corresponding value between 0 and 1. Think of it as similar to 7 | the random numbers generated by the JavaScript `Math.random()` function, but smoothly transitioning from one value to the next (this is "interpolation"), rather than just jumping all over the place. That, in essence, is exactly 8 | what this class does. 9 | 10 | The service is simply a wrapper around a JavaScript object which allows it to be injected into an Angular app. It can be easily used in any non-Angular app just by using the object itself, and pulling it out of the 11 | Angular `.factory()` wrapper. 12 | 13 | ## Demo 14 | 15 | [http://plnkr.co/edit/u4uIjX5V698tI5CkBNPc?p=preview](http://plnkr.co/edit/u4uIjX5V698tI5CkBNPc?p=preview) 16 | 17 | ## Usage 18 | 19 | In your Angular app, include the service by usual Angular dependency injection, then you can use it like this: 20 | 21 | ```JavaScript 22 | var generator = noise.newGenerator(); 23 | 24 | var x = 1; // can be any number 25 | var y = generator.getVal(x); // number between 0 and 1, e.g. 0.231144 26 | ``` 27 | 28 | You can generate a continuously varying sequence like this: 29 | 30 | ```JavaScript 31 | var generator = noise.newGenerator(); 32 | for (var i = 0; i < 200; i ++) { 33 | console.log(generator.getVal(i); 34 | } 35 | ``` 36 | 37 | ## Options 38 | 39 | You can optionally set two properties via these methods: 40 | 41 | - `setAmplitude(number)` : Alter the range between which the generated values will fall, e.g. setting it to 2 will generate values between 0 and 2. Default is 1. 42 | - `setScale(number)` : Change the frequency of the function. A larger number "squashes" the values closer together, and vice-versa. When using the generator for animations (as in the second demo), this parameter can be thought of a "frequency". Default is 1. 43 | 44 | -------------------------------------------------------------------------------- /src/services/noise/noise.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Michael on 12/03/14. 3 | */ 4 | 5 | angular.module('angularUtils.services.noise', []) 6 | /** 7 | * Service to generate 1-dimensional Perlin noise. Based on the excellent article at Scratchapixel: 8 | * http://www.scratchapixel.com/lessons/3d-advanced-lessons/noise-part-1/creating-a-simple-1d-noise/ 9 | * 10 | */ 11 | .factory('noise', function() { 12 | 13 | var Simple1DNoise = function() { 14 | var MAX_VERTICES = 256; 15 | var MAX_VERTICES_MASK = MAX_VERTICES -1; 16 | var amplitude = 1; 17 | var scale = 1; 18 | 19 | var r = []; 20 | 21 | for ( var i = 0; i < MAX_VERTICES; ++i ) { 22 | r.push(Math.random()); 23 | } 24 | 25 | var getVal = function( x ){ 26 | var scaledX = x * scale; 27 | var xFloor = Math.floor(scaledX); 28 | var t = scaledX - xFloor; 29 | var tRemapSmoothstep = t * t * ( 3 - 2 * t ); 30 | 31 | /// Modulo using & 32 | var xMin = xFloor & MAX_VERTICES_MASK; 33 | var xMax = ( xMin + 1 ) & MAX_VERTICES_MASK; 34 | 35 | var y = lerp( r[ xMin ], r[ xMax ], tRemapSmoothstep ); 36 | 37 | return y * amplitude; 38 | }; 39 | 40 | /** 41 | * Linear interpolation function. 42 | * @param a The lower integer value 43 | * @param b The upper integer value 44 | * @param t The value between the two 45 | * @returns {number} 46 | */ 47 | var lerp = function(a, b, t ) { 48 | return a * ( 1 - t ) + b * t; 49 | }; 50 | 51 | // return the API 52 | return { 53 | getVal: getVal, 54 | setAmplitude: function(newAmplitude) { 55 | amplitude = newAmplitude; 56 | }, 57 | setScale: function(newScale) { 58 | scale = newScale; 59 | } 60 | }; 61 | }; 62 | 63 | return { 64 | newGenerator: function() { 65 | return new Simple1DNoise(); 66 | } 67 | }; 68 | }); -------------------------------------------------------------------------------- /src/services/noise/noise.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Michael on 13/03/14. 3 | */ 4 | 5 | describe('Perlin noise service', function() { 6 | var generator; 7 | var generateLotsOfNoise; 8 | 9 | beforeEach(module('angularUtils.services.noise')); 10 | 11 | beforeEach(inject(function(_noise_) { 12 | generator = _noise_.newGenerator(); 13 | 14 | // generate 10,000 values as a large enough sample to check they fall within the expected bounds. 15 | generateLotsOfNoise = function(generator) { 16 | var noiseValues = []; 17 | for (var i = 0; i < 10000; i ++ ) { 18 | noiseValues.push(generator.getVal(i)); 19 | } 20 | return noiseValues; 21 | }; 22 | 23 | jasmine.addMatchers({ 24 | /** 25 | * Matcher to check that all values in the array fall within the specified range (inclusive of the bounds) 26 | * @returns {{compare: compare}} 27 | */ 28 | toAllBeWithinRange: function() { 29 | return { 30 | compare: function(valuesArray, lowerBound, upperBound) { 31 | var pass = true; 32 | var failingValue = 0; 33 | for (var i = 0; i < valuesArray.length; i ++) { 34 | if (valuesArray[i] < lowerBound || upperBound < valuesArray[i]) { 35 | pass = false; 36 | failingValue = valuesArray[i]; 37 | break; 38 | } 39 | } 40 | 41 | var result = { 42 | pass: pass 43 | }; 44 | if (!result.pass) { 45 | result.message = failingValue + ' is not between ' + lowerBound + ' and ' + upperBound; 46 | } 47 | return result; 48 | } 49 | }; 50 | }, 51 | /** 52 | * Matcher to check if at least one of the values in the array lies in the specified range 53 | * @returns {{compare: compare}} 54 | */ 55 | toIncludeRange: function() { 56 | return { 57 | compare: function(valuesArray, lowerBound, upperBound) { 58 | var pass = false; 59 | 60 | for (var i = 0; i < valuesArray.length; i ++) { 61 | if (valuesArray[i] > lowerBound && upperBound > valuesArray[i]) { 62 | pass = true; 63 | break; 64 | } 65 | } 66 | 67 | var result = { 68 | pass: pass 69 | }; 70 | if (!result.pass) { 71 | result.message = 'Array has no values between ' + lowerBound + ' and ' + upperBound; 72 | } 73 | return result; 74 | } 75 | }; 76 | } 77 | }); 78 | })); 79 | 80 | it('should generate values between 0 and 1 on default settings', function() { 81 | var noiseValues = generateLotsOfNoise(generator); 82 | expect(noiseValues).toAllBeWithinRange(0, 1); 83 | }); 84 | 85 | it('should amplify correctly', function() { 86 | generator.setAmplitude(2); 87 | var noiseValues = generateLotsOfNoise(generator); 88 | 89 | expect(noiseValues).toAllBeWithinRange(0, 2); 90 | expect(noiseValues).toIncludeRange(1, 2); 91 | }); 92 | }); --------------------------------------------------------------------------------