├── .bowerrc ├── .editorconfig ├── .gitignore ├── .jshintrc ├── .yo-rc.json ├── LICENSE ├── README.md ├── bower.json ├── demo └── app.js ├── dist ├── v-accordion.css ├── v-accordion.js ├── v-accordion.min.css └── v-accordion.min.js ├── gulpfile.js ├── index.html ├── index.js ├── karma.conf.js ├── package.json ├── src └── vAccordion │ ├── directives │ ├── vAccordion.js │ ├── vPane.js │ ├── vPaneContent.js │ └── vPaneHeader.js │ ├── styles │ ├── _base.scss │ ├── _settings.scss │ ├── _theme.scss │ └── vAccordion.scss │ ├── vAccordion.js │ ├── vAccordion.prefix │ └── vAccordion.suffix └── test └── unit └── vAccordion ├── controllers ├── vAccordionController.spec.js └── vPaneController.spec.js ├── directives ├── vAccordion.spec.js ├── vPane.spec.js ├── vPaneContent.spec.js └── vPaneHeader.spec.js └── vAccordion.spec.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower" 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ._* 2 | .~lock.* 3 | .buildpath 4 | .DS_Store 5 | .idea 6 | .project 7 | .settings 8 | 9 | # Ignore node stuff 10 | node_modules/ 11 | npm-debug.log 12 | libpeerconnection.log 13 | 14 | # OS-specific 15 | .DS_Store 16 | 17 | # Bower components 18 | bower 19 | 20 | # Sublime 21 | *.sublime-* 22 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "bitwise": true, 5 | "camelcase": true, 6 | "curly": true, 7 | "eqeqeq": true, 8 | "immed": true, 9 | "indent": 2, 10 | "latedef": false, 11 | "newcap": true, 12 | "noarg": true, 13 | "quotmark": "single", 14 | "regexp": true, 15 | "undef": true, 16 | "unused": true, 17 | "strict": false, 18 | "trailing": true, 19 | "smarttabs": true, 20 | "white": true, 21 | "validthis": true, 22 | 23 | "jasmine": true, 24 | 25 | "globals": { 26 | "angular": false 27 | }, 28 | 29 | "predef": [ 30 | "inject" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-angularjs-library": { 3 | "props": { 4 | "author": { 5 | "name": "Łukasz Wątroba", 6 | "email": "l@lukaszwatroba.com" 7 | }, 8 | "libraryName": { 9 | "original": "v-accordion", 10 | "camelized": "vAccordion", 11 | "dasherized": "v-accordion", 12 | "slugified": "v-accordion", 13 | "parts": [ 14 | "v", 15 | "accordion" 16 | ] 17 | }, 18 | "includeModuleDirectives": false, 19 | "includeModuleFilters": false, 20 | "includeModuleServices": false, 21 | "includeAngularModuleResource": false, 22 | "includeAngularModuleCookies": false, 23 | "includeAngularModuleSanitize": false, 24 | "librarySrcDirectory": "src/vAccordion", 25 | "libraryUnitTestDirectory": "test/unit/vAccordion", 26 | "libraryUnitE2eDirectory": "test/e2e/vAccordion" 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014 Łukasz Wątroba 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # AngularJS multi-level accordion 3 | 4 | - Allows for a nested structure 5 | - Works with (or without) `ng-repeat` 6 | - Allows multiple sections to be open at once 7 | 8 | 9 | ## Examples 10 | 11 | - [GitHub](http://lukaszwatroba.github.io/v-accordion) 12 | - [CodePen](http://codepen.io/LukaszWatroba/pen/MwdaLo) 13 | - [Linksfridge](https://linksfridge.com/help) 14 | 15 | 16 | ## Usage 17 | 18 | - If you use [bower](http://bower.io/) or [npm](https://www.npmjs.com/), just `bower/npm install v-accordion`. If not, download files [from the github repo](./dist). 19 | 20 | - Include `angular.js`, `angular-animate.js`, `v-accordion.js`, and `v-accordion.css`: 21 | ```html 22 | 23 | 24 | 25 | 26 | 27 | 28 | ``` 29 | 30 | - Add `vAccordion` and `ngAnimate` as dependencies to your application module: 31 | ```js 32 | angular.module('myApp', ['vAccordion', 'ngAnimate']); 33 | ``` 34 | 35 | - Put the following markup in your template: 36 | ```html 37 | 38 | 39 | 40 | 41 | 42 | 43 | Pane header #1 44 | 45 | 46 | 47 | Pane content #1 48 | 49 | 50 | 51 | 52 | 53 | Pane header #2 54 | 55 | 56 | 57 | Pane content #2 58 | 59 | 60 | 61 | 62 | ``` 63 | 64 | - You can also use `v-accordion` with `ng-repeat`: 65 | ```html 66 | 67 | 68 | 69 | 70 | {{ ::pane.header }} 71 | 72 | 73 | 74 | {{ ::pane.content }} 75 | 76 | 77 | 78 | 79 | 80 | {{ ::subpane.header }} 81 | 82 | 83 | {{ ::subpane.content }} 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | ``` 92 | 93 | 94 | ## API 95 | 96 | #### Control 97 | 98 | Add `control` attribute and use the following methods to control vAccordion from it's parent scope: 99 | 100 | - `toggle(indexOrId)` 101 | - `expand(indexOrId)` 102 | - `collapse(indexOrId)` 103 | - `expandAll()` 104 | - `collapseAll()` 105 | - `hasExpandedPane()` 106 | 107 | ```html 108 | 109 | 110 | 111 | 112 | {{ ::pane.header }} 113 | 114 | 115 | 116 | {{ ::pane.content }} 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ``` 126 | 127 | ```js 128 | $scope.$on('my-accordion:onReady', function () { 129 | var firstPane = $scope.panes[0]; 130 | $scope.accordion.toggle(firstPane.id); 131 | }); 132 | ``` 133 | 134 | #### Scope 135 | 136 | `$accordion` and `$pane` properties allows you to control the directive from it's transcluded scope. 137 | 138 | ##### $accordion 139 | 140 | - `toggle(indexOrId)` 141 | - `expand(indexOrId)` 142 | - `collapse(indexOrId)` 143 | - `expandAll()` 144 | - `collapseAll()` 145 | - `hasExpandedPane()` 146 | - `id` 147 | 148 | ##### $pane 149 | 150 | - `toggle()` 151 | - `expand()` 152 | - `collapse()` 153 | - `isExpanded()` 154 | - `id` 155 | 156 | ```html 157 | 158 | 159 | 160 | 161 | 162 | {{ ::pane.header }} 163 | 164 | 165 | 166 | 167 | {{ ::pane.content }} 168 | 169 | 170 | 171 | 172 | 173 | 174 | ``` 175 | 176 | 177 | #### Events 178 | 179 | The directive emits the following events: 180 | 181 | - `vAccordion:onReady` or `yourAccordionId:onReady` 182 | - `vAccordion:onExpand` or `yourAccordionId:onExpand` 183 | - `vAccordion:onExpandAnimationEnd` or `yourAccordionId:onExpandAnimationEnd` 184 | - `vAccordion:onCollapse` or `yourAccordionId:onCollapse` 185 | - `vAccordion:onCollapseAnimationEnd` or `yourAccordionId:onCollapseAnimationEnd` 186 | 187 | 188 | ## Callbacks 189 | 190 | Use these callbacks to get the expanded/collapsed pane index and id: 191 | 192 | ```html 193 | 194 | 195 | 196 | 197 | {{ ::pane.header }} 198 | 199 | 200 | 201 | {{ ::pane.content }} 202 | 203 | 204 | 205 | 206 | ``` 207 | 208 | ```js 209 | $scope.expandCallback = function (index, id) { 210 | console.log('expanded pane:', index, id); 211 | }; 212 | 213 | $scope.collapseCallback = function (index, id) { 214 | console.log('collapsed pane:', index, id); 215 | }; 216 | ``` 217 | 218 | 219 | ## Configuration 220 | 221 | #### Module 222 | To change the default animation duration, inject `accordionConfig` provider in your app config: 223 | 224 | ```javascript 225 | angular 226 | .module('myApp', ['vAccordion']) 227 | .config(function (accordionConfig) { 228 | accordionConfig.expandAnimationDuration = 0.5; 229 | }); 230 | ``` 231 | 232 | #### SCSS 233 | If you are using SASS, you can import vAccordion.scss file and override the following variables: 234 | 235 | ```scss 236 | $v-accordion-default-theme: true !default; 237 | 238 | $v-accordion-spacing: 20px !default; 239 | 240 | $v-pane-border-color: #D8D8D8 !default; 241 | $v-pane-expanded-border-color: #2196F3 !default; 242 | $v-pane-icon-color: #2196F3 !default; 243 | $v-pane-hover-color: #2196F3 !default; 244 | 245 | $v-pane-disabled-opacity: 0.6 !default; 246 | 247 | $v-pane-expand-animation-duration: 0.5s !default; 248 | $v-pane-hover-animation-duration: 0.25s !default; 249 | ``` 250 | 251 | 252 | ## Accessibility 253 | vAccordion manages keyboard focus and adds some common aria-* attributes. BUT you should additionally place the `aria-controls` and `aria-labelledby` as follows: 254 | 255 | ```html 256 | 257 | 258 | 259 | 260 | {{ ::pane.header }} 261 | 262 | 263 | 264 | {{ ::pane.content }} 265 | 266 | 267 | 268 | 269 | ``` 270 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-accordion", 3 | "description": "vAccordion - AngularJS multi-level accordion component", 4 | "keywords": [ 5 | "AngularJS", 6 | "accordion", 7 | "multi-level", 8 | "component", 9 | "directive", 10 | "module" 11 | ], 12 | "authors": [ 13 | "Łukasz Wątroba " 14 | ], 15 | "main": [ 16 | "dist/v-accordion.js", 17 | "dist/v-accordion.css" 18 | ], 19 | "dependencies": { 20 | "angular": "^1.3.0", 21 | "angular-animate": "^1.3.0" 22 | }, 23 | "devDependencies": { 24 | "angular-mocks": "~1.4.0", 25 | "angular-scenario": "~1.4.0", 26 | "valitycss": "~0.3.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/app.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | angular 5 | .module('myApp', [ 'ngAnimate', 'vAccordion' ]) 6 | 7 | .controller('MainController', function ($scope) { 8 | 9 | $scope.panesA = [ 10 | { 11 | id: 'pane-1a', 12 | header: 'Pane 1', 13 | content: 'Curabitur et ligula. Ut molestie a, ultricies porta urna. Vestibulum commodo volutpat a, convallis ac, laoreet enim. Phasellus fermentum in, dolor. Pellentesque facilisis. Nulla imperdiet sit amet magna. Vestibulum dapibus, mauris nec malesuada fames ac turpis velit, rhoncus eu, luctus et interdum adipiscing wisi.', 14 | isExpanded: true 15 | }, 16 | { 17 | id: 'pane-2a', 18 | header: 'Pane 2', 19 | content: 'Lorem ipsum dolor sit amet enim. Etiam ullamcorper. Suspendisse a pellentesque dui, non felis. Maecenas malesuada elit lectus felis, malesuada ultricies.' 20 | }, 21 | { 22 | id: 'pane-3a', 23 | header: 'Pane 3', 24 | content: 'Aliquam erat ac ipsum. Integer aliquam purus. Quisque lorem tortor fringilla sed, vestibulum id, eleifend justo vel bibendum sapien massa ac turpis faucibus orci luctus non.', 25 | 26 | subpanes: [ 27 | { 28 | id: 'subpane-1a', 29 | header: 'Subpane 1', 30 | content: 'Quisque lorem tortor fringilla sed, vestibulum id, eleifend justo vel bibendum sapien massa ac turpis faucibus orci luctus non.' 31 | }, 32 | { 33 | id: 'subpane-2a', 34 | header: 'Subpane 2 (disabled)', 35 | content: 'Curabitur et ligula. Ut molestie a, ultricies porta urna. Quisque lorem tortor fringilla sed, vestibulum id.', 36 | isDisabled: true 37 | } 38 | ] 39 | } 40 | ]; 41 | 42 | $scope.panesB = [ 43 | { 44 | id: 'pane-1b', 45 | header: 'Pane 1', 46 | content: 'Curabitur et ligula. Ut molestie a, ultricies porta urna. Vestibulum commodo volutpat a, convallis ac, laoreet enim. Phasellus fermentum in, dolor. Pellentesque facilisis. Nulla imperdiet sit amet magna. Vestibulum dapibus, mauris nec malesuada fames ac turpis velit, rhoncus eu, luctus et interdum adipiscing wisi.', 47 | isExpanded: true 48 | }, 49 | { 50 | id: 'pane-2b', 51 | header: 'Pane 2', 52 | content: 'Lorem ipsum dolor sit amet enim. Etiam ullamcorper. Suspendisse a pellentesque dui, non felis. Maecenas malesuada elit lectus felis, malesuada ultricies.' 53 | }, 54 | { 55 | id: 'pane-3b', 56 | header: 'Pane 3', 57 | content: 'Aliquam erat ac ipsum. Integer aliquam purus. Quisque lorem tortor fringilla sed, vestibulum id, eleifend justo vel bibendum sapien massa ac turpis faucibus orci luctus non.', 58 | 59 | subpanes: [ 60 | { 61 | id: 'subpane-1b', 62 | header: 'Subpane 1', 63 | content: 'Quisque lorem tortor fringilla sed, vestibulum id, eleifend justo vel bibendum sapien massa ac turpis faucibus orci luctus non.' 64 | }, 65 | { 66 | id: 'subpane-2b', 67 | header: 'Subpane 2 (disabled)', 68 | content: 'Curabitur et ligula. Ut molestie a, ultricies porta urna. Quisque lorem tortor fringilla sed, vestibulum id.', 69 | isDisabled: true 70 | } 71 | ] 72 | } 73 | ]; 74 | 75 | $scope.expandCallback = function (index, id) { 76 | console.log('expand:', index, id); 77 | }; 78 | 79 | $scope.collapseCallback = function (index, id) { 80 | console.log('collapse:', index, id); 81 | }; 82 | 83 | $scope.$on('accordionA:onReady', function () { 84 | console.log('accordionA is ready!'); 85 | }); 86 | 87 | }); 88 | 89 | })(angular); 90 | -------------------------------------------------------------------------------- /dist/v-accordion.css: -------------------------------------------------------------------------------- 1 | /** 2 | * vAccordion - AngularJS multi-level accordion component 3 | * @version v1.6.0 4 | * @link http://lukaszwatroba.github.io/v-accordion 5 | * @author Łukasz Wątroba 6 | * @license MIT License, http://www.opensource.org/licenses/MIT 7 | */ 8 | 9 | /*************************************** 10 | vAccordion 11 | ***************************************/ 12 | /** 13 | * Example HTML: 14 | * 15 | 16 | 17 | 18 | [content] 19 | 20 | 21 | [content] 22 | 23 | 24 | 25 | */ 26 | /* Base styles 27 | ***************************************/ 28 | v-accordion { 29 | display: block; } 30 | 31 | v-pane { 32 | display: block; } 33 | v-pane.is-expanded > v-pane-content > div { 34 | display: visible; } 35 | v-pane[disabled] > v-pane-header { 36 | opacity: 0.6; 37 | pointer-events: none; } 38 | 39 | v-pane-header { 40 | display: block; 41 | position: relative; 42 | cursor: pointer; 43 | -webkit-user-select: none; 44 | -moz-user-select: none; 45 | -ms-user-select: none; 46 | user-select: none; 47 | outline: none; } 48 | v-pane-header:focus { 49 | outline: none; } 50 | v-pane-header > div { 51 | display: block; } 52 | 53 | v-pane-content { 54 | display: block; 55 | position: relative; 56 | overflow: hidden; 57 | max-height: 0px; } 58 | v-pane-content > div { 59 | visibility: none; } 60 | 61 | /* Theme: default 62 | ***************************************/ 63 | .vAccordion--default v-accordion { 64 | margin-top: 20px; 65 | padding-left: 20px; } 66 | 67 | .vAccordion--default v-pane-content > div { 68 | padding-bottom: 20px; 69 | opacity: 0; 70 | -webkit-transform: translate3d(0, 30px, 0); 71 | transform: translate3d(0, 30px, 0); 72 | -webkit-transition: all 0.5s; 73 | transition: all 0.5s; } 74 | 75 | .vAccordion--default v-pane { 76 | overflow: hidden; } 77 | .vAccordion--default v-pane.is-expanded > v-pane-header { 78 | border-bottom-color: #2196F3; } 79 | .vAccordion--default v-pane.is-expanded > v-pane-header::after { 80 | -webkit-transform: rotate(90deg); 81 | transform: rotate(90deg); 82 | opacity: 0; } 83 | .vAccordion--default v-pane.is-expanded > v-pane-header::before { 84 | -webkit-transform: rotate(0deg); 85 | transform: rotate(0deg); } 86 | .vAccordion--default v-pane.is-expanded > v-pane-content > div { 87 | opacity: 1; 88 | -webkit-transform: translate3d(0, 0, 0); 89 | transform: translate3d(0, 0, 0); } 90 | .vAccordion--default v-pane[disabled] v-pane-header::after, .vAccordion--default v-pane[disabled] v-pane-header::before { 91 | display: none; } 92 | 93 | .vAccordion--default v-pane-header { 94 | padding: 5px 0; 95 | margin-bottom: 20px; 96 | border-bottom: 2px solid #D8D8D8; 97 | -webkit-transition: all 0.25s; 98 | transition: all 0.25s; } 99 | .vAccordion--default v-pane-header::after, .vAccordion--default v-pane-header::before { 100 | content: ''; 101 | display: block; 102 | position: absolute; 103 | top: 50%; 104 | right: 0; 105 | width: 10px; 106 | height: 1px; 107 | background-color: #2196F3; 108 | -webkit-transform-origin: 50% 50%; 109 | transform-origin: 50% 50%; 110 | will-change: transform; 111 | -webkit-transition: all 0.25s; 112 | transition: all 0.25s; } 113 | .vAccordion--default v-pane-header::before { 114 | -webkit-transform: rotate(-90deg); 115 | transform: rotate(-90deg); } 116 | .vAccordion--default v-pane-header:hover, .vAccordion--default v-pane-header:focus { 117 | color: #2196F3; } 118 | -------------------------------------------------------------------------------- /dist/v-accordion.js: -------------------------------------------------------------------------------- 1 | /** 2 | * vAccordion - AngularJS multi-level accordion component 3 | * @version v1.6.0 4 | * @link http://lukaszwatroba.github.io/v-accordion 5 | * @author Łukasz Wątroba 6 | * @license MIT License, http://www.opensource.org/licenses/MIT 7 | */ 8 | 9 | (function (angular) { 10 | 'use strict'; 11 | 12 | // Config 13 | angular.module('vAccordion.config', []) 14 | .constant('accordionConfig', { 15 | states: { 16 | expanded: 'is-expanded' 17 | }, 18 | expandAnimationDuration: 0.5 19 | }) 20 | .animation('.is-expanded', [ '$animateCss', 'accordionConfig', function ($animateCss, accordionConfig) { 21 | return { 22 | addClass: function (element, className, done) { 23 | var paneContent = angular.element(element[0].querySelector('v-pane-content')), 24 | paneInner = angular.element(paneContent[0].querySelector('div')); 25 | 26 | var height = paneInner[0].offsetHeight; 27 | 28 | var expandAnimation = $animateCss(paneContent, { 29 | easing: 'ease', 30 | from: { maxHeight: '0px' }, 31 | to: { maxHeight: height + 'px' }, 32 | duration: accordionConfig.expandAnimationDuration 33 | }); 34 | 35 | expandAnimation.start().done(function () { 36 | paneContent.css('max-height', 'none'); 37 | done(); 38 | }); 39 | 40 | return function (isCancelled) { 41 | if (isCancelled) { 42 | paneContent.css('max-height', 'none'); 43 | } 44 | }; 45 | }, 46 | removeClass: function (element, className, done) { 47 | var paneContent = angular.element(element[0].querySelector('v-pane-content')), 48 | paneInner = angular.element(paneContent[0].querySelector('div')); 49 | 50 | var height = paneInner[0].offsetHeight; 51 | 52 | var collapseAnimation = $animateCss(paneContent, { 53 | easing: 'ease', 54 | from: { maxHeight: height + 'px' }, 55 | to: { maxHeight: '0px' }, 56 | duration: accordionConfig.expandAnimationDuration 57 | }); 58 | 59 | collapseAnimation.start().done(done); 60 | 61 | return function (isCancelled) { 62 | if (isCancelled) { 63 | paneContent.css('max-height', '0px'); 64 | } 65 | }; 66 | } 67 | }; 68 | } ]); 69 | 70 | 71 | // Modules 72 | angular.module('vAccordion.directives', []); 73 | angular.module('vAccordion', 74 | [ 75 | 'vAccordion.config', 76 | 'vAccordion.directives' 77 | ]); 78 | 79 | 80 | 81 | // vAccordion directive 82 | angular.module('vAccordion.directives') 83 | .directive('vAccordion', vAccordionDirective); 84 | 85 | 86 | function vAccordionDirective ($timeout) { 87 | return { 88 | restrict: 'E', 89 | transclude: true, 90 | controller: vAccordionController, 91 | scope: { 92 | control: '=?', 93 | expandCb: '&?onexpand', 94 | collapseCb: '&?oncollapse', 95 | id: '@?' 96 | }, 97 | link: { 98 | pre: function (scope, iElement, iAttrs) { 99 | scope.allowMultiple = (angular.isDefined(iAttrs.multiple) && (iAttrs.multiple === '' || iAttrs.multiple === 'true')); 100 | }, 101 | post: function (scope, iElement, iAttrs, ctrl, transclude) { 102 | transclude(scope.$parent.$new(), function (clone, transclusionScope) { 103 | transclusionScope.$accordion = scope.internalControl; 104 | if (scope.id) { transclusionScope.$accordion.id = scope.id; } 105 | iElement.append(clone); 106 | }); 107 | 108 | iAttrs.$set('role', 'tablist'); 109 | 110 | if (scope.allowMultiple) { 111 | iAttrs.$set('aria-multiselectable', 'true'); 112 | } 113 | 114 | if (angular.isDefined(scope.control)) { 115 | checkCustomControlAPIMethods(); 116 | 117 | var mergedControl = angular.extend({}, scope.internalControl, scope.control); 118 | scope.control = scope.internalControl = mergedControl; 119 | } 120 | else { 121 | scope.control = scope.internalControl; 122 | } 123 | 124 | function checkCustomControlAPIMethods () { 125 | var protectedApiMethods = ['toggle', 'expand', 'collapse', 'expandAll', 'collapseAll', 'hasExpandedPane']; 126 | 127 | angular.forEach(protectedApiMethods, function (iteratedMethodName) { 128 | if (scope.control[iteratedMethodName]) { 129 | throw new Error('The `' + iteratedMethodName + '` method can not be overwritten'); 130 | } 131 | }); 132 | } 133 | 134 | $timeout(function () { 135 | var eventName = (angular.isDefined(ctrl.getAccordionId())) ? ctrl.getAccordionId() + ':onReady' : 'vAccordion:onReady'; 136 | scope.$emit(eventName); 137 | }, 0); 138 | } 139 | } 140 | }; 141 | } 142 | vAccordionDirective.$inject = ['$timeout']; 143 | 144 | 145 | // vAccordion directive controller 146 | function vAccordionController ($scope) { 147 | var ctrl = this; 148 | var isDisabled = false; 149 | 150 | $scope.panes = []; 151 | 152 | $scope.expandCb = (angular.isFunction($scope.expandCb)) ? $scope.expandCb : angular.noop; 153 | $scope.collapseCb = (angular.isFunction($scope.collapseCb)) ? $scope.collapseCb : angular.noop; 154 | 155 | ctrl.hasExpandedPane = function hasExpandedPane () { 156 | var bool = false; 157 | 158 | for (var i = 0, length = $scope.panes.length; i < length; i++) { 159 | var iteratedPane = $scope.panes[i]; 160 | 161 | if (iteratedPane.isExpanded) { 162 | bool = true; 163 | break; 164 | } 165 | } 166 | 167 | return bool; 168 | }; 169 | 170 | ctrl.getPaneByIndex = function getPaneByIndex (index) { 171 | var thePane; 172 | 173 | angular.forEach($scope.panes, function (iteratedPane) { 174 | if (iteratedPane.$parent && angular.isDefined(iteratedPane.$parent.$index) && iteratedPane.$parent.$index === index) { 175 | thePane = iteratedPane; 176 | } 177 | }); 178 | 179 | return (thePane) ? thePane : $scope.panes[index]; 180 | }; 181 | 182 | ctrl.getPaneIndex = function getPaneIndex (pane) { 183 | var theIndex; 184 | 185 | angular.forEach($scope.panes, function (iteratedPane) { 186 | if (iteratedPane.$parent && angular.isDefined(iteratedPane.$parent.$index) && iteratedPane === pane) { 187 | theIndex = iteratedPane.$parent.$index; 188 | } 189 | }); 190 | 191 | return (angular.isDefined(theIndex)) ? theIndex : $scope.panes.indexOf(pane); 192 | }; 193 | 194 | ctrl.getPaneById = function getPaneById (id) { 195 | var thePane; 196 | 197 | angular.forEach($scope.panes, function (iteratedPane) { 198 | if (iteratedPane.id && iteratedPane.id === id) { 199 | thePane = iteratedPane; 200 | } 201 | }); 202 | 203 | return thePane; 204 | }; 205 | 206 | ctrl.getPaneId = function getPaneId (pane) { 207 | return pane.id; 208 | }; 209 | 210 | ctrl.getAccordionId = function getAccordionId () { 211 | return $scope.id; 212 | }; 213 | 214 | 215 | ctrl.disable = function disable () { 216 | isDisabled = true; 217 | }; 218 | 219 | ctrl.enable = function enable () { 220 | isDisabled = false; 221 | }; 222 | 223 | ctrl.addPane = function addPane (paneToAdd) { 224 | if (!$scope.allowMultiple) { 225 | if (ctrl.hasExpandedPane() && paneToAdd.isExpanded) { 226 | throw new Error('The `multiple` attribute can\'t be found'); 227 | } 228 | } 229 | 230 | $scope.panes.push(paneToAdd); 231 | 232 | if (paneToAdd.isExpanded) { 233 | $scope.expandCb({ index: ctrl.getPaneIndex(paneToAdd), id: paneToAdd.id, pane: paneToAdd }); 234 | } 235 | }; 236 | 237 | ctrl.focusNext = function focusNext () { 238 | var length = $scope.panes.length; 239 | 240 | for (var i = 0; i < length; i++) { 241 | var iteratedPane = $scope.panes[i]; 242 | 243 | if (iteratedPane.isFocused) { 244 | var paneToFocusIndex = i + 1; 245 | 246 | if (paneToFocusIndex > $scope.panes.length - 1) { 247 | paneToFocusIndex = 0; 248 | } 249 | 250 | var paneToFocus = $scope.panes[paneToFocusIndex]; 251 | paneToFocus.paneElement.find('v-pane-header')[0].focus(); 252 | 253 | break; 254 | } 255 | } 256 | }; 257 | 258 | ctrl.focusPrevious = function focusPrevious () { 259 | var length = $scope.panes.length; 260 | 261 | for (var i = 0; i < length; i++) { 262 | var iteratedPane = $scope.panes[i]; 263 | 264 | if (iteratedPane.isFocused) { 265 | var paneToFocusIndex = i - 1; 266 | 267 | if (paneToFocusIndex < 0) { 268 | paneToFocusIndex = $scope.panes.length - 1; 269 | } 270 | 271 | var paneToFocus = $scope.panes[paneToFocusIndex]; 272 | paneToFocus.paneElement.find('v-pane-header')[0].focus(); 273 | 274 | break; 275 | } 276 | } 277 | }; 278 | 279 | ctrl.toggle = function toggle (paneToToggle) { 280 | if (isDisabled || !paneToToggle) { return; } 281 | 282 | if (!$scope.allowMultiple) { 283 | ctrl.collapseAll(paneToToggle); 284 | } 285 | 286 | paneToToggle.isExpanded = !paneToToggle.isExpanded; 287 | 288 | if (paneToToggle.isExpanded) { 289 | $scope.expandCb({ index: ctrl.getPaneIndex(paneToToggle), id: paneToToggle.id, pane: paneToToggle }); 290 | } else { 291 | $scope.collapseCb({ index: ctrl.getPaneIndex(paneToToggle), id: paneToToggle.id, pane: paneToToggle }); 292 | } 293 | }; 294 | 295 | ctrl.expand = function expand (paneToExpand) { 296 | if (isDisabled || !paneToExpand) { return; } 297 | 298 | if (!$scope.allowMultiple) { 299 | ctrl.collapseAll(paneToExpand); 300 | } 301 | 302 | if (!paneToExpand.isExpanded) { 303 | paneToExpand.isExpanded = true; 304 | $scope.expandCb({ index: ctrl.getPaneIndex(paneToExpand), id: paneToExpand.id, pane: paneToExpand }); 305 | } 306 | }; 307 | 308 | ctrl.collapse = function collapse (paneToCollapse) { 309 | if (isDisabled || !paneToCollapse) { return; } 310 | 311 | if (paneToCollapse.isExpanded) { 312 | paneToCollapse.isExpanded = false; 313 | $scope.collapseCb({ index: ctrl.getPaneIndex(paneToCollapse), id: paneToCollapse.id, pane: paneToCollapse }); 314 | } 315 | }; 316 | 317 | ctrl.expandAll = function expandAll () { 318 | if (isDisabled) { return; } 319 | 320 | if ($scope.allowMultiple) { 321 | angular.forEach($scope.panes, function (iteratedPane) { 322 | ctrl.expand(iteratedPane); 323 | }); 324 | } else { 325 | throw new Error('The `multiple` attribute can\'t be found'); 326 | } 327 | }; 328 | 329 | ctrl.collapseAll = function collapseAll (exceptionalPane) { 330 | if (isDisabled) { return; } 331 | 332 | angular.forEach($scope.panes, function (iteratedPane) { 333 | if (iteratedPane !== exceptionalPane) { 334 | ctrl.collapse(iteratedPane); 335 | } 336 | }); 337 | }; 338 | 339 | // API 340 | $scope.internalControl = { 341 | toggle: function toggle (indexOrId) { 342 | if (angular.isString(indexOrId)) { 343 | ctrl.toggle( ctrl.getPaneById(indexOrId) ); 344 | } else { 345 | ctrl.toggle( ctrl.getPaneByIndex(indexOrId) ); 346 | } 347 | }, 348 | expand: function expand (indexOrId) { 349 | if (angular.isString(indexOrId)) { 350 | ctrl.expand( ctrl.getPaneById(indexOrId) ); 351 | } else { 352 | ctrl.expand( ctrl.getPaneByIndex(indexOrId) ); 353 | } 354 | }, 355 | collapse: function collapse (indexOrId) { 356 | if (angular.isString(indexOrId)) { 357 | ctrl.collapse( ctrl.getPaneById(indexOrId) ); 358 | } else { 359 | ctrl.collapse( ctrl.getPaneByIndex(indexOrId) ); 360 | } 361 | }, 362 | expandAll: ctrl.expandAll, 363 | collapseAll: ctrl.collapseAll, 364 | hasExpandedPane: ctrl.hasExpandedPane 365 | }; 366 | } 367 | vAccordionController.$inject = ['$scope']; 368 | 369 | 370 | 371 | // vPane directive 372 | angular.module('vAccordion.directives') 373 | .directive('vPane', vPaneDirective); 374 | 375 | 376 | function vPaneDirective ($timeout, $animate, accordionConfig) { 377 | return { 378 | restrict: 'E', 379 | require: '^vAccordion', 380 | transclude: true, 381 | controller: vPaneController, 382 | scope: { 383 | isExpanded: '=?expanded', 384 | isDisabled: '=?ngDisabled', 385 | id: '@?' 386 | }, 387 | link: function (scope, iElement, iAttrs, accordionCtrl, transclude) { 388 | transclude(scope.$parent.$new(), function (clone, transclusionScope) { 389 | transclusionScope.$pane = scope.internalControl; 390 | if (scope.id) { transclusionScope.$pane.id = scope.id; } 391 | iElement.append(clone); 392 | }); 393 | 394 | if (!angular.isDefined(scope.isExpanded)) { 395 | scope.isExpanded = (angular.isDefined(iAttrs.expanded) && (iAttrs.expanded === '')); 396 | } 397 | 398 | if (angular.isDefined(iAttrs.disabled)) { 399 | scope.isDisabled = true; 400 | } 401 | 402 | var states = accordionConfig.states; 403 | 404 | var paneHeader = iElement.find('v-pane-header'), 405 | paneContent = iElement.find('v-pane-content'), 406 | paneInner = paneContent.find('div'); 407 | 408 | var accordionId = accordionCtrl.getAccordionId(); 409 | 410 | if (!paneHeader[0]) { 411 | throw new Error('The `v-pane-header` directive can\'t be found'); 412 | } 413 | 414 | if (!paneContent[0]) { 415 | throw new Error('The `v-pane-content` directive can\'t be found'); 416 | } 417 | 418 | scope.paneElement = iElement; 419 | scope.paneContentElement = paneContent; 420 | scope.paneInnerElement = paneInner; 421 | 422 | scope.accordionCtrl = accordionCtrl; 423 | 424 | accordionCtrl.addPane(scope); 425 | 426 | function emitEvent (eventName) { 427 | eventName = (angular.isDefined(accordionId)) ? accordionId + ':' + eventName : 'vAccordion:' + eventName; 428 | scope.$emit(eventName); 429 | } 430 | 431 | function expand () { 432 | accordionCtrl.disable(); 433 | 434 | paneContent.attr('aria-hidden', 'false'); 435 | 436 | paneHeader.attr({ 437 | 'aria-selected': 'true', 438 | 'aria-expanded': 'true' 439 | }); 440 | 441 | emitEvent('onExpand'); 442 | 443 | $animate 444 | .addClass(iElement, states.expanded) 445 | .then(function () { 446 | accordionCtrl.enable(); 447 | emitEvent('onExpandAnimationEnd'); 448 | }); 449 | } 450 | 451 | function collapse () { 452 | accordionCtrl.disable(); 453 | 454 | paneContent.attr('aria-hidden', 'true'); 455 | 456 | paneHeader.attr({ 457 | 'aria-selected': 'false', 458 | 'aria-expanded': 'false' 459 | }); 460 | 461 | emitEvent('onCollapse'); 462 | 463 | $animate 464 | .removeClass(iElement, states.expanded) 465 | .then(function () { 466 | accordionCtrl.enable(); 467 | emitEvent('onCollapseAnimationEnd'); 468 | }); 469 | } 470 | 471 | scope.$evalAsync(function () { 472 | if (scope.isExpanded) { 473 | iElement.addClass(states.expanded); 474 | paneContent 475 | .css('max-height', 'none') 476 | .attr('aria-hidden', 'false'); 477 | 478 | paneHeader.attr({ 479 | 'aria-selected': 'true', 480 | 'aria-expanded': 'true' 481 | }); 482 | } else { 483 | paneContent 484 | .css('max-height', '0px') 485 | .attr('aria-hidden', 'true'); 486 | 487 | paneHeader.attr({ 488 | 'aria-selected': 'false', 489 | 'aria-expanded': 'false' 490 | }); 491 | } 492 | }); 493 | 494 | scope.$watch('isExpanded', function (newValue, oldValue) { 495 | if (newValue === oldValue) { return true; } 496 | if (newValue) { expand(); } 497 | else { collapse(); } 498 | }); 499 | } 500 | }; 501 | } 502 | vPaneDirective.$inject = ['$timeout', '$animate', 'accordionConfig']; 503 | 504 | 505 | // vPane directive controller 506 | function vPaneController ($scope) { 507 | var ctrl = this; 508 | 509 | ctrl.isExpanded = function isExpanded () { 510 | return $scope.isExpanded; 511 | }; 512 | 513 | ctrl.toggle = function toggle () { 514 | if (!$scope.isAnimating && !$scope.isDisabled) { 515 | $scope.accordionCtrl.toggle($scope); 516 | } 517 | }; 518 | 519 | ctrl.expand = function expand () { 520 | if (!$scope.isAnimating && !$scope.isDisabled) { 521 | $scope.accordionCtrl.expand($scope); 522 | } 523 | }; 524 | 525 | ctrl.collapse = function collapse () { 526 | if (!$scope.isAnimating && !$scope.isDisabled) { 527 | $scope.accordionCtrl.collapse($scope); 528 | } 529 | }; 530 | 531 | ctrl.focusPane = function focusPane () { 532 | $scope.isFocused = true; 533 | }; 534 | 535 | ctrl.blurPane = function blurPane () { 536 | $scope.isFocused = false; 537 | }; 538 | 539 | $scope.internalControl = { 540 | toggle: ctrl.toggle, 541 | expand: ctrl.expand, 542 | collapse: ctrl.collapse, 543 | isExpanded: ctrl.isExpanded 544 | }; 545 | } 546 | vPaneController.$inject = ['$scope']; 547 | 548 | 549 | 550 | // vPaneContent directive 551 | angular.module('vAccordion.directives') 552 | .directive('vPaneContent', vPaneContentDirective); 553 | 554 | 555 | function vPaneContentDirective () { 556 | return { 557 | restrict: 'E', 558 | require: '^vPane', 559 | transclude: true, 560 | template: '
', 561 | scope: {}, 562 | link: function (scope, iElement, iAttrs) { 563 | iAttrs.$set('role', 'tabpanel'); 564 | iAttrs.$set('aria-hidden', 'true'); 565 | } 566 | }; 567 | } 568 | 569 | 570 | 571 | // vPaneHeader directive 572 | angular.module('vAccordion.directives') 573 | .directive('vPaneHeader', vPaneHeaderDirective); 574 | 575 | 576 | function vPaneHeaderDirective () { 577 | return { 578 | restrict: 'E', 579 | require: ['^vPane', '^vAccordion'], 580 | transclude: true, 581 | template: '
', 582 | scope: {}, 583 | link: function (scope, iElement, iAttrs, ctrls) { 584 | iAttrs.$set('role', 'tab'); 585 | iAttrs.$set('tabindex', '0'); 586 | 587 | var paneCtrl = ctrls[0], 588 | accordionCtrl = ctrls[1]; 589 | 590 | var isInactive = angular.isDefined(iAttrs.inactive); 591 | 592 | function onClick () { 593 | if (isInactive) { return false; } 594 | scope.$apply(function () { paneCtrl.toggle(); }); 595 | } 596 | 597 | function onKeydown (event) { 598 | if (event.keyCode === 32 || event.keyCode === 13) { 599 | scope.$apply(function () { paneCtrl.toggle(); }); 600 | event.preventDefault(); 601 | } else if (event.keyCode === 39 || event.keyCode === 40) { 602 | scope.$apply(function () { accordionCtrl.focusNext(); }); 603 | event.preventDefault(); 604 | } else if (event.keyCode === 37 || event.keyCode === 38) { 605 | scope.$apply(function () { accordionCtrl.focusPrevious(); }); 606 | event.preventDefault(); 607 | } 608 | } 609 | 610 | function onFocus () { 611 | paneCtrl.focusPane(); 612 | } 613 | 614 | function onBlur () { 615 | paneCtrl.blurPane(); 616 | } 617 | 618 | iElement[0].onfocus = onFocus; 619 | iElement[0].onblur = onBlur; 620 | iElement.bind('click', onClick); 621 | iElement.bind('keydown', onKeydown); 622 | 623 | scope.$on('$destroy', function () { 624 | iElement.unbind('click', onClick); 625 | iElement.unbind('keydown', onKeydown); 626 | iElement[0].onfocus = null; 627 | iElement[0].onblur = null; 628 | }); 629 | } 630 | }; 631 | } 632 | 633 | })(angular); -------------------------------------------------------------------------------- /dist/v-accordion.min.css: -------------------------------------------------------------------------------- 1 | /** 2 | * vAccordion - AngularJS multi-level accordion component 3 | * @version v1.6.0 4 | * @link http://lukaszwatroba.github.io/v-accordion 5 | * @author Łukasz Wątroba 6 | * @license MIT License, http://www.opensource.org/licenses/MIT 7 | */ 8 | 9 | v-accordion,v-pane{display:block}v-pane.is-expanded>v-pane-content>div{display:visible}v-pane[disabled]>v-pane-header{opacity:.6;pointer-events:none}v-pane-header{display:block;position:relative;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;outline:0}v-pane-header:focus{outline:0}v-pane-header>div{display:block}v-pane-content{display:block;position:relative;overflow:hidden;max-height:0}v-pane-content>div{visibility:none}.vAccordion--default v-accordion{margin-top:20px;padding-left:20px}.vAccordion--default v-pane-content>div{padding-bottom:20px;opacity:0;-webkit-transform:translate3d(0,30px,0);transform:translate3d(0,30px,0);-webkit-transition:all .5s;transition:all .5s}.vAccordion--default v-pane{overflow:hidden}.vAccordion--default v-pane.is-expanded>v-pane-header{border-bottom-color:#2196F3}.vAccordion--default v-pane.is-expanded>v-pane-header::after{-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}.vAccordion--default v-pane.is-expanded>v-pane-header::before{-webkit-transform:rotate(0deg);transform:rotate(0deg)}.vAccordion--default v-pane.is-expanded>v-pane-content>div{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.vAccordion--default v-pane[disabled] v-pane-header::after,.vAccordion--default v-pane[disabled] v-pane-header::before{display:none}.vAccordion--default v-pane-header{padding:5px 0;margin-bottom:20px;border-bottom:2px solid #D8D8D8;-webkit-transition:all .25s;transition:all .25s}.vAccordion--default v-pane-header::after,.vAccordion--default v-pane-header::before{content:'';display:block;position:absolute;top:50%;right:0;width:10px;height:1px;background-color:#2196F3;-webkit-transform-origin:50% 50%;transform-origin:50% 50%;will-change:transform;-webkit-transition:all .25s;transition:all .25s}.vAccordion--default v-pane-header::before{-webkit-transform:rotate(-90deg);transform:rotate(-90deg)}.vAccordion--default v-pane-header:focus,.vAccordion--default v-pane-header:hover{color:#2196F3} -------------------------------------------------------------------------------- /dist/v-accordion.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * vAccordion - AngularJS multi-level accordion component 3 | * @version v1.6.0 4 | * @link http://lukaszwatroba.github.io/v-accordion 5 | * @author Łukasz Wątroba 6 | * @license MIT License, http://www.opensource.org/licenses/MIT 7 | */ 8 | 9 | !function(e){"use strict";function n(n){return{restrict:"E",transclude:!0,controller:a,scope:{control:"=?",expandCb:"&?onexpand",collapseCb:"&?oncollapse",id:"@?"},link:{pre:function(n,a,i){n.allowMultiple=e.isDefined(i.multiple)&&(""===i.multiple||"true"===i.multiple)},post:function(a,i,t,o,d){function r(){var n=["toggle","expand","collapse","expandAll","collapseAll","hasExpandedPane"];e.forEach(n,function(e){if(a.control[e])throw new Error("The `"+e+"` method can not be overwritten")})}if(d(a.$parent.$new(),function(e,n){n.$accordion=a.internalControl,a.id&&(n.$accordion.id=a.id),i.append(e)}),t.$set("role","tablist"),a.allowMultiple&&t.$set("aria-multiselectable","true"),e.isDefined(a.control)){r();var c=e.extend({},a.internalControl,a.control);a.control=a.internalControl=c}else a.control=a.internalControl;n(function(){var n=e.isDefined(o.getAccordionId())?o.getAccordionId()+":onReady":"vAccordion:onReady";a.$emit(n)},0)}}}}function a(n){var a=this,i=!1;n.panes=[],n.expandCb=e.isFunction(n.expandCb)?n.expandCb:e.noop,n.collapseCb=e.isFunction(n.collapseCb)?n.collapseCb:e.noop,a.hasExpandedPane=function(){for(var e=!1,a=0,i=n.panes.length;i>a;a++){var t=n.panes[a];if(t.isExpanded){e=!0;break}}return e},a.getPaneByIndex=function(a){var i;return e.forEach(n.panes,function(n){n.$parent&&e.isDefined(n.$parent.$index)&&n.$parent.$index===a&&(i=n)}),i?i:n.panes[a]},a.getPaneIndex=function(a){var i;return e.forEach(n.panes,function(n){n.$parent&&e.isDefined(n.$parent.$index)&&n===a&&(i=n.$parent.$index)}),e.isDefined(i)?i:n.panes.indexOf(a)},a.getPaneById=function(a){var i;return e.forEach(n.panes,function(e){e.id&&e.id===a&&(i=e)}),i},a.getPaneId=function(e){return e.id},a.getAccordionId=function(){return n.id},a.disable=function(){i=!0},a.enable=function(){i=!1},a.addPane=function(e){if(!n.allowMultiple&&a.hasExpandedPane()&&e.isExpanded)throw new Error("The `multiple` attribute can't be found");n.panes.push(e),e.isExpanded&&n.expandCb({index:a.getPaneIndex(e),id:e.id,pane:e})},a.focusNext=function(){for(var e=n.panes.length,a=0;e>a;a++){var i=n.panes[a];if(i.isFocused){var t=a+1;t>n.panes.length-1&&(t=0);var o=n.panes[t];o.paneElement.find("v-pane-header")[0].focus();break}}},a.focusPrevious=function(){for(var e=n.panes.length,a=0;e>a;a++){var i=n.panes[a];if(i.isFocused){var t=a-1;0>t&&(t=n.panes.length-1);var o=n.panes[t];o.paneElement.find("v-pane-header")[0].focus();break}}},a.toggle=function(e){!i&&e&&(n.allowMultiple||a.collapseAll(e),e.isExpanded=!e.isExpanded,e.isExpanded?n.expandCb({index:a.getPaneIndex(e),id:e.id,pane:e}):n.collapseCb({index:a.getPaneIndex(e),id:e.id,pane:e}))},a.expand=function(e){!i&&e&&(n.allowMultiple||a.collapseAll(e),e.isExpanded||(e.isExpanded=!0,n.expandCb({index:a.getPaneIndex(e),id:e.id,pane:e})))},a.collapse=function(e){!i&&e&&e.isExpanded&&(e.isExpanded=!1,n.collapseCb({index:a.getPaneIndex(e),id:e.id,pane:e}))},a.expandAll=function(){if(!i){if(!n.allowMultiple)throw new Error("The `multiple` attribute can't be found");e.forEach(n.panes,function(e){a.expand(e)})}},a.collapseAll=function(t){i||e.forEach(n.panes,function(e){e!==t&&a.collapse(e)})},n.internalControl={toggle:function(n){e.isString(n)?a.toggle(a.getPaneById(n)):a.toggle(a.getPaneByIndex(n))},expand:function(n){e.isString(n)?a.expand(a.getPaneById(n)):a.expand(a.getPaneByIndex(n))},collapse:function(n){e.isString(n)?a.collapse(a.getPaneById(n)):a.collapse(a.getPaneByIndex(n))},expandAll:a.expandAll,collapseAll:a.collapseAll,hasExpandedPane:a.hasExpandedPane}}function i(n,a,i){return{restrict:"E",require:"^vAccordion",transclude:!0,controller:t,scope:{isExpanded:"=?expanded",isDisabled:"=?ngDisabled",id:"@?"},link:function(n,t,o,d,r){function c(a){a=e.isDefined(v)?v+":"+a:"vAccordion:"+a,n.$emit(a)}function l(){d.disable(),f.attr("aria-hidden","false"),u.attr({"aria-selected":"true","aria-expanded":"true"}),c("onExpand"),a.addClass(t,p.expanded).then(function(){d.enable(),c("onExpandAnimationEnd")})}function s(){d.disable(),f.attr("aria-hidden","true"),u.attr({"aria-selected":"false","aria-expanded":"false"}),c("onCollapse"),a.removeClass(t,p.expanded).then(function(){d.enable(),c("onCollapseAnimationEnd")})}r(n.$parent.$new(),function(e,a){a.$pane=n.internalControl,n.id&&(a.$pane.id=n.id),t.append(e)}),e.isDefined(n.isExpanded)||(n.isExpanded=e.isDefined(o.expanded)&&""===o.expanded),e.isDefined(o.disabled)&&(n.isDisabled=!0);var p=i.states,u=t.find("v-pane-header"),f=t.find("v-pane-content"),x=f.find("div"),v=d.getAccordionId();if(!u[0])throw new Error("The `v-pane-header` directive can't be found");if(!f[0])throw new Error("The `v-pane-content` directive can't be found");n.paneElement=t,n.paneContentElement=f,n.paneInnerElement=x,n.accordionCtrl=d,d.addPane(n),n.$evalAsync(function(){n.isExpanded?(t.addClass(p.expanded),f.css("max-height","none").attr("aria-hidden","false"),u.attr({"aria-selected":"true","aria-expanded":"true"})):(f.css("max-height","0px").attr("aria-hidden","true"),u.attr({"aria-selected":"false","aria-expanded":"false"}))}),n.$watch("isExpanded",function(e,n){return e===n?!0:(e?l():s(),void 0)})}}}function t(e){var n=this;n.isExpanded=function(){return e.isExpanded},n.toggle=function(){e.isAnimating||e.isDisabled||e.accordionCtrl.toggle(e)},n.expand=function(){e.isAnimating||e.isDisabled||e.accordionCtrl.expand(e)},n.collapse=function(){e.isAnimating||e.isDisabled||e.accordionCtrl.collapse(e)},n.focusPane=function(){e.isFocused=!0},n.blurPane=function(){e.isFocused=!1},e.internalControl={toggle:n.toggle,expand:n.expand,collapse:n.collapse,isExpanded:n.isExpanded}}function o(){return{restrict:"E",require:"^vPane",transclude:!0,template:"
",scope:{},link:function(e,n,a){a.$set("role","tabpanel"),a.$set("aria-hidden","true")}}}function d(){return{restrict:"E",require:["^vPane","^vAccordion"],transclude:!0,template:"
",scope:{},link:function(n,a,i,t){function o(){return p?!1:(n.$apply(function(){l.toggle()}),void 0)}function d(e){32===e.keyCode||13===e.keyCode?(n.$apply(function(){l.toggle()}),e.preventDefault()):39===e.keyCode||40===e.keyCode?(n.$apply(function(){s.focusNext()}),e.preventDefault()):(37===e.keyCode||38===e.keyCode)&&(n.$apply(function(){s.focusPrevious()}),e.preventDefault())}function r(){l.focusPane()}function c(){l.blurPane()}i.$set("role","tab"),i.$set("tabindex","0");var l=t[0],s=t[1],p=e.isDefined(i.inactive);a[0].onfocus=r,a[0].onblur=c,a.bind("click",o),a.bind("keydown",d),n.$on("$destroy",function(){a.unbind("click",o),a.unbind("keydown",d),a[0].onfocus=null,a[0].onblur=null})}}}e.module("vAccordion.config",[]).constant("accordionConfig",{states:{expanded:"is-expanded"},expandAnimationDuration:.5}).animation(".is-expanded",["$animateCss","accordionConfig",function(n,a){return{addClass:function(i,t,o){var d=e.element(i[0].querySelector("v-pane-content")),r=e.element(d[0].querySelector("div")),c=r[0].offsetHeight,l=n(d,{easing:"ease",from:{maxHeight:"0px"},to:{maxHeight:c+"px"},duration:a.expandAnimationDuration});return l.start().done(function(){d.css("max-height","none"),o()}),function(e){e&&d.css("max-height","none")}},removeClass:function(i,t,o){var d=e.element(i[0].querySelector("v-pane-content")),r=e.element(d[0].querySelector("div")),c=r[0].offsetHeight,l=n(d,{easing:"ease",from:{maxHeight:c+"px"},to:{maxHeight:"0px"},duration:a.expandAnimationDuration});return l.start().done(o),function(e){e&&d.css("max-height","0px")}}}}]),e.module("vAccordion.directives",[]),e.module("vAccordion",["vAccordion.config","vAccordion.directives"]),e.module("vAccordion.directives").directive("vAccordion",n),n.$inject=["$timeout"],a.$inject=["$scope"],e.module("vAccordion.directives").directive("vPane",i),i.$inject=["$timeout","$animate","accordionConfig"],t.$inject=["$scope"],e.module("vAccordion.directives").directive("vPaneContent",o),e.module("vAccordion.directives").directive("vPaneHeader",d)}(angular); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var pkg = require('./package.json'); 2 | 3 | var banner = '/**\n' + 4 | ' * <%= pkg.description %>\n' + 5 | ' * @version v<%= pkg.version %>\n' + 6 | ' * @link <%= pkg.homepage %>\n' + 7 | ' * @author <%= pkg.author %>\n' + 8 | ' * @license MIT License, http://www.opensource.org/licenses/MIT\n' + 9 | ' */\n\n'; 10 | 11 | var gulp = require('gulp'), 12 | karma = require('karma').server, 13 | jshint = require('gulp-jshint'), 14 | concat = require('gulp-concat'), 15 | uglify = require('gulp-uglify'), 16 | rename = require('gulp-rename'), 17 | sass = require('gulp-sass'); 18 | autoprefixer = require('gulp-autoprefixer'), 19 | minifycss = require('gulp-minify-css'), 20 | header = require('gulp-header'); 21 | 22 | gulp.task('scripts', function() { 23 | gulp.src([ 24 | 'src/vAccordion/vAccordion.prefix', 25 | 'src/vAccordion/*.js', 26 | 'src/vAccordion/directives/*.js', 27 | 'src/vAccordion/services/*.js', 28 | 'src/vAccordion/vAccordion.suffix' 29 | ]) 30 | .pipe(concat('v-accordion.js')) 31 | .pipe(header(banner, { pkg : pkg } )) 32 | .pipe(gulp.dest('./dist/')) 33 | .pipe(uglify()) 34 | .pipe(rename('v-accordion.min.js')) 35 | .pipe(header(banner, { pkg : pkg } )) 36 | .pipe(gulp.dest('./dist')) 37 | }); 38 | 39 | gulp.task('styles', function() { 40 | return gulp.src('src/vAccordion/styles/vAccordion.scss') 41 | .pipe(sass({style: 'expanded'})) 42 | .pipe(rename({basename: 'v-accordion'} )) 43 | .pipe(autoprefixer('last 2 version')) 44 | .pipe(header(banner, { pkg : pkg } )) 45 | .pipe(gulp.dest('dist/')) 46 | .pipe(rename({suffix: '.min'} )) 47 | .pipe(minifycss()) 48 | .pipe(header(banner, { pkg : pkg } )) 49 | .pipe(gulp.dest('dist/')) 50 | }); 51 | 52 | gulp.task('test', function (done) { 53 | karma.start({ 54 | configFile: __dirname + '/karma.conf.js', 55 | singleRun: true 56 | }, done); 57 | }); 58 | 59 | gulp.task('lint-src', function() { 60 | return gulp.src([ 61 | 'src/vAccordion/**/*.js', 62 | ]) 63 | .pipe(jshint('.jshintrc')) 64 | .pipe(jshint.reporter('jshint-stylish')); 65 | }); 66 | 67 | gulp.task('lint-tests', function() { 68 | return gulp.src([ 69 | 'test/**/*.spec.js' 70 | ]) 71 | .pipe(jshint('.jshintrc')) 72 | .pipe(jshint.reporter('jshint-stylish')); 73 | }); 74 | 75 | gulp.task('default', ['lint-src', 'test', 'scripts', 'styles']); 76 | 77 | gulp.task('watch', function() { 78 | gulp.watch('src/vAccordion/**/*.js', ['lint-src', 'scripts', 'test']); 79 | gulp.watch('test/**/*.spec.js', ['lint-tests', 'test']); 80 | 81 | gulp.watch('src/vAccordion/styles/**/*.scss', ['styles']); 82 | }); 83 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | vAccordion 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | 33 |
34 |
35 | 36 |

vAccordion

37 |
38 | 39 |
AngularJS multi-level accordion component.
40 | 41 |
42 | 43 |
44 | 45 |
46 |
47 |

Works with (or without) ng-repeat

48 | 49 | 50 | 51 | 52 | 53 |
{{ ::pane.header }}
54 |
55 | 56 | 57 |

{{ ::pane.content }}

58 | 59 | 60 | 61 | 62 |
{{ ::subpane.header }}
63 |
64 | 65 |

{{ ::subpane.content }}

66 |
67 |
68 |
69 |
70 |
71 | 72 |
73 | 74 | 75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 |
83 |
84 | 85 |
86 |

Allows multiple sections open at once

87 | 88 | 89 | 90 | 91 | 92 |
{{ ::pane.header }}
93 |
94 | 95 | 96 |

{{ ::pane.content }}

97 | 98 | 99 | 100 | 101 |
{{ ::subpane.header }}
102 |
103 | 104 |

{{ ::subpane.content }}

105 |
106 |
107 |
108 |
109 |
110 | 111 |
112 | 113 | 114 |
115 |
116 | 117 |
118 |
119 | 120 |
121 |
122 |
123 |
124 | 125 |
126 | 127 | 140 | 141 |
142 | 143 | 144 | 145 | Fork me on GitHub 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('./dist/v-accordion'); 2 | module.exports = 'vAccordion'; 3 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Thu Aug 21 2014 10:24:39 GMT+0200 (CEST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine', 'jquery-1.8.3'], 14 | 15 | plugins: [ 16 | 'karma-jasmine', 17 | 'karma-chrome-launcher', 18 | 'karma-phantomjs-launcher', 19 | 'karma-jquery' 20 | ], 21 | 22 | // list of files / patterns to load in the browser 23 | files: [ 24 | 'bower/angular/angular.js', 25 | 'bower/angular-mocks/angular-mocks.js', 26 | 'bower/angular-animate/angular-animate.js', 27 | 'src/vAccordion/*.js', 28 | 'src/vAccordion/directives/*.js', 29 | 'test/unit/**/*.js' 30 | ], 31 | 32 | 33 | // list of files to exclude 34 | exclude: [ 35 | ], 36 | 37 | 38 | // preprocess matching files before serving them to the browser 39 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 40 | preprocessors: { 41 | }, 42 | 43 | 44 | // test results reporter to use 45 | // possible values: 'dots', 'progress' 46 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 47 | reporters: ['progress'], 48 | 49 | 50 | // web server port 51 | port: 9876, 52 | 53 | 54 | // enable / disable colors in the output (reporters and logs) 55 | colors: true, 56 | 57 | 58 | // level of logging 59 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 60 | logLevel: config.LOG_INFO, 61 | 62 | 63 | // enable / disable watching file and executing tests whenever any file changes 64 | autoWatch: true, 65 | 66 | 67 | // start these browsers 68 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 69 | browsers: ['Chrome'], 70 | 71 | 72 | // Continuous Integration mode 73 | // if true, Karma captures browsers, runs the tests and exits 74 | singleRun: false 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-accordion", 3 | "description": "vAccordion - AngularJS multi-level accordion component", 4 | "version": "1.6.0", 5 | "author": "Łukasz Wątroba ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "angular", 9 | "accordion", 10 | "multi-level", 11 | "component", 12 | "directive", 13 | "module" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/LukaszWatroba/v-accordion.git" 18 | }, 19 | "main": "index.js", 20 | "bugs": { 21 | "url": "https://github.com/LukaszWatroba/v-accordion/issues" 22 | }, 23 | "homepage": "http://lukaszwatroba.github.io/v-accordion", 24 | "devDependencies": { 25 | "chai": "^1.9.1", 26 | "chai-jquery": "^1.2.3", 27 | "gulp": "^3.8.11", 28 | "gulp-autoprefixer": "^2.0.0", 29 | "gulp-concat": "^2.3.4", 30 | "gulp-header": "^1.2.2", 31 | "gulp-jshint": "^1.9.0", 32 | "gulp-minify-css": "^0.3.11", 33 | "gulp-rename": "^1.2.0", 34 | "gulp-sass": "^2.3.1", 35 | "gulp-uglify": "^0.3.2", 36 | "jshint-stylish": "^1.0.0", 37 | "karma": "^0.12.22", 38 | "karma-chai": "^0.1.0", 39 | "karma-chai-jquery": "^1.0.0", 40 | "karma-chrome-launcher": "^0.1.4", 41 | "karma-jasmine": "^0.1.5", 42 | "karma-jquery": "^0.1.0", 43 | "karma-mocha": "^0.1.8", 44 | "karma-phantomjs-launcher": "^0.1.4", 45 | "karma-sinon-chai": "^0.2.0", 46 | "mocha": "^1.21.4", 47 | "sinon": "^1.10.3", 48 | "sinon-chai": "^2.5.0" 49 | }, 50 | "engines": { 51 | "node": ">=0.8.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/vAccordion/directives/vAccordion.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // vAccordion directive 4 | angular.module('vAccordion.directives') 5 | .directive('vAccordion', vAccordionDirective); 6 | 7 | 8 | function vAccordionDirective ($timeout) { 9 | return { 10 | restrict: 'E', 11 | transclude: true, 12 | controller: vAccordionController, 13 | scope: { 14 | control: '=?', 15 | expandCb: '&?onexpand', 16 | collapseCb: '&?oncollapse', 17 | id: '@?' 18 | }, 19 | link: { 20 | pre: function (scope, iElement, iAttrs) { 21 | scope.allowMultiple = (angular.isDefined(iAttrs.multiple) && (iAttrs.multiple === '' || iAttrs.multiple === 'true')); 22 | }, 23 | post: function (scope, iElement, iAttrs, ctrl, transclude) { 24 | transclude(scope.$parent.$new(), function (clone, transclusionScope) { 25 | transclusionScope.$accordion = scope.internalControl; 26 | if (scope.id) { transclusionScope.$accordion.id = scope.id; } 27 | iElement.append(clone); 28 | }); 29 | 30 | iAttrs.$set('role', 'tablist'); 31 | 32 | if (scope.allowMultiple) { 33 | iAttrs.$set('aria-multiselectable', 'true'); 34 | } 35 | 36 | if (angular.isDefined(scope.control)) { 37 | checkCustomControlAPIMethods(); 38 | 39 | var mergedControl = angular.extend({}, scope.internalControl, scope.control); 40 | scope.control = scope.internalControl = mergedControl; 41 | } 42 | else { 43 | scope.control = scope.internalControl; 44 | } 45 | 46 | function checkCustomControlAPIMethods () { 47 | var protectedApiMethods = ['toggle', 'expand', 'collapse', 'expandAll', 'collapseAll', 'hasExpandedPane']; 48 | 49 | angular.forEach(protectedApiMethods, function (iteratedMethodName) { 50 | if (scope.control[iteratedMethodName]) { 51 | throw new Error('The `' + iteratedMethodName + '` method can not be overwritten'); 52 | } 53 | }); 54 | } 55 | 56 | $timeout(function () { 57 | var eventName = (angular.isDefined(ctrl.getAccordionId())) ? ctrl.getAccordionId() + ':onReady' : 'vAccordion:onReady'; 58 | scope.$emit(eventName); 59 | }, 0); 60 | } 61 | } 62 | }; 63 | } 64 | vAccordionDirective.$inject = ['$timeout']; 65 | 66 | 67 | // vAccordion directive controller 68 | function vAccordionController ($scope) { 69 | var ctrl = this; 70 | var isDisabled = false; 71 | 72 | $scope.panes = []; 73 | 74 | $scope.expandCb = (angular.isFunction($scope.expandCb)) ? $scope.expandCb : angular.noop; 75 | $scope.collapseCb = (angular.isFunction($scope.collapseCb)) ? $scope.collapseCb : angular.noop; 76 | 77 | ctrl.hasExpandedPane = function hasExpandedPane () { 78 | var bool = false; 79 | 80 | for (var i = 0, length = $scope.panes.length; i < length; i++) { 81 | var iteratedPane = $scope.panes[i]; 82 | 83 | if (iteratedPane.isExpanded) { 84 | bool = true; 85 | break; 86 | } 87 | } 88 | 89 | return bool; 90 | }; 91 | 92 | ctrl.getPaneByIndex = function getPaneByIndex (index) { 93 | var thePane; 94 | 95 | angular.forEach($scope.panes, function (iteratedPane) { 96 | if (iteratedPane.$parent && angular.isDefined(iteratedPane.$parent.$index) && iteratedPane.$parent.$index === index) { 97 | thePane = iteratedPane; 98 | } 99 | }); 100 | 101 | return (thePane) ? thePane : $scope.panes[index]; 102 | }; 103 | 104 | ctrl.getPaneIndex = function getPaneIndex (pane) { 105 | var theIndex; 106 | 107 | angular.forEach($scope.panes, function (iteratedPane) { 108 | if (iteratedPane.$parent && angular.isDefined(iteratedPane.$parent.$index) && iteratedPane === pane) { 109 | theIndex = iteratedPane.$parent.$index; 110 | } 111 | }); 112 | 113 | return (angular.isDefined(theIndex)) ? theIndex : $scope.panes.indexOf(pane); 114 | }; 115 | 116 | ctrl.getPaneById = function getPaneById (id) { 117 | var thePane; 118 | 119 | angular.forEach($scope.panes, function (iteratedPane) { 120 | if (iteratedPane.id && iteratedPane.id === id) { 121 | thePane = iteratedPane; 122 | } 123 | }); 124 | 125 | return thePane; 126 | }; 127 | 128 | ctrl.getPaneId = function getPaneId (pane) { 129 | return pane.id; 130 | }; 131 | 132 | ctrl.getAccordionId = function getAccordionId () { 133 | return $scope.id; 134 | }; 135 | 136 | 137 | ctrl.disable = function disable () { 138 | isDisabled = true; 139 | }; 140 | 141 | ctrl.enable = function enable () { 142 | isDisabled = false; 143 | }; 144 | 145 | ctrl.addPane = function addPane (paneToAdd) { 146 | if (!$scope.allowMultiple) { 147 | if (ctrl.hasExpandedPane() && paneToAdd.isExpanded) { 148 | throw new Error('The `multiple` attribute can\'t be found'); 149 | } 150 | } 151 | 152 | $scope.panes.push(paneToAdd); 153 | 154 | if (paneToAdd.isExpanded) { 155 | $scope.expandCb({ index: ctrl.getPaneIndex(paneToAdd), id: paneToAdd.id, pane: paneToAdd }); 156 | } 157 | }; 158 | 159 | ctrl.focusNext = function focusNext () { 160 | var length = $scope.panes.length; 161 | 162 | for (var i = 0; i < length; i++) { 163 | var iteratedPane = $scope.panes[i]; 164 | 165 | if (iteratedPane.isFocused) { 166 | var paneToFocusIndex = i + 1; 167 | 168 | if (paneToFocusIndex > $scope.panes.length - 1) { 169 | paneToFocusIndex = 0; 170 | } 171 | 172 | var paneToFocus = $scope.panes[paneToFocusIndex]; 173 | paneToFocus.paneElement.find('v-pane-header')[0].focus(); 174 | 175 | break; 176 | } 177 | } 178 | }; 179 | 180 | ctrl.focusPrevious = function focusPrevious () { 181 | var length = $scope.panes.length; 182 | 183 | for (var i = 0; i < length; i++) { 184 | var iteratedPane = $scope.panes[i]; 185 | 186 | if (iteratedPane.isFocused) { 187 | var paneToFocusIndex = i - 1; 188 | 189 | if (paneToFocusIndex < 0) { 190 | paneToFocusIndex = $scope.panes.length - 1; 191 | } 192 | 193 | var paneToFocus = $scope.panes[paneToFocusIndex]; 194 | paneToFocus.paneElement.find('v-pane-header')[0].focus(); 195 | 196 | break; 197 | } 198 | } 199 | }; 200 | 201 | ctrl.toggle = function toggle (paneToToggle) { 202 | if (isDisabled || !paneToToggle) { return; } 203 | 204 | if (!$scope.allowMultiple) { 205 | ctrl.collapseAll(paneToToggle); 206 | } 207 | 208 | paneToToggle.isExpanded = !paneToToggle.isExpanded; 209 | 210 | if (paneToToggle.isExpanded) { 211 | $scope.expandCb({ index: ctrl.getPaneIndex(paneToToggle), id: paneToToggle.id, pane: paneToToggle }); 212 | } else { 213 | $scope.collapseCb({ index: ctrl.getPaneIndex(paneToToggle), id: paneToToggle.id, pane: paneToToggle }); 214 | } 215 | }; 216 | 217 | ctrl.expand = function expand (paneToExpand) { 218 | if (isDisabled || !paneToExpand) { return; } 219 | 220 | if (!$scope.allowMultiple) { 221 | ctrl.collapseAll(paneToExpand); 222 | } 223 | 224 | if (!paneToExpand.isExpanded) { 225 | paneToExpand.isExpanded = true; 226 | $scope.expandCb({ index: ctrl.getPaneIndex(paneToExpand), id: paneToExpand.id, pane: paneToExpand }); 227 | } 228 | }; 229 | 230 | ctrl.collapse = function collapse (paneToCollapse) { 231 | if (isDisabled || !paneToCollapse) { return; } 232 | 233 | if (paneToCollapse.isExpanded) { 234 | paneToCollapse.isExpanded = false; 235 | $scope.collapseCb({ index: ctrl.getPaneIndex(paneToCollapse), id: paneToCollapse.id, pane: paneToCollapse }); 236 | } 237 | }; 238 | 239 | ctrl.expandAll = function expandAll () { 240 | if (isDisabled) { return; } 241 | 242 | if ($scope.allowMultiple) { 243 | angular.forEach($scope.panes, function (iteratedPane) { 244 | ctrl.expand(iteratedPane); 245 | }); 246 | } else { 247 | throw new Error('The `multiple` attribute can\'t be found'); 248 | } 249 | }; 250 | 251 | ctrl.collapseAll = function collapseAll (exceptionalPane) { 252 | if (isDisabled) { return; } 253 | 254 | angular.forEach($scope.panes, function (iteratedPane) { 255 | if (iteratedPane !== exceptionalPane) { 256 | ctrl.collapse(iteratedPane); 257 | } 258 | }); 259 | }; 260 | 261 | // API 262 | $scope.internalControl = { 263 | toggle: function toggle (indexOrId) { 264 | if (angular.isString(indexOrId)) { 265 | ctrl.toggle( ctrl.getPaneById(indexOrId) ); 266 | } else { 267 | ctrl.toggle( ctrl.getPaneByIndex(indexOrId) ); 268 | } 269 | }, 270 | expand: function expand (indexOrId) { 271 | if (angular.isString(indexOrId)) { 272 | ctrl.expand( ctrl.getPaneById(indexOrId) ); 273 | } else { 274 | ctrl.expand( ctrl.getPaneByIndex(indexOrId) ); 275 | } 276 | }, 277 | collapse: function collapse (indexOrId) { 278 | if (angular.isString(indexOrId)) { 279 | ctrl.collapse( ctrl.getPaneById(indexOrId) ); 280 | } else { 281 | ctrl.collapse( ctrl.getPaneByIndex(indexOrId) ); 282 | } 283 | }, 284 | expandAll: ctrl.expandAll, 285 | collapseAll: ctrl.collapseAll, 286 | hasExpandedPane: ctrl.hasExpandedPane 287 | }; 288 | } 289 | vAccordionController.$inject = ['$scope']; 290 | -------------------------------------------------------------------------------- /src/vAccordion/directives/vPane.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // vPane directive 4 | angular.module('vAccordion.directives') 5 | .directive('vPane', vPaneDirective); 6 | 7 | 8 | function vPaneDirective ($timeout, $animate, accordionConfig) { 9 | return { 10 | restrict: 'E', 11 | require: '^vAccordion', 12 | transclude: true, 13 | controller: vPaneController, 14 | scope: { 15 | isExpanded: '=?expanded', 16 | isDisabled: '=?ngDisabled', 17 | id: '@?' 18 | }, 19 | link: function (scope, iElement, iAttrs, accordionCtrl, transclude) { 20 | transclude(scope.$parent.$new(), function (clone, transclusionScope) { 21 | transclusionScope.$pane = scope.internalControl; 22 | if (scope.id) { transclusionScope.$pane.id = scope.id; } 23 | iElement.append(clone); 24 | }); 25 | 26 | if (!angular.isDefined(scope.isExpanded)) { 27 | scope.isExpanded = (angular.isDefined(iAttrs.expanded) && (iAttrs.expanded === '')); 28 | } 29 | 30 | if (angular.isDefined(iAttrs.disabled)) { 31 | scope.isDisabled = true; 32 | } 33 | 34 | var states = accordionConfig.states; 35 | 36 | var paneHeader = iElement.find('v-pane-header'), 37 | paneContent = iElement.find('v-pane-content'), 38 | paneInner = paneContent.find('div'); 39 | 40 | var accordionId = accordionCtrl.getAccordionId(); 41 | 42 | if (!paneHeader[0]) { 43 | throw new Error('The `v-pane-header` directive can\'t be found'); 44 | } 45 | 46 | if (!paneContent[0]) { 47 | throw new Error('The `v-pane-content` directive can\'t be found'); 48 | } 49 | 50 | scope.paneElement = iElement; 51 | scope.paneContentElement = paneContent; 52 | scope.paneInnerElement = paneInner; 53 | 54 | scope.accordionCtrl = accordionCtrl; 55 | 56 | accordionCtrl.addPane(scope); 57 | 58 | function emitEvent (eventName) { 59 | eventName = (angular.isDefined(accordionId)) ? accordionId + ':' + eventName : 'vAccordion:' + eventName; 60 | scope.$emit(eventName); 61 | } 62 | 63 | function expand () { 64 | accordionCtrl.disable(); 65 | 66 | paneContent.attr('aria-hidden', 'false'); 67 | 68 | paneHeader.attr({ 69 | 'aria-selected': 'true', 70 | 'aria-expanded': 'true' 71 | }); 72 | 73 | emitEvent('onExpand'); 74 | 75 | $animate 76 | .addClass(iElement, states.expanded) 77 | .then(function () { 78 | accordionCtrl.enable(); 79 | emitEvent('onExpandAnimationEnd'); 80 | }); 81 | } 82 | 83 | function collapse () { 84 | accordionCtrl.disable(); 85 | 86 | paneContent.attr('aria-hidden', 'true'); 87 | 88 | paneHeader.attr({ 89 | 'aria-selected': 'false', 90 | 'aria-expanded': 'false' 91 | }); 92 | 93 | emitEvent('onCollapse'); 94 | 95 | $animate 96 | .removeClass(iElement, states.expanded) 97 | .then(function () { 98 | accordionCtrl.enable(); 99 | emitEvent('onCollapseAnimationEnd'); 100 | }); 101 | } 102 | 103 | scope.$evalAsync(function () { 104 | if (scope.isExpanded) { 105 | iElement.addClass(states.expanded); 106 | paneContent 107 | .css('max-height', 'none') 108 | .attr('aria-hidden', 'false'); 109 | 110 | paneHeader.attr({ 111 | 'aria-selected': 'true', 112 | 'aria-expanded': 'true' 113 | }); 114 | } else { 115 | paneContent 116 | .css('max-height', '0px') 117 | .attr('aria-hidden', 'true'); 118 | 119 | paneHeader.attr({ 120 | 'aria-selected': 'false', 121 | 'aria-expanded': 'false' 122 | }); 123 | } 124 | }); 125 | 126 | scope.$watch('isExpanded', function (newValue, oldValue) { 127 | if (newValue === oldValue) { return true; } 128 | if (newValue) { expand(); } 129 | else { collapse(); } 130 | }); 131 | } 132 | }; 133 | } 134 | vPaneDirective.$inject = ['$timeout', '$animate', 'accordionConfig']; 135 | 136 | 137 | // vPane directive controller 138 | function vPaneController ($scope) { 139 | var ctrl = this; 140 | 141 | ctrl.isExpanded = function isExpanded () { 142 | return $scope.isExpanded; 143 | }; 144 | 145 | ctrl.toggle = function toggle () { 146 | if (!$scope.isAnimating && !$scope.isDisabled) { 147 | $scope.accordionCtrl.toggle($scope); 148 | } 149 | }; 150 | 151 | ctrl.expand = function expand () { 152 | if (!$scope.isAnimating && !$scope.isDisabled) { 153 | $scope.accordionCtrl.expand($scope); 154 | } 155 | }; 156 | 157 | ctrl.collapse = function collapse () { 158 | if (!$scope.isAnimating && !$scope.isDisabled) { 159 | $scope.accordionCtrl.collapse($scope); 160 | } 161 | }; 162 | 163 | ctrl.focusPane = function focusPane () { 164 | $scope.isFocused = true; 165 | }; 166 | 167 | ctrl.blurPane = function blurPane () { 168 | $scope.isFocused = false; 169 | }; 170 | 171 | $scope.internalControl = { 172 | toggle: ctrl.toggle, 173 | expand: ctrl.expand, 174 | collapse: ctrl.collapse, 175 | isExpanded: ctrl.isExpanded 176 | }; 177 | } 178 | vPaneController.$inject = ['$scope']; 179 | -------------------------------------------------------------------------------- /src/vAccordion/directives/vPaneContent.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // vPaneContent directive 4 | angular.module('vAccordion.directives') 5 | .directive('vPaneContent', vPaneContentDirective); 6 | 7 | 8 | function vPaneContentDirective () { 9 | return { 10 | restrict: 'E', 11 | require: '^vPane', 12 | transclude: true, 13 | template: '
', 14 | scope: {}, 15 | link: function (scope, iElement, iAttrs) { 16 | iAttrs.$set('role', 'tabpanel'); 17 | iAttrs.$set('aria-hidden', 'true'); 18 | } 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/vAccordion/directives/vPaneHeader.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // vPaneHeader directive 4 | angular.module('vAccordion.directives') 5 | .directive('vPaneHeader', vPaneHeaderDirective); 6 | 7 | 8 | function vPaneHeaderDirective () { 9 | return { 10 | restrict: 'E', 11 | require: ['^vPane', '^vAccordion'], 12 | transclude: true, 13 | template: '
', 14 | scope: {}, 15 | link: function (scope, iElement, iAttrs, ctrls) { 16 | iAttrs.$set('role', 'tab'); 17 | iAttrs.$set('tabindex', '0'); 18 | 19 | var paneCtrl = ctrls[0], 20 | accordionCtrl = ctrls[1]; 21 | 22 | var isInactive = angular.isDefined(iAttrs.inactive); 23 | 24 | function onClick () { 25 | if (isInactive) { return false; } 26 | scope.$apply(function () { paneCtrl.toggle(); }); 27 | } 28 | 29 | function onKeydown (event) { 30 | if (event.keyCode === 32 || event.keyCode === 13) { 31 | scope.$apply(function () { paneCtrl.toggle(); }); 32 | event.preventDefault(); 33 | } else if (event.keyCode === 39 || event.keyCode === 40) { 34 | scope.$apply(function () { accordionCtrl.focusNext(); }); 35 | event.preventDefault(); 36 | } else if (event.keyCode === 37 || event.keyCode === 38) { 37 | scope.$apply(function () { accordionCtrl.focusPrevious(); }); 38 | event.preventDefault(); 39 | } 40 | } 41 | 42 | function onFocus () { 43 | paneCtrl.focusPane(); 44 | } 45 | 46 | function onBlur () { 47 | paneCtrl.blurPane(); 48 | } 49 | 50 | iElement[0].onfocus = onFocus; 51 | iElement[0].onblur = onBlur; 52 | iElement.bind('click', onClick); 53 | iElement.bind('keydown', onKeydown); 54 | 55 | scope.$on('$destroy', function () { 56 | iElement.unbind('click', onClick); 57 | iElement.unbind('keydown', onKeydown); 58 | iElement[0].onfocus = null; 59 | iElement[0].onblur = null; 60 | }); 61 | } 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/vAccordion/styles/_base.scss: -------------------------------------------------------------------------------- 1 | /* Base styles 2 | ***************************************/ 3 | 4 | 5 | v-accordion { 6 | display: block; 7 | } 8 | 9 | v-pane { 10 | display: block; 11 | 12 | &.is-expanded { 13 | > v-pane-content { 14 | > div { 15 | display: visible; 16 | } 17 | } 18 | } 19 | 20 | &[disabled] > v-pane-header { 21 | opacity: $v-pane-disabled-opacity; 22 | pointer-events: none; 23 | } 24 | } 25 | 26 | v-pane-header { 27 | display: block; 28 | position: relative; 29 | cursor: pointer; 30 | user-select: none; 31 | outline: none; 32 | 33 | &:focus { 34 | outline: none; 35 | } 36 | 37 | > div { 38 | display: block; 39 | } 40 | } 41 | 42 | v-pane-content { 43 | display: block; 44 | position: relative; 45 | overflow: hidden; 46 | max-height: 0px; 47 | 48 | > div { 49 | visibility: none; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/vAccordion/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | // ------------------------------------- 2 | // Settings 3 | // ------------------------------------- 4 | 5 | 6 | $v-accordion-default-theme: true !default; 7 | 8 | 9 | // Accordion 10 | // ------------------------------------- 11 | 12 | $v-accordion-spacing: 20px !default; 13 | 14 | $v-pane-border-color: #D8D8D8 !default; 15 | $v-pane-expanded-border-color: #2196F3 !default; 16 | $v-pane-icon-color: #2196F3 !default; 17 | $v-pane-hover-color: #2196F3 !default; 18 | $v-pane-disabled-opacity: 0.6 !default; 19 | 20 | $v-pane-expand-animation-duration: 0.5s !default; 21 | $v-pane-hover-animation-duration: 0.25s !default; 22 | -------------------------------------------------------------------------------- /src/vAccordion/styles/_theme.scss: -------------------------------------------------------------------------------- 1 | @if $v-accordion-default-theme { 2 | /* Theme: default 3 | ***************************************/ 4 | 5 | 6 | .vAccordion--default { 7 | 8 | v-accordion { 9 | margin-top: $v-accordion-spacing; 10 | padding-left: $v-accordion-spacing; 11 | } 12 | 13 | v-pane-content { 14 | > div { 15 | padding-bottom: $v-accordion-spacing; 16 | opacity: 0; 17 | transform: translate3d(0, 30px, 0); 18 | transition: all $v-pane-expand-animation-duration; 19 | } 20 | } 21 | 22 | v-pane { 23 | overflow: hidden; 24 | 25 | &.is-expanded { 26 | > v-pane-header { 27 | border-bottom-color: $v-pane-expanded-border-color; 28 | 29 | &::after { 30 | transform: rotate(90deg); 31 | opacity: 0; 32 | } 33 | &::before { 34 | transform: rotate(0deg); 35 | } 36 | } 37 | 38 | > v-pane-content > div { 39 | opacity: 1; 40 | transform: translate3d(0, 0, 0); 41 | } 42 | } 43 | 44 | &[disabled] v-pane-header { 45 | &::after, 46 | &::before { 47 | display: none; 48 | } 49 | } 50 | } 51 | 52 | v-pane-header { 53 | padding: 5px 0; 54 | margin-bottom: $v-accordion-spacing; 55 | border-bottom: 2px solid $v-pane-border-color; 56 | transition: all $v-pane-hover-animation-duration; 57 | 58 | &::after, 59 | &::before { 60 | content: ''; 61 | display: block; 62 | position: absolute; 63 | top: 50%; 64 | right: 0; 65 | width: 10px; 66 | height: 1px; 67 | background-color: $v-pane-icon-color; 68 | transform-origin: 50% 50%; 69 | will-change: transform; 70 | transition: all $v-pane-hover-animation-duration; 71 | } 72 | 73 | &::before { 74 | transform: rotate(-90deg); 75 | } 76 | 77 | &:hover, 78 | &:focus { 79 | color: $v-pane-hover-color; 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/vAccordion/styles/vAccordion.scss: -------------------------------------------------------------------------------- 1 | /*************************************** 2 | vAccordion 3 | ***************************************/ 4 | 5 | 6 | /** 7 | * Example HTML: 8 | * 9 | 10 | 11 | 12 | [content] 13 | 14 | 15 | [content] 16 | 17 | 18 | 19 | */ 20 | 21 | 22 | @import 'settings'; 23 | @import 'base'; 24 | @import 'theme'; 25 | 26 | -------------------------------------------------------------------------------- /src/vAccordion/vAccordion.js: -------------------------------------------------------------------------------- 1 | 2 | // Config 3 | angular.module('vAccordion.config', []) 4 | .constant('accordionConfig', { 5 | states: { 6 | expanded: 'is-expanded' 7 | }, 8 | expandAnimationDuration: 0.5 9 | }) 10 | .animation('.is-expanded', [ '$animateCss', 'accordionConfig', function ($animateCss, accordionConfig) { 11 | return { 12 | addClass: function (element, className, done) { 13 | var paneContent = angular.element(element[0].querySelector('v-pane-content')), 14 | paneInner = angular.element(paneContent[0].querySelector('div')); 15 | 16 | var height = paneInner[0].offsetHeight; 17 | 18 | var expandAnimation = $animateCss(paneContent, { 19 | easing: 'ease', 20 | from: { maxHeight: '0px' }, 21 | to: { maxHeight: height + 'px' }, 22 | duration: accordionConfig.expandAnimationDuration 23 | }); 24 | 25 | expandAnimation.start().done(function () { 26 | paneContent.css('max-height', 'none'); 27 | done(); 28 | }); 29 | 30 | return function (isCancelled) { 31 | if (isCancelled) { 32 | paneContent.css('max-height', 'none'); 33 | } 34 | }; 35 | }, 36 | removeClass: function (element, className, done) { 37 | var paneContent = angular.element(element[0].querySelector('v-pane-content')), 38 | paneInner = angular.element(paneContent[0].querySelector('div')); 39 | 40 | var height = paneInner[0].offsetHeight; 41 | 42 | var collapseAnimation = $animateCss(paneContent, { 43 | easing: 'ease', 44 | from: { maxHeight: height + 'px' }, 45 | to: { maxHeight: '0px' }, 46 | duration: accordionConfig.expandAnimationDuration 47 | }); 48 | 49 | collapseAnimation.start().done(done); 50 | 51 | return function (isCancelled) { 52 | if (isCancelled) { 53 | paneContent.css('max-height', '0px'); 54 | } 55 | }; 56 | } 57 | }; 58 | } ]); 59 | 60 | 61 | // Modules 62 | angular.module('vAccordion.directives', []); 63 | angular.module('vAccordion', 64 | [ 65 | 'vAccordion.config', 66 | 'vAccordion.directives' 67 | ]); 68 | -------------------------------------------------------------------------------- /src/vAccordion/vAccordion.prefix: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; -------------------------------------------------------------------------------- /src/vAccordion/vAccordion.suffix: -------------------------------------------------------------------------------- 1 | })(angular); -------------------------------------------------------------------------------- /test/unit/vAccordion/controllers/vAccordionController.spec.js: -------------------------------------------------------------------------------- 1 | describe('vAccordionController', function () { 2 | 3 | var scope; 4 | var accordion; 5 | var isolateScope; 6 | var controller; 7 | 8 | var generateTemplate = function (options) { 9 | var dafaults = { 10 | attributes: '', 11 | content: '' 12 | }; 13 | 14 | if (options) { 15 | angular.extend(dafaults, options); 16 | } 17 | 18 | var template = '\n'; 19 | template += dafaults.content + '\n'; 20 | template += ''; 21 | 22 | return template; 23 | }; 24 | 25 | var generatePanes = function (length) { 26 | var samplePanes = []; 27 | 28 | for (var i = 0; i < length; i++) { 29 | var samplePane = { 30 | header: 'Pane header #' + i, 31 | content: 'Pane content #' + i 32 | }; 33 | 34 | samplePanes.push(samplePane); 35 | } 36 | 37 | return samplePanes; 38 | }; 39 | 40 | 41 | beforeEach(module('vAccordion')); 42 | 43 | beforeEach(inject(function (_$rootScope_, _$compile_) { 44 | scope = _$rootScope_.$new(); 45 | 46 | var options = { attributes: 'multiple control="control" onexpand="onExpand(index)" oncollapse="onCollapse(index)"' }; 47 | var template = generateTemplate(options); 48 | 49 | accordion = _$compile_(template)(scope); 50 | _$rootScope_.$digest(); 51 | 52 | isolateScope = accordion.isolateScope(); 53 | controller = accordion.controller('vAccordion'); 54 | })); 55 | 56 | afterEach(function () { 57 | scope.$destroy(); 58 | }); 59 | 60 | 61 | it('should add new pane object to `panes` array', function () { 62 | var samplePane = generatePanes(1)[0]; 63 | 64 | expect(isolateScope.panes.length).toBe(0); 65 | controller.addPane(samplePane); 66 | expect(isolateScope.panes.length).toBeGreaterThan(0); 67 | }); 68 | 69 | 70 | it('should expand pane and call `onExpand` callback', function () { 71 | var samplePanes = generatePanes(5); 72 | var paneToExpandIndex = 0; 73 | var paneToExpand = samplePanes[paneToExpandIndex]; 74 | 75 | for (var i = 0; i < samplePanes.length; i++) { 76 | controller.addPane(samplePanes[i]); 77 | } 78 | 79 | scope.onExpand = jasmine.createSpy('onExpand'); 80 | scope.$digest(); 81 | 82 | expect(isolateScope.panes[paneToExpandIndex].isExpanded).toBeFalsy(); 83 | controller.expand(paneToExpand); 84 | expect(isolateScope.panes[paneToExpandIndex].isExpanded).toBeTruthy(); 85 | 86 | expect(scope.onExpand).toHaveBeenCalled(); 87 | }); 88 | 89 | 90 | it('should collapse pane and call `onCollapse` callback', function () { 91 | var samplePanes = generatePanes(5); 92 | var paneToExpandIndex = 0; 93 | var paneToExpand = samplePanes[paneToExpandIndex]; 94 | paneToExpand.isExpanded = true; 95 | 96 | for (var i = 0; i < samplePanes.length; i++) { 97 | controller.addPane(samplePanes[i]); 98 | } 99 | 100 | scope.onCollapse = jasmine.createSpy('onCollapse'); 101 | scope.$digest(); 102 | 103 | expect(isolateScope.panes[paneToExpandIndex].isExpanded).toBeTruthy(); 104 | controller.collapse(paneToExpand); 105 | expect(isolateScope.panes[paneToExpandIndex].isExpanded).toBeFalsy(); 106 | 107 | expect(scope.onCollapse).toHaveBeenCalled(); 108 | }); 109 | 110 | }); 111 | -------------------------------------------------------------------------------- /test/unit/vAccordion/controllers/vPaneController.spec.js: -------------------------------------------------------------------------------- 1 | describe('vPaneController', function () { 2 | 3 | var scope; 4 | var pane; 5 | var isolateScope; 6 | var controller; 7 | 8 | var generatePanes = function (length) { 9 | var samplePanes = []; 10 | 11 | for (var i = 0; i < length; i++) { 12 | var samplePane = { 13 | header: 'Pane header #' + i, 14 | content: 'Pane content #' + i 15 | }; 16 | 17 | samplePanes.push(samplePane); 18 | } 19 | 20 | return samplePanes; 21 | }; 22 | 23 | var generateTemplate = function (options) { 24 | var dafaults = { 25 | content: '', 26 | accordionAttributes: '', 27 | paneAttributes: '' 28 | }; 29 | 30 | if (options) { 31 | angular.extend(dafaults, options); 32 | } 33 | 34 | var template = '\n'; 35 | template += '\n'; 36 | template += '\n'; 37 | template += '' + dafaults.content + '\n'; 38 | template += '\n'; 39 | template += ''; 40 | 41 | return template; 42 | }; 43 | 44 | 45 | beforeEach(module('vAccordion')); 46 | 47 | beforeEach(inject(function (_$rootScope_, _$compile_) { 48 | scope = _$rootScope_.$new(); 49 | 50 | var options = { accordionAttributes: 'multiple' }; 51 | var template = generateTemplate(options); 52 | 53 | var accordion = _$compile_(template)(scope); 54 | pane = accordion.find('v-pane'); 55 | _$rootScope_.$digest(); 56 | 57 | isolateScope = pane.isolateScope(); 58 | controller = pane.controller('vPane'); 59 | })); 60 | 61 | afterEach(function () { 62 | scope.$destroy(); 63 | }); 64 | 65 | 66 | it('should change isExpanded scope value using `expand()`, `collapse()` and `toggle()` methods', function () { 67 | expect(controller.isExpanded()).toBe(false); 68 | controller.expand(); 69 | expect(controller.isExpanded()).toBe(true); 70 | controller.collapse(); 71 | expect(controller.isExpanded()).toBe(false); 72 | controller.toggle(); 73 | expect(controller.isExpanded()).toBe(true); 74 | }); 75 | 76 | 77 | it('should change isFocused scope value using `focusPane()` and `blurPane()` methods', function () { 78 | expect(isolateScope.isFocused).toBeFalsy(); 79 | controller.focusPane(); 80 | expect(isolateScope.isFocused).toBe(true); 81 | controller.blurPane(); 82 | expect(isolateScope.isFocused).toBe(false); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /test/unit/vAccordion/directives/vAccordion.spec.js: -------------------------------------------------------------------------------- 1 | describe('vAccordion', function () { 2 | 3 | var $compile; 4 | var $rootScope; 5 | var accordionConfig; 6 | var scope; 7 | 8 | var generateTemplate = function (options) { 9 | var dafaults = { 10 | attributes: '', 11 | content: '' 12 | }; 13 | 14 | if (options) { 15 | angular.extend(dafaults, options); 16 | } 17 | 18 | var template = '\n'; 19 | template += dafaults.content + '\n'; 20 | template += ''; 21 | 22 | return template; 23 | }; 24 | 25 | 26 | 27 | beforeEach(module('vAccordion')); 28 | 29 | beforeEach(inject(function (_$rootScope_, _$compile_, _accordionConfig_) { 30 | $rootScope = _$rootScope_; 31 | scope = $rootScope.$new(); 32 | $compile = _$compile_; 33 | accordionConfig = _accordionConfig_; 34 | })); 35 | 36 | afterEach(function () { 37 | scope.$destroy(); 38 | }); 39 | 40 | 41 | 42 | it('should transclude scope', function () { 43 | var message = 'Hello World!'; 44 | 45 | var template = generateTemplate({ content: '{{ message }}' }); 46 | var accordion = $compile(template)(scope); 47 | 48 | scope.message = message; 49 | scope.$digest(); 50 | 51 | expect(accordion.html()).toContain(message); 52 | }); 53 | 54 | 55 | it('should allow multiple selections to be expanded if `multiple` attribute is defined', function () { 56 | var options = { attributes: 'multiple' }; 57 | var template = generateTemplate(options); 58 | var accordion = $compile(template)(scope); 59 | 60 | expect(accordion.isolateScope().allowMultiple).toBe(true); 61 | }); 62 | 63 | 64 | it('should add the ARIA `tablist` role', function () { 65 | var template = generateTemplate(); 66 | var accordion = $compile(template)(scope); 67 | 68 | expect(accordion.attr('role')).toBe('tablist'); 69 | }); 70 | 71 | 72 | it('should set `aria-multiselectable` attribute to `true` if `multiple` attribute is defined', function () { 73 | var options = { attributes: 'multiple' }; 74 | var template = generateTemplate(options); 75 | var accordion = $compile(template)(scope); 76 | 77 | expect(accordion.attr('aria-multiselectable')).toBeDefined(); 78 | }); 79 | 80 | 81 | it('should extend custom control object', function () { 82 | scope.control = { someProperty: 'test' }; 83 | 84 | var options = { attributes: 'control="control"' }; 85 | var template = generateTemplate(options); 86 | var accordion = $compile(template)(scope); 87 | 88 | expect(accordion.isolateScope().internalControl.someProperty).toBeDefined(); 89 | expect(accordion.isolateScope().internalControl.someProperty).toBe('test'); 90 | }); 91 | 92 | 93 | it('should throw an error if the API method is overriden', function () { 94 | scope.control = { toggle: function () {} }; 95 | 96 | var options = { attributes: 'control="control"' }; 97 | var template = generateTemplate(options); 98 | 99 | expect(function () { $compile(template)(scope); }).toThrow(); 100 | }); 101 | 102 | 103 | it('should set accordion `internalControl` as `$accordion` property on transcluded scope', function () { 104 | var options = { 105 | attributes: 'id="accordion"', 106 | content: '' 107 | }; 108 | 109 | var template = generateTemplate(options); 110 | var accordion = $compile(template)(scope); 111 | var pane = accordion.find('v-pane'); 112 | var transcludedScope = pane.scope(); 113 | 114 | expect(scope.$accordion).not.toBeDefined(); 115 | expect(transcludedScope.$accordion).toBeDefined(); 116 | expect(transcludedScope.$accordion.id).toEqual('accordion'); 117 | expect(transcludedScope.$accordion.toggle).toBeDefined(); 118 | expect(transcludedScope.$accordion.expand).toBeDefined(); 119 | expect(transcludedScope.$accordion.collapse).toBeDefined(); 120 | expect(transcludedScope.$accordion.expandAll).toBeDefined(); 121 | expect(transcludedScope.$accordion.collapseAll).toBeDefined(); 122 | expect(transcludedScope.$accordion.hasExpandedPane).toBeDefined(); 123 | }); 124 | 125 | }); 126 | -------------------------------------------------------------------------------- /test/unit/vAccordion/directives/vPane.spec.js: -------------------------------------------------------------------------------- 1 | describe('vPane', function () { 2 | 3 | var $compile; 4 | var $rootScope; 5 | var accordionConfig; 6 | var scope; 7 | 8 | var generatePanes = function (length) { 9 | var samplePanes = []; 10 | 11 | for (var i = 0; i < length; i++) { 12 | var samplePane = { 13 | header: 'Pane header #' + i, 14 | content: 'Pane content #' + i 15 | }; 16 | 17 | samplePanes.push(samplePane); 18 | } 19 | 20 | return samplePanes; 21 | }; 22 | 23 | var generateTemplate = function (options) { 24 | var dafaults = { 25 | content: '', 26 | accordionAttributes: '', 27 | paneAttributes: '' 28 | }; 29 | 30 | if (options) { 31 | angular.extend(dafaults, options); 32 | } 33 | 34 | var template = '\n'; 35 | template += '\n'; 36 | template += '\n'; 37 | template += '' + dafaults.content + '\n'; 38 | template += '\n'; 39 | template += ''; 40 | 41 | return template; 42 | }; 43 | 44 | 45 | 46 | beforeEach(module('vAccordion')); 47 | 48 | beforeEach(inject(function (_$rootScope_, _$compile_, _accordionConfig_) { 49 | $rootScope = _$rootScope_; 50 | scope = $rootScope.$new(); 51 | $compile = _$compile_; 52 | accordionConfig = _accordionConfig_; 53 | })); 54 | 55 | afterEach(function () { 56 | scope.$destroy(); 57 | }); 58 | 59 | 60 | 61 | it('should throw an error if `v-accordion` directive controller can\'t be found', function () { 62 | var template = ''; 63 | 64 | expect(function () { $compile(template)(scope); }).toThrow(); 65 | }); 66 | 67 | 68 | it('should throw an error if `v-pane-header` can\'t be found found', function () { 69 | var template = '\n' + 70 | ' \n' + 71 | ''; 72 | 73 | expect(function () { $compile(template)(scope); }).toThrow(); 74 | }); 75 | 76 | 77 | it('should throw an error if `v-pane-content` can\'t be found found', function () { 78 | var template = '\n' + 79 | ' \n' + 80 | ' \n' + 81 | ' \n' + 82 | ''; 83 | 84 | expect(function () { $compile(template)(scope); }).toThrow(); 85 | }); 86 | 87 | 88 | it('should transclude scope', function () { 89 | var message = 'Hello World!'; 90 | var options = { content: '{{ message }}' }; 91 | 92 | var template = generateTemplate(options); 93 | 94 | var accordion = $compile(template)(scope); 95 | var paneContent = accordion.find('v-pane-content'); 96 | 97 | scope.message = message; 98 | scope.$digest(); 99 | 100 | expect(paneContent.html()).toContain(message); 101 | }); 102 | 103 | 104 | it('should set pane `internalControl` as `$pane` property on transcluded scope', function () { 105 | var options = { paneAttributes: 'id="pane"' }; 106 | 107 | var template = generateTemplate(options); 108 | 109 | var accordion = $compile(template)(scope); 110 | var paneContent = accordion.find('v-pane-content'); 111 | var transcludedScope = paneContent.scope(); 112 | 113 | expect(scope.$pane).not.toBeDefined(); 114 | expect(transcludedScope.$pane).toBeDefined(); 115 | expect(transcludedScope.$pane.id).toEqual('pane'); 116 | expect(transcludedScope.$pane.toggle).toBeDefined(); 117 | expect(transcludedScope.$pane.expand).toBeDefined(); 118 | expect(transcludedScope.$pane.collapse).toBeDefined(); 119 | expect(transcludedScope.$pane.isExpanded).toBeDefined(); 120 | }); 121 | 122 | 123 | it('should throw an error if multiple panes has `expanded` attribute, but `multiple` is not set', function () { 124 | var template = '\n' + 125 | ' \n' + 126 | ' \n' + 127 | ' \n' + 128 | ' \n' + 129 | ' \n' + 130 | ' \n' + 131 | ' \n' + 132 | ' \n' + 133 | ''; 134 | 135 | expect(function () { 136 | $compile(template)(scope); 137 | scope.$digest(); 138 | }).toThrow(); 139 | }); 140 | 141 | 142 | it('should set `isExpanded` property to `true` if expanded attribute is added and has no value', function () { 143 | var options = { paneAttributes: 'expanded' }; 144 | var template = generateTemplate(options); 145 | 146 | var accordion = $compile(template)(scope); 147 | var pane = accordion.find('v-pane'); 148 | 149 | expect(pane.isolateScope().isExpanded).toBe(true); 150 | }); 151 | 152 | 153 | it('should watch the `isExpanded` value and add `is-expanded` class when it is changed to `true`', function () { 154 | var template = generateTemplate(); 155 | 156 | var accordion = $compile(template)(scope); 157 | var pane = accordion.find('v-pane'); 158 | 159 | var paneIsolateScope = pane.isolateScope(); 160 | paneIsolateScope.$digest(); 161 | 162 | expect(pane.hasClass('is-expanded')).toBe(false); 163 | 164 | paneIsolateScope.isExpanded = true; 165 | paneIsolateScope.$digest(); 166 | 167 | expect(pane.hasClass('is-expanded')).toBe(true); 168 | }); 169 | 170 | 171 | it('should set `isDisabled` property to `true` if `disabled` attribute is set', function () { 172 | var options = { paneAttributes: 'disabled' }; 173 | var template = generateTemplate(options); 174 | 175 | var accordion = $compile(template)(scope); 176 | var pane = accordion.find('v-pane'); 177 | var paneHeader = accordion.find('v-pane-header'); 178 | 179 | var paneIsolateScope = pane.isolateScope(); 180 | 181 | paneHeader.click(); 182 | 183 | expect(paneIsolateScope.isDisabled).toBe(true); 184 | expect(paneIsolateScope.isExpanded).toBe(false); 185 | }); 186 | 187 | 188 | it('should works with `ng-repeat` directive', function () { 189 | var length = 3; 190 | 191 | var template = '\n' + 192 | ' \n' + 193 | ' {{ pane.header }}\n' + 194 | ' {{ pane.content }}\n' + 195 | ' \n' + 196 | ''; 197 | 198 | var accordion = $compile(template)(scope); 199 | 200 | scope.panes = generatePanes(length); 201 | scope.$digest(); 202 | 203 | expect(accordion.find('v-pane').length).toEqual(length); 204 | }); 205 | 206 | 207 | it('should emit `onExpand` and `onCollapse` events', function () { 208 | var options = { accordionAttributes: 'id="accordion"' }; 209 | var template = generateTemplate(options); 210 | 211 | var accordion = $compile(template)(scope); 212 | var pane = accordion.find('v-pane'); 213 | 214 | var paneIsolateScope = pane.isolateScope(); 215 | paneIsolateScope.$digest(); 216 | 217 | spyOn(paneIsolateScope, '$emit'); 218 | 219 | paneIsolateScope.isExpanded = true; 220 | paneIsolateScope.$digest(); 221 | 222 | expect(paneIsolateScope.$emit).toHaveBeenCalledWith('accordion:onExpand'); 223 | 224 | paneIsolateScope.isExpanded = false; 225 | paneIsolateScope.$digest(); 226 | 227 | expect(paneIsolateScope.$emit).toHaveBeenCalledWith('accordion:onCollapse'); 228 | }); 229 | 230 | }); 231 | -------------------------------------------------------------------------------- /test/unit/vAccordion/directives/vPaneContent.spec.js: -------------------------------------------------------------------------------- 1 | describe('vPaneContent', function () { 2 | 3 | var $compile; 4 | var $rootScope; 5 | var accordionConfig; 6 | var scope; 7 | 8 | var generateTemplate = function (options) { 9 | var dafaults = { 10 | content: '' 11 | }; 12 | 13 | if (options) { 14 | angular.extend(dafaults, options); 15 | } 16 | 17 | var template = '\n'; 18 | template += '\n'; 19 | template += '\n'; 20 | template += '' + dafaults.content + '\n'; 21 | template += '\n'; 22 | template += ''; 23 | 24 | return template; 25 | }; 26 | 27 | 28 | beforeEach(module('vAccordion')); 29 | 30 | beforeEach(inject(function (_$rootScope_, _$compile_, _accordionConfig_) { 31 | $rootScope = _$rootScope_; 32 | scope = $rootScope.$new(); 33 | $compile = _$compile_; 34 | accordionConfig = _accordionConfig_; 35 | })); 36 | 37 | afterEach(function () { 38 | scope.$destroy(); 39 | }); 40 | 41 | 42 | 43 | it('should throw an error if `v-pane` directive controller can\'t be found', function () { 44 | var template = ''; 45 | 46 | expect(function () { $compile(template)(scope); }).toThrow(); 47 | }); 48 | 49 | 50 | it('should transclude scope and add inner `div` wrapper', function () { 51 | var message = 'Hello World!'; 52 | 53 | var template = generateTemplate({ content: '{{ message }}' }); 54 | 55 | var accordion = $compile(template)(scope); 56 | var paneContent = accordion.find('v-pane-content'); 57 | 58 | scope.message = message; 59 | scope.$digest(); 60 | 61 | expect(paneContent.html()).toContain(message); 62 | expect(paneContent.html()).toContain(''); 63 | }); 64 | 65 | 66 | it('should add ARIA attributes', function () { 67 | var template = generateTemplate(); 68 | 69 | var accordion = $compile(template)(scope); 70 | var paneContent = accordion.find('v-pane-content'); 71 | 72 | expect(paneContent.attr('role')).toBe('tabpanel'); 73 | expect(paneContent.attr('aria-hidden')).toBeDefined(); 74 | }); 75 | 76 | it('should expand when `v-pane-header` is clicked', function () { 77 | var template = generateTemplate(); 78 | 79 | var accordion = $compile(template)(scope); 80 | var paneHeader = accordion.find('v-pane-header'); 81 | var paneContent = accordion.find('v-pane-content'); 82 | 83 | expect(paneContent.css('max-height')).toBe(''); 84 | expect(paneContent.attr('aria-hidden')).toBe('true'); 85 | 86 | paneHeader.click(); 87 | 88 | expect(paneContent.css('max-height')).toBe('none'); 89 | expect(paneContent.attr('aria-hidden')).toBe('false'); 90 | }); 91 | 92 | }); 93 | -------------------------------------------------------------------------------- /test/unit/vAccordion/directives/vPaneHeader.spec.js: -------------------------------------------------------------------------------- 1 | describe('vPaneHeader', function () { 2 | 3 | var $compile; 4 | var $rootScope; 5 | var accordionConfig; 6 | var scope; 7 | 8 | var generateTemplate = function (options) { 9 | var dafaults = { 10 | content: '' 11 | }; 12 | 13 | if (options) { 14 | angular.extend(dafaults, options); 15 | } 16 | 17 | var template = '\n'; 18 | template += '\n'; 19 | template += '' + dafaults.content + '\n'; 20 | template += '\n'; 21 | template += '\n'; 22 | template += ''; 23 | 24 | return template; 25 | }; 26 | 27 | 28 | 29 | beforeEach(module('vAccordion')); 30 | 31 | beforeEach(inject(function (_$rootScope_, _$compile_, _accordionConfig_) { 32 | $rootScope = _$rootScope_; 33 | scope = $rootScope.$new(); 34 | $compile = _$compile_; 35 | accordionConfig = _accordionConfig_; 36 | })); 37 | 38 | 39 | afterEach(function () { 40 | scope.$destroy(); 41 | }); 42 | 43 | 44 | 45 | it('should throw an error if `v-pane` directive controller can\'t be found', function () { 46 | var template = ''; 47 | 48 | expect(function () { $compile(template)(scope); }).toThrow(); 49 | }); 50 | 51 | 52 | it('should transclude scope and create inner `div` wrapper', function () { 53 | var message = 'Hello World!'; 54 | 55 | var template = generateTemplate({ content: '{{ message }}' }); 56 | 57 | var accordion = $compile(template)(scope); 58 | var paneHeader = accordion.find('v-pane-header'); 59 | 60 | scope.message = message; 61 | scope.$digest(); 62 | 63 | expect(paneHeader.html()).toContain(message); 64 | expect(paneHeader.html()).toContain(''); 65 | }); 66 | 67 | 68 | it('should have `role` and `tabindex` attribute', function () { 69 | var template = generateTemplate(); 70 | 71 | var accordion = $compile(template)(scope); 72 | var paneHeader = accordion.find('v-pane-header'); 73 | 74 | expect(paneHeader.attr('role')).toBe('tab'); 75 | expect(paneHeader.attr('tabindex')).toBe('0'); 76 | }); 77 | 78 | 79 | it('should toggle the pane on click', function () { 80 | var template = generateTemplate(); 81 | 82 | var accordion = $compile(template)(scope); 83 | var pane = accordion.find('v-pane'); 84 | var paneHeader = accordion.find('v-pane-header'); 85 | 86 | var paneIsolateScope = pane.isolateScope(); 87 | paneIsolateScope.$digest(); 88 | 89 | expect(paneIsolateScope.isExpanded).toBe(false); 90 | expect(paneHeader.attr('aria-selected')).toBe('false'); 91 | expect(paneHeader.attr('aria-expanded')).toBe('false'); 92 | 93 | paneHeader.click(); 94 | 95 | expect(paneIsolateScope.isExpanded).toBe(true); 96 | expect(paneHeader.attr('aria-selected')).toBe('true'); 97 | expect(paneHeader.attr('aria-expanded')).toBe('true'); 98 | }); 99 | 100 | }); 101 | -------------------------------------------------------------------------------- /test/unit/vAccordion/vAccordion.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('vAccordion', function () { 4 | 5 | var dependencies = []; 6 | 7 | var hasModule = function(module) { 8 | return dependencies.indexOf(module) >= 0; 9 | }; 10 | 11 | 12 | 13 | beforeEach(function () { 14 | dependencies = angular.module('vAccordion').requires; 15 | }); 16 | 17 | 18 | 19 | it('should load config module', function () { 20 | expect(hasModule('vAccordion.config')).toBe(true); 21 | }); 22 | 23 | 24 | it('should load directives module', function () { 25 | expect(hasModule('vAccordion.directives')).toBe(true); 26 | }); 27 | 28 | }); --------------------------------------------------------------------------------